Merge "Fixed a Galaxy Note 5 issue that camera gets stuck after taking pictures with flash on/auto in dark environment." into androidx-main
diff --git a/activity/activity-lint/build.gradle b/activity/activity-lint/build.gradle
index 282ac60..5e00727 100644
--- a/activity/activity-lint/build.gradle
+++ b/activity/activity-lint/build.gradle
@@ -23,8 +23,7 @@
}
dependencies {
- compileOnly(libs.androidBuilderModel)
- compileOnly(libs.androidLintApi)
+ compileOnly(libs.androidLintMinApi)
compileOnly(libs.kotlinStdlib)
testImplementation(libs.kotlinStdlib)
diff --git a/activity/activity-lint/src/main/java/androidx/activity/lint/ActivityIssueRegistry.kt b/activity/activity-lint/src/main/java/androidx/activity/lint/ActivityIssueRegistry.kt
index 60f497b..1a23b1a 100644
--- a/activity/activity-lint/src/main/java/androidx/activity/lint/ActivityIssueRegistry.kt
+++ b/activity/activity-lint/src/main/java/androidx/activity/lint/ActivityIssueRegistry.kt
@@ -14,12 +14,9 @@
* limitations under the License.
*/
-@file:Suppress("UnstableApiUsage")
-
package androidx.activity.lint
import com.android.tools.lint.client.api.IssueRegistry
-import com.android.tools.lint.client.api.Vendor
import com.android.tools.lint.detector.api.CURRENT_API
/**
@@ -32,8 +29,4 @@
override val issues get() = listOf(
ActivityResultFragmentVersionDetector.ISSUE
)
- override val vendor = Vendor(
- vendorName = "Android Open Source Project",
- feedbackUrl = "https://issuetracker.google.com/issues/new?component=527362"
- )
}
diff --git a/activity/activity-lint/src/test/java/androidx/activity/lint/ApiLintVersionsTest.kt b/activity/activity-lint/src/test/java/androidx/activity/lint/ApiLintVersionsTest.kt
index 4e977bd..212284f 100644
--- a/activity/activity-lint/src/test/java/androidx/activity/lint/ApiLintVersionsTest.kt
+++ b/activity/activity-lint/src/test/java/androidx/activity/lint/ApiLintVersionsTest.kt
@@ -35,6 +35,6 @@
assertThat(registry.api).isEqualTo(CURRENT_API)
// Intentionally fails in IDE, because we use different API version in
// studio and command line
- assertThat(registry.minApi).isEqualTo(10)
+ assertThat(registry.minApi).isEqualTo(8)
}
}
diff --git a/annotation/annotation-experimental-lint/build.gradle b/annotation/annotation-experimental-lint/build.gradle
index 37fc54f..517db7f 100644
--- a/annotation/annotation-experimental-lint/build.gradle
+++ b/annotation/annotation-experimental-lint/build.gradle
@@ -31,7 +31,7 @@
}
dependencies {
- compileOnly(libs.androidLintApi)
+ compileOnly(libs.androidLintMinApi)
compileOnly(libs.kotlinStdlib)
testImplementation(libs.kotlinStdlib)
diff --git a/annotation/annotation-experimental-lint/src/main/java/androidx/annotation/experimental/lint/ExperimentalIssueRegistry.kt b/annotation/annotation-experimental-lint/src/main/java/androidx/annotation/experimental/lint/ExperimentalIssueRegistry.kt
index bbd3b0f..93cbde7 100644
--- a/annotation/annotation-experimental-lint/src/main/java/androidx/annotation/experimental/lint/ExperimentalIssueRegistry.kt
+++ b/annotation/annotation-experimental-lint/src/main/java/androidx/annotation/experimental/lint/ExperimentalIssueRegistry.kt
@@ -14,20 +14,13 @@
* limitations under the License.
*/
-@file:Suppress("UnstableApiUsage")
-
package androidx.annotation.experimental.lint
import com.android.tools.lint.client.api.IssueRegistry
-import com.android.tools.lint.client.api.Vendor
import com.android.tools.lint.detector.api.CURRENT_API
class ExperimentalIssueRegistry : IssueRegistry() {
override val minApi = CURRENT_API
override val api = 10
override val issues get() = ExperimentalDetector.ISSUES + AnnotationRetentionDetector.ISSUE
- override val vendor = Vendor(
- vendorName = "Android Open Source Project",
- feedbackUrl = "https://issuetracker.google.com/issues/new?component=459778"
- )
}
diff --git a/annotation/annotation-experimental-lint/src/test/kotlin/androidx/annotation/experimental/lint/ApiLintVersionsTest.kt b/annotation/annotation-experimental-lint/src/test/kotlin/androidx/annotation/experimental/lint/ApiLintVersionsTest.kt
index 54f4f01..e2cc0bd 100644
--- a/annotation/annotation-experimental-lint/src/test/kotlin/androidx/annotation/experimental/lint/ApiLintVersionsTest.kt
+++ b/annotation/annotation-experimental-lint/src/test/kotlin/androidx/annotation/experimental/lint/ApiLintVersionsTest.kt
@@ -36,6 +36,6 @@
// We hardcode version registry.api to the version that is used to run tests.
assertEquals("registry.api matches version used to run tests", CURRENT_API, registry.api)
// Intentionally fails in IDE, because we use different API version in Studio and CLI.
- assertEquals("registry.minApi is set to minimum level of 10", 10, registry.minApi)
+ assertEquals("registry.minApi is set to minimum level of 8", 8, registry.minApi)
}
}
diff --git a/appcompat/appcompat-lint/src/main/kotlin/androidx/appcompat/AppCompatIssueRegistry.kt b/appcompat/appcompat-lint/src/main/kotlin/androidx/appcompat/AppCompatIssueRegistry.kt
index 75d8829..056c0f17 100644
--- a/appcompat/appcompat-lint/src/main/kotlin/androidx/appcompat/AppCompatIssueRegistry.kt
+++ b/appcompat/appcompat-lint/src/main/kotlin/androidx/appcompat/AppCompatIssueRegistry.kt
@@ -27,7 +27,6 @@
import androidx.appcompat.widget.TextViewCompoundDrawablesApiDetector
import androidx.appcompat.widget.TextViewCompoundDrawablesXmlDetector
import com.android.tools.lint.client.api.IssueRegistry
-import com.android.tools.lint.client.api.Vendor
@Suppress("UnstableApiUsage")
class AppCompatIssueRegistry : IssueRegistry() {
@@ -45,8 +44,4 @@
TextViewCompoundDrawablesXmlDetector.NOT_USING_COMPAT_TEXT_VIEW_DRAWABLE_ATTRS,
OnClickXmlDetector.USING_ON_CLICK_IN_XML
)
- override val vendor = Vendor(
- vendorName = "Android Open Source Project",
- feedbackUrl = "https://issuetracker.google.com/issues/new?component=460343"
- )
}
diff --git a/benchmark/common/src/androidTest/java/androidx/benchmark/CpuInfoTest.kt b/benchmark/common/src/androidTest/java/androidx/benchmark/CpuInfoTest.kt
index 62439ad..a0dd439 100644
--- a/benchmark/common/src/androidTest/java/androidx/benchmark/CpuInfoTest.kt
+++ b/benchmark/common/src/androidTest/java/androidx/benchmark/CpuInfoTest.kt
@@ -31,38 +31,38 @@
assertTrue(
CpuInfo.isCpuLocked(
listOf(
- CpuInfo.CoreDir(true, listOf(1, 2, 3), 2, 1000),
- CpuInfo.CoreDir(true, listOf(1, 2, 3), 2, 1000),
- CpuInfo.CoreDir(true, listOf(1, 2, 3), 2, 1000),
- CpuInfo.CoreDir(true, listOf(1, 2, 3), 2, 1000)
+ CpuInfo.CoreDir("", true, listOf(1, 2, 3), 2, 1000),
+ CpuInfo.CoreDir("", true, listOf(1, 2, 3), 2, 1000),
+ CpuInfo.CoreDir("", true, listOf(1, 2, 3), 2, 1000),
+ CpuInfo.CoreDir("", true, listOf(1, 2, 3), 2, 1000)
)
)
)
}
@Test
- fun differentMaxFrequencies() {
+ fun differentCurrentMinFrequencies_similarCoresLockedDifferently() {
assertFalse(
CpuInfo.isCpuLocked(
listOf(
- CpuInfo.CoreDir(true, listOf(1, 2, 3), 2, 1000),
- CpuInfo.CoreDir(true, listOf(1, 2, 3), 2, 1000),
- CpuInfo.CoreDir(true, listOf(1, 2), 2, 1000),
- CpuInfo.CoreDir(true, listOf(1, 2), 2, 1000)
+ CpuInfo.CoreDir("", true, listOf(1, 2, 3), 3, 1000),
+ CpuInfo.CoreDir("", true, listOf(1, 2, 3), 3, 1000),
+ CpuInfo.CoreDir("", true, listOf(1, 2, 3), 3, 1000),
+ CpuInfo.CoreDir("", true, listOf(1, 2, 3), 2, 1000)
)
)
)
}
@Test
- fun differentCurrentMinFrequencies() {
- assertFalse(
+ fun differentCurrentMinFrequencies_differentCoresLockedSimilarly() {
+ assertTrue(
CpuInfo.isCpuLocked(
listOf(
- CpuInfo.CoreDir(true, listOf(1, 2, 3), 3, 1000),
- CpuInfo.CoreDir(true, listOf(1, 2, 3), 3, 1000),
- CpuInfo.CoreDir(true, listOf(1, 2, 3), 3, 1000),
- CpuInfo.CoreDir(true, listOf(1, 2, 3), 2, 1000)
+ CpuInfo.CoreDir("", true, listOf(1, 2, 3), 2, 1000),
+ CpuInfo.CoreDir("", true, listOf(1, 2, 3), 2, 1000),
+ CpuInfo.CoreDir("", true, listOf(2, 3), 3, 1000),
+ CpuInfo.CoreDir("", true, listOf(2, 3), 3, 1000)
)
)
)
@@ -73,10 +73,10 @@
assertFalse(
CpuInfo.isCpuLocked(
listOf(
- CpuInfo.CoreDir(true, listOf(1, 2, 3), 1, 1000),
- CpuInfo.CoreDir(true, listOf(1, 2, 3), 1, 1000),
- CpuInfo.CoreDir(true, listOf(1, 2, 3), 1, 1000),
- CpuInfo.CoreDir(true, listOf(1, 2, 3), 1, 1000)
+ CpuInfo.CoreDir("", true, listOf(1, 2, 3), 1, 1000),
+ CpuInfo.CoreDir("", true, listOf(1, 2, 3), 1, 1000),
+ CpuInfo.CoreDir("", true, listOf(1, 2, 3), 1, 1000),
+ CpuInfo.CoreDir("", true, listOf(1, 2, 3), 1, 1000)
)
)
)
diff --git a/benchmark/common/src/androidTest/java/androidx/benchmark/ShellTest.kt b/benchmark/common/src/androidTest/java/androidx/benchmark/ShellTest.kt
new file mode 100644
index 0000000..dce8b61
--- /dev/null
+++ b/benchmark/common/src/androidTest/java/androidx/benchmark/ShellTest.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.benchmark
+
+import android.os.Build
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SmallTest
+import org.junit.Assume.assumeTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+@SmallTest
+@RunWith(AndroidJUnit4::class)
+class ShellTest {
+ @Test
+ fun optionalCommand_ls() {
+ // command isn't important, it's just something that's not `echo`, and guaranteed to print
+ val output = Shell.optionalCommand("ls /sys/devices/system/cpu")
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ assertNotNull(output)
+ } else {
+ assertNull(output)
+ }
+ }
+
+ @Test
+ fun optionalCommand_echo() {
+ val output = Shell.optionalCommand("echo foo")
+
+ val expected = when {
+ Build.VERSION.SDK_INT >= 23 -> "foo\n"
+ // known bug in the shell on L (21,22). `echo` doesn't work with shell
+ // programmatically, only works in interactive shell :|
+ Build.VERSION.SDK_INT in 21..22 -> ""
+ else -> null
+ }
+
+ assertEquals(expected, output)
+ }
+
+ private fun CpuInfo.CoreDir.scalingMinFreqPath() = "$path/cpufreq/scaling_min_freq"
+
+ @Test
+ fun catProcFileLong() {
+ val onlineCores = CpuInfo.coreDirs.filter { it.online }
+
+ // skip test on devices that can't read scaling_min_freq, like emulators
+ assumeTrue(
+ "cpufreq dirs don't have scaling_min_freq, bypassing test",
+ onlineCores
+ .all { File(it.scalingMinFreqPath()).exists() }
+ )
+
+ onlineCores.forEach {
+ // While CpuInfo actually uses this function to read scaling_setspeed, reading
+ // that value isn't enabled on all devices, so we use scaling_min_freq, which
+ // is more likely to be readable
+ val output = Shell.catProcFileLong(it.scalingMinFreqPath())
+
+ // if the path exists, it should be readable by shell for every online core
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ assertNotNull(output)
+ } else {
+ assertNull(output)
+ }
+ }
+ }
+}
diff --git a/benchmark/common/src/main/java/androidx/benchmark/CpuInfo.kt b/benchmark/common/src/main/java/androidx/benchmark/CpuInfo.kt
index ecfd672..de8b57d 100644
--- a/benchmark/common/src/main/java/androidx/benchmark/CpuInfo.kt
+++ b/benchmark/common/src/main/java/androidx/benchmark/CpuInfo.kt
@@ -16,10 +16,12 @@
package androidx.benchmark
+import android.annotation.SuppressLint
import android.util.Log
import java.io.File
import java.io.IOException
+@SuppressLint("ClassVerificationFailure")
internal object CpuInfo {
private const val TAG = "Benchmark"
@@ -31,14 +33,16 @@
* Representation of clock info in `/sys/devices/system/cpu/cpu#/`
*/
data class CoreDir(
+ val path: String,
+
// online, or true if can't access
val online: Boolean,
// sorted list of scaling_available_frequencies, or listOf(-1) if can't access
- val availableFreqs: List<Int>,
+ val availableFreqs: List<Long>,
- // scaling_min_freq, or -1 if can't access
- val currentMinFreq: Int,
+ // scaling_setspeed, or scaling_min_freq if inaccessible, or -1 if can't access either
+ val setSpeedKhz: Long,
// cpuinfo_max_freq, or -1 if can't access
val maxFreqKhz: Long
@@ -48,9 +52,13 @@
val cpuDir = File("/sys/devices/system/cpu")
coreDirs = cpuDir.list { current, name ->
File(current, name).isDirectory && name.matches(Regex("^cpu[0-9]+"))
- }?.map {
- val path = "${cpuDir.path}/$it"
+ }?.map { coreDir ->
+ val path = "${cpuDir.absolutePath}/$coreDir"
+
CoreDir(
+ // enables testing
+ path = path,
+
// online, or true if can't access
online = readFileTextOrNull("$path/online") != "0",
@@ -58,12 +66,13 @@
availableFreqs = readFileTextOrNull("$path/cpufreq/scaling_available_frequencies")
?.split(Regex("\\s+"))
?.filter { it.isNotBlank() }
- ?.map { Integer.parseInt(it) }
+ ?.map { it.toLong() }
?.sorted()
?: listOf(-1),
// scaling_min_freq, or -1 if can't access
- currentMinFreq = readFileTextOrNull("$path/cpufreq/scaling_min_freq")?.toInt()
+ setSpeedKhz = Shell.catProcFileLong("$path/cpufreq/scaling_setspeed")
+ ?: readFileTextOrNull("$path/cpufreq/scaling_min_freq")?.toLong()
?: -1,
maxFreqKhz = readFileTextOrNull("$path/cpufreq/cpuinfo_max_freq")?.toLong() ?: -1L
)
@@ -83,20 +92,18 @@
fun isCpuLocked(coreDirs: List<CoreDir>): Boolean {
val onlineCores = coreDirs.filter { it.online }
- if (onlineCores.any {
- it.availableFreqs.maxOrNull() != onlineCores[0].availableFreqs.maxOrNull()
- }
- ) {
- Log.d(TAG, "Clocks not locked: cores with different max frequencies")
- return false
+ onlineCores.groupBy { it.availableFreqs }.forEach { (_, similarCores) ->
+ if (similarCores.any { it.setSpeedKhz != similarCores.first().setSpeedKhz }) {
+ Log.d(
+ TAG,
+ "Clocks not locked: cores with same available frequencies " +
+ "running with different current min freq"
+ )
+ return false
+ }
}
- if (onlineCores.any { it.currentMinFreq != onlineCores[0].currentMinFreq }) {
- Log.d(TAG, "Clocks not locked: cores with different current min freq")
- return false
- }
-
- if (onlineCores.any { it.availableFreqs.minOrNull() == it.currentMinFreq }) {
+ if (onlineCores.any { it.availableFreqs.minOrNull() == it.setSpeedKhz }) {
Log.d(TAG, "Clocks not locked: online cores with min freq == min avail freq")
return false
}
diff --git a/benchmark/common/src/main/java/androidx/benchmark/Shell.kt b/benchmark/common/src/main/java/androidx/benchmark/Shell.kt
new file mode 100644
index 0000000..5ef5943
--- /dev/null
+++ b/benchmark/common/src/main/java/androidx/benchmark/Shell.kt
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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 android.os.Build
+import android.os.Looper
+import android.os.ParcelFileDescriptor.AutoCloseInputStream
+import androidx.annotation.RequiresApi
+import androidx.annotation.RestrictTo
+import androidx.test.platform.app.InstrumentationRegistry
+import java.nio.charset.Charset
+
+/**
+ * Shell command helpers, which no-op below API 21
+ *
+ * Eventually, ShellUtils in macrobenchmark should likely merge into this.
+ *
+ * @suppress
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+object Shell {
+ fun connectUiAutomation() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ ShellImpl // force initialization
+ }
+ }
+
+ /**
+ * Run a command, and capture stdout
+ *
+ * Below L, returns null
+ */
+ fun optionalCommand(command: String): String? {
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+ ShellImpl.executeShellCommand(command)
+ } else {
+ null
+ }
+ }
+
+ /**
+ * Function for reading shell-accessible proc files, like scaling_max_freq, which can't be
+ * read directly by the app process.
+ */
+ fun catProcFileLong(path: String): Long? {
+ return optionalCommand("cat $path")
+ ?.trim()
+ ?.run {
+ try {
+ toLong()
+ } catch (exception: NumberFormatException) {
+ // silently catch exception, as it may be not readable (e.g. due to offline)
+ null
+ }
+ }
+ }
+}
+
+@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
+private object ShellImpl {
+ init {
+ require(Looper.getMainLooper().thread != Thread.currentThread()) {
+ "ShellImpl must not be initialized on the UI thread - UiAutomation must not be " +
+ "connected on the main thread!"
+ }
+ }
+
+ private val uiAutomation = InstrumentationRegistry.getInstrumentation().uiAutomation
+
+ /**
+ * Reimplementation of UiAutomator's Device.executeShellCommand,
+ * to avoid the UiAutomator dependency
+ */
+ fun executeShellCommand(cmd: String): String {
+ val parcelFileDescriptor = uiAutomation.executeShellCommand(cmd)
+ AutoCloseInputStream(parcelFileDescriptor).use { inputStream ->
+ return inputStream.readBytes().toString(Charset.defaultCharset())
+ }
+ }
+}
\ No newline at end of file
diff --git a/benchmark/integration-tests/crystalball-experiment/build.gradle b/benchmark/integration-tests/crystalball-experiment/build.gradle
deleted file mode 100644
index 068b678..0000000
--- a/benchmark/integration-tests/crystalball-experiment/build.gradle
+++ /dev/null
@@ -1,96 +0,0 @@
-/*
- * Copyright (C) 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import androidx.build.LibraryGroups
-import androidx.build.Publish
-
-plugins {
- id("AndroidXPlugin")
- id("com.android.library")
- id("kotlin-android")
-}
-
-android {
- defaultConfig {
- minSdkVersion 18
- multiDexEnabled true
- // testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
- // Listener List
- testInstrumentationRunnerArgument "listener",
- "android.device.collectors.CpuUsageListener," +
- "android.device.collectors.ProcLoadListener," +
- "android.device.collectors.PerfettoListener," +
- "android.device.collectors.AppStartupListener," +
- "android.device.collectors.JankListener," +
- "android.device.collectors.CrashListener," +
- "android.device.collectors.ScreenshotOnFailureCollector," +
- "android.device.collectors.LogcatOnFailureCollector," +
- "android.device.collectors.IncidentReportListener," +
- "android.device.collectors.TotalPssMetricListener"
-
- // ProcLoadListener
- testInstrumentationRunnerArgument "procload-collector:proc-loadavg-interval", "2000"
- testInstrumentationRunnerArgument "procload-collector:proc-loadavg-threshold", "0.5"
- testInstrumentationRunnerArgument "procload-collector:proc-loadavg-timeout", "90000"
-
- // CpuUsageListener
- testInstrumentationRunnerArgument "cpuusage-collector:disable_per_freq", "true"
- testInstrumentationRunnerArgument "cpuusage-collector:disable_per_pkg", "true"
-
- // TotalPssMetricListener
- testInstrumentationRunnerArgument "totalpss-collector:process-names", "androidx.compose.integration.demos"
-
- // JankListener (disable)
- testInstrumentationRunnerArgument "jank-listener:log", "true"
-
- // Microbenchmark runner configuration
- testInstrumentationRunnerArgument "iterations", "1"
- }
-}
-
-dependencies {
- api(libs.junit)
- api(libs.kotlinStdlib)
- api("androidx.annotation:annotation:1.1.0")
- // TODO: remove, once we remove the minor usages in CrystalBall
- implementation(libs.guavaAndroid)
- androidTestImplementation("com.android:collector-device-lib:0.1.0")
- androidTestImplementation("com.android:collector-device-lib-platform:0.1.0")
- androidTestImplementation("com.android:collector-helper-utilities:0.1.0")
- androidTestImplementation("com.android:jank-helper:0.1.0")
- androidTestImplementation("com.android:memory-helper:0.1.0")
- androidTestImplementation("com.android:perfetto-helper:0.1.0")
- androidTestImplementation("com.android:platform-test-composers:0.1.0")
- androidTestImplementation("com.android:power-helper:0.1.0")
- androidTestImplementation("com.android:simpleperf-helper:0.1.0")
- androidTestImplementation("com.android:statsd-helper:0.1.0")
- androidTestImplementation("com.android:system-metric-helper:0.1.0")
- androidTestImplementation("com.android:test-composers:0.1.0")
- androidTestImplementation("com.android:platform-test-rules:0.1.0")
- androidTestImplementation("com.android:microbenchmark-device-lib:0.1.0")
- androidTestImplementation("androidx.test:rules:1.3.0")
- androidTestImplementation("androidx.test:runner:1.3.0")
- implementation(libs.testExtJunit)
- implementation(libs.testUiautomator)
-}
-
-androidx {
- name = "Android Benchmark - Crystalball experiment"
- publish = Publish.NONE
- mavenGroup = LibraryGroups.BENCHMARK
- inceptionYear = "2020"
- description = "Android Benchmark - Crystalball experiment"
-}
diff --git a/benchmark/integration-tests/crystalball-experiment/src/androidTest/java/androidx/benchmark/macro/JankCollectionHelperTest.kt b/benchmark/integration-tests/crystalball-experiment/src/androidTest/java/androidx/benchmark/macro/JankCollectionHelperTest.kt
deleted file mode 100644
index af49322..0000000
--- a/benchmark/integration-tests/crystalball-experiment/src/androidTest/java/androidx/benchmark/macro/JankCollectionHelperTest.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * Copyright 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.benchmark.macro
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.filters.MediumTest
-import androidx.test.filters.SdkSuppress
-import com.android.helpers.JankCollectionHelper
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@MediumTest
-@RunWith(AndroidJUnit4::class)
-class JankCollectionHelperTest {
- // Setting a minSdkVersion of 27 because JankHelper fails with an error on API 26.
- // Needs a fix in JankHelper and re-import of prebults.
- // https://android-build.googleplex.com/builds/tests/view?invocationId=I00300005943166534&redirect=http://sponge2/025964b6-d278-44a7-805c-56d8010935a8
- @Test
- @SdkSuppress(minSdkVersion = 27)
- fun trivialTest() {
- JankCollectionHelper()
- }
-}
diff --git a/benchmark/integration-tests/crystalball-experiment/src/androidTest/java/androidx/benchmark/macro/OpenAppMicrobenchmark.kt b/benchmark/integration-tests/crystalball-experiment/src/androidTest/java/androidx/benchmark/macro/OpenAppMicrobenchmark.kt
deleted file mode 100644
index d975384..0000000
--- a/benchmark/integration-tests/crystalball-experiment/src/androidTest/java/androidx/benchmark/macro/OpenAppMicrobenchmark.kt
+++ /dev/null
@@ -1,86 +0,0 @@
-/*
- * Copyright 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.benchmark.macro
-
-import android.annotation.SuppressLint
-import android.content.Context
-import android.content.Intent
-import android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK
-import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
-import android.platform.test.microbenchmark.Microbenchmark
-import android.platform.test.rule.CompilationFilterRule
-import android.platform.test.rule.DropCachesRule
-import android.platform.test.rule.KillAppsRule
-import android.platform.test.rule.NaturalOrientationRule
-import android.platform.test.rule.PressHomeRule
-import androidx.test.filters.LargeTest
-import androidx.test.platform.app.InstrumentationRegistry
-import androidx.test.uiautomator.By
-import androidx.test.uiautomator.UiDevice
-import androidx.test.uiautomator.Until
-import org.junit.AfterClass
-import org.junit.ClassRule
-import org.junit.Ignore
-import org.junit.Rule
-import org.junit.Test
-import org.junit.rules.RuleChain
-import org.junit.runner.RunWith
-
-@SuppressLint("UnsupportedTestRunner")
-@RunWith(Microbenchmark::class)
-class OpenAppMicrobenchmark {
-
- @Test
- @LargeTest
- @Ignore("Gradle arguments are not passed to the TestRunner")
- fun open() {
- // Launch the app
- val context: Context = InstrumentationRegistry.getInstrumentation().context
- val intent: Intent = context.packageManager.getLaunchIntentForPackage(thePackage)!!
- // Clear out any previous instances
- intent.flags = FLAG_ACTIVITY_NEW_TASK or FLAG_ACTIVITY_CLEAR_TASK
- context.startActivity(intent)
- device.wait(
- Until.hasObject(By.pkg(thePackage).depth(0)),
- 5000
- )
- }
-
- // Method-level rules
- @get:Rule
- var rules: RuleChain = RuleChain.outerRule(KillAppsRule(thePackage))
- .around(DropCachesRule())
- .around(CompilationFilterRule(thePackage))
- .around(PressHomeRule())
-
- companion object {
- // Pixel Settings App
- const val thePackage = "com.android.settings"
- private val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
-
- @JvmStatic
- @AfterClass
- fun close() {
- device.pressHome()
- }
-
- // Class-level rules
- @JvmField
- @ClassRule
- var orientationRule = NaturalOrientationRule()
- }
-}
diff --git a/benchmark/integration-tests/crystalball-experiment/src/androidTest/java/androidx/benchmark/macro/TrivialTest.kt b/benchmark/integration-tests/crystalball-experiment/src/androidTest/java/androidx/benchmark/macro/TrivialTest.kt
deleted file mode 100644
index 4c660b6..0000000
--- a/benchmark/integration-tests/crystalball-experiment/src/androidTest/java/androidx/benchmark/macro/TrivialTest.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-/*
- * Copyright 2020 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package androidx.benchmark.macro
-
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.filters.MediumTest
-import org.junit.Test
-import org.junit.runner.RunWith
-
-@MediumTest
-@RunWith(AndroidJUnit4::class)
-class TrivialTest {
- @Test
- fun foo() {
- BenchmarkClass()
- }
-}
diff --git a/benchmark/integration-tests/crystalball-experiment/src/main/AndroidManifest.xml b/benchmark/integration-tests/crystalball-experiment/src/main/AndroidManifest.xml
deleted file mode 100644
index a599d46..0000000
--- a/benchmark/integration-tests/crystalball-experiment/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,17 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!--
- ~ Copyright (C) 2020 The Android Open Source Project
- ~
- ~ Licensed under the Apache License, Version 2.0 (the "License");
- ~ you may not use this file except in compliance with the License.
- ~ You may obtain a copy of the License at
- ~
- ~ http://www.apache.org/licenses/LICENSE-2.0
- ~
- ~ Unless required by applicable law or agreed to in writing, software
- ~ distributed under the License is distributed on an "AS IS" BASIS,
- ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- ~ See the License for the specific language governing permissions and
- ~ limitations under the License.
- -->
-<manifest package="androidx.benchmark.macro"/>
diff --git a/benchmark/integration-tests/crystalball-experiment/src/scripts/copy_crystalball_prebuilts.py b/benchmark/integration-tests/crystalball-experiment/src/scripts/copy_crystalball_prebuilts.py
deleted file mode 100755
index e3880a7..0000000
--- a/benchmark/integration-tests/crystalball-experiment/src/scripts/copy_crystalball_prebuilts.py
+++ /dev/null
@@ -1,77 +0,0 @@
-#!/usr/bin/python3
-
-import argparse
-import os
-import shutil
-
-
-
-SOONG_SUFFIX = '/android_common/javac'
-VERSION = '0.1.0'
-
-
-def main():
- parser = argparse.ArgumentParser(description='Copy Crystalball Prebuilts')
- parser.add_argument('soong_path', action="store", help='Looks like "/usr/local/google/home/rahulrav/Workspace/internal_android/out/soong/.intermediates"')
- parser.add_argument('prebuilts_directory', action="store", help='Looks like "/mnt/Android/Flatfoot/androidx_main/prebuilts/androidx/external/com/android/"')
- parser.add_argument('--verbose', action="store_true", default=False)
- parse_result = parser.parse_args()
-
- soong_path = parse_result.soong_path
- soong_output_directory = f'{soong_path}/platform_testing/libraries/'
- prebuilts_directory = parse_result.prebuilts_directory
-
- mapping = dict([
- # Device Collectors
- (f'{soong_output_directory}/device-collectors/src/main/collector-device-lib/{SOONG_SUFFIX}/collector-device-lib.jar', f'{prebuilts_directory}/collector-device-lib/{VERSION}/collector-device-lib-{VERSION}.jar'),
- # Collectors Device Lib Platform
- (f'{soong_output_directory}/device-collectors/src/main/platform-collectors/collector-device-lib-platform/{SOONG_SUFFIX}/collector-device-lib-platform.jar', f'{prebuilts_directory}/collector-device-lib-platform/{VERSION}/collector-device-lib-platform-{VERSION}.jar'),
- # Collector Helpers
- (f'{soong_output_directory}/collectors-helper/utilities/collector-helper-utilities/{SOONG_SUFFIX}/collector-helper-utilities.jar', f'{prebuilts_directory}/collector-helper-utilities/{VERSION}/collector-helper-utilities-{VERSION}.jar'),
- (f'{soong_output_directory}/collectors-helper/jank/jank-helper/{SOONG_SUFFIX}/jank-helper.jar', f'{prebuilts_directory}/jank-helper/{VERSION}/jank-helper-{VERSION}.jar'),
- (f'{soong_output_directory}/collectors-helper/memory/memory-helper/{SOONG_SUFFIX}/memory-helper.jar', f'{prebuilts_directory}/memory-helper/{VERSION}/memory-helper-{VERSION}.jar'),
- # Microbenchmarks
- (f'{soong_output_directory}/health/runners/microbenchmark/microbenchmark-device-lib/{SOONG_SUFFIX}/microbenchmark-device-lib.jar', f'{prebuilts_directory}/microbenchmark-device-lib/{VERSION}/microbenchmark-device-lib-{VERSION}.jar'),
- # Perfetto Helper
- (f'{soong_output_directory}/collectors-helper/perfetto/perfetto-helper/{SOONG_SUFFIX}/perfetto-helper.jar', f'{prebuilts_directory}/perfetto-helper/{VERSION}/perfetto-helper-{VERSION}.jar'),
- # Platform Protos
- (f'{soong_path}/frameworks/base/platformprotoslite/{SOONG_SUFFIX}/platformprotoslite.jar', f'{prebuilts_directory}/platformprotoslite/{VERSION}/platformprotoslite-{VERSION}.jar'),
- (f'{soong_path}/frameworks/base/platformprotosnano/{SOONG_SUFFIX}/platformprotosnano.jar', f'{prebuilts_directory}/platformprotosnano/{VERSION}/platformprotosnano-{VERSION}.jar'),
- # Platform Test Composers
- (f'{soong_output_directory}/health/composers/platform/platform-test-composers/{SOONG_SUFFIX}/platform-test-composers.jar', f'{prebuilts_directory}/platform-test-composers/{VERSION}/platform-test-composers-{VERSION}.jar'),
- # Platform Test Rules
- (f'{soong_output_directory}/health/rules/platform-test-rules/{SOONG_SUFFIX}/platform-test-rules.jar', f'{prebuilts_directory}/platform-test-rules/{VERSION}/platform-test-rules-{VERSION}.jar'),
- # Power Helper
- (f'{soong_output_directory}/collectors-helper/power/power-helper/{SOONG_SUFFIX}/power-helper.jar', f'{prebuilts_directory}/power-helper/{VERSION}/power-helper-{VERSION}.jar'),
- # Simple Perf Helper
- (f'{soong_output_directory}/collectors-helper/simpleperf/simpleperf-helper/{SOONG_SUFFIX}/simpleperf-helper.jar', f'{prebuilts_directory}/simpleperf-helper/{VERSION}/simpleperf-helper-{VERSION}.jar'),
- # Statsd Helper
- (f'{soong_output_directory}/collectors-helper/statsd/statsd-helper/{SOONG_SUFFIX}/statsd-helper.jar', f'{prebuilts_directory}/statsd-helper/{VERSION}/statsd-helper-{VERSION}.jar'),
- # Statsd Protos
- (f'{soong_path}/frameworks/base/cmds/statsd/statsdprotonano/{SOONG_SUFFIX}/statsdprotonano.jar', f'{prebuilts_directory}/statsdprotonano/{VERSION}/statsdprotonano-{VERSION}.jar'),
- # System Metric Helpers
- (f'{soong_output_directory}/collectors-helper/system/system-metric-helper/{SOONG_SUFFIX}/system-metric-helper.jar', f'{prebuilts_directory}/system-metric-helper/{VERSION}/system-metric-helper-{VERSION}.jar'),
- # Test Composers
- (f'{soong_output_directory}/health/composers/host/test-composers/{SOONG_SUFFIX}/test-composers.jar', f'{prebuilts_directory}/test-composers/{VERSION}/test-composers-{VERSION}.jar'),
- ])
-
- size = len(mapping)
- if parse_result.verbose:
- print(f'Total number of entries = {size}')
-
- for key in mapping:
- if not os.path.exists(key):
- print(f'Artifact at {key} does not exist. You may have forgotten to build the necessary artifacts.')
-
- if os.path.exists(mapping[key]) and parse_result.verbose:
- print(f'Artifact at {mapping[key]} will be overwritten')
-
- shutil.copyfile(key, mapping[key])
-
- if parse_result.verbose:
- print(f'Copied from {key} to {mapping[key]}\n')
-
- print('All done.')
-
-if __name__ == "__main__":
- main()
diff --git a/benchmark/junit4/src/main/java/androidx/benchmark/junit4/AndroidBenchmarkRunner.kt b/benchmark/junit4/src/main/java/androidx/benchmark/junit4/AndroidBenchmarkRunner.kt
index a43d2aa..9cbb6d2 100644
--- a/benchmark/junit4/src/main/java/androidx/benchmark/junit4/AndroidBenchmarkRunner.kt
+++ b/benchmark/junit4/src/main/java/androidx/benchmark/junit4/AndroidBenchmarkRunner.kt
@@ -18,6 +18,7 @@
import androidx.annotation.CallSuper
import androidx.benchmark.IsolationActivity
+import androidx.benchmark.Shell
import androidx.test.runner.AndroidJUnitRunner
/**
@@ -63,9 +64,14 @@
*/
@Suppress("unused") // Note: not referenced by code
public open class AndroidBenchmarkRunner : AndroidJUnitRunner() {
-
@CallSuper
override fun waitForActivitiesToComplete() {
+ // IsolationActivity may lazily use UiAutomation to access [CpuInfo.locked] on the UI
+ // thread, which on some platform versions (observed locally and in CI on API 26) can cause
+ // UiAutomation to fail to connect if that's the first use. To prevent that, initialize
+ // here, before the main thread. See also b/193064052
+ Shell.connectUiAutomation()
+
// We don't call the super method here, since we have
// an activity we intend to persist between tests
// TODO: somehow wait for every activity but IsolationActivity
diff --git a/buildSrc/src/main/kotlin/androidx/build/AndroidXPlugin.kt b/buildSrc/src/main/kotlin/androidx/build/AndroidXPlugin.kt
index 0dcb56b..d72986e 100644
--- a/buildSrc/src/main/kotlin/androidx/build/AndroidXPlugin.kt
+++ b/buildSrc/src/main/kotlin/androidx/build/AndroidXPlugin.kt
@@ -836,15 +836,22 @@
* Expected to be called in afterEvaluate when all extensions are available
*/
internal fun Project.hasAndroidTestSourceCode(): Boolean {
- // assume test modules are non-empty
- if (this.extensions.findByType(TestExtension::class.java) != null) {
- // for some reason, sourceSets.findByName("main").java.getSourceFiles().isEmpty always
- // returns true
- return true
+ // com.android.test modules keep test code in main sourceset
+ extensions.findByType(TestExtension::class.java)?.let { extension ->
+ extension.sourceSets.findByName("main")?.let { sourceSet ->
+ if (!sourceSet.java.getSourceFiles().isEmpty) return true
+ }
+ // check kotlin-android main source set
+ extensions.findByType(KotlinAndroidProjectExtension::class.java)
+ ?.sourceSets?.findByName("main")?.let {
+ if (it.kotlin.files.isNotEmpty()) return true
+ }
+ // Note, don't have to check for kotlin-multiplatform as it is not compatible with
+ // com.android.test modules
}
// check Java androidTest source set
- this.extensions.findByType(TestedExtension::class.java)
+ extensions.findByType(TestedExtension::class.java)
?.sourceSets
?.findByName("androidTest")
?.let { sourceSet ->
@@ -853,13 +860,13 @@
}
// check kotlin-android androidTest source set
- this.extensions.findByType(KotlinAndroidProjectExtension::class.java)
+ extensions.findByType(KotlinAndroidProjectExtension::class.java)
?.sourceSets?.findByName("androidTest")?.let {
if (it.kotlin.files.isNotEmpty()) return true
}
// check kotlin-multiplatform androidAndroidTest source set
- this.multiplatformExtension?.apply {
+ multiplatformExtension?.apply {
sourceSets.findByName("androidAndroidTest")?.let {
if (it.kotlin.files.isNotEmpty()) return true
}
diff --git a/buildSrc/src/main/kotlin/androidx/build/AndroidXRootPlugin.kt b/buildSrc/src/main/kotlin/androidx/build/AndroidXRootPlugin.kt
index b7e9cf1..3e175c1 100644
--- a/buildSrc/src/main/kotlin/androidx/build/AndroidXRootPlugin.kt
+++ b/buildSrc/src/main/kotlin/androidx/build/AndroidXRootPlugin.kt
@@ -25,7 +25,8 @@
import androidx.build.studio.StudioTask.Companion.registerStudioTask
import androidx.build.testConfiguration.registerOwnersServiceTasks
import androidx.build.uptodatedness.TaskUpToDateValidator
-import com.android.build.gradle.api.AndroidBasePlugin
+import com.android.build.gradle.AppPlugin
+import com.android.build.gradle.LibraryPlugin
import org.gradle.api.GradleException
import org.gradle.api.Plugin
import org.gradle.api.Project
@@ -114,10 +115,13 @@
}
)
)
- project.plugins.withType(AndroidBasePlugin::class.java) {
- buildOnServerTask.dependsOn("${project.path}:assembleRelease")
- if (!project.usingMaxDepVersions()) {
- project.afterEvaluate {
+ project.afterEvaluate {
+ if (project.plugins.hasPlugin(LibraryPlugin::class.java) ||
+ project.plugins.hasPlugin(AppPlugin::class.java)
+ ) {
+
+ buildOnServerTask.dependsOn("${project.path}:assembleRelease")
+ if (!project.usingMaxDepVersions()) {
project.agpVariants.all { variant ->
// in AndroidX, release and debug variants are essentially the same,
// so we don't run the lintRelease task on the build server
diff --git a/buildSrc/src/main/kotlin/androidx/build/LibraryVersions.kt b/buildSrc/src/main/kotlin/androidx/build/LibraryVersions.kt
index 7d2af98..3e3254a 100644
--- a/buildSrc/src/main/kotlin/androidx/build/LibraryVersions.kt
+++ b/buildSrc/src/main/kotlin/androidx/build/LibraryVersions.kt
@@ -35,11 +35,11 @@
val BIOMETRIC = Version("1.2.0-alpha03")
val BROWSER = Version("1.4.0-alpha01")
val BUILDSRC_TESTS = Version("1.0.0-alpha01")
- val CAMERA = Version("1.1.0-alpha06")
- val CAMERA_EXTENSIONS = Version("1.0.0-alpha26")
+ val CAMERA = Version("1.1.0-alpha07")
+ val CAMERA_EXTENSIONS = Version("1.0.0-alpha27")
val CAMERA_PIPE = Version("1.0.0-alpha01")
val CAMERA_VIDEO = Version("1.0.0-alpha01")
- val CAMERA_VIEW = Version("1.0.0-alpha26")
+ val CAMERA_VIEW = Version("1.0.0-alpha27")
val CARDVIEW = Version("1.1.0-alpha01")
val CAR_APP = Version("1.1.0-alpha02")
// Pre-release before confirming to the same version as the rest of the CAR_APP library group.
@@ -56,7 +56,7 @@
val CORE_ROLE = Version("1.1.0-alpha02")
val CURSORADAPTER = Version("1.1.0-alpha01")
val CUSTOMVIEW = Version("1.2.0-alpha01")
- val DATASTORE = Version("1.0.0-rc01")
+ val DATASTORE = Version("1.0.0-rc02")
val DOCUMENTFILE = Version("1.1.0-alpha01")
val DRAWERLAYOUT = Version("1.2.0-alpha01")
val DYNAMICANIMATION = Version("1.1.0-alpha04")
@@ -85,7 +85,7 @@
val LIFECYCLE_VIEWMODEL_COMPOSE = Version("1.0.0-alpha08")
val LIFECYCLE_EXTENSIONS = Version("2.2.0")
val LOADER = Version("1.2.0-alpha01")
- val MEDIA = Version("1.4.0-beta01")
+ val MEDIA = Version("1.5.0-alpha01")
val MEDIA2 = Version("1.2.0-beta01")
val MEDIAROUTER = Version("1.3.0-alpha01")
val NAVIGATION = Version("2.4.0-alpha04")
@@ -132,7 +132,7 @@
val VIEWPAGER2 = Version("1.1.0-alpha02")
val WEAR = Version("1.2.0-alpha11")
val WEAR_COMPLICATIONS_DATA = Version("1.0.0-alpha17")
- val WEAR_COMPLICATIONS_PROVIDER = Version("1.0.0-alpha17")
+ val WEAR_COMPLICATIONS_DATA_SOURCE = Version("1.0.0-alpha17")
val WEAR_COMPOSE = Version("1.0.0-alpha01")
val WEAR_INPUT = Version("1.1.0-alpha03")
val WEAR_INPUT_TESTING = WEAR_INPUT
diff --git a/buildSrc/src/main/kotlin/androidx/build/LintConfiguration.kt b/buildSrc/src/main/kotlin/androidx/build/LintConfiguration.kt
index fbb14fe..8dcd7dc 100644
--- a/buildSrc/src/main/kotlin/androidx/build/LintConfiguration.kt
+++ b/buildSrc/src/main/kotlin/androidx/build/LintConfiguration.kt
@@ -47,6 +47,15 @@
lintTask.configure { task ->
AffectedModuleDetector.configureTaskGuard(task)
}
+ afterEvaluate {
+ tasks.named("lintAnalyze").configure { task ->
+ AffectedModuleDetector.configureTaskGuard(task)
+ }
+ /* TODO: uncomment when we upgrade to AGP 7.1.0-alpha04
+ tasks.named("lintReport").configure { task ->
+ AffectedModuleDetector.configureTaskGuard(task)
+ }*/
+ }
tasks.register("lintDebug") {
it.dependsOn(lintTask)
it.enabled = false
@@ -84,6 +93,13 @@
tasks.named("lint${variant.name.capitalize(Locale.US)}").configure { task ->
AffectedModuleDetector.configureTaskGuard(task)
}
+ tasks.named("lintAnalyze${variant.name.capitalize(Locale.US)}").configure { task ->
+ AffectedModuleDetector.configureTaskGuard(task)
+ }
+ /* TODO: uncomment when we upgrade to AGP 7.1.0-alpha04
+ tasks.named("lintReport${variant.name.capitalize(Locale.US)}").configure { task ->
+ AffectedModuleDetector.configureTaskGuard(task)
+ }*/
}
}
}
diff --git a/buildSrc/src/main/kotlin/androidx/build/dackka/DackkaTask.kt b/buildSrc/src/main/kotlin/androidx/build/dackka/DackkaTask.kt
index 5b48527..0166db9 100644
--- a/buildSrc/src/main/kotlin/androidx/build/dackka/DackkaTask.kt
+++ b/buildSrc/src/main/kotlin/androidx/build/dackka/DackkaTask.kt
@@ -44,6 +44,10 @@
@InputFiles
lateinit var dependenciesClasspath: FileCollection
+ // Directory containing the code samples from framework
+ @InputFiles
+ lateinit var frameworkSamplesDir: File
+
// Directory containing the code samples
@InputFiles
lateinit var samplesDir: File
@@ -102,7 +106,11 @@
// Configuration of sources. The generated string looks like this:
// "-sourceSet -src /path/to/src -samples /path/to/samples ..."
"-sourceSet",
- "-src $sourcesDir -samples $samplesDir -classpath $classPath $includesString",
+ "-src $sourcesDir" +
+ " -samples $samplesDir" +
+ " -samples $frameworkSamplesDir" +
+ " -classpath $classPath" +
+ " $includesString",
"-offlineMode"
)
diff --git a/buildSrc/src/main/kotlin/androidx/build/dependencyTracker/AffectedModuleDetector.kt b/buildSrc/src/main/kotlin/androidx/build/dependencyTracker/AffectedModuleDetector.kt
index 99e2728..e993e52 100644
--- a/buildSrc/src/main/kotlin/androidx/build/dependencyTracker/AffectedModuleDetector.kt
+++ b/buildSrc/src/main/kotlin/androidx/build/dependencyTracker/AffectedModuleDetector.kt
@@ -508,6 +508,10 @@
":emoji2:integration-tests:init-enabled-macrobenchmark-target",
),
setOf(
+ ":wear:benchmark:integration-tests:macrobenchmark",
+ ":wear:benchmark:integration-tests:macrobenchmark-target"
+ ),
+ setOf(
":wear:compose:integration-tests:macrobenchmark",
":wear:compose:integration-tests:macrobenchmark-target"
),
diff --git a/buildSrc/src/main/kotlin/androidx/build/docs/AndroidXDocsPlugin.kt b/buildSrc/src/main/kotlin/androidx/build/docs/AndroidXDocsPlugin.kt
index a8ffc35..bc81fb6 100644
--- a/buildSrc/src/main/kotlin/androidx/build/docs/AndroidXDocsPlugin.kt
+++ b/buildSrc/src/main/kotlin/androidx/build/docs/AndroidXDocsPlugin.kt
@@ -310,6 +310,7 @@
dackkaClasspath.from(project.files(dackkaConfiguration))
destinationDir = generatedDocsDir
+ frameworkSamplesDir = File(project.rootDir, "samples")
samplesDir = unzippedSamplesSources
sourcesDir = unzippedDocsSources
docsProjectDir = File(project.rootDir, "docs-public")
@@ -559,7 +560,7 @@
}
}
-private const val DACKKA_DEPENDENCY = "com.google.devsite:dackka:0.0.7"
+private const val DACKKA_DEPENDENCY = "com.google.devsite:dackka:0.0.8"
private const val DOCLAVA_DEPENDENCY = "com.android:doclava:1.0.6"
// Allowlist for directories that should be processed by Dackka
@@ -581,7 +582,7 @@
"androidx/collection/**",
"androidx/compose/**",
"androidx/concurrent/**",
-// "androidx/contentpager/**",
+ "androidx/contentpager/**",
"androidx/coordinatorlayout/**",
// "androidx/core/**",
"androidx/cursoradapter/**",
@@ -590,23 +591,23 @@
"androidx/documentfile/**",
"androidx/drawerlayout/**",
"androidx/dynamicanimation/**",
-// "androidx/emoji/**",
-// "androidx/emoji2/**",
+ "androidx/emoji/**",
+ "androidx/emoji2/**",
"androidx/enterprise/**",
"androidx/exifinterface/**",
// "androidx/fragment/**",
"androidx/gridlayout/**",
"androidx/health/**",
"androidx/heifwriter/**",
-// "androidx/hilt/**",
+ "androidx/hilt/**",
"androidx/interpolator/**",
// "androidx/leanback/**",
-// "androidx/legacy/**",
+ "androidx/legacy/**",
"androidx/lifecycle/**",
"androidx/loader/**",
"androidx/localbroadcastmanager/**",
"androidx/media/**",
-// "androidx/media2/**",
+ "androidx/media2/**",
"androidx/mediarouter/**",
"androidx/navigation/**",
"androidx/paging/**",
@@ -622,7 +623,7 @@
"androidx/savedstate/**",
"androidx/security/**",
"androidx/sharetarget/**",
-// "androidx/slice/**",
+ "androidx/slice/**",
"androidx/slidingpanelayout/**",
"androidx/sqlite/**",
"androidx/startup/**",
@@ -631,12 +632,12 @@
"androidx/tracing/**",
"androidx/transition/**",
"androidx/tvprovider/**",
-// "androidx/vectordrawable/**",
+ "androidx/vectordrawable/**",
"androidx/versionedparcelable/**",
"androidx/viewpager/**",
"androidx/viewpager2/**",
"androidx/wear/**",
-// "androidx/webkit/**",
+ "androidx/webkit/**",
"androidx/window/**",
"androidx/work/**"
)
diff --git a/buildSrc/src/main/kotlin/androidx/build/resources/ResourceTasks.kt b/buildSrc/src/main/kotlin/androidx/build/resources/ResourceTasks.kt
index a63ac027..829a4768 100644
--- a/buildSrc/src/main/kotlin/androidx/build/resources/ResourceTasks.kt
+++ b/buildSrc/src/main/kotlin/androidx/build/resources/ResourceTasks.kt
@@ -21,6 +21,7 @@
import androidx.build.addToCheckTask
import androidx.build.checkapi.ApiLocation
import androidx.build.checkapi.getRequiredCompatibilityApiLocation
+import androidx.build.dependencyTracker.AffectedModuleDetector
import androidx.build.metalava.UpdateApiTask
import androidx.build.uptodatedness.cacheEvenIfNoOutputs
import org.gradle.api.Project
@@ -58,6 +59,7 @@
task.description = "Generates resource API files from source"
task.builtApi.set(builtApiFile)
task.apiLocation.set(builtApiLocation)
+ AffectedModuleDetector.configureTaskGuard(task)
}
// Policy: If the artifact has previously been released, e.g. has a beta or later API file
@@ -75,6 +77,7 @@
// Since apiLocation isn't a File, we have to manually set up the dependency.
task.dependsOn(generateResourceApi)
task.cacheEvenIfNoOutputs()
+ AffectedModuleDetector.configureTaskGuard(task)
}
}
@@ -97,6 +100,7 @@
checkResourceApiRelease?.let {
task.dependsOn(it)
}
+ AffectedModuleDetector.configureTaskGuard(task)
}
@Suppress("UnstableApiUsage") // flatMap
@@ -119,6 +123,7 @@
// compatible
task.dependsOn(it)
}
+ AffectedModuleDetector.configureTaskGuard(task)
}
// Ensure that this task runs as part of "updateApi" task from MetalavaTasks.
diff --git a/buildSrc/src/main/kotlin/androidx/build/testConfiguration/TestSuiteConfiguration.kt b/buildSrc/src/main/kotlin/androidx/build/testConfiguration/TestSuiteConfiguration.kt
index f723ca4..0fb51b6 100644
--- a/buildSrc/src/main/kotlin/androidx/build/testConfiguration/TestSuiteConfiguration.kt
+++ b/buildSrc/src/main/kotlin/androidx/build/testConfiguration/TestSuiteConfiguration.kt
@@ -119,18 +119,24 @@
* alternative project. Default is for the project to register the new config task to itself
*/
fun Project.addAppApkToTestConfigGeneration(overrideProject: Project = this) {
+ if (project.isMacrobenchmarkTarget()) {
+ return
+ }
// TODO(aurimas): migrate away from this when upgrading to AGP 7.1.0-alpha03 or newer
@Suppress("DEPRECATION")
extensions.getByType<
com.android.build.api.extension.ApplicationAndroidComponentsExtension
>().apply {
- onVariants(selector().withBuildType("debug")) { debugVariant ->
- overrideProject.tasks.withType(GenerateTestConfigurationTask::class.java)
- .configureEach {
- it.appFolder.set(debugVariant.artifacts.get(SingleArtifact.APK))
- it.appLoader.set(debugVariant.artifacts.getBuiltArtifactsLoader())
- it.appProjectPath.set(overrideProject.path)
- }
+ onVariants(selector().withBuildType("debug")) { appVariant ->
+ overrideProject.tasks.named(
+ "${AndroidXPlugin.GENERATE_TEST_CONFIGURATION_TASK}" +
+ "${appVariant.name}AndroidTest"
+ ) { configTask ->
+ configTask as GenerateTestConfigurationTask
+ configTask.appFolder.set(appVariant.artifacts.get(SingleArtifact.APK))
+ configTask.appLoader.set(appVariant.artifacts.getBuiltArtifactsLoader())
+ configTask.appProjectPath.set(overrideProject.path)
+ }
}
}
}
@@ -340,7 +346,7 @@
this.rootProject.tasks.findByName(
AndroidXPlugin.ZIP_CONSTRAINED_TEST_CONFIGS_WITH_APKS_TASK
)!!.dependsOn(configTask)
- } else if (path.endsWith("macrobenchmark-target")) {
+ } else if (isMacrobenchmarkTarget()) {
configTask.configure { task ->
task.appFolder.set(artifacts.get(SingleArtifact.APK))
task.appLoader.set(artifacts.getBuiltArtifactsLoader())
@@ -349,6 +355,13 @@
}
}
+/**
+ * Tells whether this project is the macrobenchmark-target project
+ */
+fun Project.isMacrobenchmarkTarget(): Boolean {
+ return path.endsWith("macrobenchmark-target")
+}
+
fun Project.configureTestConfigGeneration(baseExtension: BaseExtension) {
// TODO(aurimas): migrate away from this when upgrading to AGP 7.1.0-alpha03 or newer
@Suppress("DEPRECATION")
@@ -381,7 +394,7 @@
)
}
path.endsWith("macrobenchmark") ||
- path.endsWith("macrobenchmark-target") -> {
+ isMacrobenchmarkTarget() -> {
configureMacrobenchmarkConfigTask(
androidTest.name,
androidTest.artifacts,
diff --git a/busytown/androidx-studio-integration.sh b/busytown/androidx-studio-integration.sh
index d4193f5..4ba41d1 100755
--- a/busytown/androidx-studio-integration.sh
+++ b/busytown/androidx-studio-integration.sh
@@ -24,7 +24,7 @@
fi
TOOLS_DIR=$STUDIO_DIR/tools
-gw=$TOOLS_DIR/gradlew
+gw="$TOOLS_DIR/gradlew -Dorg.gradle.jvmargs=-Xmx24g"
function buildStudio() {
STUDIO_BUILD_LOG="$OUT_DIR/studio.log"
@@ -46,8 +46,9 @@
function buildAndroidx() {
LOG_PROCESSOR="$SCRIPT_DIR/../development/build_log_processor.sh"
- properties="-Pandroidx.summarizeStderr --no-daemon -Pandroidx.allWarningsAsErrors -Pandroid.experimental.runLintInProcess=false"
- "$LOG_PROCESSOR" $gw $properties -p frameworks/support listTaskOutputs bOS -x verifyDependencyVersions --stacktrace -PverifyUpToDate --profile
+ properties="-Pandroidx.summarizeStderr --no-daemon -Pandroidx.allWarningsAsErrors"
+ # runErrorProne is disabled due to I77d9800990e2a46648f7ed2713c54398cd798a0d in AGP
+ "$LOG_PROCESSOR" $gw $properties -p frameworks/support listTaskOutputs bOS -x verifyDependencyVersions -x runErrorProne --stacktrace -PverifyUpToDate --profile
$SCRIPT_DIR/impl/parse_profile_htmls.sh
}
diff --git a/camera/camera-extensions/build.gradle b/camera/camera-extensions/build.gradle
index c9cfa28..03abc84 100644
--- a/camera/camera-extensions/build.gradle
+++ b/camera/camera-extensions/build.gradle
@@ -50,6 +50,7 @@
androidTestImplementation(libs.testCore)
androidTestImplementation(libs.testRules)
androidTestImplementation(libs.dexmakerMockito, excludes.bytebuddy) // DexMaker has its own MockMaker
+ androidTestImplementation(libs.kotlinCoroutinesAndroid)
androidTestImplementation(libs.kotlinStdlib)
androidTestImplementation(libs.mockitoCore, excludes.bytebuddy) // DexMaker has its own MockMaker
androidTestImplementation(libs.truth)
diff --git a/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/ExtensionsManagerTest.kt b/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/ExtensionsManagerTest.kt
index 6a3e7a6..36fb556 100644
--- a/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/ExtensionsManagerTest.kt
+++ b/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/ExtensionsManagerTest.kt
@@ -360,6 +360,17 @@
instrumentation.runOnMainSync {
val fakeLifecycleOwner = FakeLifecycleOwner()
+
+ // This test works only if the camera is the same no matter running normal or
+ // extension modes.
+ val normalCamera =
+ cameraProvider.bindToLifecycle(fakeLifecycleOwner, baseCameraSelector)
+ val extensionCamera = cameraProvider.bindToLifecycle(
+ fakeLifecycleOwner,
+ extensionCameraSelector
+ )
+ assumeTrue(extensionCamera == normalCamera)
+
// Binds a use case with the basic camera selector first.
cameraProvider.bindToLifecycle(fakeLifecycleOwner, baseCameraSelector, FakeUseCase())
diff --git a/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/internal/ImageCaptureConfigProviderTest.kt b/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/internal/ImageCaptureConfigProviderTest.kt
new file mode 100644
index 0000000..0eeb10e
--- /dev/null
+++ b/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/internal/ImageCaptureConfigProviderTest.kt
@@ -0,0 +1,268 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.extensions.internal
+
+import android.content.Context
+import android.graphics.ImageFormat
+import android.hardware.camera2.CameraCharacteristics
+import android.hardware.camera2.CaptureRequest
+import android.util.Pair
+import android.util.Size
+import androidx.camera.camera2.interop.Camera2CameraInfo
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.ImageCapture
+import androidx.camera.core.impl.ImageOutputConfig
+import androidx.camera.extensions.ExtensionMode
+import androidx.camera.extensions.impl.CaptureProcessorImpl
+import androidx.camera.extensions.impl.CaptureStageImpl
+import androidx.camera.extensions.impl.ImageCaptureExtenderImpl
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.camera.testing.CameraUtil
+import androidx.camera.testing.fakes.FakeLifecycleOwner
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import org.junit.After
+import org.junit.Assume.assumeTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentMatchers.any
+import org.mockito.Mockito
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.timeout
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoMoreInteractions
+import java.util.concurrent.TimeUnit
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+
+class ImageCaptureConfigProviderTest {
+ @get:Rule
+ val useCamera = CameraUtil.grantCameraPermissionAndPreTest()
+
+ private val context = ApplicationProvider.getApplicationContext<Context>()
+
+ // Tests in this class majorly use mock objects to run the test. No matter which extension
+ // mode is use, it should not affect the test results.
+ @ExtensionMode.Mode
+ private val extensionMode = ExtensionMode.NONE
+ private var cameraSelector = CameraSelector.Builder().build()
+ private val fakeLifecycleOwner = FakeLifecycleOwner()
+
+ private lateinit var cameraProvider: ProcessCameraProvider
+
+ @Before
+ fun setUp() {
+ assumeTrue(CameraUtil.deviceHasCamera())
+
+ cameraProvider = ProcessCameraProvider.getInstance(context)[10000, TimeUnit.MILLISECONDS]
+ fakeLifecycleOwner.startAndResume()
+ }
+
+ @After
+ fun cleanUp() {
+ if (::cameraProvider.isInitialized) {
+ cameraProvider.shutdown()[10000, TimeUnit.MILLISECONDS]
+ }
+ }
+
+ @Test
+ @MediumTest
+ fun extenderLifeCycleTest_noMoreGetCaptureStagesBeforeAndAfterInitDeInit(): Unit = runBlocking {
+ val mockImageCaptureExtenderImpl = mock(ImageCaptureExtenderImpl::class.java)
+ val captureStages = mutableListOf<CaptureStageImpl>()
+
+ captureStages.add(FakeCaptureStage())
+
+ Mockito.`when`(mockImageCaptureExtenderImpl.captureStages).thenReturn(captureStages)
+ Mockito.`when`(mockImageCaptureExtenderImpl.captureProcessor).thenReturn(
+ mock(CaptureProcessorImpl::class.java)
+ )
+
+ val imageCapture = createImageCaptureWithExtenderImpl(mockImageCaptureExtenderImpl)
+
+ withContext(Dispatchers.Main) {
+ cameraProvider.bindToLifecycle(fakeLifecycleOwner, cameraSelector, imageCapture)
+ }
+
+ // To verify the event callbacks in order, and to verification of the getCaptureStages()
+ // is also used to wait for the capture session created. The test for the unbind
+ // would come after the capture session was created.
+ verify(mockImageCaptureExtenderImpl, timeout(3000)).captureProcessor
+ verify(mockImageCaptureExtenderImpl, timeout(3000)).maxCaptureStage
+
+ // getSupportedResolutions supported since version 1.1
+ val version = ExtensionVersion.getRuntimeVersion()
+ if (version != null && version >= Version.VERSION_1_1) {
+ verify(mockImageCaptureExtenderImpl, timeout(3000)).supportedResolutions
+ }
+
+ val inOrder = Mockito.inOrder(mockImageCaptureExtenderImpl)
+ inOrder.verify(mockImageCaptureExtenderImpl, timeout(3000)).onInit(
+ any(String::class.java), any(CameraCharacteristics::class.java), any()
+ )
+ inOrder.verify(mockImageCaptureExtenderImpl, timeout(3000).atLeastOnce()).captureStages
+
+ withContext(Dispatchers.Main) {
+ // Unbind the use case to test the onDeInit.
+ cameraProvider.unbind(imageCapture)
+ }
+
+ // To verify the deInit should been called.
+ inOrder.verify(mockImageCaptureExtenderImpl, timeout(3000)).onDeInit()
+
+ // To verify there is no any other calls on the mock.
+ verifyNoMoreInteractions(mockImageCaptureExtenderImpl)
+ }
+
+ @Test
+ @MediumTest
+ fun extenderLifeCycleTest_noMoreCameraEventCallbacksBeforeAndAfterInitDeInit(): Unit =
+ runBlocking {
+ val mockImageCaptureExtenderImpl = mock(ImageCaptureExtenderImpl::class.java)
+ val captureStages = mutableListOf<CaptureStageImpl>()
+
+ captureStages.add(FakeCaptureStage())
+
+ Mockito.`when`(mockImageCaptureExtenderImpl.captureStages).thenReturn(captureStages)
+ Mockito.`when`(mockImageCaptureExtenderImpl.captureProcessor).thenReturn(
+ mock(CaptureProcessorImpl::class.java)
+ )
+
+ val imageCapture = createImageCaptureWithExtenderImpl(mockImageCaptureExtenderImpl)
+
+ // Binds the use case to trigger the camera pipeline operations
+ withContext(Dispatchers.Main) {
+ cameraProvider.bindToLifecycle(fakeLifecycleOwner, cameraSelector, imageCapture)
+ }
+
+ // To verify the event callbacks in order, and to verification of the onEnableSession()
+ // is also used to wait for the capture session created. The test for the unbind
+ // would come after the capture session was created.
+ verify(mockImageCaptureExtenderImpl, timeout(3000)).captureProcessor
+ verify(mockImageCaptureExtenderImpl, timeout(3000)).maxCaptureStage
+
+ // getSupportedResolutions supported since version 1.1
+ val version = ExtensionVersion.getRuntimeVersion()
+ if (version != null && version >= Version.VERSION_1_1) {
+ verify(mockImageCaptureExtenderImpl, timeout(3000)).supportedResolutions
+ }
+
+ val inOrder = Mockito.inOrder(mockImageCaptureExtenderImpl)
+ inOrder.verify(mockImageCaptureExtenderImpl, timeout(3000)).onInit(
+ any(String::class.java),
+ any(CameraCharacteristics::class.java),
+ any(Context::class.java)
+ )
+ inOrder.verify(mockImageCaptureExtenderImpl, timeout(3000).atLeastOnce())
+ .onPresetSession()
+ inOrder.verify(mockImageCaptureExtenderImpl, timeout(3000).atLeastOnce())
+ .onEnableSession()
+
+ withContext(Dispatchers.Main) {
+ // Unbind the use case to test the onDisableSession and onDeInit.
+ cameraProvider.unbind(imageCapture)
+ }
+
+ // To verify the onDisableSession and onDeInit.
+ inOrder.verify(mockImageCaptureExtenderImpl, timeout(3000).atLeastOnce())
+ .onDisableSession()
+ inOrder.verify(mockImageCaptureExtenderImpl, timeout(3000)).onDeInit()
+
+ // This test item only focus on onPreset, onEnable and onDisable callback testing,
+ // ignore all the getCaptureStages callbacks.
+ verify(mockImageCaptureExtenderImpl, Mockito.atLeastOnce()).captureStages
+
+ // To verify there is no any other calls on the mock.
+ verifyNoMoreInteractions(mockImageCaptureExtenderImpl)
+ }
+
+ @Test
+ @MediumTest
+ fun canSetSupportedResolutionsToConfigTest(): Unit = runBlocking {
+ assumeTrue(CameraUtil.deviceHasCamera())
+
+ // getSupportedResolutions supported since version 1.1
+ val version = ExtensionVersion.getRuntimeVersion()
+ assumeTrue(version != null && version >= Version.VERSION_1_1)
+
+ val mockImageCaptureExtenderImpl = mock(ImageCaptureExtenderImpl::class.java)
+
+ Mockito.`when`(mockImageCaptureExtenderImpl.isExtensionAvailable(any(), any()))
+ .thenReturn(true)
+
+ val targetFormatResolutionsPairList = generateImageCaptureSupportedResolutions()
+ Mockito.`when`(mockImageCaptureExtenderImpl.supportedResolutions).thenReturn(
+ targetFormatResolutionsPairList
+ )
+
+ val preview = createImageCaptureWithExtenderImpl(mockImageCaptureExtenderImpl)
+
+ withContext(Dispatchers.Main) {
+ cameraProvider.bindToLifecycle(fakeLifecycleOwner, cameraSelector, preview)
+ }
+
+ val resultFormatResolutionsPairList = (preview.currentConfig as ImageOutputConfig)
+ .supportedResolutions
+
+ // Checks the result and target pair lists are the same
+ for (resultPair in resultFormatResolutionsPairList) {
+ val firstTargetSizes = targetFormatResolutionsPairList.filter {
+ it.first == resultPair.first
+ }.map {
+ it.second
+ }.first()
+
+ Truth.assertThat(mutableListOf(resultPair.second)).containsExactly(firstTargetSizes)
+ }
+ }
+
+ private fun createImageCaptureWithExtenderImpl(impl: ImageCaptureExtenderImpl) =
+ ImageCapture.Builder().also {
+ val cameraInfo = cameraSelector.filter(cameraProvider.availableCameraInfos)[0]
+ ImageCaptureConfigProvider(extensionMode, cameraInfo, context).apply {
+ updateBuilderConfig(it, extensionMode, impl, context)
+ }
+ }.build()
+
+ private fun generateImageCaptureSupportedResolutions(): List<Pair<Int, Array<Size>>> {
+ val formatResolutionsPairList = mutableListOf<Pair<Int, Array<Size>>>()
+ val cameraInfo = cameraProvider.availableCameraInfos[0]
+ val characteristics = Camera2CameraInfo.extractCameraCharacteristics(cameraInfo)
+ val map = characteristics[CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP]
+
+ // Retrieves originally supported resolutions from CameraCharacteristics for JPEG
+ // format to return.
+ map?.getOutputSizes(ImageFormat.JPEG)?.let {
+ formatResolutionsPairList.add(Pair.create(ImageFormat.JPEG, it))
+ }
+
+ return formatResolutionsPairList
+ }
+
+ private class FakeCaptureStage : CaptureStageImpl {
+ override fun getId() = 0
+ override fun getParameters(): List<Pair<CaptureRequest.Key<*>, Any>> = emptyList()
+ }
+}
diff --git a/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/internal/PreviewConfigProviderTest.kt b/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/internal/PreviewConfigProviderTest.kt
new file mode 100644
index 0000000..4e84433
--- /dev/null
+++ b/camera/camera-extensions/src/androidTest/java/androidx/camera/extensions/internal/PreviewConfigProviderTest.kt
@@ -0,0 +1,330 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.extensions.internal
+
+import android.content.Context
+import android.graphics.ImageFormat
+import android.graphics.SurfaceTexture
+import android.hardware.camera2.CameraCharacteristics
+import android.hardware.camera2.CaptureRequest
+import android.hardware.camera2.TotalCaptureResult
+import android.media.Image
+import android.util.Pair
+import android.util.Size
+import androidx.camera.camera2.interop.Camera2CameraInfo
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.Preview
+import androidx.camera.core.impl.ImageOutputConfig
+import androidx.camera.extensions.ExtensionMode
+import androidx.camera.extensions.impl.CaptureStageImpl
+import androidx.camera.extensions.impl.PreviewExtenderImpl
+import androidx.camera.extensions.impl.PreviewImageProcessorImpl
+import androidx.camera.extensions.impl.RequestUpdateProcessorImpl
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.camera.testing.CameraUtil
+import androidx.camera.testing.SurfaceTextureProvider
+import androidx.camera.testing.SurfaceTextureProvider.SurfaceTextureCallback
+import androidx.camera.testing.fakes.FakeLifecycleOwner
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import org.junit.After
+import org.junit.Assume.assumeTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.ArgumentCaptor
+import org.mockito.ArgumentMatchers
+import org.mockito.ArgumentMatchers.any
+import org.mockito.Mockito
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.timeout
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.verifyNoMoreInteractions
+import java.util.concurrent.TimeUnit
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class PreviewConfigProviderTest {
+ @get:Rule
+ val useCamera = CameraUtil.grantCameraPermissionAndPreTest()
+
+ private val context = ApplicationProvider.getApplicationContext<Context>()
+
+ private val surfaceTextureCallback: SurfaceTextureCallback =
+ object : SurfaceTextureCallback {
+ override fun onSurfaceTextureReady(
+ surfaceTexture: SurfaceTexture,
+ resolution: Size
+ ) {
+ // No-op.
+ }
+
+ override fun onSafeToRelease(surfaceTexture: SurfaceTexture) {
+ // No-op.
+ }
+ }
+
+ // Tests in this class majorly use mock objects to run the test. No matter which extension
+ // mode is use, it should not affect the test results.
+ @ExtensionMode.Mode
+ private val extensionMode = ExtensionMode.NONE
+ private var cameraSelector = CameraSelector.Builder().build()
+ private val fakeLifecycleOwner = FakeLifecycleOwner()
+
+ private lateinit var cameraProvider: ProcessCameraProvider
+
+ @Before
+ fun setUp() {
+ assumeTrue(CameraUtil.deviceHasCamera())
+
+ cameraProvider = ProcessCameraProvider.getInstance(context)[10000, TimeUnit.MILLISECONDS]
+ fakeLifecycleOwner.startAndResume()
+ }
+
+ @After
+ fun cleanUp() {
+ if (::cameraProvider.isInitialized) {
+ cameraProvider.shutdown()[10000, TimeUnit.MILLISECONDS]
+ }
+ }
+
+ @Test
+ fun extenderLifeCycleTest_noMoreInvokeBeforeAndAfterInitDeInit(): Unit = runBlocking {
+ val mockPreviewExtenderImpl = mock(PreviewExtenderImpl::class.java)
+
+ Mockito.`when`(mockPreviewExtenderImpl.processorType).thenReturn(
+ PreviewExtenderImpl.ProcessorType.PROCESSOR_TYPE_IMAGE_PROCESSOR
+ )
+ Mockito.`when`(mockPreviewExtenderImpl.processor)
+ .thenReturn(mock(PreviewImageProcessorImpl::class.java))
+ Mockito.`when`(
+ mockPreviewExtenderImpl.isExtensionAvailable(
+ any(String::class.java),
+ any(CameraCharacteristics::class.java)
+ )
+ ).thenReturn(true)
+
+ val preview = createPreviewWithExtenderImpl(mockPreviewExtenderImpl)
+
+ withContext(Dispatchers.Main) {
+ // To set the update listener and Preview will change to active state.
+ preview.setSurfaceProvider(
+ SurfaceTextureProvider.createSurfaceTextureProvider(surfaceTextureCallback)
+ )
+
+ cameraProvider.bindToLifecycle(fakeLifecycleOwner, cameraSelector, preview)
+ }
+
+ // To verify the call in order after bind to life cycle, and to verification of the
+ // getCaptureStages() is also used to wait for the capture session created. The test for
+ // the unbind would come after the capture session was created. Ignore any of the calls
+ // unrelated to the ExtenderStateListener.
+ verify(mockPreviewExtenderImpl, timeout(3000)).processorType
+ verify(mockPreviewExtenderImpl, timeout(3000)).processor
+
+ // getSupportedResolutions supported since version 1.1
+ val version = ExtensionVersion.getRuntimeVersion()
+ if (version != null && version >= Version.VERSION_1_1) {
+ verify(mockPreviewExtenderImpl, timeout(3000)).supportedResolutions
+ }
+
+ val inOrder = Mockito.inOrder(*Mockito.ignoreStubs(mockPreviewExtenderImpl))
+ inOrder.verify(mockPreviewExtenderImpl, timeout(3000)).onInit(
+ any(String::class.java),
+ any(CameraCharacteristics::class.java),
+ any(Context::class.java)
+ )
+
+ inOrder.verify(mockPreviewExtenderImpl, timeout(3000)).onPresetSession()
+ inOrder.verify(mockPreviewExtenderImpl, timeout(3000)).onEnableSession()
+ inOrder.verify(mockPreviewExtenderImpl, timeout(3000)).captureStage
+
+ withContext(Dispatchers.Main) {
+ // Unbind the use case to test the onDisableSession and onDeInit.
+ cameraProvider.unbind(preview)
+ }
+
+ // To verify the onDisableSession and onDeInit.
+ inOrder.verify(mockPreviewExtenderImpl, timeout(3000)).onDisableSession()
+ inOrder.verify(mockPreviewExtenderImpl, timeout(3000)).onDeInit()
+
+ // To verify there is no any other calls on the mock.
+ verifyNoMoreInteractions(mockPreviewExtenderImpl)
+ }
+
+ @Test
+ fun getCaptureStagesTest_shouldSetToRepeatingRequest(): Unit = runBlocking {
+ // Set up a result for getCaptureStages() testing.
+ val fakeCaptureStageImpl: CaptureStageImpl = FakeCaptureStageImpl()
+ val mockPreviewExtenderImpl = mock(PreviewExtenderImpl::class.java)
+ val mockRequestUpdateProcessorImpl = mock(RequestUpdateProcessorImpl::class.java)
+
+ // The mock an RequestUpdateProcessorImpl to capture the returned TotalCaptureResult
+ Mockito.`when`(mockPreviewExtenderImpl.processorType).thenReturn(
+ PreviewExtenderImpl.ProcessorType.PROCESSOR_TYPE_REQUEST_UPDATE_ONLY
+ )
+ Mockito.`when`(mockPreviewExtenderImpl.processor).thenReturn(mockRequestUpdateProcessorImpl)
+ Mockito.`when`(
+ mockPreviewExtenderImpl.isExtensionAvailable(
+ any(String::class.java),
+ any(CameraCharacteristics::class.java)
+ )
+ ).thenReturn(true)
+ Mockito.`when`(mockPreviewExtenderImpl.captureStage).thenReturn(fakeCaptureStageImpl)
+
+ val preview = createPreviewWithExtenderImpl(mockPreviewExtenderImpl)
+
+ withContext(Dispatchers.Main) {
+ // To set the update listener and Preview will change to active state.
+ preview.setSurfaceProvider(
+ SurfaceTextureProvider.createSurfaceTextureProvider(surfaceTextureCallback)
+ )
+
+ cameraProvider.bindToLifecycle(fakeLifecycleOwner, cameraSelector, preview)
+ }
+
+ val captureResultArgumentCaptor = ArgumentCaptor.forClass(
+ TotalCaptureResult::class.java
+ )
+ verify(mockRequestUpdateProcessorImpl, timeout(3000).atLeastOnce()).process(
+ captureResultArgumentCaptor.capture()
+ )
+
+ // TotalCaptureResult might be captured multiple times. Only care to get one instance of
+ // it, since they should all have the same value for the tested key
+ val totalCaptureResult = captureResultArgumentCaptor.value
+
+ // To verify the capture result should include the parameter of the getCaptureStages().
+ val parameters = fakeCaptureStageImpl.parameters
+ for (parameter: Pair<CaptureRequest.Key<*>?, Any> in parameters) {
+ assertThat(totalCaptureResult.request[parameter.first] == parameter.second)
+ }
+ }
+
+ @Test
+ fun processShouldBeInvoked_typeImageProcessor(): Unit = runBlocking {
+ // The type image processor will invoke PreviewImageProcessor.process()
+ val mockPreviewImageProcessorImpl = mock(PreviewImageProcessorImpl::class.java)
+ val mockPreviewExtenderImpl = mock(PreviewExtenderImpl::class.java)
+
+ Mockito.`when`(mockPreviewExtenderImpl.processor).thenReturn(mockPreviewImageProcessorImpl)
+ Mockito.`when`(mockPreviewExtenderImpl.processorType)
+ .thenReturn(PreviewExtenderImpl.ProcessorType.PROCESSOR_TYPE_IMAGE_PROCESSOR)
+ Mockito.`when`(
+ mockPreviewExtenderImpl.isExtensionAvailable(
+ any(String::class.java),
+ any(CameraCharacteristics::class.java)
+ )
+ ).thenReturn(true)
+
+ val preview = createPreviewWithExtenderImpl(mockPreviewExtenderImpl)
+
+ withContext(Dispatchers.Main) {
+ // To set the update listener and Preview will change to active state.
+ preview.setSurfaceProvider(
+ SurfaceTextureProvider.createSurfaceTextureProvider(surfaceTextureCallback)
+ )
+
+ cameraProvider.bindToLifecycle(fakeLifecycleOwner, cameraSelector, preview)
+ }
+
+ // To verify the process() method was invoked with non-null TotalCaptureResult input.
+ verify(mockPreviewImageProcessorImpl, Mockito.timeout(3000).atLeastOnce())
+ .process(any(Image::class.java), ArgumentMatchers.any(TotalCaptureResult::class.java))
+ }
+
+ @Test
+ @MediumTest
+ fun canSetSupportedResolutionsToConfigTest(): Unit = runBlocking {
+ assumeTrue(CameraUtil.deviceHasCamera())
+
+ // getSupportedResolutions supported since version 1.1
+ val version = ExtensionVersion.getRuntimeVersion()
+ assumeTrue(version != null && version >= Version.VERSION_1_1)
+
+ val mockPreviewExtenderImpl = mock(PreviewExtenderImpl::class.java)
+
+ Mockito.`when`(mockPreviewExtenderImpl.isExtensionAvailable(any(), any())).thenReturn(true)
+ Mockito.`when`(mockPreviewExtenderImpl.processorType).thenReturn(
+ PreviewExtenderImpl.ProcessorType.PROCESSOR_TYPE_NONE
+ )
+
+ val targetFormatResolutionsPairList = generatePreviewSupportedResolutions()
+ Mockito.`when`(mockPreviewExtenderImpl.supportedResolutions).thenReturn(
+ targetFormatResolutionsPairList
+ )
+
+ val preview = createPreviewWithExtenderImpl(mockPreviewExtenderImpl)
+
+ withContext(Dispatchers.Main) {
+ cameraProvider.bindToLifecycle(fakeLifecycleOwner, cameraSelector, preview)
+ }
+
+ val resultFormatResolutionsPairList = (preview.currentConfig as ImageOutputConfig)
+ .supportedResolutions
+
+ // Checks the result and target pair lists are the same
+ for (resultPair in resultFormatResolutionsPairList) {
+ val firstTargetSizes = targetFormatResolutionsPairList.filter {
+ it.first == resultPair.first
+ }.map {
+ it.second
+ }.first()
+
+ assertThat(mutableListOf(resultPair.second)).containsExactly(firstTargetSizes)
+ }
+ }
+
+ private fun createPreviewWithExtenderImpl(impl: PreviewExtenderImpl) =
+ Preview.Builder().also {
+ val cameraInfo = cameraSelector.filter(cameraProvider.availableCameraInfos)[0]
+ PreviewConfigProvider(extensionMode, cameraInfo, context).apply {
+ updateBuilderConfig(it, extensionMode, impl, context)
+ }
+ }.build()
+
+ private fun generatePreviewSupportedResolutions(): List<Pair<Int, Array<Size>>> {
+ val formatResolutionsPairList = mutableListOf<Pair<Int, Array<Size>>>()
+ val cameraInfo = cameraProvider.availableCameraInfos[0]
+ val characteristics = Camera2CameraInfo.extractCameraCharacteristics(cameraInfo)
+ val map = characteristics[CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP]
+
+ // Retrieves originally supported resolutions from CameraCharacteristics for PRIVATE
+ // format to return.
+ map?.getOutputSizes(ImageFormat.PRIVATE)?.let {
+ formatResolutionsPairList.add(Pair.create(ImageFormat.PRIVATE, it))
+ }
+
+ return formatResolutionsPairList
+ }
+
+ private class FakeCaptureStageImpl : CaptureStageImpl {
+ override fun getId() = 0
+ override fun getParameters(): List<Pair<CaptureRequest.Key<*>, Any>> = mutableListOf(
+ Pair.create(
+ CaptureRequest.CONTROL_EFFECT_MODE,
+ CaptureRequest.CONTROL_EFFECT_MODE_SEPIA
+ )
+ )
+ }
+}
diff --git a/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/ImageCaptureConfigProvider.java b/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/ImageCaptureConfigProvider.java
index e6c2dd4..9eb8711 100644
--- a/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/ImageCaptureConfigProvider.java
+++ b/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/ImageCaptureConfigProvider.java
@@ -122,7 +122,7 @@
/**
* Update extension related configs to the builder.
*/
- private void updateBuilderConfig(@NonNull ImageCapture.Builder builder,
+ void updateBuilderConfig(@NonNull ImageCapture.Builder builder,
@ExtensionMode.Mode int effectMode, @NonNull ImageCaptureExtenderImpl impl,
@NonNull Context context) {
CaptureProcessorImpl captureProcessor = impl.getCaptureProcessor();
diff --git a/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/PreviewConfigProvider.java b/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/PreviewConfigProvider.java
index bac5fa6..8628680 100644
--- a/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/PreviewConfigProvider.java
+++ b/camera/camera-extensions/src/main/java/androidx/camera/extensions/internal/PreviewConfigProvider.java
@@ -116,7 +116,7 @@
/**
* Update extension related configs to the builder.
*/
- private void updateBuilderConfig(@NonNull Preview.Builder builder,
+ void updateBuilderConfig(@NonNull Preview.Builder builder,
@ExtensionMode.Mode int effectMode, @NonNull PreviewExtenderImpl impl,
@NonNull Context context) {
PreviewEventAdapter previewEventAdapter;
diff --git a/camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderTest.kt b/camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderTest.kt
index e6fabf6..190550f 100644
--- a/camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderTest.kt
+++ b/camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderTest.kt
@@ -445,7 +445,7 @@
val captor = ArgumentCaptor.forClass(VideoRecordEvent::class.java)
verify(videoRecordEventListener, atLeastOnce()).accept(captor.capture())
- assertThat(captor.value.eventType).isEqualTo(VideoRecordEvent.EventType.FINALIZE)
+ assertThat(captor.value.eventType).isEqualTo(VideoRecordEvent.EVENT_TYPE_FINALIZE)
val finalize = captor.value as VideoRecordEvent.Finalize
assertThat(finalize.error).isEqualTo(VideoRecordEvent.ERROR_FILE_SIZE_LIMIT_REACHED)
assertThat(file.length()).isGreaterThan(0)
@@ -526,6 +526,11 @@
@Test
fun pause_beforeSurfaceRequested() {
+ // Skip for b/192995523
+ assumeFalse(
+ "MediaMuxer fails to stop if there's no data provided.",
+ Build.DEVICE.equals("sailfish", true)
+ )
clearInvocations(videoRecordEventListener)
val file = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
val outputOptions = FileOutputOptions.builder().setFile(file).build()
diff --git a/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoCaptureRotationTest.kt b/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoCaptureRotationTest.kt
deleted file mode 100644
index 6aed56b..0000000
--- a/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoCaptureRotationTest.kt
+++ /dev/null
@@ -1,200 +0,0 @@
-/*
- * Copyright 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * 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.video
-
-import android.Manifest
-import android.content.ContentResolver
-import android.content.Context
-import android.media.MediaMetadataRetriever
-import android.media.MediaRecorder
-import android.net.Uri
-import android.os.Build
-import android.view.Surface
-import androidx.camera.camera2.Camera2Config
-import androidx.camera.core.CameraSelector
-import androidx.camera.core.CameraX
-import androidx.camera.core.VideoCapture
-import androidx.camera.core.impl.utils.CameraOrientationUtil
-import androidx.camera.core.impl.utils.executor.CameraXExecutors
-import androidx.camera.core.internal.CameraUseCaseAdapter
-import androidx.camera.testing.AudioUtil
-import androidx.camera.testing.CameraUtil
-import androidx.test.core.app.ApplicationProvider
-import androidx.test.filters.LargeTest
-import androidx.test.platform.app.InstrumentationRegistry
-import androidx.test.rule.GrantPermissionRule
-import com.google.common.truth.Truth.assertThat
-import com.nhaarman.mockitokotlin2.any
-import org.junit.After
-import org.junit.Assume
-import org.junit.Before
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.junit.runners.Parameterized
-import org.mockito.ArgumentCaptor
-import org.mockito.Mockito
-import java.io.File
-import java.io.IOException
-import java.util.ArrayList
-import java.util.concurrent.ExecutionException
-import java.util.concurrent.TimeUnit
-import java.util.concurrent.TimeoutException
-
-@LargeTest
-@RunWith(Parameterized::class)
-class VideoCaptureRotationTest(
- private var cameraSelector: CameraSelector,
- private var targetRotation: Int
-) {
- companion object {
- @JvmStatic
- @Parameterized.Parameters
- fun data(): Collection<Array<Any>> {
- val result: MutableList<Array<Any>> = ArrayList()
- result.add(arrayOf(CameraSelector.DEFAULT_BACK_CAMERA, Surface.ROTATION_90))
- result.add(arrayOf(CameraSelector.DEFAULT_BACK_CAMERA, Surface.ROTATION_180))
- result.add(arrayOf(CameraSelector.DEFAULT_FRONT_CAMERA, Surface.ROTATION_90))
- result.add(arrayOf(CameraSelector.DEFAULT_FRONT_CAMERA, Surface.ROTATION_180))
- return result
- }
- }
-
- private val instrumentation = InstrumentationRegistry.getInstrumentation()
-
- @get:Rule
- val cameraRule = CameraUtil.grantCameraPermissionAndPreTest()
-
- @get:Rule
- val runtimePermissionRule: GrantPermissionRule = GrantPermissionRule.grant(
- Manifest.permission.WRITE_EXTERNAL_STORAGE,
- Manifest.permission.RECORD_AUDIO
- )
-
- private lateinit var cameraUseCaseAdapter: CameraUseCaseAdapter
- private lateinit var context: Context
- private lateinit var contentResolver: ContentResolver
- private lateinit var videoUseCase: VideoCapture
- private lateinit var callback: VideoCapture.OnVideoSavedCallback
- private lateinit var outputFileResultsArgumentCaptor:
- ArgumentCaptor<VideoCapture.OutputFileResults>
-
- @Before
- fun setUp() {
- // TODO(b/168175357): Fix VideoCaptureTest problems on CuttleFish API 29
- Assume.assumeFalse(
- "Cuttlefish has MediaCodec dequeueInput/Output buffer fails issue. Unable to test.",
- Build.MODEL.contains("Cuttlefish") && Build.VERSION.SDK_INT == 29
- )
-
- // TODO(b/168187087): Video: Unable to record Video on Pixel 1 API 26,27 when only
- // VideoCapture is bound
- Assume.assumeFalse(
- "Pixel running API 26 has CameraDevice.onError when set repeating request",
- Build.DEVICE == "sailfish" &&
- (Build.VERSION.SDK_INT == 26 || Build.VERSION.SDK_INT == 27)
- )
- Assume.assumeTrue(AudioUtil.canStartAudioRecord(MediaRecorder.AudioSource.CAMCORDER))
-
- context = ApplicationProvider.getApplicationContext()
- CameraX.initialize(context, Camera2Config.defaultConfig())
- Assume.assumeTrue(
- CameraUtil.hasCameraWithLensFacing(
- cameraSelector.lensFacing!!
- )
- )
- cameraUseCaseAdapter = CameraUtil.createCameraUseCaseAdapter(context, cameraSelector)
- contentResolver = context.contentResolver
-
- callback = Mockito.mock(
- VideoCapture.OnVideoSavedCallback::class.java
- )
- outputFileResultsArgumentCaptor = ArgumentCaptor.forClass(
- VideoCapture.OutputFileResults::class.java
- )
- }
-
- @After
- @Throws(InterruptedException::class, ExecutionException::class, TimeoutException::class)
- fun tearDown() {
- instrumentation.runOnMainSync {
- if (this::cameraUseCaseAdapter.isInitialized) {
- cameraUseCaseAdapter.removeUseCases(cameraUseCaseAdapter.useCases)
- }
- }
- CameraX.shutdown()[10000, TimeUnit.MILLISECONDS]
- }
-
- @Test
- @Throws(IOException::class)
- fun metadataGetCorrectRotation_afterVideoCaptureRecording() {
- // Sets the device rotation.
- videoUseCase = VideoCapture.Builder()
- .setTargetRotation(targetRotation)
- .build()
- val savedFile = File.createTempFile("CameraX", ".tmp")
- savedFile.deleteOnExit()
-
- instrumentation.runOnMainSync {
- try {
- cameraUseCaseAdapter.addUseCases(setOf(videoUseCase))
- } catch (e: CameraUseCaseAdapter.CameraException) {
- e.printStackTrace()
- }
- }
-
- // Start recording
- videoUseCase.startRecording(
- VideoCapture.OutputFileOptions.Builder(savedFile).build(),
- CameraXExecutors.mainThreadExecutor(), callback
- )
- // The way to control recording length might not be applicable in the new VideoCapture.
- try {
- Thread.sleep(3000)
- } catch (e: InterruptedException) {
- e.printStackTrace()
- }
-
- // Assert.
- // Checks the target rotation is correct when the use case is bound.
- videoUseCase.stopRecording()
-
- Mockito.verify(callback, Mockito.timeout(2000)).onVideoSaved(any())
-
- val targetRotationDegree = CameraOrientationUtil.surfaceRotationToDegrees(targetRotation)
- val videoRotation: Int
- val mediaRetriever = MediaMetadataRetriever()
-
- mediaRetriever.apply {
- setDataSource(context, Uri.fromFile(savedFile))
- videoRotation = extractMetadata(
- MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION
- )?.toInt()!!
- }
-
- val sensorRotation = CameraUtil.getSensorOrientation(cameraSelector.lensFacing!!)
- // Whether the camera lens and display are facing opposite directions.
- val isOpposite = cameraSelector.lensFacing == CameraSelector.LENS_FACING_BACK
- val relativeRotation = CameraOrientationUtil.getRelativeImageRotation(
- targetRotationDegree,
- sensorRotation!!,
- isOpposite
- )
-
- // Checks the rotation from video file's metadata is matched with the relative rotation.
- assertThat(videoRotation).isEqualTo(relativeRotation)
- }
-}
\ No newline at end of file
diff --git a/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoRecordingTest.kt b/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoRecordingTest.kt
new file mode 100644
index 0000000..c482c6d
--- /dev/null
+++ b/camera/camera-video/src/androidTest/java/androidx/camera/video/VideoRecordingTest.kt
@@ -0,0 +1,305 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.video
+
+import android.content.Context
+import android.graphics.SurfaceTexture
+import android.media.MediaMetadataRetriever
+import android.net.Uri
+import android.os.Build
+import android.util.Log
+import android.util.Size
+import android.view.Surface
+import androidx.camera.camera2.Camera2Config
+import androidx.camera.core.CameraInfo
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.CameraX
+import androidx.camera.core.Preview
+import androidx.camera.core.impl.utils.CameraOrientationUtil
+import androidx.camera.core.impl.utils.executor.CameraXExecutors
+import androidx.camera.core.internal.CameraUseCaseAdapter
+import androidx.camera.testing.CameraUtil
+import androidx.camera.testing.SurfaceTextureProvider
+import androidx.core.util.Consumer
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.filters.LargeTest
+import androidx.test.platform.app.InstrumentationRegistry
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import org.junit.After
+import org.junit.Assume
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import java.io.File
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+@LargeTest
+@RunWith(Parameterized::class)
+class VideoRecordingTest(
+ private var cameraSelector: CameraSelector
+) {
+
+ @get:Rule
+ val cameraRule = CameraUtil.grantCameraPermissionAndPreTest()
+
+ companion object {
+ private const val VIDEO_TIMEOUT = 10_000L
+ private const val TAG = "VideoRecordingTest"
+ @JvmStatic
+ @Parameterized.Parameters
+ fun data(): Collection<Array<Any>> {
+ return listOf(
+ arrayOf(CameraSelector.DEFAULT_BACK_CAMERA),
+ arrayOf(CameraSelector.DEFAULT_FRONT_CAMERA),
+ )
+ }
+ }
+
+ private val instrumentation = InstrumentationRegistry.getInstrumentation()
+ private val context: Context = ApplicationProvider.getApplicationContext()
+ private lateinit var cameraUseCaseAdapter: CameraUseCaseAdapter
+ private lateinit var preview: Preview
+ private lateinit var cameraInfo: CameraInfo
+
+ private lateinit var latchForVideoSaved: CountDownLatch
+ private lateinit var latchForVideoRecording: CountDownLatch
+
+ private lateinit var finalize: VideoRecordEvent.Finalize
+
+ private val videoRecordEventListener = Consumer<VideoRecordEvent> {
+ when (it.eventType) {
+ VideoRecordEvent.EVENT_TYPE_START -> {
+ // Recording start.
+ Log.d(TAG, "Recording start")
+ }
+ VideoRecordEvent.EVENT_TYPE_FINALIZE -> {
+ // Recording stop.
+ Log.d(TAG, "Recording finalize")
+ finalize = it as VideoRecordEvent.Finalize
+ latchForVideoSaved.countDown()
+ }
+ VideoRecordEvent.EVENT_TYPE_STATUS -> {
+ // Make sure the recording proceed for a while.
+ latchForVideoRecording.countDown()
+ }
+ VideoRecordEvent.EVENT_TYPE_PAUSE, VideoRecordEvent.EVENT_TYPE_RESUME -> {
+ // no op for this test, skip these event now.
+ }
+ else -> {
+ throw IllegalStateException()
+ }
+ }
+ }
+
+ @Before
+ fun setUp() {
+ Assume.assumeTrue(CameraUtil.hasCameraWithLensFacing(cameraSelector.lensFacing!!))
+ // Skip for b/168175357
+ Assume.assumeFalse(
+ "Cuttlefish has MediaCodec dequeueInput/Output buffer fails issue. Unable to test.",
+ Build.MODEL.contains("Cuttlefish") && Build.VERSION.SDK_INT == 29
+ )
+
+ CameraX.initialize(context, Camera2Config.defaultConfig()).get()
+ cameraUseCaseAdapter = CameraUtil.createCameraUseCaseAdapter(context, cameraSelector)
+ cameraInfo = cameraUseCaseAdapter.cameraInfo
+
+ // Add extra Preview to provide an additional surface for b/168187087.
+ preview = Preview.Builder().build()
+ // Sets surface provider to preview
+ instrumentation.runOnMainSync {
+ preview.setSurfaceProvider(
+ getSurfaceProvider()
+ )
+ }
+ }
+
+ @After
+ fun tearDown() {
+ if (this::cameraUseCaseAdapter.isInitialized) {
+ instrumentation.runOnMainSync {
+ cameraUseCaseAdapter.apply {
+ removeUseCases(useCases)
+ }
+ }
+ }
+ CameraX.shutdown().get(10, TimeUnit.SECONDS)
+ }
+
+ @Test
+ fun getMetadataRotation_when_setTargetRotation() {
+ // Arrange.
+ val videoCapture = VideoCapture.withOutput(Recorder.Builder().build())
+ // Just set one Surface.ROTATION_90 to verify the function work or not.
+ val targetRotation = Surface.ROTATION_90
+ videoCapture.targetRotation = targetRotation
+
+ val file = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
+ latchForVideoSaved = CountDownLatch(1)
+ latchForVideoRecording = CountDownLatch(5)
+
+ instrumentation.runOnMainSync {
+ cameraUseCaseAdapter.addUseCases(listOf(preview, videoCapture))
+ }
+
+ // Act.
+ completeVideoRecording(videoCapture, file)
+
+ // Verify.
+ verifyMetadataRotation(targetRotation, file)
+ file.delete()
+ }
+
+ // TODO: Add other metadata info check, e.g. location, after Recorder add more metadata.
+
+ @Test
+ fun getCorrectResolution_when_setSupportedQuality() {
+ Assume.assumeTrue(QualitySelector.getSupportedQualities(cameraInfo).isNotEmpty())
+
+ val qualityList = QualitySelector.getSupportedQualities(cameraInfo)
+ instrumentation.runOnMainSync {
+ cameraUseCaseAdapter.addUseCases(listOf(preview))
+ }
+
+ Log.d(TAG, "CameraSelector: ${cameraSelector.lensFacing}, QualityList: $qualityList ")
+ qualityList.forEach loop@{ quality ->
+ val targetResolution = QualitySelector.getResolution(cameraInfo, quality)
+ if (targetResolution == null) {
+ // If targetResolution is null, try next one
+ Log.e(TAG, "Unable to get resolution for the quality: $quality")
+ return@loop
+ }
+
+ val recorder = Recorder.Builder()
+ .setQualitySelector(QualitySelector.of(quality)).build()
+
+ val videoCapture = VideoCapture.withOutput(recorder)
+ val file = File.createTempFile("video_$targetResolution", ".tmp")
+ .apply { deleteOnExit() }
+
+ latchForVideoSaved = CountDownLatch(1)
+ latchForVideoRecording = CountDownLatch(5)
+
+ instrumentation.runOnMainSync {
+ cameraUseCaseAdapter.addUseCases(listOf(videoCapture))
+ }
+
+ // Act.
+ completeVideoRecording(videoCapture, file)
+
+ // Verify.
+ verifyVideoResolution(targetResolution, file)
+
+ // Cleanup.
+ file.delete()
+ instrumentation.runOnMainSync {
+ cameraUseCaseAdapter.apply {
+ removeUseCases(listOf(videoCapture))
+ }
+ }
+ }
+ }
+
+ private fun completeVideoRecording(videoCapture: VideoCapture<Recorder>, file: File) {
+ val outputOptions = FileOutputOptions.builder().setFile(file).build()
+
+ val activeRecording = videoCapture.output
+ .prepareRecording(outputOptions)
+ .withEventListener(
+ CameraXExecutors.directExecutor(),
+ videoRecordEventListener
+ )
+ .start()
+
+ // Wait for status event to proceed recording for a while.
+ assertThat(latchForVideoRecording.await(VIDEO_TIMEOUT, TimeUnit.MILLISECONDS)).isTrue()
+
+ activeRecording.stop()
+ // Wait for finalize event to saved file.
+ assertThat(latchForVideoSaved.await(VIDEO_TIMEOUT, TimeUnit.MILLISECONDS)).isTrue()
+
+ // Check if any error after recording finalized
+ assertWithMessage(TAG + "Finalize with error: ${finalize.error}, ${finalize.cause}.")
+ .that(finalize.hasError()).isFalse()
+ }
+
+ private fun verifyMetadataRotation(targetRotation: Int, file: File) {
+ // Whether the camera lens and display are facing opposite directions.
+ val isOpposite = cameraSelector.lensFacing == CameraSelector.LENS_FACING_BACK
+ val relativeRotation = CameraOrientationUtil.getRelativeImageRotation(
+ CameraOrientationUtil.surfaceRotationToDegrees(targetRotation),
+ CameraUtil.getSensorOrientation(cameraSelector.lensFacing!!)!!,
+ isOpposite
+ )
+ val videoRotation = getRotationInMetadata(Uri.fromFile(file))
+
+ // Checks the rotation from video file's metadata is matched with the relative rotation.
+ assertWithMessage(
+ TAG + ", $targetRotation rotation test failure:" +
+ ", videoRotation: $videoRotation" +
+ ", relativeRotation: $relativeRotation"
+ ).that(videoRotation).isEqualTo(relativeRotation)
+ }
+
+ private fun verifyVideoResolution(targetResolution: Size, file: File) {
+ val mediaRetriever = MediaMetadataRetriever()
+ lateinit var resolution: Size
+ mediaRetriever.apply {
+ setDataSource(context, Uri.fromFile(file))
+ val height = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)!!
+ .toInt()
+ val width = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)!!
+ .toInt()
+ resolution = Size(width, height)
+ }
+
+ // Compare with the resolution of video and the targetResolution in QualitySelector
+ assertWithMessage(
+ TAG + ", verifyVideoResolution failure:" +
+ ", videoResolution: $resolution" +
+ ", targetResolution: $targetResolution"
+ ).that(resolution).isEqualTo(targetResolution)
+ }
+
+ private fun getRotationInMetadata(uri: Uri): Int {
+ val mediaRetriever = MediaMetadataRetriever()
+ return mediaRetriever.let {
+ it.setDataSource(context, uri)
+ it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toInt()!!
+ }
+ }
+
+ private fun getSurfaceProvider(): Preview.SurfaceProvider {
+ return SurfaceTextureProvider.createSurfaceTextureProvider(
+ object : SurfaceTextureProvider.SurfaceTextureCallback {
+ override fun onSurfaceTextureReady(
+ surfaceTexture: SurfaceTexture,
+ resolution: Size
+ ) {
+ // No-op
+ }
+ override fun onSafeToRelease(surfaceTexture: SurfaceTexture) {
+ surfaceTexture.release()
+ }
+ }
+ )
+ }
+}
\ No newline at end of file
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/AudioSpec.java b/camera/camera-video/src/main/java/androidx/camera/video/AudioSpec.java
index 703d831..95d2746 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/AudioSpec.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/AudioSpec.java
@@ -23,6 +23,7 @@
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.RestrictTo;
+import androidx.annotation.RestrictTo.Scope;
import com.google.auto.value.AutoValue;
@@ -31,7 +32,9 @@
/**
* Audio specification that is options to config audio source and encoding.
+ * @hide
*/
+@RestrictTo(Scope.LIBRARY)
@AutoValue
public abstract class AudioSpec {
@@ -158,7 +161,11 @@
@NonNull
public abstract Builder toBuilder();
- /** The builder of the {@link AudioSpec}. */
+ /**
+ * The builder of the {@link AudioSpec}.
+ * @hide
+ */
+ @RestrictTo(Scope.LIBRARY)
@SuppressWarnings("StaticFinalBuilder")
@AutoValue.Builder
public abstract static class Builder {
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/MediaSpec.java b/camera/camera-video/src/main/java/androidx/camera/video/MediaSpec.java
index f27d8c6..66a78ee 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/MediaSpec.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/MediaSpec.java
@@ -22,6 +22,7 @@
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.RestrictTo;
+import androidx.annotation.RestrictTo.Scope;
import androidx.core.util.Consumer;
import com.google.auto.value.AutoValue;
@@ -32,7 +33,9 @@
/**
* MediaSpec communicates the encoding type and encoder-specific options for both the
* video and audio inputs to the VideoOutput.
+ * @hide
*/
+@RestrictTo(Scope.LIBRARY)
@AutoValue
public abstract class MediaSpec {
@@ -120,7 +123,9 @@
/**
* The builder for {@link MediaSpec}.
+ * @hide
*/
+ @RestrictTo(Scope.LIBRARY)
@SuppressWarnings("StaticFinalBuilder")
@AutoValue.Builder
public abstract static class Builder {
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/QualitySelector.java b/camera/camera-video/src/main/java/androidx/camera/video/QualitySelector.java
index c570ce8..9ec3888b 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/QualitySelector.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/QualitySelector.java
@@ -39,21 +39,20 @@
/**
* QualitySelector defines the desired quality setting.
*
- * <p>There are several defined quality constants such as {@link #QUALITY_SD},
- * {@link #QUALITY_HD}, {@link #QUALITY_FHD} and {@link #QUALITY_FHD}, but not all of them
- * are supported on every device since each device has its own capabilities.
+ * <p>There are pre-defined quality constants that are universally used for video, such as
+ * {@link #QUALITY_SD}, {@link #QUALITY_HD}, {@link #QUALITY_FHD} and {@link #QUALITY_UHD}, but
+ * not all of them are supported on every device since each device has its own capabilities.
* {@link #isQualitySupported(CameraInfo, int)} can be used to check whether a quality is
* supported on the device or not and {@link #getResolution(CameraInfo, int)} can be used to get
- * the actual resolution defined in the device. However, checking qualities one by one is not
- * so inconvenient for the quality setting. QualitySelector is designed to facilitate the quality
- * setting. The typical usage is
+ * the actual resolution defined in the device. Aside from checking the qualities one by one,
+ * QualitySelector provides a more convenient way to select a quality. The typical usage of
+ * selecting a single desired quality is:
* <pre>
* <code>
* QualitySelector qualitySelector = QualitySelector.of(QualitySelector.QUALITY_FHD)
* </code>
* </pre>
- * if there is only one desired quality, or a series of quality constants can be set by desired
- * order
+ * Or the usage of selecting a series of qualities by desired order:
* <pre>
* <code>
* QualitySelector qualitySelector = QualitySelector
@@ -62,12 +61,12 @@
* .finallyTry(QualitySelector.QUALITY_SHD)
* </code>
* </pre>
- * A recommended way to set the {@link Procedure#finallyTry(int)} is giving guaranteed supported
+ * The recommended way to set the {@link Procedure#finallyTry(int)} is giving guaranteed supported
* qualities such as {@link #QUALITY_LOWEST} and {@link #QUALITY_HIGHEST}, which ensures the
- * QualitySelector can always choose a supported quality. Another way to ensure a quality is
+ * QualitySelector can always choose a supported quality. Another way to ensure a quality will be
* selected when none of the desired qualities are supported is to use
* {@link Procedure#finallyTry(int, int)} with an open-ended fallback strategy such as
- * {@link #FALLBACK_STRATEGY_LOWER}.
+ * {@link #FALLBACK_STRATEGY_LOWER}:
* <pre>
* <code>
* QualitySelector qualitySelector = QualitySelector
@@ -75,18 +74,18 @@
* .finallyTry(QualitySelector.QUALITY_FHD, FALLBACK_STRATEGY_LOWER)
* </code>
* </pre>
- * If QUALITY_UHD and QUALITY_FHD are not supported on the device, the next lower supported
- * quality than QUALITY_FHD will be attempted. If no lower quality is supported, the next higher
- * supported quality will be selected. {@link #select(CameraInfo)} can obtain the final result
- * quality based on the desired qualities and fallback strategy, {@link #QUALITY_NONE} will be
- * returned if all desired qualities are not supported and fallback strategy also cannot find a
- * supported one.
+ * If QUALITY_UHD and QUALITY_FHD are not supported on the device, QualitySelector will select
+ * the quality that is closest to and lower than QUALITY_FHD. If no lower quality is supported,
+ * the quality that is closest to and higher than QUALITY_FHD will be selected.
+ * {@link #select(CameraInfo)} can be used to obtain the final result quality based on the desired
+ * qualities and fallback strategy, {@link #QUALITY_NONE} will be returned if all desired
+ * qualities are not supported and fallback strategy also cannot result in a supported quality.
*/
public class QualitySelector {
private static final String TAG = "QualitySelector";
/**
- * Indicates no quality.
+ * A non-applicable quality.
*
* <p>Check QUALITY_NONE via {@link #isQualitySupported(CameraInfo, int)} will return
* {@code false}. {@link #select(CameraInfo)} will return QUALITY_NONE if all desired
@@ -94,11 +93,11 @@
*/
public static final int QUALITY_NONE = -1;
/**
- * Choose the lowest video quality supported by the video frame producer.
+ * The lowest video quality supported by the video frame producer.
*/
public static final int QUALITY_LOWEST = CamcorderProfile.QUALITY_LOW;
/**
- * Choose the highest video quality supported by the video frame producer.
+ * The highest video quality supported by the video frame producer.
*/
public static final int QUALITY_HIGHEST = CamcorderProfile.QUALITY_HIGH;
/**
@@ -144,7 +143,7 @@
QUALITY_FHD, QUALITY_HD, QUALITY_SD);
/**
- * No fallback strategy.
+ * The strategy that no fallback strategy will be applied.
*
* <p>When using this fallback strategy, if {@link #select(CameraInfo)} fails to find a
* supported quality, it will return {@link #QUALITY_NONE}.
@@ -152,13 +151,14 @@
public static final int FALLBACK_STRATEGY_NONE = 0;
/**
- * Choose a higher quality if the desired quality isn't supported. Choose a lower quality if
- * no higher quality is supported.
+ * Choose the quality that is closest to and higher than the desired quality. If that can not
+ * result in a supported quality, choose the quality that is closest to and lower than the
+ * desired quality.
*/
public static final int FALLBACK_STRATEGY_HIGHER = 1;
/**
- * Choose a higher quality if the desired quality isn't supported.
+ * Choose the quality that is closest to and higher than the desired quality.
*
* <p>When a higher quality can't be found, {@link #select(CameraInfo)} will return
* {@link #QUALITY_NONE}.
@@ -166,13 +166,14 @@
public static final int FALLBACK_STRATEGY_STRICTLY_HIGHER = 2;
/**
- * Choose a lower quality if the desired quality isn't supported. Choose a higher quality if
- * no lower quality is supported.
+ * Choose the quality that is closest to and lower than the desired quality. If that can not
+ * result in a supported quality, choose the quality that is closest to and higher than the
+ * desired quality.
*/
public static final int FALLBACK_STRATEGY_LOWER = 3;
/**
- * Choose a lower quality if the desired quality isn't supported.
+ * Choose the quality that is closest to and lower than the desired quality.
*
* <p>When a lower quality can't be found, {@link #select(CameraInfo)} will return
* {@link #QUALITY_NONE}.
@@ -199,7 +200,7 @@
}
/**
- * Check if the input quality is one of video quality constants.
+ * Checks if the input quality is one of video quality constants.
*
* @hide
*/
@@ -209,7 +210,7 @@
}
/**
- * Get all video quality constants with clearly defined size sorted from large to small.
+ * Gets all video quality constants with clearly defined size sorted from largest to smallest.
*
* <p>{@link #QUALITY_NONE}, {@link #QUALITY_HIGHEST} and {@link #QUALITY_LOWEST} are not
* included.
@@ -225,7 +226,7 @@
/**
* Gets all supported qualities on the device.
*
- * <p>The returned list is sorted by quality size from large to small. For the qualities in
+ * <p>The returned list is sorted by quality size from largest to smallest. For the qualities in
* the returned list, with the same input cameraInfo,
* {@link #isQualitySupported(CameraInfo, int)} will return {@code true} and
* {@link #getResolution(CameraInfo, int)} will return the corresponding resolution.
@@ -243,13 +244,20 @@
/**
* Checks if the quality is supported.
*
- * <p>For the qualities in the list of {@link #getSupportedQualities}, calling this method with
- * these qualities will return {@code true}.
+ * <p>Calling this method with one of the qualities contained in the returned list of
+ * {@link #getSupportedQualities} will return {@code true}.
*
- * @param cameraInfo the cameraInfo
- * @param quality one of the quality constants. Possible values include
- * {@link #QUALITY_LOWEST}, {@link #QUALITY_HIGHEST}, {@link #QUALITY_SD}, {@link #QUALITY_HD},
- * {@link #QUALITY_FHD}, or {@link #QUALITY_UHD}.
+ * <p>Possible values for {@code quality} include {@link #QUALITY_LOWEST},
+ * {@link #QUALITY_HIGHEST}, {@link #QUALITY_SD}, {@link #QUALITY_HD}, {@link #QUALITY_FHD},
+ * {@link #QUALITY_UHD} and {@link #QUALITY_NONE}.
+ *
+ * <p>If this method is called with {@link #QUALITY_LOWEST} or {@link #QUALITY_HIGHEST}, it
+ * will return {@code true} except the case that none of the qualities can be supported.
+ *
+ * <p>If this method is called with {@link #QUALITY_NONE}, it will always return {@code false}.
+ *
+ * @param cameraInfo the cameraInfo for checking the quality.
+ * @param quality one of the quality constants.
* @return {@code true} if the quality is supported; {@code false} otherwise.
* @see #getSupportedQualities(CameraInfo)
*/
@@ -261,11 +269,14 @@
/**
* Gets the corresponding resolution from the input quality.
*
- * @param cameraInfo the cameraInfo
- * @param quality one of the quality constants. Possible values include
- * {@link QualitySelector#QUALITY_LOWEST}, {@link QualitySelector#QUALITY_HIGHEST},
- * {@link QualitySelector#QUALITY_SD}, {@link QualitySelector#QUALITY_HD},
- * {@link QualitySelector#QUALITY_FHD}, or {@link QualitySelector#QUALITY_UHD}.
+ * <p>Possible values for {@code quality} include {@link #QUALITY_LOWEST},
+ * {@link #QUALITY_HIGHEST}, {@link #QUALITY_SD}, {@link #QUALITY_HD}, {@link #QUALITY_FHD},
+ * {@link #QUALITY_UHD} and {@link #QUALITY_NONE}.
+ *
+ * <p>If this method is called with {@link #QUALITY_NONE}, it will always return {@code null}.
+ *
+ * @param cameraInfo the cameraInfo for checking the quality.
+ * @param quality one of the quality constants.
* @return the corresponding resolution from the input quality, or {@code null} if the
* quality is not supported on the device. {@link #isQualitySupported(CameraInfo, int)} can
* be used to check if the input quality is supported.
@@ -296,13 +307,17 @@
}
/**
- * Sets the first desired quality.
+ * Sets the desired quality with the highest priority.
+ *
+ * <p>This method initiates a procedure for specifying the requirements of selecting
+ * qualities. Other requirements can be further added with {@link Procedure} methods.
*
* @param quality the quality constant. Possible values include {@link #QUALITY_LOWEST},
* {@link #QUALITY_HIGHEST}, {@link #QUALITY_SD}, {@link #QUALITY_HD}, {@link #QUALITY_FHD},
* or {@link #QUALITY_UHD}.
- * @return the procedure that can continue to be set
- * @throws IllegalArgumentException if not a quality constant.
+ * @return the {@link Procedure} for specifying quality selection requirements.
+ * @throws IllegalArgumentException if the given quality is not a quality constant.
+ * @see Procedure
*/
@NonNull
public static Procedure firstTry(@VideoQuality int quality) {
@@ -318,7 +333,7 @@
* {@link #QUALITY_HIGHEST}, {@link #QUALITY_SD}, {@link #QUALITY_HD}, {@link #QUALITY_FHD},
* or {@link #QUALITY_UHD}.
* @return the QualitySelector instance.
- * @throws IllegalArgumentException if not a quality constant.
+ * @throws IllegalArgumentException if the given quality is not a quality constant.
*/
@NonNull
public static QualitySelector of(@VideoQuality int quality) {
@@ -349,23 +364,24 @@
}
/**
- * Find a quality match to the desired quality settings.
+ * Finds a quality that matches the desired quality settings.
*
- * <p>The method bases on the desired qualities and the fallback strategy to find out a
- * supported quality on this device. The desired qualities can be set by a series of try
- * methods such as {@link #firstTry(int)}, {@link #of(int)},
- * {@link Procedure#thenTry(int)} and {@link Procedure#finallyTry(int)}. The fallback strategy
- * can be set via {@link #of(int, int)} and {@link Procedure#finallyTry(int, int)}. If no
- * fallback strategy is specified, {@link #FALLBACK_STRATEGY_NONE} will be applied by default.
+ * <p>The method bases on the desired qualities and the fallback strategy to find a supported
+ * quality on this device. The desired qualities can be set by a series of try methods such
+ * as {@link #firstTry(int)}, {@link #of(int)}, {@link Procedure#thenTry(int)} and
+ * {@link Procedure#finallyTry(int)}. The fallback strategy can be set via
+ * {@link #of(int, int)} and {@link Procedure#finallyTry(int, int)}. If no fallback strategy
+ * is specified, {@link #FALLBACK_STRATEGY_NONE} will be applied by default.
*
* <p>The search algorithm first checks which desired quality is supported according to the
* set sequence. If no desired quality is supported, the fallback strategy will be applied to
* the quality set with it. If there is still no quality can be found, {@link #QUALITY_NONE}
* will be returned.
*
- * @param cameraInfo the cameraInfo
+ * @param cameraInfo the cameraInfo for checking the quality.
* @return the first supported quality of the desired qualities, or a supported quality
* searched by fallback strategy, or {@link #QUALITY_NONE} when no quality is found.
+ * @see Procedure
*/
@VideoQuality
public int select(@NonNull CameraInfo cameraInfo) {
@@ -474,7 +490,7 @@
}
/**
- * The procedure can continue to set the desired quality and fallback strategy.
+ * The procedure can be used to set desired qualities and fallback strategy.
*/
public static class Procedure {
private final List<Integer> mPreferredQualityList = new ArrayList<>();
@@ -484,13 +500,13 @@
}
/**
- * Sets the next desired quality.
+ * Adds a quality candidate.
*
* @param quality the quality constant. Possible values include {@link #QUALITY_LOWEST},
* {@link #QUALITY_HIGHEST}, {@link #QUALITY_SD}, {@link #QUALITY_HD},
* {@link #QUALITY_FHD} or {@link #QUALITY_UHD}.
* @return the procedure that can continue to be set
- * @throws IllegalArgumentException if not a quality constant
+ * @throws IllegalArgumentException if the given quality is not a quality constant
*/
@NonNull
public Procedure thenTry(@VideoQuality int quality) {
@@ -503,11 +519,14 @@
*
* <p>The returned QualitySelector will adopt {@link #FALLBACK_STRATEGY_NONE}.
*
+ * <p>This method finishes the setting procedure and generates a {@link QualitySelector}
+ * with the requirements set to the procedure.
+ *
* @param quality the quality constant. Possible values include {@link #QUALITY_LOWEST},
* {@link #QUALITY_HIGHEST}, {@link #QUALITY_SD}, {@link #QUALITY_HD},
* {@link #QUALITY_FHD} or {@link #QUALITY_UHD}.
- * @return the QualitySelector.
- * @throws IllegalArgumentException if not a quality constant
+ * @return the {@link QualitySelector}.
+ * @throws IllegalArgumentException if the given quality is not a quality constant
*/
@NonNull
public QualitySelector finallyTry(@VideoQuality int quality) {
@@ -520,6 +539,9 @@
* <p>The fallback strategy will be applied on this quality when all desired qualities are
* not supported.
*
+ * <p>This method finishes the setting procedure and generates a {@link QualitySelector}
+ * with the requirements set to the procedure.
+ *
* @param quality the quality constant. Possible values include {@link #QUALITY_LOWEST},
* {@link #QUALITY_HIGHEST}, {@link #QUALITY_SD}, {@link #QUALITY_HD},
* {@link #QUALITY_FHD} or {@link #QUALITY_UHD}.
@@ -527,7 +549,7 @@
* {@link #FALLBACK_STRATEGY_NONE}, {@link #FALLBACK_STRATEGY_HIGHER},
* {@link #FALLBACK_STRATEGY_STRICTLY_HIGHER}, {@link #FALLBACK_STRATEGY_LOWER} and
* {@link #FALLBACK_STRATEGY_STRICTLY_LOWER}.
- * @return the QualitySelector.
+ * @return the {@link QualitySelector}.
* @throws IllegalArgumentException if {@code quality} is not a quality constant or
* {@code fallbackStrategy} is not a fallback strategy constant.
*/
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/Recorder.java b/camera/camera-video/src/main/java/androidx/camera/video/Recorder.java
index 8a02596..8a2df5e 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/Recorder.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/Recorder.java
@@ -16,6 +16,11 @@
package androidx.camera.video;
+import static androidx.camera.video.QualitySelector.FALLBACK_STRATEGY_HIGHER;
+import static androidx.camera.video.QualitySelector.QUALITY_FHD;
+import static androidx.camera.video.QualitySelector.QUALITY_HD;
+import static androidx.camera.video.QualitySelector.QUALITY_SD;
+
import android.Manifest;
import android.annotation.SuppressLint;
import android.content.ContentValues;
@@ -63,6 +68,7 @@
import androidx.camera.video.internal.encoder.VideoEncoderConfig;
import androidx.camera.video.internal.utils.OutputUtil;
import androidx.concurrent.futures.CallbackToFutureAdapter;
+import androidx.core.util.Consumer;
import androidx.core.util.Preconditions;
import com.google.common.util.concurrent.ListenableFuture;
@@ -77,11 +83,39 @@
import java.util.concurrent.atomic.AtomicBoolean;
/**
- * Provides functionality to generate {@link PendingRecording} and record video to the location
- * specified by {@link OutputOptions}.
+ * An implementation of {@link VideoOutput} for starting video recordings that are saved
+ * to a {@link File}, {@link ParcelFileDescriptor}, or {@link MediaStore}.
*
- * <p>The {@link MediaSpec} associated with the Recorder can not be changed once it's created.
- * Create a new Recorder for using different {@link MediaSpec}.
+ * <p>A recorder can be used to save the video frames sent from the {@link VideoCapture} use case
+ * in common recording formats such as MPEG4.
+ *
+ * <p>Usage example of setting up {@link VideoCapture} with a recorder as output:
+ * <pre>
+ * ProcessCameraProvider cameraProvider = ...;
+ * CameraSelector cameraSelector = ...;
+ * ...
+ * // Create our preview to show on screen
+ * Preview preview = new Preview.Builder.build();
+ * // Create the video capture use case with a Recorder as the output
+ * VideoCapture<Recorder> videoCapture = VideoCapture.withOutput(new Recorder.Builder().build());
+ *
+ * // Bind use cases to Fragment/Activity lifecycle
+ * cameraProvider.bindToLifecycle(this, cameraSelector, preview, videoCapture);
+ * </pre>
+ *
+ * <p>Once the recorder is attached to a video source, a new recording can be configured with one of
+ * the {@link PendingRecording} methods, such as
+ * {@link #prepareRecording(MediaStoreOutputOptions)}. The {@link PendingRecording} class also
+ * allows setting a listener with {@link PendingRecording#withEventListener(Executor, Consumer)}
+ * to listen for {@link VideoRecordEvent}s such as {@link VideoRecordEvent.Start},
+ * {@link VideoRecordEvent.Pause}, {@link VideoRecordEvent.Resume}, and
+ * {@link VideoRecordEvent.Finalize}. This listener will also receive regular recording status
+ * updates via the {@link VideoRecordEvent.Status} event.
+ *
+ * <p>A recorder can also capture and save audio alongside video. The audio must be explicitly
+ * enabled with {@link PendingRecording#withAudioEnabled()} before starting the recording.
+ * @see VideoCapture#withOutput(VideoOutput)
+ * @see PendingRecording
*/
public final class Recorder implements VideoOutput {
@@ -156,6 +190,24 @@
ENCODER_ERROR
}
+ /**
+ * Default quality selector for recordings.
+ *
+ * <p>The default quality selector chooses a video quality suitable for recordings based on
+ * device and compatibility constraints. It is equivalent to:
+ * <pre>{@code
+ * QualitySelector.firstTry(QUALITY_FHD)
+ * .thenTry(QUALITY_HD)
+ * .thenTry(QUALITY_SD)
+ * .finallyTry(QUALITY_FHD, FALLBACK_STRATEGY_HIGHER);
+ * }</pre>
+ */
+ public static final QualitySelector DEFAULT_QUALITY_SELECTOR =
+ QualitySelector.firstTry(QUALITY_FHD)
+ .thenTry(QUALITY_HD)
+ .thenTry(QUALITY_SD)
+ .finallyTry(QUALITY_FHD, FALLBACK_STRATEGY_HIGHER);
+
private static final AudioSpec AUDIO_SPEC_DEFAULT =
AudioSpec.builder()
.setSourceFormat(
@@ -167,6 +219,7 @@
.build();
private static final VideoSpec VIDEO_SPEC_DEFAULT =
VideoSpec.builder()
+ .setQualitySelector(DEFAULT_QUALITY_SELECTOR)
.setAspectRatio(VideoSpec.ASPECT_RATIO_16_9)
.build();
private static final MediaSpec MEDIA_SPEC_DEFAULT =
@@ -254,10 +307,8 @@
}
}
- /** {@inheritDoc} */
@SuppressLint("MissingPermission")
@Override
- @OptIn(markerClass = ExperimentalUseCaseGroup.class)
public void onSurfaceRequested(@NonNull SurfaceRequest surfaceRequest) {
synchronized (mLock) {
switch (getObservableData(mState)) {
@@ -289,7 +340,8 @@
}
}
- /** {@inheritDoc} */
+ /** @hide */
+ @RestrictTo(RestrictTo.Scope.LIBRARY)
@Override
@NonNull
public Observable<MediaSpec> getMediaSpec() {
@@ -305,14 +357,18 @@
}
/**
- * Generates a {@link PendingRecording} that is associated with this Recorder with a
- * {@link FileOutputOptions}.
+ * Prepares a recording that will be saved to a {@link File}.
*
- * <p>The recording generated by this method will be saved to a {@link java.io.File}.
+ * <p>The provided {@link FileOutputOptions} specifies the file to use.
+ *
+ * <p>The recording will not begin until {@link PendingRecording#start()} is called on the
+ * returned {@link PendingRecording}. Only a single pending recording can be started per
+ * {@link Recorder} instance.
*
* @param fileOutputOptions the options that configures how the output will be handled.
* @return a {@link PendingRecording} that is associated with this Recorder.
* @throws IllegalStateException if the Recorder is released.
+ * @see FileOutputOptions
*/
@NonNull
public PendingRecording prepareRecording(@NonNull FileOutputOptions fileOutputOptions) {
@@ -320,18 +376,23 @@
}
/**
- * Generates a {@link PendingRecording} that is associated with this Recorder with a
- * {@link FileDescriptorOutputOptions}.
+ * Prepares a recording that will be saved to a {@link ParcelFileDescriptor}.
*
- * <p>The recording generated by this method will be saved to a {@link java.io.FileDescriptor}.
+ * <p>The provided {@link FileDescriptorOutputOptions} specifies the
+ * {@link ParcelFileDescriptor} to use.
*
* <p>Currently, file descriptors as output destinations are not supported on pre-Android O
- * devices.
+ * (API 26) devices.
+ *
+ * <p>The recording will not begin until {@link PendingRecording#start()} is called on the
+ * returned {@link PendingRecording}. Only a single pending recording can be started per
+ * {@link Recorder} instance.
*
* @param fileDescriptorOutputOptions the options that configures how the output will be
* handled.
* @return a {@link PendingRecording} that is associated with this Recorder.
* @throws IllegalStateException if the Recorder is released.
+ * @see FileDescriptorOutputOptions
*/
@RequiresApi(26)
@NonNull
@@ -343,15 +404,19 @@
}
/**
- * Generates a {@link PendingRecording} that is associated with this Recorder with a
- * {@link MediaStoreOutputOptions}.
+ * Prepares a recording that will be saved to a {@link MediaStore}.
*
- * <p>The recording generated by this method will be saved to {@link MediaStore}.
+ * <p>The provided {@link MediaStoreOutputOptions} specifies the options which will be used
+ * to save the recording to a {@link MediaStore}.
+ *
+ * <p>The recording will not begin until {@link PendingRecording#start()} is called on the
+ * returned {@link PendingRecording}. Only a single pending recording can be started per
+ * {@link Recorder} instance.
*
* @param mediaStoreOutputOptions the options that configures how the output will be handled.
* @return a {@link PendingRecording} that is associated with this Recorder.
-
* @throws IllegalStateException if the Recorder is released.
+ * @see MediaStoreOutputOptions
*/
@NonNull
public PendingRecording prepareRecording(
@@ -389,7 +454,12 @@
}
/**
- * Gets the {@link QualitySelector} of this Recorder.
+ * Gets the quality selector of this Recorder.
+ *
+ * @return the {@link QualitySelector} provided to
+ * {@link Builder#setQualitySelector(QualitySelector)} on the builder used to create this
+ * recorder, or the default value of {@link Recorder#DEFAULT_QUALITY_SELECTOR} if no quality
+ * selector was provided.
*/
@NonNull
public QualitySelector getQualitySelector() {
@@ -398,8 +468,13 @@
/**
* Gets the audio source of this Recorder.
+ *
+ * @return the value provided to {@link Builder#setAudioSource(int)} on the builder used to
+ * create this recorder, or the default value of {@link AudioSpec#SOURCE_AUTO} if no source was
+ * set.
*/
- public int getAudioSource() {
+ @AudioSpec.Source
+ int getAudioSource() {
return getObservableData(mMediaSpec).getAudioSpec().getSource();
}
@@ -1250,11 +1325,25 @@
@ExecutedBy("mSequentialExecutor")
void finalizeRecording(@VideoRecordEvent.VideoRecordError int error,
@Nullable Throwable throwable) {
+ int errorToSend = error;
+ if (mMediaMuxer != null) {
+ try {
+ mMediaMuxer.stop();
+ } catch (IllegalStateException e) {
+ Logger.e(TAG, "MediaMuxer failed to stop with error: " + e.getMessage());
+ if (errorToSend == VideoRecordEvent.ERROR_NONE) {
+ errorToSend = VideoRecordEvent.ERROR_UNKNOWN;
+ }
+ }
+ mMediaMuxer.release();
+ mMediaMuxer = null;
+ }
+
OutputOptions outputOptions =
Preconditions.checkNotNull(mRunningRecording).getOutputOptions();
RecordingStats stats = getCurrentRecordingStats();
OutputResults outputResults = OutputResults.of(mOutputUri);
- updateVideoRecordEvent(error == VideoRecordEvent.ERROR_NONE
+ updateVideoRecordEvent(errorToSend == VideoRecordEvent.ERROR_NONE
? VideoRecordEvent.finalize(
outputOptions,
stats,
@@ -1263,15 +1352,9 @@
outputOptions,
stats,
outputResults,
- error,
+ errorToSend,
throwable));
- if (mMediaMuxer != null) {
- mMediaMuxer.stop();
- mMediaMuxer.release();
- mMediaMuxer = null;
- }
-
mAudioTrackIndex = null;
mVideoTrackIndex = null;
mEncodingFutures.clear();
@@ -1283,6 +1366,7 @@
mFirstRecordingVideoDataTimeUs = 0L;
mRecordingStopError = VideoRecordEvent.ERROR_UNKNOWN;
mFileSizeLimitInBytes = OutputOptions.FILE_SIZE_UNLIMITED;
+
// Reset audio setting to the Recorder default.
if (getObservableData(mMediaSpec).getAudioSpec().getChannelCount()
== AudioSpec.CHANNEL_COUNT_NONE) {
@@ -1290,7 +1374,6 @@
} else {
setAudioState(AudioState.INITIALIZING);
}
-
synchronized (mLock) {
if (getObservableData(mState) == State.RELEASING) {
releaseInternal();
@@ -1361,13 +1444,19 @@
}
/**
- * The builder of the Recorder.
+ * Builder class for {@link Recorder} objects.
*/
public static final class Builder {
private final MediaSpec.Builder mMediaSpecBuilder;
private Executor mExecutor = null;
+ /**
+ * Constructor for {@code Recorder.Builder}.
+ *
+ * <p>Creates a builder which is pre-populated with appropriate default configuration
+ * options.
+ */
public Builder() {
mMediaSpecBuilder = MediaSpec.builder();
}
@@ -1376,9 +1465,9 @@
* Sets the {@link Executor} that runs the Recorder background task.
*
* <p>The executor is used to run the Recorder tasks, the audio encoding and the video
- * encoding. For the best performance, it's recommended to be a
- * {@link java.util.concurrent.ThreadPoolExecutor} and is capable of generating at lest 3
- * threads.
+ * encoding. For the best performance, it's recommended to be an {@link Executor} that is
+ * capable of running at least two tasks concurrently, such as a
+ * {@link java.util.concurrent.ThreadPoolExecutor} backed by 2 or more threads.
*/
@NonNull
public Builder setExecutor(@NonNull Executor executor) {
@@ -1392,6 +1481,13 @@
/**
* Sets the {@link QualitySelector} of this Recorder.
+ *
+ * <p>The provided quality selector is used to select the resolution of the recording
+ * depending on the resolutions supported by the camera and codec capabilities.
+ *
+ * <p>If no quality selector is provided, the default is
+ * {@link #DEFAULT_QUALITY_SELECTOR}.
+ * @see QualitySelector
*/
@NonNull
public Builder setQualitySelector(@NonNull QualitySelector qualitySelector) {
@@ -1412,7 +1508,27 @@
}
/**
- * Builds the Recorder instance.
+ * Sets the audio source for recordings with audio enabled.
+ *
+ * <p>This will only set the source of audio for recordings, but audio must still be
+ * enabled on a per-recording basis with {@link PendingRecording#withAudioEnabled()}
+ * before starting the recording.
+ *
+ * @param source The audio source to use. One of {@link AudioSpec#SOURCE_AUTO} or
+ * {@link AudioSpec#SOURCE_CAMCORDER}. Default is {@link AudioSpec#SOURCE_AUTO}.
+ */
+ @NonNull
+ Builder setAudioSource(@AudioSpec.Source int source) {
+ mMediaSpecBuilder.configureAudio(builder -> builder.setSource(source));
+ return this;
+ }
+
+ /**
+ * Builds the {@link Recorder} instance.
+ *
+ * <p>The {code build()} method can be called multiple times, generating a new
+ * {@link Recorder} instance each time. The returned instance is configured with the
+ * options set on this builder.
*/
@NonNull
public Recorder build() {
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/VideoCapture.java b/camera/camera-video/src/main/java/androidx/camera/video/VideoCapture.java
index b8791541..acb2ae7 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/VideoCapture.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/VideoCapture.java
@@ -90,19 +90,32 @@
/**
* A use case that provides camera stream suitable for video application.
*
- * <p>VideoCapture is used to create a camera stream suitable for video application. This stream
- * is used by the implementation of {@link VideoOutput}. Calling {@link #withOutput(VideoOutput)}
- * can generate a VideoCapture use case binding to the given VideoOutput.
+ * <p>VideoCapture is used to create a camera stream suitable for video application. The camera
+ * stream is used by the extended classes of {@link VideoOutput}.
+ * {@link #withOutput(VideoOutput)} can be used to create a VideoCapture instance associated with
+ * the given VideoOutput.
*
- * <p>When binding VideoCapture, VideoCapture will initialize the camera stream according to the
- * resolution found by the {@link QualitySelector} in VideoOutput. Then VideoCapture will invoke
- * {@link VideoOutput#onSurfaceRequested(SurfaceRequest)} to request VideoOutput to provide a
- * {@link Surface} via {@link SurfaceRequest#provideSurface} to complete the initialization
- * process. After VideoCapture is bound, updating the QualitySelector in VideoOutput will have no
- * effect. If it needs to change the resolution of the camera stream after VideoCapture is bound,
- * it has to unbind the original VideoCapture, update the QualitySelector in VideoOutput and then
- * re-bind the VideoCapture. If the implementation of VideoOutput does not support modifying the
- * QualitySelector afterwards, it has to create a new VideoOutput and VideoCapture for re-bind.
+ * <p>When {@linkplain androidx.camera.lifecycle.ProcessCameraProvider#bindToLifecycle binding}
+ * VideoCapture to lifecycle, VideoCapture will create a camera stream with the resolution
+ * selected by the {@link QualitySelector} in VideoOutput. Example:
+ * <pre>
+ * <code>
+ * Recorder recorder = new Recorder.Builder()
+ * .setQualitySelector(QualitySelector.of(QualitySelector.QUALITY_FHD))
+ * .build();
+ * VideoCapture<Recorder> videoCapture = VideoCapture.withOutput(recorder);
+ * </code>
+ * </pre>
+ *
+ * <p>Then VideoCapture will {@link VideoOutput#onSurfaceRequested(SurfaceRequest) ask}
+ * VideoOutput to {@link SurfaceRequest#provideSurface provide} a {@link Surface} for setting up
+ * the camera stream. After VideoCapture is bound, update QualitySelector in VideoOutput will not
+ * have any effect. If it needs to change the resolution of the camera stream after VideoCapture
+ * is bound, it has to update QualitySelector of VideoOutput and rebind
+ * ({@linkplain androidx.camera.lifecycle.ProcessCameraProvider#unbind unbind} and
+ * {@linkplain androidx.camera.lifecycle.ProcessCameraProvider#bindToLifecycle bind}) the
+ * VideoCapture. If the extended class of VideoOutput does not have API to modify the
+ * QualitySelector, it has to create new VideoOutput and VideoCapture for rebinding.
*
* @param <T> the type of VideoOutput
*/
@@ -118,14 +131,13 @@
private SurfaceRequest mSurfaceRequest;
/**
- * Create a VideoCapture builder with a {@link VideoOutput}.
+ * Create a VideoCapture associated with the given {@link VideoOutput}.
*
- * @param videoOutput the associated VideoOutput.
- * @return the new Builder
+ * @throws NullPointerException if {@code videoOutput} is null.
*/
@NonNull
public static <T extends VideoOutput> VideoCapture<T> withOutput(@NonNull T videoOutput) {
- return new VideoCapture.Builder<T>(videoOutput).build();
+ return new VideoCapture.Builder<T>(Preconditions.checkNotNull(videoOutput)).build();
}
/**
@@ -138,7 +150,10 @@
}
/**
- * Gets the {@link VideoOutput} associated to this VideoCapture.
+ * Gets the {@link VideoOutput} associated with this VideoCapture.
+ *
+ * @return the value provided to {@link #withOutput(VideoOutput)} used to create this
+ * VideoCapture.
*/
@SuppressWarnings("unchecked")
@NonNull
@@ -155,7 +170,10 @@
* has been attached to a camera.
*
* @return The rotation of the intended target.
+ *
+ * @hide
*/
+ @RestrictTo(Scope.LIBRARY_GROUP)
@RotationValue
public int getTargetRotation() {
return getTargetRotationInternal();
@@ -173,7 +191,10 @@
* created. The use case is fully created once it has been attached to a camera.
*
* @param rotation Desired rotation of the output video.
+ *
+ * @hide
*/
+ @RestrictTo(Scope.LIBRARY_GROUP)
@OptIn(markerClass = ExperimentalUseCaseGroup.class)
public void setTargetRotation(@RotationValue int rotation) {
if (setTargetRotationInternal(rotation)) {
@@ -181,6 +202,12 @@
}
}
+ /**
+ * {@inheritDoc}
+ *
+ * @hide
+ */
+ @RestrictTo(Scope.LIBRARY_GROUP)
@Override
public void onAttached() {
getOutput().getStreamState().addObserver(CameraXExecutors.mainThreadExecutor(),
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/VideoOutput.java b/camera/camera-video/src/main/java/androidx/camera/video/VideoOutput.java
index f75556b..4a1b310 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/VideoOutput.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/VideoOutput.java
@@ -122,7 +122,9 @@
* changes may not come for free and may require the video frame producer to re-initialize,
* which could cause a new {@link SurfaceRequest} to be sent to
* {@link #onSurfaceRequested(SurfaceRequest)}.
+ * @hide
*/
+ @RestrictTo(Scope.LIBRARY)
@NonNull
default Observable<MediaSpec> getMediaSpec() {
return ConstantObservable.withValue(null);
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/VideoRecordEvent.java b/camera/camera-video/src/main/java/androidx/camera/video/VideoRecordEvent.java
index 74ef4a2..9c0cd22 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/VideoRecordEvent.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/VideoRecordEvent.java
@@ -20,14 +20,20 @@
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
+import androidx.annotation.RestrictTo.Scope;
+import androidx.core.util.Consumer;
import androidx.core.util.Preconditions;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
+import java.util.concurrent.Executor;
/**
* VideoRecordEvent is used to report the video recording events and status.
*
+ * <p>Upon starting a recording by {@link PendingRecording#start()}, recording events will start to
+ * be sent to the listener set in {@link PendingRecording#withEventListener(Executor, Consumer)}.
+ *
* <p>There are {@link Start}, {@link Finalize}, {@link Status}, {@link Pause} and {@link Resume}
* events. The {@link #getEventType()} can be used to check what type of event is.
*
@@ -37,19 +43,19 @@
*
* VideoRecordEvent videoRecordEvent = obtainVideoRecordEvent();
* switch (videoRecordEvent.getEventType()) {
- * case START:
+ * case VideoRecordEvent.EVENT_TYPE_START:
* VideoRecordEvent.Start start = (VideoRecordEvent.Start) videoRecordEvent;
* break;
- * case FINALIZE:
+ * case VideoRecordEvent.EVENT_TYPE_FINALIZE:
* VideoRecordEvent.Finalize finalize = (VideoRecordEvent.Finalize) videoRecordEvent;
* break;
- * case STATUS:
+ * case VideoRecordEvent.EVENT_TYPE_STATUS:
* VideoRecordEvent.Status status = (VideoRecordEvent.Status) videoRecordEvent;
* break;
- * case PAUSE:
+ * case VideoRecordEvent.EVENT_TYPE_PAUSE:
* VideoRecordEvent.Pause pause = (VideoRecordEvent.Pause) videoRecordEvent;
* break;
- * case RESUME:
+ * case VideoRecordEvent.EVENT_TYPE_RESUME:
* VideoRecordEvent.Resume resume = (VideoRecordEvent.Resume) videoRecordEvent;
* break;
* }
@@ -70,42 +76,47 @@
*/
public abstract class VideoRecordEvent {
- /** The event types. */
- public enum EventType {
- /**
- * Indicates the start of recording.
- *
- * @see Start
- */
- START,
+ /**
+ * Indicates the start of recording.
+ *
+ * @see Start
+ */
+ public static final int EVENT_TYPE_START = 0;
- /**
- * Indicates the finalization of recording.
- *
- * @see Finalize
- */
- FINALIZE,
+ /**
+ * Indicates the finalization of recording.
+ *
+ * @see Finalize
+ */
+ public static final int EVENT_TYPE_FINALIZE = 1;
- /**
- * The status report of the recording in progress.
- *
- * @see Status
- */
- STATUS,
+ /**
+ * The status report of the recording in progress.
+ *
+ * @see Status
+ */
+ public static final int EVENT_TYPE_STATUS = 2;
- /**
- * Indicates the pause event of recording.
- *
- * @see Pause
- */
- PAUSE,
+ /**
+ * Indicates the pause event of recording.
+ *
+ * @see Pause
+ */
+ public static final int EVENT_TYPE_PAUSE = 3;
- /**
- * Indicates the resume event of recording.
- *
- * @see Resume
- */
- RESUME
+ /**
+ * Indicates the resume event of recording.
+ *
+ * @see Resume
+ */
+ public static final int EVENT_TYPE_RESUME = 4;
+
+ /** @hide */
+ @IntDef({EVENT_TYPE_START, EVENT_TYPE_FINALIZE, EVENT_TYPE_STATUS, EVENT_TYPE_PAUSE,
+ EVENT_TYPE_RESUME})
+ @Retention(RetentionPolicy.SOURCE)
+ @RestrictTo(Scope.LIBRARY)
+ public @interface EventType {
}
/**
@@ -120,6 +131,8 @@
/**
* The recording failed due to file size limitation.
+ *
+ * <p>The file size limitation will refer to {@link OutputOptions#getFileSizeLimit()}.
*/
// TODO(b/167481981): add more descriptions about the restrictions after getting into more
// details.
@@ -190,9 +203,12 @@
/**
* Gets the event type.
+ *
+ * <p>Possible values are {@link #EVENT_TYPE_START}, {@link #EVENT_TYPE_FINALIZE},
+ * {@link #EVENT_TYPE_PAUSE}, {@link #EVENT_TYPE_RESUME} and {@link #EVENT_TYPE_STATUS}.
*/
- @NonNull
- public abstract EventType getEventType();
+ @EventType
+ public abstract int getEventType();
/**
* Gets the recording status of current event.
@@ -219,7 +235,8 @@
/**
* Indicates the start of recording.
*
- * <p>When a video recording is requested, start event will be reported at first.
+ * <p>When a video recording is successfully requested by {@link PendingRecording#start()},
+ * a {@code Start} event will be the first event.
*/
public static final class Start extends VideoRecordEvent {
@@ -229,10 +246,10 @@
}
/** {@inheritDoc} */
- @NonNull
+ @EventType
@Override
- public EventType getEventType() {
- return EventType.START;
+ public int getEventType() {
+ return EVENT_TYPE_START;
}
}
@@ -254,16 +271,20 @@
}
/**
- * Indicates the stop of recording.
+ * Indicates the finalization of recording.
*
- * <p>The stop event will be triggered regardless of whether the recording succeeds or
+ * <p>The finalize event will be triggered regardless of whether the recording succeeds or
* fails. Use {@link Finalize#getError()} to obtain the error type and
* {@link Finalize#getCause()} to get the error cause. If there is no error,
- * {@link #ERROR_NONE} will be returned. Other error code indicate the recording is failed or
- * stopped due to a certain reason. Please note that a failed result does not mean that the
- * video file has not been generated. In some cases, the file can still be successfully
- * generated. For example, the result {@link #ERROR_INSUFFICIENT_DISK} will still have video
- * file.
+ * {@link #ERROR_NONE} will be returned. Other error types indicate the recording is failed or
+ * stopped due to a certain reasons. Please note that receiving a finalize event with error
+ * does not necessarily mean that the video file has not been generated. In some cases, the
+ * file can still be successfully generated depending on the error type. For example, a file
+ * will still be generated when the recording is finalized with
+ * {@link #ERROR_INSUFFICIENT_DISK}.
+ *
+ * <p>If there's no error that prevents the file to be generated, the file can be accessed
+ * safely after receiving the finalize event.
*/
public static final class Finalize extends VideoRecordEvent {
private final OutputResults mOutputResults;
@@ -284,10 +305,10 @@
}
/** {@inheritDoc} */
- @NonNull
+ @EventType
@Override
- public EventType getEventType() {
- return EventType.FINALIZE;
+ public int getEventType() {
+ return EVENT_TYPE_FINALIZE;
}
/**
@@ -311,7 +332,11 @@
/**
* Gets the error type for a video recording.
*
- * <p>Returns {@link #ERROR_NONE} if the recording did not stop due to an error.
+ * <p>Possible values are {@link #ERROR_NONE}, {@link #ERROR_UNKNOWN},
+ * {@link #ERROR_FILE_SIZE_LIMIT_REACHED}, {@link #ERROR_INSUFFICIENT_DISK},
+ * {@link #ERROR_CAMERA_CLOSED}, {@link #ERROR_INVALID_OUTPUT_OPTIONS},
+ * {@link #ERROR_ENCODING_FAILED}, {@link #ERROR_RECORDER_ERROR} and
+ * {@link #ERROR_RECORDER_UNINITIALIZED}.
*/
@VideoRecordError
public int getError() {
@@ -346,10 +371,10 @@
}
/** {@inheritDoc} */
- @NonNull
+ @EventType
@Override
- public EventType getEventType() {
- return EventType.STATUS;
+ public int getEventType() {
+ return EVENT_TYPE_STATUS;
}
}
@@ -361,6 +386,8 @@
/**
* Indicates the pause event of recording.
+ *
+ * <p>A {@code Pause} event will be triggered after calling {@link ActiveRecording#pause()}.
*/
public static final class Pause extends VideoRecordEvent {
@@ -370,10 +397,10 @@
}
/** {@inheritDoc} */
- @NonNull
+ @EventType
@Override
- public EventType getEventType() {
- return EventType.PAUSE;
+ public int getEventType() {
+ return EVENT_TYPE_PAUSE;
}
}
@@ -385,6 +412,8 @@
/**
* Indicates the resume event of recording.
+ *
+ * <p>A {@code Resume} event will be triggered after calling {@link ActiveRecording#resume()}.
*/
public static final class Resume extends VideoRecordEvent {
@@ -394,10 +423,10 @@
}
/** {@inheritDoc} */
- @NonNull
+ @EventType
@Override
- public EventType getEventType() {
- return EventType.RESUME;
+ public int getEventType() {
+ return EVENT_TYPE_RESUME;
}
}
}
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/VideoSpec.java b/camera/camera-video/src/main/java/androidx/camera/video/VideoSpec.java
index bb14990..e286cbd 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/VideoSpec.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/VideoSpec.java
@@ -25,6 +25,8 @@
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
+import androidx.annotation.RestrictTo;
+import androidx.annotation.RestrictTo.Scope;
import com.google.auto.value.AutoValue;
@@ -33,7 +35,9 @@
/**
* Video specification that is options to config video encoding.
+ * @hide
*/
+@RestrictTo(Scope.LIBRARY)
@AutoValue
public abstract class VideoSpec {
@@ -124,7 +128,11 @@
@NonNull
public abstract Builder toBuilder();
- /** The builder of the {@link VideoSpec}. */
+ /**
+ * The builder of the {@link VideoSpec}.
+ * @hide
+ */
+ @RestrictTo(Scope.LIBRARY)
@SuppressWarnings("StaticFinalBuilder")
@AutoValue.Builder
public abstract static class Builder {
diff --git a/camera/camera-video/src/test/java/androidx/camera/video/VideoRecordEventTest.kt b/camera/camera-video/src/test/java/androidx/camera/video/VideoRecordEventTest.kt
index 3637857..82059d9 100644
--- a/camera/camera-video/src/test/java/androidx/camera/video/VideoRecordEventTest.kt
+++ b/camera/camera-video/src/test/java/androidx/camera/video/VideoRecordEventTest.kt
@@ -45,7 +45,7 @@
TEST_RECORDING_STATE
)
- assertThat(event.eventType).isEqualTo(VideoRecordEvent.EventType.START)
+ assertThat(event.eventType).isEqualTo(VideoRecordEvent.EVENT_TYPE_START)
assertThat(event.outputOptions).isEqualTo(TEST_OUTPUT_OPTION)
assertThat(event.recordingStats).isEqualTo(TEST_RECORDING_STATE)
}
@@ -58,7 +58,7 @@
TEST_OUTPUT_RESULT
)
- assertThat(event.eventType).isEqualTo(VideoRecordEvent.EventType.FINALIZE)
+ assertThat(event.eventType).isEqualTo(VideoRecordEvent.EVENT_TYPE_FINALIZE)
assertThat(event.outputOptions).isEqualTo(TEST_OUTPUT_OPTION)
assertThat(event.recordingStats).isEqualTo(TEST_RECORDING_STATE)
assertThat(event.outputResults).isEqualTo(TEST_OUTPUT_RESULT)
@@ -79,7 +79,7 @@
cause
)
- assertThat(event.eventType).isEqualTo(VideoRecordEvent.EventType.FINALIZE)
+ assertThat(event.eventType).isEqualTo(VideoRecordEvent.EVENT_TYPE_FINALIZE)
assertThat(event.outputOptions).isEqualTo(TEST_OUTPUT_OPTION)
assertThat(event.recordingStats).isEqualTo(TEST_RECORDING_STATE)
assertThat(event.outputResults).isEqualTo(TEST_OUTPUT_RESULT)
@@ -108,7 +108,7 @@
TEST_RECORDING_STATE
)
- assertThat(event.eventType).isEqualTo(VideoRecordEvent.EventType.STATUS)
+ assertThat(event.eventType).isEqualTo(VideoRecordEvent.EVENT_TYPE_STATUS)
assertThat(event.outputOptions).isEqualTo(TEST_OUTPUT_OPTION)
assertThat(event.recordingStats).isEqualTo(TEST_RECORDING_STATE)
}
@@ -120,7 +120,7 @@
TEST_RECORDING_STATE
)
- assertThat(event.eventType).isEqualTo(VideoRecordEvent.EventType.PAUSE)
+ assertThat(event.eventType).isEqualTo(VideoRecordEvent.EVENT_TYPE_PAUSE)
assertThat(event.outputOptions).isEqualTo(TEST_OUTPUT_OPTION)
assertThat(event.recordingStats).isEqualTo(TEST_RECORDING_STATE)
}
@@ -132,7 +132,7 @@
TEST_RECORDING_STATE
)
- assertThat(event.eventType).isEqualTo(VideoRecordEvent.EventType.RESUME)
+ assertThat(event.eventType).isEqualTo(VideoRecordEvent.EVENT_TYPE_RESUME)
assertThat(event.outputOptions).isEqualTo(TEST_OUTPUT_OPTION)
assertThat(event.recordingStats).isEqualTo(TEST_RECORDING_STATE)
}
diff --git a/camera/camera-view/src/main/java/androidx/camera/view/PreviewTransformation.java b/camera/camera-view/src/main/java/androidx/camera/view/PreviewTransformation.java
index 6561438..4382484 100644
--- a/camera/camera-view/src/main/java/androidx/camera/view/PreviewTransformation.java
+++ b/camera/camera-view/src/main/java/androidx/camera/view/PreviewTransformation.java
@@ -53,6 +53,7 @@
import androidx.camera.core.ViewPort;
import androidx.camera.view.internal.compat.quirk.DeviceQuirks;
import androidx.camera.view.internal.compat.quirk.PreviewOneThirdWiderQuirk;
+import androidx.camera.view.internal.compat.quirk.TextureViewRotationQuirk;
import androidx.core.util.Preconditions;
/**
@@ -152,8 +153,14 @@
Matrix getTextureViewCorrectionMatrix() {
Preconditions.checkState(isTransformationInfoReady());
RectF surfaceRect = new RectF(0, 0, mResolution.getWidth(), mResolution.getHeight());
- return getRectToRect(surfaceRect, surfaceRect,
- -surfaceRotationToRotationDegrees(mTargetRotation));
+ int rotationDegrees = -surfaceRotationToRotationDegrees(mTargetRotation);
+
+ TextureViewRotationQuirk textureViewRotationQuirk =
+ DeviceQuirks.get(TextureViewRotationQuirk.class);
+ if (textureViewRotationQuirk != null) {
+ rotationDegrees += textureViewRotationQuirk.getCorrectionRotation(mIsFrontCamera);
+ }
+ return getRectToRect(surfaceRect, surfaceRect, rotationDegrees);
}
/**
diff --git a/camera/camera-view/src/main/java/androidx/camera/view/internal/compat/quirk/DeviceQuirksLoader.java b/camera/camera-view/src/main/java/androidx/camera/view/internal/compat/quirk/DeviceQuirksLoader.java
index d73a851..00641c7 100644
--- a/camera/camera-view/src/main/java/androidx/camera/view/internal/compat/quirk/DeviceQuirksLoader.java
+++ b/camera/camera-view/src/main/java/androidx/camera/view/internal/compat/quirk/DeviceQuirksLoader.java
@@ -47,6 +47,10 @@
quirks.add(new SurfaceViewStretchedQuirk());
}
+ if (TextureViewRotationQuirk.load()) {
+ quirks.add(new TextureViewRotationQuirk());
+ }
+
return quirks;
}
}
diff --git a/camera/camera-view/src/main/java/androidx/camera/view/internal/compat/quirk/TextureViewRotationQuirk.java b/camera/camera-view/src/main/java/androidx/camera/view/internal/compat/quirk/TextureViewRotationQuirk.java
new file mode 100644
index 0000000..d7e1880
--- /dev/null
+++ b/camera/camera-view/src/main/java/androidx/camera/view/internal/compat/quirk/TextureViewRotationQuirk.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.view.internal.compat.quirk;
+
+import android.os.Build;
+import android.view.TextureView;
+
+import androidx.camera.core.impl.Quirk;
+
+/**
+ * A quirk that requires applying extra rotation on {@link TextureView}
+ *
+ * <p> On certain devices, the rotation of the output is incorrect. One example is b/177561470.
+ * In which case, the extra rotation is needed to correct the output on {@link TextureView}.
+ */
+public class TextureViewRotationQuirk implements Quirk {
+
+ private static final String FAIRPHONE = "Fairphone";
+ private static final String FAIRPHONE_2_MODEL = "FP2";
+
+ static boolean load() {
+ return isFairphone2();
+ }
+
+ /**
+ * Gets correction needed for the given camera.
+ */
+ public int getCorrectionRotation(boolean isFrontCamera) {
+ if (isFairphone2() && isFrontCamera) {
+ // On Fairphone2, the front camera output on TextureView is rotated 180°.
+ // See: b/177561470.
+ return 180;
+ }
+ return 0;
+ }
+
+ private static boolean isFairphone2() {
+ return FAIRPHONE.equalsIgnoreCase(Build.MANUFACTURER)
+ && FAIRPHONE_2_MODEL.equalsIgnoreCase(Build.MODEL);
+ }
+}
diff --git a/camera/camera-view/src/test/java/androidx/camera/view/PreviewTransformationTest.kt b/camera/camera-view/src/test/java/androidx/camera/view/PreviewTransformationTest.kt
index f37ef2d0e..bc3c951 100644
--- a/camera/camera-view/src/test/java/androidx/camera/view/PreviewTransformationTest.kt
+++ b/camera/camera-view/src/test/java/androidx/camera/view/PreviewTransformationTest.kt
@@ -35,6 +35,7 @@
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.internal.DoNotInstrument
+import org.robolectric.util.ReflectionHelpers
import kotlin.math.roundToInt
// Size of the PreviewView. Aspect ratio 2:1.
@@ -71,19 +72,19 @@
@RunWith(RobolectricTestRunner::class)
@DoNotInstrument
@Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
-public class PreviewTransformationTest {
+class PreviewTransformationTest {
private lateinit var mPreviewTransform: PreviewTransformation
private lateinit var mView: View
@Before
- public fun setUp() {
+ fun setUp() {
mPreviewTransform = PreviewTransformation()
mView = View(ApplicationProvider.getApplicationContext())
}
@Test
- public fun withPreviewStretchedQuirk_cropRectIsAdjusted() {
+ fun withPreviewStretchedQuirk_cropRectIsAdjusted() {
// Arrange.
QuirkInjector.inject(PreviewOneThirdWiderQuirk())
@@ -100,7 +101,7 @@
}
@Test
- public fun cropRectWidthOffByOnePixel_match() {
+ fun cropRectWidthOffByOnePixel_match() {
assertThat(
isCropRectAspectRatioMatchPreviewView(
Rect(
@@ -114,7 +115,7 @@
}
@Test
- public fun cropRectWidthOffByTwoPixels_mismatch() {
+ fun cropRectWidthOffByTwoPixels_mismatch() {
assertThat(
isCropRectAspectRatioMatchPreviewView(
Rect(
@@ -138,7 +139,43 @@
}
@Test
- public fun correctTextureViewWith0Rotation() {
+ fun fairphone2BackCamera_noCorrection() {
+ ReflectionHelpers.setStaticField(Build::class.java, "MANUFACTURER", "Fairphone")
+ ReflectionHelpers.setStaticField(Build::class.java, "MODEL", "FP2")
+ assertThat(getTextureViewCorrection(Surface.ROTATION_0, BACK_CAMERA)).isEqualTo(
+ intArrayOf(
+ 0,
+ 0,
+ SURFACE_SIZE.width,
+ 0,
+ SURFACE_SIZE.width,
+ SURFACE_SIZE.height,
+ 0,
+ SURFACE_SIZE.height
+ )
+ )
+ }
+
+ @Test
+ fun fairphone2BackCamera_corrected() {
+ ReflectionHelpers.setStaticField(Build::class.java, "MANUFACTURER", "Fairphone")
+ ReflectionHelpers.setStaticField(Build::class.java, "MODEL", "FP2")
+ assertThat(getTextureViewCorrection(Surface.ROTATION_0, FRONT_CAMERA)).isEqualTo(
+ intArrayOf(
+ SURFACE_SIZE.width,
+ SURFACE_SIZE.height,
+ 0,
+ SURFACE_SIZE.height,
+ 0,
+ 0,
+ SURFACE_SIZE.width,
+ 0
+ )
+ )
+ }
+
+ @Test
+ fun correctTextureViewWith0Rotation() {
assertThat(getTextureViewCorrection(Surface.ROTATION_0)).isEqualTo(
intArrayOf(
0,
@@ -154,7 +191,7 @@
}
@Test
- public fun correctTextureViewWith90Rotation() {
+ fun correctTextureViewWith90Rotation() {
assertThat(getTextureViewCorrection(Surface.ROTATION_90)).isEqualTo(
intArrayOf(
0,
@@ -170,7 +207,7 @@
}
@Test
- public fun correctTextureViewWith180Rotation() {
+ fun correctTextureViewWith180Rotation() {
assertThat(getTextureViewCorrection(Surface.ROTATION_180)).isEqualTo(
intArrayOf(
SURFACE_SIZE.width,
@@ -186,7 +223,7 @@
}
@Test
- public fun correctTextureViewWith270Rotation() {
+ fun correctTextureViewWith270Rotation() {
assertThat(getTextureViewCorrection(Surface.ROTATION_270)).isEqualTo(
intArrayOf(
SURFACE_SIZE.width,
@@ -201,15 +238,22 @@
)
}
+ private fun getTextureViewCorrection(@RotationValue rotation: Int): IntArray {
+ return getTextureViewCorrection(rotation, BACK_CAMERA)
+ }
+
/**
* Corrects TextureView based on target rotation and return the corrected vertices.
*/
- private fun getTextureViewCorrection(@RotationValue rotation: Int): IntArray {
+ private fun getTextureViewCorrection(
+ @RotationValue rotation: Int,
+ isFrontCamera: Boolean
+ ): IntArray {
// Arrange.
mPreviewTransform.setTransformationInfo(
SurfaceRequest.TransformationInfo.of(CROP_RECT, 90, rotation),
SURFACE_SIZE,
- BACK_CAMERA
+ isFrontCamera
)
// Act.
@@ -220,18 +264,16 @@
private fun convertToIntArray(elements: FloatArray): IntArray {
var result = IntArray(elements.size)
- var index = 0
- for (element in elements) {
- result.set(index, element.roundToInt())
- index++
+ for ((index, element) in elements.withIndex()) {
+ result[index] = element.roundToInt()
}
return result
}
@Test
- public fun ratioMatch_surfaceIsScaledToFillPreviewView() {
+ fun ratioMatch_surfaceIsScaledToFillPreviewView() {
// Arrange.
mPreviewTransform.setTransformationInfo(
SurfaceRequest.TransformationInfo.of(
@@ -260,7 +302,7 @@
}
@Test
- public fun mismatchedCropRect_fitStart() {
+ fun mismatchedCropRect_fitStart() {
assertForMismatchedCropRect(
PreviewView.ScaleType.FIT_START,
LayoutDirection.LTR,
@@ -272,7 +314,7 @@
}
@Test
- public fun mismatchedCropRect_fitCenter() {
+ fun mismatchedCropRect_fitCenter() {
assertForMismatchedCropRect(
PreviewView.ScaleType.FIT_CENTER,
LayoutDirection.LTR,
@@ -284,7 +326,7 @@
}
@Test
- public fun mismatchedCropRect_fitEnd() {
+ fun mismatchedCropRect_fitEnd() {
assertForMismatchedCropRect(
PreviewView.ScaleType.FIT_END,
LayoutDirection.LTR,
@@ -296,7 +338,7 @@
}
@Test
- public fun mismatchedCropRectFrontCamera_fitStart() {
+ fun mismatchedCropRectFrontCamera_fitStart() {
assertForMismatchedCropRect(
PreviewView.ScaleType.FIT_START,
LayoutDirection.LTR,
@@ -308,7 +350,7 @@
}
@Test
- public fun mismatchedCropRect_fillStart() {
+ fun mismatchedCropRect_fillStart() {
assertForMismatchedCropRect(
PreviewView.ScaleType.FILL_START,
LayoutDirection.LTR,
@@ -320,7 +362,7 @@
}
@Test
- public fun mismatchedCropRect_fillCenter() {
+ fun mismatchedCropRect_fillCenter() {
assertForMismatchedCropRect(
PreviewView.ScaleType.FILL_CENTER,
LayoutDirection.LTR,
@@ -332,7 +374,7 @@
}
@Test
- public fun mismatchedCropRect_fillEnd() {
+ fun mismatchedCropRect_fillEnd() {
assertForMismatchedCropRect(
PreviewView.ScaleType.FILL_END,
LayoutDirection.LTR,
@@ -344,7 +386,7 @@
}
@Test
- public fun mismatchedCropRect_fitStartWithRtl_actsLikeFitEnd() {
+ fun mismatchedCropRect_fitStartWithRtl_actsLikeFitEnd() {
assertForMismatchedCropRect(
PreviewView.ScaleType.FIT_START,
LayoutDirection.RTL,
@@ -382,7 +424,7 @@
}
@Test
- public fun frontCamera0_transformationIsMirrored() {
+ fun frontCamera0_transformationIsMirrored() {
testOffCenterCropRectMirroring(FRONT_CAMERA, CROP_RECT_0, PREVIEW_VIEW_SIZE, 0)
// Assert:
@@ -393,7 +435,7 @@
}
@Test
- public fun backCamera0_transformationIsNotMirrored() {
+ fun backCamera0_transformationIsNotMirrored() {
testOffCenterCropRectMirroring(BACK_CAMERA, CROP_RECT_0, PREVIEW_VIEW_SIZE, 0)
// Assert:
@@ -404,7 +446,7 @@
}
@Test
- public fun frontCameraRotated90_transformationIsMirrored() {
+ fun frontCameraRotated90_transformationIsMirrored() {
testOffCenterCropRectMirroring(
FRONT_CAMERA, CROP_RECT_90, PIVOTED_PREVIEW_VIEW_SIZE, 90
)
@@ -417,7 +459,7 @@
}
@Test
- public fun previewViewSizeIs0_noOps() {
+ fun previewViewSizeIs0_noOps() {
testOffCenterCropRectMirroring(
FRONT_CAMERA, CROP_RECT_90, Size(0, 0), 90
)
@@ -430,7 +472,7 @@
}
@Test
- public fun backCameraRotated90_transformationIsNotMirrored() {
+ fun backCameraRotated90_transformationIsNotMirrored() {
testOffCenterCropRectMirroring(BACK_CAMERA, CROP_RECT_90, PIVOTED_PREVIEW_VIEW_SIZE, 90)
// Assert:
diff --git a/camera/integration-tests/coretestapp/build.gradle b/camera/integration-tests/coretestapp/build.gradle
index f678dc4..78c6756 100644
--- a/camera/integration-tests/coretestapp/build.gradle
+++ b/camera/integration-tests/coretestapp/build.gradle
@@ -66,9 +66,6 @@
implementation(project(":camera:camera-core"))
implementation(project(":camera:camera-lifecycle"))
implementation(project(":camera:camera-video"))
- implementation(project(":appcompat:appcompat"))
- implementation("androidx.activity:activity:1.2.0")
- implementation("androidx.fragment:fragment:1.3.0")
// Needed because AGP enforces same version between main and androidTest classpaths
implementation(project(":concurrent:concurrent-futures"))
@@ -76,6 +73,7 @@
api(libs.constraintLayout)
implementation(libs.guavaAndroid)
implementation(libs.espressoIdlingResource)
+ implementation("androidx.appcompat:appcompat:1.3.0")
// MLKit library: Barcode scanner
implementation(libs.mlkitBarcode, excludes.mlkit)
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 fa5f41b..06b2eff 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
@@ -21,6 +21,7 @@
import static androidx.camera.core.ImageCapture.FLASH_MODE_ON;
import android.Manifest;
+import android.annotation.SuppressLint;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
@@ -351,6 +352,7 @@
});
}
+ @SuppressLint("MissingPermission")
private void setUpRecordButton() {
mRecordUi.getButtonRecord().setOnClickListener((view) -> {
RecordUi.State state = mRecordUi.getState();
@@ -359,6 +361,7 @@
createDefaultVideoFolderIfNotExist();
mActiveRecording = getVideoCapture().getOutput()
.prepareRecording(getNewVideoOutputFileOptions())
+ .withAudioEnabled()
.withEventListener(ContextCompat.getMainExecutor(CameraXActivity.this),
mVideoRecordEventListener)
.start();
@@ -403,7 +406,7 @@
updateRecordingStats(event.getRecordingStats());
switch (event.getEventType()) {
- case FINALIZE:
+ case VideoRecordEvent.EVENT_TYPE_FINALIZE:
VideoRecordEvent.Finalize finalize = (VideoRecordEvent.Finalize) event;
switch (finalize.getError()) {
diff --git a/car/app/app-automotive/build.gradle b/car/app/app-automotive/build.gradle
index 7b0488e..e912238 100644
--- a/car/app/app-automotive/build.gradle
+++ b/car/app/app-automotive/build.gradle
@@ -56,6 +56,9 @@
buildFeatures {
aidl = true
}
+ buildTypes.all {
+ consumerProguardFiles "proguard-rules.pro"
+ }
useLibrary 'android.car'
testOptions.unitTests.includeAndroidResources = true
}
diff --git a/car/app/app-automotive/proguard-rules.pro b/car/app/app-automotive/proguard-rules.pro
new file mode 100644
index 0000000..462529f
--- /dev/null
+++ b/car/app/app-automotive/proguard-rules.pro
@@ -0,0 +1,13 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# Keep all IInterfaces which are needed for host communications.
+-keep class androidx.car.app.** extends android.os.IInterface { *; }
+
+# Don't obfuscate classes instantiated from outside the library via reflection
+-keep public class androidx.car.app.activity.** extends androidx.car.app.managers.Manager { *; }
+-keep public class androidx.car.app.hardware.AutomotiveCarHardwareManager { *; }
diff --git a/car/app/app-automotive/src/main/java/androidx/car/app/activity/CarAppActivity.java b/car/app/app-automotive/src/main/java/androidx/car/app/activity/CarAppActivity.java
index 830baad..9d70074 100644
--- a/car/app/app-automotive/src/main/java/androidx/car/app/activity/CarAppActivity.java
+++ b/car/app/app-automotive/src/main/java/androidx/car/app/activity/CarAppActivity.java
@@ -187,8 +187,6 @@
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
- // Set before the onCreate() as this method sets windowing information based on the theme.
- setTheme(android.R.style.Theme_DeviceDefault_NoActionBar);
super.onCreate(savedInstanceState);
setSoftInputHandling();
setContentView(R.layout.activity_template);
@@ -207,6 +205,7 @@
serviceComponentName);
mViewModel = new ViewModelProvider(this, factory).get(CarAppViewModel.class);
mViewModel.setActivity(this);
+ mViewModel.resetState();
mViewModel.getError().observe(this, this::onErrorChanged);
mViewModel.getState().observe(this, this::onStateChanged);
@@ -241,43 +240,50 @@
}
private void onErrorChanged(@Nullable ErrorHandler.ErrorType errorType) {
- mErrorMessageView.setError(errorType);
+ ThreadUtils.runOnMain(() -> {
+ mErrorMessageView.setError(errorType);
+ });
}
private void onStateChanged(@NonNull CarAppViewModel.State state) {
- requireNonNull(mSurfaceView);
- requireNonNull(mSurfaceHolderListener);
+ ThreadUtils.runOnMain(() -> {
+ requireNonNull(mSurfaceView);
+ requireNonNull(mSurfaceHolderListener);
- switch (state) {
- case IDLE:
- mSurfaceView.setVisibility(View.GONE);
- mSurfaceHolderListener.setSurfaceListener(null);
- mErrorMessageView.setVisibility(View.GONE);
- mLoadingView.setVisibility(View.GONE);
- break;
- case ERROR:
- mSurfaceView.setVisibility(View.GONE);
- mSurfaceHolderListener.setSurfaceListener(null);
- mErrorMessageView.setVisibility(View.VISIBLE);
- mLoadingView.setVisibility(View.GONE);
- break;
- case CONNECTING:
- mSurfaceView.setVisibility(View.GONE);
- mSurfaceHolderListener.setSurfaceListener(null);
- mErrorMessageView.setVisibility(View.GONE);
- mLoadingView.setVisibility(View.VISIBLE);
- break;
- case CONNECTED:
- mSurfaceView.setVisibility(View.VISIBLE);
- mErrorMessageView.setVisibility(View.GONE);
- mLoadingView.setVisibility(View.GONE);
- break;
- }
+ switch (state) {
+ case IDLE:
+ mSurfaceView.setVisibility(View.GONE);
+ mSurfaceHolderListener.setSurfaceListener(null);
+ mErrorMessageView.setVisibility(View.GONE);
+ mLoadingView.setVisibility(View.GONE);
+ break;
+ case ERROR:
+ mSurfaceView.setVisibility(View.GONE);
+ mSurfaceHolderListener.setSurfaceListener(null);
+ mErrorMessageView.setVisibility(View.VISIBLE);
+ mLoadingView.setVisibility(View.GONE);
+ break;
+ case CONNECTING:
+ mSurfaceView.setVisibility(View.GONE);
+ mErrorMessageView.setVisibility(View.GONE);
+ mLoadingView.setVisibility(View.VISIBLE);
+ break;
+ case CONNECTED:
+ mSurfaceView.setVisibility(View.VISIBLE);
+ mErrorMessageView.setVisibility(View.GONE);
+ mLoadingView.setVisibility(View.GONE);
+ break;
+ }
+ });
}
@Override
protected void onNewIntent(@NonNull Intent intent) {
super.onNewIntent(intent);
+
+ requireNonNull(mSurfaceHolderListener).setSurfaceListener(null);
+ requireNonNull(mActivityLifecycleDelegate).registerRendererCallback(null);
+
requireNonNull(mViewModel).bind(intent, mCarActivity, getDisplayId());
}
diff --git a/car/app/app-automotive/src/main/java/androidx/car/app/activity/CarAppViewModel.java b/car/app/app-automotive/src/main/java/androidx/car/app/activity/CarAppViewModel.java
index ad3f433..7011f44 100644
--- a/car/app/app-automotive/src/main/java/androidx/car/app/activity/CarAppViewModel.java
+++ b/car/app/app-automotive/src/main/java/androidx/car/app/activity/CarAppViewModel.java
@@ -93,11 +93,18 @@
mIRendererCallback = rendererCallback;
}
- /** Updates the activity hosting this view model */
+ /** Updates the activity hosting this view model. */
void setActivity(@Nullable Activity activity) {
sActivity = new WeakReference<>(activity);
}
+ /** Resets the internal state of this view model. */
+ @SuppressWarnings("NullAway")
+ void resetState() {
+ mState.setValue(State.IDLE);
+ mError.setValue(null);
+ }
+
/**
* Binds to the renderer service and initializes the service if not bound already.
*
@@ -107,8 +114,8 @@
@SuppressWarnings("NullAway")
void bind(@NonNull Intent intent, @NonNull ICarAppActivity iCarAppActivity,
int displayId) {
- mState.postValue(State.CONNECTING);
- mError.postValue(null);
+ mState.setValue(State.CONNECTING);
+ mError.setValue(null);
mServiceConnectionManager.bind(intent, iCarAppActivity, displayId);
}
@@ -123,7 +130,7 @@
if (mIRendererCallback != null) {
getServiceDispatcher().dispatch("onDestroyed", mIRendererCallback::onDestroyed);
}
- mState.postValue(State.IDLE);
+ mState.setValue(State.IDLE);
unbind();
}
@@ -158,9 +165,9 @@
// displayed to the user.
return;
}
- mError.postValue(errorCode);
+ mState.setValue(State.ERROR);
+ mError.setValue(errorCode);
});
- mState.postValue(State.ERROR);
unbind();
}
@@ -170,16 +177,15 @@
@SuppressWarnings("NullAway")
@Override
public void onConnect() {
- mState.postValue(State.CONNECTED);
- mError.postValue(null);
+ mState.setValue(State.CONNECTED);
+ mError.setValue(null);
}
/** Attempts to rebind to the host service */
@SuppressWarnings("NullAway")
public void retryBinding() {
Activity activity = requireNonNull(sActivity.get());
- mState.postValue(State.CONNECTING);
- mError.postValue(null);
+ mError.setValue(null);
activity.recreate();
}
diff --git a/car/app/app-automotive/src/main/java/androidx/car/app/hardware/common/PropertyRequestProcessor.java b/car/app/app-automotive/src/main/java/androidx/car/app/hardware/common/PropertyRequestProcessor.java
index ecda272..5d258a9 100644
--- a/car/app/app-automotive/src/main/java/androidx/car/app/hardware/common/PropertyRequestProcessor.java
+++ b/car/app/app-automotive/src/main/java/androidx/car/app/hardware/common/PropertyRequestProcessor.java
@@ -23,6 +23,7 @@
import android.car.hardware.CarPropertyValue;
import android.car.hardware.property.CarPropertyManager;
import android.content.Context;
+import android.util.ArraySet;
import android.util.Pair;
import androidx.annotation.NonNull;
@@ -31,6 +32,8 @@
import java.util.ArrayList;
import java.util.List;
+import javax.annotation.Nullable;
+
/**
* A class for interacting with the {@link CarPropertyManager} for getting any vehicle property.
*
@@ -119,8 +122,7 @@
List<CarInternalError> errors = new ArrayList<>();
for (Pair<Integer, Integer> request : requests) {
try {
- CarPropertyConfig<?> propertyConfig =
- mCarPropertyManager.getCarPropertyConfig(request.first);
+ CarPropertyConfig<?> propertyConfig = getPropertyConfig(request.first);
if (propertyConfig == null) {
errors.add(CarInternalError.create(request.first, request.second,
CarValue.STATUS_UNIMPLEMENTED));
@@ -131,7 +133,6 @@
values.add(propertyValue);
}
} catch (IllegalArgumentException e) {
- // TODO(b/191084385): consider using exception inside CarValue
errors.add(CarInternalError.create(request.first, request.second,
CarValue.STATUS_UNIMPLEMENTED));
} catch (Exception e) {
@@ -150,7 +151,7 @@
* @throws IllegalArgumentException if a property is not implemented in the car
*/
public void registerProperty(int propertyId, float sampleRate) {
- if (mCarPropertyManager.getCarPropertyConfig(propertyId) == null) {
+ if (getPropertyConfig(propertyId) == null) {
throw new IllegalArgumentException("Property is not implemented in the car: "
+ propertyId);
}
@@ -164,7 +165,7 @@
* @throws IllegalArgumentException if a property is not implemented in the car
*/
public void unregisterProperty(int propertyId) {
- if (mCarPropertyManager.getCarPropertyConfig(propertyId) == null) {
+ if (getPropertyConfig(propertyId) == null) {
throw new IllegalArgumentException("Property is not implemented in the car: "
+ propertyId);
}
@@ -176,4 +177,13 @@
mCarPropertyManager = (CarPropertyManager) car.getCarManager(Car.PROPERTY_SERVICE);
mPropertyEventCallback = callback;
}
+
+ @SuppressWarnings("rawtypes")
+ @Nullable
+ private CarPropertyConfig<?> getPropertyConfig(int propertyId) {
+ ArraySet<Integer> propertySet = new ArraySet<>(1);
+ propertySet.add(propertyId);
+ List<CarPropertyConfig> configs = mCarPropertyManager.getPropertyList(propertySet);
+ return configs.size() == 0 ? null : configs.get(0);
+ }
}
diff --git a/car/app/app-automotive/src/main/java/androidx/car/app/hardware/info/AutomotiveCarInfo.java b/car/app/app-automotive/src/main/java/androidx/car/app/hardware/info/AutomotiveCarInfo.java
index a8c748c..1d13783 100644
--- a/car/app/app-automotive/src/main/java/androidx/car/app/hardware/info/AutomotiveCarInfo.java
+++ b/car/app/app-automotive/src/main/java/androidx/car/app/hardware/info/AutomotiveCarInfo.java
@@ -337,7 +337,7 @@
if (responseListener != null) {
mPropertyManager.submitUnregisterListenerRequest(responseListener);
} else {
- throw new IllegalArgumentException("Listener is not registered yet");
+ Log.d(LogTags.TAG_CAR_HARDWARE, "Listener is not registered yet");
}
}
diff --git a/car/app/app-automotive/src/test/java/androidx/car/app/activity/CarAppActivityTest.java b/car/app/app-automotive/src/test/java/androidx/car/app/activity/CarAppActivityTest.java
index 1435075..a633647 100644
--- a/car/app/app-automotive/src/test/java/androidx/car/app/activity/CarAppActivityTest.java
+++ b/car/app/app-automotive/src/test/java/androidx/car/app/activity/CarAppActivityTest.java
@@ -262,10 +262,10 @@
verify(rendererCallback, times(1)).onBackPressed();
// Verify focus request sent to host.
- activity.mSurfaceView.requestFocus();
- verify(callback, times(1)).onWindowFocusChanged(true, false);
activity.mSurfaceView.clearFocus();
verify(callback, times(1)).onWindowFocusChanged(false, false);
+ activity.mSurfaceView.requestFocus();
+ verify(callback, times(1)).onWindowFocusChanged(true, false);
long downTime = SystemClock.uptimeMillis();
long eventTime = SystemClock.uptimeMillis();
diff --git a/car/app/app-automotive/src/test/java/androidx/car/app/activity/CarAppViewModelTest.java b/car/app/app-automotive/src/test/java/androidx/car/app/activity/CarAppViewModelTest.java
index 7c80e2c..9a2a52e 100644
--- a/car/app/app-automotive/src/test/java/androidx/car/app/activity/CarAppViewModelTest.java
+++ b/car/app/app-automotive/src/test/java/androidx/car/app/activity/CarAppViewModelTest.java
@@ -131,7 +131,7 @@
mMainLooper.idle();
assertThat(mCarAppViewModel.getState().getValue())
- .isEqualTo(CarAppViewModel.State.CONNECTING);
+ .isEqualTo(CarAppViewModel.State.IDLE);
assertThat(mCarAppViewModel.getError().getValue()).isNull();
}
}
diff --git a/car/app/app-automotive/src/test/java/androidx/car/app/hardware/common/MockedCarTestBase.java b/car/app/app-automotive/src/test/java/androidx/car/app/hardware/common/MockedCarTestBase.java
index 6ada7e0..e6cb9f1 100644
--- a/car/app/app-automotive/src/test/java/androidx/car/app/hardware/common/MockedCarTestBase.java
+++ b/car/app/app-automotive/src/test/java/androidx/car/app/hardware/common/MockedCarTestBase.java
@@ -19,6 +19,7 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.when;
@@ -38,6 +39,8 @@
import org.robolectric.annotation.Config;
import org.robolectric.annotation.internal.DoNotInstrument;
+import java.util.Collections;
+
/**
* Base class for testing with mocked car.
*/
@@ -82,16 +85,17 @@
when(mCarMock.getCarManager(anyString())).thenReturn(mCarPropertyManagerMock);
// Mocks car property manager
- doReturn(mModelYearConfigMock).when(mCarPropertyManagerMock)
- .getCarPropertyConfig(eq(VehiclePropertyIds.INFO_MODEL_YEAR));
+ doReturn(Collections.singletonList(mModelYearConfigMock)).when(mCarPropertyManagerMock)
+ .getPropertyList(
+ argThat((set) -> set.contains(VehiclePropertyIds.INFO_MODEL_YEAR)));
doReturn(mModelYearValueMock).when(mCarPropertyManagerMock).getProperty(
any(), eq(VehiclePropertyIds.INFO_MODEL_YEAR), anyInt());
- doReturn(mManufacturerConfigMock).when(mCarPropertyManagerMock)
- .getCarPropertyConfig(eq(VehiclePropertyIds.INFO_MAKE));
+ doReturn(Collections.singletonList(mManufacturerConfigMock)).when(mCarPropertyManagerMock)
+ .getPropertyList(argThat((set) -> set.contains(VehiclePropertyIds.INFO_MAKE)));
doReturn(mManufacturerValueMock).when(mCarPropertyManagerMock).getProperty(
any(), eq(VehiclePropertyIds.INFO_MAKE), anyInt());
- doReturn(mModelNameConfigMock).when(mCarPropertyManagerMock)
- .getCarPropertyConfig(eq(VehiclePropertyIds.INFO_MODEL));
+ doReturn(Collections.singletonList(mModelNameConfigMock)).when(mCarPropertyManagerMock)
+ .getPropertyList(argThat((set) -> set.contains(VehiclePropertyIds.INFO_MODEL)));
doReturn(mModelNameValueMock).when(mCarPropertyManagerMock).getProperty(
any(), eq(VehiclePropertyIds.INFO_MODEL), anyInt());
diff --git a/car/app/app-automotive/src/test/java/androidx/car/app/hardware/common/PropertyManagerTest.java b/car/app/app-automotive/src/test/java/androidx/car/app/hardware/common/PropertyManagerTest.java
index 341b66d..ff5ac31 100644
--- a/car/app/app-automotive/src/test/java/androidx/car/app/hardware/common/PropertyManagerTest.java
+++ b/car/app/app-automotive/src/test/java/androidx/car/app/hardware/common/PropertyManagerTest.java
@@ -129,8 +129,7 @@
requests.add(GetPropertyRequest.create(VehiclePropertyIds.INFO_FUEL_TYPE));
ListenableFuture<List<CarPropertyResponse<?>>> future =
- mPropertyManager.submitGetPropertyRequest(requests,
- mExecutor);
+ mPropertyManager.submitGetPropertyRequest(requests, mExecutor);
List<CarPropertyResponse<?>> responses = future.get();
CarPropertyResponse<?> response = responses.get(0);
diff --git a/car/app/app-projected/proguard-rules.pro b/car/app/app-projected/proguard-rules.pro
index fd43f97d..ac3430f 100644
--- a/car/app/app-projected/proguard-rules.pro
+++ b/car/app/app-projected/proguard-rules.pro
@@ -7,3 +7,6 @@
# Keep all IInterfaces which are needed for host communications.
-keep class androidx.car.app.** extends android.os.IInterface { *; }
+
+# Don't obfuscate classes instantiated from outside the library via reflection
+-keep public class androidx.car.app.hardware.ProjectedCarHardwareManager { *; }
diff --git a/car/app/app-samples/helloworld/automotive/src/main/AndroidManifest.xml b/car/app/app-samples/helloworld/automotive/src/main/AndroidManifest.xml
index b8a05e6..1a2c9bb 100644
--- a/car/app/app-samples/helloworld/automotive/src/main/AndroidManifest.xml
+++ b/car/app/app-samples/helloworld/automotive/src/main/AndroidManifest.xml
@@ -20,7 +20,22 @@
android:versionCode="1"
android:versionName="1.0">
- <uses-feature android:name="android.software.car.templates_host" />
+ <!-- Various required feature settings for an automotive app. -->
+ <uses-feature
+ android:name="android.hardware.type.automotive"
+ android:required="true" />
+ <uses-feature
+ android:name="android.software.car.templates_host"
+ android:required="true" />
+ <uses-feature
+ android:name="android.hardware.wifi"
+ android:required="false" />
+ <uses-feature
+ android:name="android.hardware.screen.portrait"
+ android:required="false" />
+ <uses-feature
+ android:name="android.hardware.screen.landscape"
+ android:required="false" />
<application
android:label="@string/app_name"
@@ -46,6 +61,7 @@
<activity
android:name="androidx.car.app.activity.CarAppActivity"
+ android:theme="@android:style/Theme.DeviceDefault.NoActionBar"
android:exported="true"
android:label="Hello World">
diff --git a/car/app/app-samples/navigation/automotive/src/main/AndroidManifest.xml b/car/app/app-samples/navigation/automotive/src/main/AndroidManifest.xml
index 90724b2..725f8d1 100644
--- a/car/app/app-samples/navigation/automotive/src/main/AndroidManifest.xml
+++ b/car/app/app-samples/navigation/automotive/src/main/AndroidManifest.xml
@@ -28,7 +28,22 @@
<uses-permission android:name="androidx.car.app.ACCESS_SURFACE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
- <uses-feature android:name="android.software.car.templates_host" />
+ <!-- Various required feature settings for an automotive app. -->
+ <uses-feature
+ android:name="android.hardware.type.automotive"
+ android:required="true" />
+ <uses-feature
+ android:name="android.software.car.templates_host"
+ android:required="true" />
+ <uses-feature
+ android:name="android.hardware.wifi"
+ android:required="false" />
+ <uses-feature
+ android:name="android.hardware.screen.portrait"
+ android:required="false" />
+ <uses-feature
+ android:name="android.hardware.screen.landscape"
+ android:required="false" />
<application
android:label="@string/app_name"
@@ -62,6 +77,7 @@
<activity
android:name="androidx.car.app.activity.CarAppActivity"
+ android:theme="@android:style/Theme.DeviceDefault.NoActionBar"
android:exported="true"
android:label="Navigation">
diff --git a/car/app/app-samples/places/automotive/src/main/AndroidManifest.xml b/car/app/app-samples/places/automotive/src/main/AndroidManifest.xml
index 08a911a..5add5d2 100644
--- a/car/app/app-samples/places/automotive/src/main/AndroidManifest.xml
+++ b/car/app/app-samples/places/automotive/src/main/AndroidManifest.xml
@@ -27,7 +27,22 @@
<!-- For PlaceListMapTemplate -->
<uses-permission android:name="androidx.car.app.MAP_TEMPLATES"/>
- <uses-feature android:name="android.software.car.templates_host" />
+ <!-- Various required feature settings for an automotive app. -->
+ <uses-feature
+ android:name="android.hardware.type.automotive"
+ android:required="true" />
+ <uses-feature
+ android:name="android.software.car.templates_host"
+ android:required="true" />
+ <uses-feature
+ android:name="android.hardware.wifi"
+ android:required="false" />
+ <uses-feature
+ android:name="android.hardware.screen.portrait"
+ android:required="false" />
+ <uses-feature
+ android:name="android.hardware.screen.landscape"
+ android:required="false" />
<application
android:label="@string/app_name"
@@ -58,6 +73,7 @@
<activity
android:name="androidx.car.app.activity.CarAppActivity"
+ android:theme="@android:style/Theme.DeviceDefault.NoActionBar"
android:exported="true"
android:label="Places">
diff --git a/car/app/app-samples/showcase/automotive/src/main/AndroidManifest.xml b/car/app/app-samples/showcase/automotive/src/main/AndroidManifest.xml
index 8fca3cc..6c2e940 100644
--- a/car/app/app-samples/showcase/automotive/src/main/AndroidManifest.xml
+++ b/car/app/app-samples/showcase/automotive/src/main/AndroidManifest.xml
@@ -41,7 +41,22 @@
<uses-permission android:name="android.car.permission.READ_CAR_DISPLAY_UNITS"/>
<uses-permission android:name="android.car.permission.CAR_ENERGY_PORTS"/>
- <uses-feature android:name="android.software.car.templates_host" />
+ <!-- Various required feature settings for an automotive app. -->
+ <uses-feature
+ android:name="android.hardware.type.automotive"
+ android:required="true" />
+ <uses-feature
+ android:name="android.software.car.templates_host"
+ android:required="true" />
+ <uses-feature
+ android:name="android.hardware.wifi"
+ android:required="false" />
+ <uses-feature
+ android:name="android.hardware.screen.portrait"
+ android:required="false" />
+ <uses-feature
+ android:name="android.hardware.screen.landscape"
+ android:required="false" />
<application
android:label="@string/app_name"
@@ -88,6 +103,7 @@
<activity
android:name="androidx.car.app.activity.CarAppActivity"
+ android:theme="@android:style/Theme.DeviceDefault.NoActionBar"
android:exported="true"
android:launchMode="singleTask"
android:label="Showcase">
@@ -101,6 +117,7 @@
<activity
android:name="androidx.car.app.sample.showcase.automotive.DebugActivity"
+ android:theme="@android:style/Theme.DeviceDefault.NoActionBar"
android:exported="true"
android:launchMode="singleTask"
android:label="Showcase - Debug">
diff --git a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/ShowcaseSession.java b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/ShowcaseSession.java
index 891da1b..93cfc6d 100644
--- a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/ShowcaseSession.java
+++ b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/ShowcaseSession.java
@@ -33,8 +33,9 @@
import androidx.car.app.sample.showcase.common.misc.RequestPermissionScreen;
import androidx.car.app.sample.showcase.common.misc.ResultDemoScreen;
import androidx.car.app.sample.showcase.common.navigation.NavigationNotificationsDemoScreen;
-import androidx.car.app.sample.showcase.common.navigation.SurfaceRenderer;
import androidx.car.app.sample.showcase.common.navigation.routing.NavigatingDemoScreen;
+import androidx.car.app.sample.showcase.common.renderer.Renderer;
+import androidx.car.app.sample.showcase.common.renderer.SurfaceController;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleOwner;
@@ -45,14 +46,14 @@
static final String URI_HOST = "showcase";
@Nullable
- private SurfaceRenderer mRenderer;
+ private SurfaceController mSurfaceController;
@NonNull
@Override
public Screen onCreateScreen(@NonNull Intent intent) {
Lifecycle lifecycle = getLifecycle();
lifecycle.addObserver(this);
- mRenderer = new SurfaceRenderer(getCarContext(), lifecycle);
+ mSurfaceController = new SurfaceController(getCarContext(), lifecycle);
if (CarContext.ACTION_NAVIGATE.equals(intent.getAction())) {
// Handle the navigation Intent by pushing first the "home" screen onto the stack, then
@@ -144,13 +145,13 @@
@Override
public void onCarConfigurationChanged(@NonNull Configuration configuration) {
- if (mRenderer != null) {
- mRenderer.onCarConfigurationChanged();
+ if (mSurfaceController != null) {
+ mSurfaceController.onCarConfigurationChanged();
}
}
- /** Tells the session whether to update the renderer to show car hardware information. */
- public void setCarHardwareSurfaceRendererEnabledState(boolean isEnabled) {
- mRenderer.setCarHardwareSurfaceRendererEnabledState(isEnabled);
+ /** Tells the session whether to override the default renderer. */
+ public void overrideRenderer(@Nullable Renderer renderer) {
+ mSurfaceController.overrideRenderer(renderer);
}
}
diff --git a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/misc/CarHardwareDemoScreen.java b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/misc/CarHardwareDemoScreen.java
index 148d26c..0e251b4 100644
--- a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/misc/CarHardwareDemoScreen.java
+++ b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/misc/CarHardwareDemoScreen.java
@@ -24,6 +24,7 @@
import androidx.car.app.model.Template;
import androidx.car.app.navigation.model.NavigationTemplate;
import androidx.car.app.sample.showcase.common.ShowcaseSession;
+import androidx.car.app.sample.showcase.common.renderer.CarHardwareRenderer;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleOwner;
@@ -31,9 +32,12 @@
/** Simple demo of how access car hardware information. */
public final class CarHardwareDemoScreen extends Screen {
+ final CarHardwareRenderer mCarHardwareRenderer;
+
public CarHardwareDemoScreen(@NonNull CarContext carContext,
@NonNull ShowcaseSession showcaseSession) {
super(carContext);
+ mCarHardwareRenderer = new CarHardwareRenderer(carContext);
Lifecycle lifecycle = getLifecycle();
lifecycle.addObserver(new DefaultLifecycleObserver() {
@@ -43,14 +47,14 @@
public void onResume(@NonNull LifecycleOwner owner) {
// When this screen is visible set the SurfaceRenderer to show
// CarHardware information.
- mShowcaseSession.setCarHardwareSurfaceRendererEnabledState(true);
+ mShowcaseSession.overrideRenderer(mCarHardwareRenderer);
}
@Override
public void onPause(@NonNull LifecycleOwner owner) {
// When this screen is hidden set the SurfaceRenderer to show
// CarHardware information.
- mShowcaseSession.setCarHardwareSurfaceRendererEnabledState(false);
+ mShowcaseSession.overrideRenderer(null);
}
});
}
diff --git a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/misc/RequestPermissionScreen.java b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/misc/RequestPermissionScreen.java
index 419896a..ad5e150 100644
--- a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/misc/RequestPermissionScreen.java
+++ b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/misc/RequestPermissionScreen.java
@@ -91,6 +91,11 @@
if (declaredPermissions != null) {
for (String declaredPermission : declaredPermissions) {
+ // Don't include permissions against the car app host as they are all normal but
+ // show up as ungranted by the system.
+ if (declaredPermission.startsWith("androidx.car.app")) {
+ continue;
+ }
try {
CarAppPermission.checkHasPermission(getCarContext(), declaredPermission);
} catch (SecurityException e) {
diff --git a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/navigation/SurfaceRenderer.java b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/navigation/SurfaceRenderer.java
deleted file mode 100644
index 02c63a3..0000000
--- a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/navigation/SurfaceRenderer.java
+++ /dev/null
@@ -1,757 +0,0 @@
-/*
- * Copyright (C) 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * 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.car.app.sample.showcase.common.navigation;
-
-import android.graphics.Canvas;
-import android.graphics.Color;
-import android.graphics.Paint;
-import android.graphics.Paint.Align;
-import android.graphics.Paint.Style;
-import android.graphics.Rect;
-import android.util.Log;
-import android.view.Surface;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.car.app.AppManager;
-import androidx.car.app.CarContext;
-import androidx.car.app.SurfaceCallback;
-import androidx.car.app.SurfaceContainer;
-import androidx.car.app.hardware.CarHardwareManager;
-import androidx.car.app.hardware.common.CarValue;
-import androidx.car.app.hardware.common.OnCarDataAvailableListener;
-import androidx.car.app.hardware.info.Accelerometer;
-import androidx.car.app.hardware.info.CarHardwareLocation;
-import androidx.car.app.hardware.info.CarInfo;
-import androidx.car.app.hardware.info.CarSensors;
-import androidx.car.app.hardware.info.Compass;
-import androidx.car.app.hardware.info.EnergyLevel;
-import androidx.car.app.hardware.info.EnergyProfile;
-import androidx.car.app.hardware.info.Gyroscope;
-import androidx.car.app.hardware.info.Mileage;
-import androidx.car.app.hardware.info.Model;
-import androidx.car.app.hardware.info.Speed;
-import androidx.car.app.hardware.info.TollCard;
-import androidx.core.content.ContextCompat;
-import androidx.lifecycle.DefaultLifecycleObserver;
-import androidx.lifecycle.Lifecycle;
-import androidx.lifecycle.LifecycleOwner;
-
-import java.util.List;
-import java.util.concurrent.Executor;
-
-/** A very simple implementation of a renderer for the app's background surface. */
-public final class SurfaceRenderer implements DefaultLifecycleObserver {
- private static final String TAG = "showcase";
-
- private static final int HORIZONTAL_TEXT_MARGIN = 10;
- private static final int VERTICAL_TEXT_MARGIN_FROM_TOP = 20;
- private static final int VERTICAL_TEXT_MARGIN_FROM_BOTTOM = 10;
-
- private final CarContext mCarContext;
- private final Executor mCarHardwareExecutor;
- private final Paint mLeftInsetPaint = new Paint();
- private final Paint mRightInsetPaint = new Paint();
- private final Paint mCenterPaint = new Paint();
- private final Paint mCarInfoPaint = new Paint();
- @Nullable
- Surface mSurface;
- @Nullable
- Rect mVisibleArea;
- @Nullable
- Rect mStableArea;
- @Nullable
- Model mModel;
- @Nullable
- EnergyProfile mEnergyProfile;
- @Nullable
- TollCard mTollCard;
- @Nullable
- EnergyLevel mEnergyLevel;
- @Nullable
- Speed mSpeed;
- @Nullable
- Mileage mMileage;
- @Nullable
- Accelerometer mAccelerometer;
- @Nullable
- Gyroscope mGyroscope;
- @Nullable
- Compass mCompass;
- @Nullable
- CarHardwareLocation mCarHardwareLocation;
- private boolean mShowCarHardwareSurfaceInfo;
- private boolean mHasModelPermission;
- private boolean mHasEnergyProfilePermission;
- private boolean mHasTollCardPermission;
- private boolean mHasEnergyLevelPermission;
- private boolean mHasSpeedPermission;
- private boolean mHasMileagePermission;
- private boolean mHasAccelerometerPermission;
- private boolean mHasGyroscopePermission;
- private boolean mHasCompassPermission;
- private boolean mHasCarHardwareLocationPermission;
- private final SurfaceCallback mSurfaceCallback =
- new SurfaceCallback() {
- @Override
- public void onSurfaceAvailable(@NonNull SurfaceContainer surfaceContainer) {
- Log.i(TAG, "Surface available " + surfaceContainer);
- mSurface = surfaceContainer.getSurface();
- renderFrame();
- }
-
- @Override
- public void onVisibleAreaChanged(@NonNull Rect visibleArea) {
- synchronized (SurfaceRenderer.this) {
- Log.i(TAG, "Visible area changed " + mSurface + ". stableArea: "
- + mStableArea + " visibleArea:" + visibleArea);
- mVisibleArea = visibleArea;
- renderFrame();
- }
- }
-
- @Override
- public void onStableAreaChanged(@NonNull Rect stableArea) {
- synchronized (SurfaceRenderer.this) {
- Log.i(TAG, "Stable area changed " + mSurface + ". stableArea: "
- + mStableArea + " visibleArea:" + mVisibleArea);
- mStableArea = stableArea;
- renderFrame();
- }
- }
-
- @Override
- public void onSurfaceDestroyed(@NonNull SurfaceContainer surfaceContainer) {
- synchronized (SurfaceRenderer.this) {
- mSurface = null;
- }
- }
- };
- private OnCarDataAvailableListener<Model> mModelListener = data -> {
- synchronized (SurfaceRenderer.this) {
- Log.i(TAG, "Received model information: " + data);
- mModel = data;
- renderFrame();
- }
- };
- private OnCarDataAvailableListener<EnergyProfile> mEnergyProfileListener = data -> {
- synchronized (SurfaceRenderer.this) {
- Log.i(TAG, "Received energy profile information: " + data);
- mEnergyProfile = data;
- renderFrame();
- }
- };
- private OnCarDataAvailableListener<TollCard> mTollListener = data -> {
- synchronized (SurfaceRenderer.this) {
- Log.i(TAG, "Received toll information:" + data);
- mTollCard = data;
- renderFrame();
- }
- };
- private OnCarDataAvailableListener<EnergyLevel> mEnergyLevelListener = data -> {
- synchronized (SurfaceRenderer.this) {
- Log.i(TAG, "Received energy level information: " + data);
- mEnergyLevel = data;
- renderFrame();
- }
- };
- private OnCarDataAvailableListener<Speed> mSpeedListener = data -> {
- synchronized (SurfaceRenderer.this) {
- Log.i(TAG, "Received speed information: " + data);
- mSpeed = data;
- renderFrame();
- }
- };
- private OnCarDataAvailableListener<Mileage> mMileageListener = data -> {
- synchronized (SurfaceRenderer.this) {
- Log.i(TAG, "Received mileage: " + data);
- mMileage = data;
- renderFrame();
- }
- };
- private OnCarDataAvailableListener<Accelerometer> mAccelerometerListener = data -> {
- synchronized (SurfaceRenderer.this) {
- Log.i(TAG, "Received accelerometer: " + data);
- mAccelerometer = data;
- renderFrame();
- }
- };
- private OnCarDataAvailableListener<Gyroscope> mGyroscopeListener = data -> {
- synchronized (SurfaceRenderer.this) {
- Log.i(TAG, "Received gyroscope: " + data);
- mGyroscope = data;
- renderFrame();
- }
- };
- private OnCarDataAvailableListener<Compass> mCompassListener = data -> {
- synchronized (SurfaceRenderer.this) {
- Log.i(TAG, "Received compass: " + data);
- mCompass = data;
- renderFrame();
- }
- };
- private OnCarDataAvailableListener<CarHardwareLocation> mCarLocationListener = data -> {
- synchronized (SurfaceRenderer.this) {
- Log.i(TAG, "Received car location: " + data);
- mCarHardwareLocation = data;
- renderFrame();
- }
- };
-
- public SurfaceRenderer(@NonNull CarContext carContext, @NonNull Lifecycle lifecycle) {
- mCarContext = carContext;
-
- mLeftInsetPaint.setColor(Color.RED);
- mLeftInsetPaint.setAntiAlias(true);
- mLeftInsetPaint.setStyle(Style.STROKE);
-
- mRightInsetPaint.setColor(Color.RED);
- mRightInsetPaint.setAntiAlias(true);
- mRightInsetPaint.setStyle(Style.STROKE);
- mRightInsetPaint.setTextAlign(Align.RIGHT);
-
- mCenterPaint.setColor(Color.BLUE);
- mCenterPaint.setAntiAlias(true);
- mCenterPaint.setStyle(Style.STROKE);
-
- mCarInfoPaint.setColor(Color.BLACK);
- mCarInfoPaint.setAntiAlias(true);
- mCarInfoPaint.setStyle(Style.STROKE);
- mCarInfoPaint.setTextAlign(Align.CENTER);
- mCarHardwareExecutor = ContextCompat.getMainExecutor(mCarContext);
-
- lifecycle.addObserver(this);
- }
-
- /** Callback called when the car configuration changes. */
- public void onCarConfigurationChanged() {
- renderFrame();
- }
-
- /** Tells the renderer whether to subscribe and show car hardware information. */
- public void setCarHardwareSurfaceRendererEnabledState(boolean isEnabled) {
- if (isEnabled == mShowCarHardwareSurfaceInfo) {
- return;
- }
- CarHardwareManager carHardwareManager =
- mCarContext.getCarService(CarHardwareManager.class);
- CarInfo carInfo = carHardwareManager.getCarInfo();
- CarSensors carSensors = carHardwareManager.getCarSensors();
- if (isEnabled) {
- // Request any single shot values.
- mModel = null;
- try {
- carInfo.fetchModel(mCarHardwareExecutor, mModelListener);
- mHasModelPermission = true;
- } catch (SecurityException e) {
- mHasModelPermission = false;
- }
-
- mEnergyProfile = null;
- try {
- carInfo.fetchModel(mCarHardwareExecutor, mModelListener);
- mHasEnergyProfilePermission = true;
- } catch (SecurityException e) {
- mHasEnergyProfilePermission = false;
- }
- carInfo.fetchEnergyProfile(mCarHardwareExecutor, mEnergyProfileListener);
-
- // Request car info subscription items.
- mTollCard = null;
- try {
- carInfo.fetchModel(mCarHardwareExecutor, mModelListener);
- mHasTollCardPermission = true;
- } catch (SecurityException e) {
- mHasTollCardPermission = false;
- }
- carInfo.addTollListener(mCarHardwareExecutor, mTollListener);
-
- mEnergyLevel = null;
- try {
- carInfo.addEnergyLevelListener(mCarHardwareExecutor, mEnergyLevelListener);
- mHasEnergyLevelPermission = true;
- } catch (SecurityException e) {
- mHasEnergyLevelPermission = false;
- }
-
- mSpeed = null;
- try {
- carInfo.addSpeedListener(mCarHardwareExecutor, mSpeedListener);
- mHasSpeedPermission = true;
- } catch (SecurityException e) {
- mHasSpeedPermission = false;
- }
-
- mMileage = null;
- try {
- carInfo.addMileageListener(mCarHardwareExecutor, mMileageListener);
- mHasMileagePermission = true;
- } catch (SecurityException e) {
- mHasMileagePermission = false;
- }
-
- // Request sensors
- mCompass = null;
- try {
- carSensors.addCompassListener(CarSensors.UPDATE_RATE_NORMAL, mCarHardwareExecutor,
- mCompassListener);
- mHasCompassPermission = true;
- } catch (SecurityException e) {
- mHasCompassPermission = false;
- }
-
- mGyroscope = null;
- try {
- carSensors.addGyroscopeListener(CarSensors.UPDATE_RATE_NORMAL, mCarHardwareExecutor,
- mGyroscopeListener);
- mHasGyroscopePermission = true;
- } catch (SecurityException e) {
- mHasGyroscopePermission = false;
- }
-
- mAccelerometer = null;
- try {
- carSensors.addAccelerometerListener(CarSensors.UPDATE_RATE_NORMAL,
- mCarHardwareExecutor,
- mAccelerometerListener);
- mHasAccelerometerPermission = true;
- } catch (SecurityException e) {
- mHasAccelerometerPermission = false;
- }
-
- mCarHardwareLocation = null;
- try {
- carSensors.addCarHardwareLocationListener(CarSensors.UPDATE_RATE_NORMAL,
- mCarHardwareExecutor, mCarLocationListener);
- mHasCarHardwareLocationPermission = true;
- } catch (SecurityException e) {
- mHasCarHardwareLocationPermission = false;
- }
- } else {
- try {
- // Unsubscribe carinfo
- carInfo.removeTollListener(mTollListener);
- mHasTollCardPermission = true;
- } catch (SecurityException e) {
- mHasTollCardPermission = false;
- }
-
- mTollCard = null;
- try {
- carInfo.removeEnergyLevelListener(mEnergyLevelListener);
- mHasEnergyLevelPermission = true;
- } catch (SecurityException e) {
- mHasEnergyLevelPermission = false;
- }
-
- mEnergyLevel = null;
- try {
- carInfo.removeSpeedListener(mSpeedListener);
- mHasSpeedPermission = true;
- } catch (SecurityException e) {
- mHasSpeedPermission = false;
- }
-
- mSpeed = null;
- try {
- carInfo.removeMileageListener(mMileageListener);
- mHasMileagePermission = true;
- } catch (SecurityException e) {
- mHasMileagePermission = false;
- }
-
- mMileage = null;
- try {
- // Unsubscribe sensors
- carSensors.removeCompassListener(mCompassListener);
- mHasCompassPermission = true;
- } catch (SecurityException e) {
- mHasCompassPermission = false;
- }
-
- mCompass = null;
- try {
- carSensors.removeGyroscopeListener(mGyroscopeListener);
- mHasGyroscopePermission = true;
- } catch (SecurityException e) {
- mHasGyroscopePermission = false;
- }
-
- mGyroscope = null;
- try {
- carSensors.removeAccelerometerListener(mAccelerometerListener);
- mHasAccelerometerPermission = true;
- } catch (SecurityException e) {
- mHasAccelerometerPermission = false;
- }
-
- mAccelerometer = null;
- try {
- carSensors.removeCarHardwareLocationListener(mCarLocationListener);
- mHasCarHardwareLocationPermission = true;
- } catch (SecurityException e) {
- mHasCarHardwareLocationPermission = false;
- }
-
- mCarHardwareLocation = null;
- }
- mShowCarHardwareSurfaceInfo = isEnabled;
- renderFrame();
- }
-
- @Override
- public void onCreate(@NonNull LifecycleOwner owner) {
- Log.i(TAG, "SurfaceRenderer created");
- mCarContext.getCarService(AppManager.class).setSurfaceCallback(mSurfaceCallback);
- }
-
- void renderFrame() {
- if (mSurface == null || !mSurface.isValid()) {
- // Surface is not available, or has been destroyed, skip this frame.
- return;
- }
- Canvas canvas = mSurface.lockCanvas(null);
-
- // Clear the background.
- canvas.drawColor(mCarContext.isDarkMode() ? Color.DKGRAY : Color.LTGRAY);
-
- if (mShowCarHardwareSurfaceInfo) {
- renderCarInfoFrame(canvas);
- } else {
- renderStandardFrame(canvas);
- }
- mSurface.unlockCanvasAndPost(canvas);
-
- }
-
- private void renderCarInfoFrame(Canvas canvas) {
- Rect visibleArea = mVisibleArea;
- if (visibleArea != null) {
- if (visibleArea.isEmpty()) {
- // No inset set. The entire area is considered safe to draw.
- visibleArea.set(0, 0, canvas.getWidth() - 1, canvas.getHeight() - 1);
- }
-
- Paint.FontMetrics fm = mCarInfoPaint.getFontMetrics();
- float height = fm.descent - fm.ascent;
- float verticalPos = visibleArea.top + VERTICAL_TEXT_MARGIN_FROM_TOP;
-
- // Prepare text for Make, Model, Year
- StringBuilder info = new StringBuilder();
- if (!mHasModelPermission) {
- info.append("No Model Permission.");
- } else if (mModel == null) {
- info.append("Fetching model info.");
- } else {
- if (mModel.getManufacturer().getStatus() != CarValue.STATUS_SUCCESS) {
- info.append("Manufacturer unavailable, ");
- } else {
- info.append(mModel.getManufacturer().getValue());
- info.append(",");
- }
- if (mModel.getName().getStatus() != CarValue.STATUS_SUCCESS) {
- info.append("Model unavailable, ");
- } else {
- info.append(mModel.getName());
- info.append(",");
- }
- if (mModel.getYear().getStatus() != CarValue.STATUS_SUCCESS) {
- info.append("Year unavailable.");
- } else {
- info.append(mModel.getYear());
- }
- }
- canvas.drawText(info.toString(), visibleArea.centerX(), verticalPos, mCarInfoPaint);
- verticalPos += height;
-
- // Prepare text for Energy Profile
- info = new StringBuilder();
- if (!mHasEnergyProfilePermission) {
- info.append("No EnergyProfile Permission.");
- } else if (mEnergyProfile == null) {
- info.append("Fetching EnergyProfile.");
- } else {
- if (mEnergyProfile.getFuelTypes().getStatus() != CarValue.STATUS_SUCCESS) {
- info.append("Fuel Types: Unavailable. ");
- } else {
- info.append("Fuel Types: [");
- for (int fuelType : mEnergyProfile.getFuelTypes().getValue()) {
- info.append(fuelType);
- info.append(" ");
- }
- info.append("].");
- }
- if (mEnergyProfile.getEvConnectorTypes().getStatus() != CarValue.STATUS_SUCCESS) {
- info.append(" EV Connector Types: Unavailable. ");
- } else {
- info.append("EV Connector Types:[");
- for (int connectorType : mEnergyProfile.getEvConnectorTypes().getValue()) {
- info.append(connectorType);
- info.append(" ");
- }
- info.append("]");
- }
- }
- canvas.drawText(info.toString(), visibleArea.centerX(), verticalPos, mCarInfoPaint);
- verticalPos += height;
-
- // Prepare text for Toll card status
- info = new StringBuilder();
- if (!mHasTollCardPermission) {
- info.append("No TollCard Permission.");
- } else if (mTollCard == null) {
- info.append("Fetching Toll information.");
- } else {
- if (mTollCard.getCardState().getStatus() != CarValue.STATUS_SUCCESS) {
- info.append("Toll card state: Unavailable. ");
- } else {
- info.append("Toll card state: ");
- info.append(mTollCard.getCardState().getValue());
- }
- }
- canvas.drawText(info.toString(), visibleArea.centerX(), verticalPos, mCarInfoPaint);
- verticalPos += height;
-
- // Prepare text for Energy Level
- info = new StringBuilder();
- if (!mHasEnergyLevelPermission) {
- info.append("No EnergyLevel Permission.");
- } else if (mEnergyLevel == null) {
- info.append("Fetching Energy Level.");
- } else {
- if (mEnergyLevel.getEnergyIsLow().getStatus() != CarValue.STATUS_SUCCESS) {
- info.append("Low energy: Unavailable. ");
- } else {
- info.append("Low energy: ");
- info.append(mEnergyLevel.getEnergyIsLow().getValue());
- info.append(" ");
- }
- if (mEnergyLevel.getRangeRemainingMeters().getStatus() != CarValue.STATUS_SUCCESS) {
- info.append("Range: Unavailable. ");
- } else {
- info.append("Range: ");
- info.append(mEnergyLevel.getRangeRemainingMeters().getValue());
- info.append(" m. ");
- }
- if (mEnergyLevel.getFuelPercent().getStatus() != CarValue.STATUS_SUCCESS) {
- info.append("Fuel Percent: Unavailable. ");
- } else {
- info.append("Fuel Percent: ");
- info.append(mEnergyLevel.getFuelPercent().getValue());
- info.append("% ");
- }
- if (mEnergyLevel.getBatteryPercent().getStatus() != CarValue.STATUS_SUCCESS) {
- info.append("Battery Percent: Unavailable. ");
- } else {
- info.append("Battery Percent: ");
- info.append(mEnergyLevel.getBatteryPercent().getValue());
- info.append("% ");
- }
- }
- canvas.drawText(info.toString(), visibleArea.centerX(), verticalPos, mCarInfoPaint);
- verticalPos += height;
-
- // Prepare text for Speed
- info = new StringBuilder();
- if (!mHasSpeedPermission) {
- info.append("No Speed Permission.");
- } else if (mSpeed == null) {
- info.append("Fetching Speed.");
- } else {
- if (mSpeed.getDisplaySpeedMetersPerSecond().getStatus()
- != CarValue.STATUS_SUCCESS) {
- info.append("Display Speed: Unavailable. ");
- } else {
- info.append("Display Speed: ");
- info.append(mSpeed.getDisplaySpeedMetersPerSecond().getValue());
- info.append(" m/s. ");
- }
- if (mSpeed.getRawSpeedMetersPerSecond().getStatus() != CarValue.STATUS_SUCCESS) {
- info.append("Raw Speed: Unavailable. ");
- } else {
- info.append("Raw Speed: ");
- info.append(mSpeed.getRawSpeedMetersPerSecond().getValue());
- info.append(" m/s. ");
- }
- if (mSpeed.getSpeedDisplayUnit().getStatus() != CarValue.STATUS_SUCCESS) {
- info.append("Speed Display Unit: Unavailable.");
- } else {
- info.append("Speed Display Unit: ");
- info.append(mSpeed.getSpeedDisplayUnit().getValue());
- info.append(" ");
- }
- }
- canvas.drawText(info.toString(), visibleArea.centerX(), verticalPos, mCarInfoPaint);
- verticalPos += height;
-
- // Prepare text for Odometer
- info = new StringBuilder();
- if (!mHasMileagePermission) {
- info.append("No Mileage Permission.");
- } else if (mMileage == null) {
- info.append("Fetching mileage.");
- } else {
- if (mMileage.getOdometerMeters().getStatus() != CarValue.STATUS_SUCCESS) {
- info.append("Odometer: Unavailable. ");
- } else {
- info.append("Odometer: ");
- info.append(mMileage.getOdometerMeters().getValue());
- info.append(" m. ");
- }
- if (mMileage.getDistanceDisplayUnit().getStatus() != CarValue.STATUS_SUCCESS) {
- info.append("Mileage Display Unit: Unavailable.");
- } else {
- info.append("Mileage Display Unit: ");
- info.append(mMileage.getDistanceDisplayUnit().getValue());
- info.append(" ");
- }
- }
- canvas.drawText(info.toString(), visibleArea.centerX(), verticalPos, mCarInfoPaint);
- verticalPos += height;
-
- // Prepare text for Accelerometer
- info = new StringBuilder();
- if (!mHasAccelerometerPermission) {
- info.append("No Accelerometer Permission.");
- } else if (mAccelerometer == null) {
- info.append("Fetching accelerometer");
- } else {
- if (mAccelerometer.getForces().getStatus() != CarValue.STATUS_SUCCESS) {
- info.append("Accelerometer unavailable.");
- } else {
- info.append("Accelerometer: ");
- appendFloatList(info, mAccelerometer.getForces().getValue());
- }
- }
- canvas.drawText(info.toString(), visibleArea.centerX(), verticalPos, mCarInfoPaint);
- verticalPos += height;
-
- // Prepare text for Gyroscope
- info = new StringBuilder();
- if (!mHasGyroscopePermission) {
- info.append("No Gyroscope Permission.");
- } else if (mGyroscope == null) {
- info.append("Fetching gyroscope");
- } else {
- if (mGyroscope.getRotations().getStatus() != CarValue.STATUS_SUCCESS) {
- info.append("Gyroscope unavailable.");
- } else {
- info.append("Gyroscope: ");
- appendFloatList(info, mGyroscope.getRotations().getValue());
- }
- }
- canvas.drawText(info.toString(), visibleArea.centerX(), verticalPos, mCarInfoPaint);
- verticalPos += height;
-
- // Prepare text for Compass
- info = new StringBuilder();
- if (!mHasCompassPermission) {
- info.append("No Compass Permission.");
- } else if (mCompass == null) {
- info.append("Fetching compass");
- } else {
- if (mCompass.getOrientations().getStatus() != CarValue.STATUS_SUCCESS) {
- info.append("Compass unavailable.");
- } else {
- info.append("Compass: ");
- appendFloatList(info, mCompass.getOrientations().getValue());
- }
- }
- canvas.drawText(info.toString(), visibleArea.centerX(), verticalPos, mCarInfoPaint);
- verticalPos += height;
-
- // Prepare text for Location
- info = new StringBuilder();
- if (!mHasCarHardwareLocationPermission) {
- info.append("No CarHardwareLocation Permission.");
- } else if (mCarHardwareLocation == null) {
- info.append("Fetching location");
- } else {
- if (mCarHardwareLocation.getLocation().getStatus() != CarValue.STATUS_SUCCESS) {
- info.append("Car Hardware Location unavailable");
- } else {
- info.append("Car Hardware location: ");
- info.append(mCarHardwareLocation.getLocation().getValue().toString());
- }
- }
- canvas.drawText(info.toString(), visibleArea.centerX(), verticalPos, mCarInfoPaint);
- }
- }
-
- private void appendFloatList(StringBuilder builder, List<Float> values) {
- builder.append("[ ");
- for (Float value : values) {
- builder.append(value);
- builder.append(" ");
- }
- builder.append("]");
- }
-
- private void renderStandardFrame(Canvas canvas) {
-
- // Draw a rectangle showing the inset.
- Rect visibleArea = mVisibleArea;
- if (visibleArea != null) {
- if (visibleArea.isEmpty()) {
- // No inset set. The entire area is considered safe to draw.
- visibleArea.set(0, 0, canvas.getWidth() - 1, canvas.getHeight() - 1);
- }
-
- canvas.drawRect(visibleArea, mLeftInsetPaint);
- canvas.drawLine(
- visibleArea.left,
- visibleArea.top,
- visibleArea.right,
- visibleArea.bottom,
- mLeftInsetPaint);
- canvas.drawLine(
- visibleArea.right,
- visibleArea.top,
- visibleArea.left,
- visibleArea.bottom,
- mLeftInsetPaint);
- canvas.drawText(
- "(" + visibleArea.left + " , " + visibleArea.top + ")",
- visibleArea.left + HORIZONTAL_TEXT_MARGIN,
- visibleArea.top + VERTICAL_TEXT_MARGIN_FROM_TOP,
- mLeftInsetPaint);
- canvas.drawText(
- "(" + visibleArea.right + " , " + visibleArea.bottom + ")",
- visibleArea.right - HORIZONTAL_TEXT_MARGIN,
- visibleArea.bottom - VERTICAL_TEXT_MARGIN_FROM_BOTTOM,
- mRightInsetPaint);
- } else {
- Log.d(TAG, "Visible area not available.");
- }
-
- if (mStableArea != null) {
- // Draw a cross-hairs at the stable center.
- final int lengthPx = 15;
- int centerX = mStableArea.centerX();
- int centerY = mStableArea.centerY();
- canvas.drawLine(centerX - lengthPx, centerY, centerX + lengthPx, centerY, mCenterPaint);
- canvas.drawLine(centerX, centerY - lengthPx, centerX, centerY + lengthPx, mCenterPaint);
- canvas.drawText(
- "(" + centerX + ", " + centerY + ")",
- centerX + HORIZONTAL_TEXT_MARGIN,
- centerY,
- mCenterPaint);
- } else {
- Log.d(TAG, "Stable area not available.");
- }
- }
-}
diff --git a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/renderer/CarHardwareRenderer.java b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/renderer/CarHardwareRenderer.java
new file mode 100644
index 0000000..5043a86
--- /dev/null
+++ b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/renderer/CarHardwareRenderer.java
@@ -0,0 +1,615 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.car.app.sample.showcase.common.renderer;
+
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.car.app.CarContext;
+import androidx.car.app.hardware.CarHardwareManager;
+import androidx.car.app.hardware.common.CarValue;
+import androidx.car.app.hardware.common.OnCarDataAvailableListener;
+import androidx.car.app.hardware.info.Accelerometer;
+import androidx.car.app.hardware.info.CarHardwareLocation;
+import androidx.car.app.hardware.info.CarInfo;
+import androidx.car.app.hardware.info.CarSensors;
+import androidx.car.app.hardware.info.Compass;
+import androidx.car.app.hardware.info.EnergyLevel;
+import androidx.car.app.hardware.info.EnergyProfile;
+import androidx.car.app.hardware.info.Gyroscope;
+import androidx.car.app.hardware.info.Mileage;
+import androidx.car.app.hardware.info.Model;
+import androidx.car.app.hardware.info.Speed;
+import androidx.car.app.hardware.info.TollCard;
+import androidx.core.content.ContextCompat;
+
+import java.util.List;
+import java.util.concurrent.Executor;
+
+/** Renderer which aggregates information about the car hardware to be drawn on a surface. */
+public final class CarHardwareRenderer implements Renderer {
+ private static final String TAG = "showcase";
+
+ private static final int VERTICAL_TEXT_MARGIN_FROM_TOP = 20;
+
+ private final Executor mCarHardwareExecutor;
+ private final Paint mCarInfoPaint = new Paint();
+ private final CarContext mCarContext;
+
+ @Nullable
+ Model mModel;
+ @Nullable
+ EnergyProfile mEnergyProfile;
+ @Nullable
+ TollCard mTollCard;
+ @Nullable
+ EnergyLevel mEnergyLevel;
+ @Nullable
+ Speed mSpeed;
+ @Nullable
+ Mileage mMileage;
+ @Nullable
+ Accelerometer mAccelerometer;
+ @Nullable
+ Gyroscope mGyroscope;
+ @Nullable
+ Compass mCompass;
+ @Nullable
+ CarHardwareLocation mCarHardwareLocation;
+ @Nullable
+ private Runnable mRequestRenderRunnable;
+ private boolean mHasModelPermission;
+ private boolean mHasEnergyProfilePermission;
+ private boolean mHasTollCardPermission;
+ private boolean mHasEnergyLevelPermission;
+ private boolean mHasSpeedPermission;
+ private boolean mHasMileagePermission;
+ private boolean mHasAccelerometerPermission;
+ private boolean mHasGyroscopePermission;
+ private boolean mHasCompassPermission;
+ private boolean mHasCarHardwareLocationPermission;
+
+ private OnCarDataAvailableListener<Model> mModelListener = data -> {
+ synchronized (this) {
+ Log.i(TAG, "Received model information: " + data);
+ mModel = data;
+ requestRenderFrame();
+ }
+ };
+ private OnCarDataAvailableListener<EnergyProfile> mEnergyProfileListener = data -> {
+ synchronized (this) {
+ Log.i(TAG, "Received energy profile information: " + data);
+ mEnergyProfile = data;
+ requestRenderFrame();
+ }
+ };
+ private OnCarDataAvailableListener<TollCard> mTollListener = data -> {
+ synchronized (this) {
+ Log.i(TAG, "Received toll information:" + data);
+ mTollCard = data;
+ requestRenderFrame();
+ }
+ };
+ private OnCarDataAvailableListener<EnergyLevel> mEnergyLevelListener = data -> {
+ synchronized (this) {
+ Log.i(TAG, "Received energy level information: " + data);
+ mEnergyLevel = data;
+ requestRenderFrame();
+ }
+ };
+ private OnCarDataAvailableListener<Speed> mSpeedListener = data -> {
+ synchronized (this) {
+ Log.i(TAG, "Received speed information: " + data);
+ mSpeed = data;
+ requestRenderFrame();
+ }
+ };
+ private OnCarDataAvailableListener<Mileage> mMileageListener = data -> {
+ synchronized (this) {
+ Log.i(TAG, "Received mileage: " + data);
+ mMileage = data;
+ requestRenderFrame();
+ }
+ };
+ private OnCarDataAvailableListener<Accelerometer> mAccelerometerListener = data -> {
+ synchronized (this) {
+ Log.i(TAG, "Received accelerometer: " + data);
+ mAccelerometer = data;
+ requestRenderFrame();
+ }
+ };
+ private OnCarDataAvailableListener<Gyroscope> mGyroscopeListener = data -> {
+ synchronized (this) {
+ Log.i(TAG, "Received gyroscope: " + data);
+ mGyroscope = data;
+ requestRenderFrame();
+ }
+ };
+ private OnCarDataAvailableListener<Compass> mCompassListener = data -> {
+ synchronized (this) {
+ Log.i(TAG, "Received compass: " + data);
+ mCompass = data;
+ requestRenderFrame();
+ }
+ };
+ private OnCarDataAvailableListener<CarHardwareLocation> mCarLocationListener = data -> {
+ synchronized (this) {
+ Log.i(TAG, "Received car location: " + data);
+ mCarHardwareLocation = data;
+ requestRenderFrame();
+ }
+ };
+
+ public CarHardwareRenderer(@NonNull CarContext carContext) {
+ mCarContext = carContext;
+ mCarInfoPaint.setColor(Color.BLACK);
+ mCarInfoPaint.setAntiAlias(true);
+ mCarInfoPaint.setStyle(Paint.Style.STROKE);
+ mCarInfoPaint.setTextAlign(Paint.Align.CENTER);
+ mCarHardwareExecutor = ContextCompat.getMainExecutor(mCarContext);
+ }
+
+ @Override
+ public void enable(@NonNull Runnable onChangeListener) {
+ mRequestRenderRunnable = onChangeListener;
+ CarHardwareManager carHardwareManager =
+ mCarContext.getCarService(CarHardwareManager.class);
+ CarInfo carInfo = carHardwareManager.getCarInfo();
+ CarSensors carSensors = carHardwareManager.getCarSensors();
+
+ // Request any single shot values.
+ mModel = null;
+ try {
+ carInfo.fetchModel(mCarHardwareExecutor, mModelListener);
+ mHasModelPermission = true;
+ } catch (SecurityException e) {
+ mHasModelPermission = false;
+ }
+
+ mEnergyProfile = null;
+ try {
+ carInfo.fetchModel(mCarHardwareExecutor, mModelListener);
+ mHasEnergyProfilePermission = true;
+ } catch (SecurityException e) {
+ mHasEnergyProfilePermission = false;
+ }
+ carInfo.fetchEnergyProfile(mCarHardwareExecutor, mEnergyProfileListener);
+
+ // Request car info subscription items.
+ mTollCard = null;
+ try {
+ carInfo.fetchModel(mCarHardwareExecutor, mModelListener);
+ mHasTollCardPermission = true;
+ } catch (SecurityException e) {
+ mHasTollCardPermission = false;
+ }
+ carInfo.addTollListener(mCarHardwareExecutor, mTollListener);
+
+ mEnergyLevel = null;
+ try {
+ carInfo.addEnergyLevelListener(mCarHardwareExecutor, mEnergyLevelListener);
+ mHasEnergyLevelPermission = true;
+ } catch (SecurityException e) {
+ mHasEnergyLevelPermission = false;
+ }
+
+ mSpeed = null;
+ try {
+ carInfo.addSpeedListener(mCarHardwareExecutor, mSpeedListener);
+ mHasSpeedPermission = true;
+ } catch (SecurityException e) {
+ mHasSpeedPermission = false;
+ }
+
+ mMileage = null;
+ try {
+ carInfo.addMileageListener(mCarHardwareExecutor, mMileageListener);
+ mHasMileagePermission = true;
+ } catch (SecurityException e) {
+ mHasMileagePermission = false;
+ }
+
+ // Request sensors
+ mCompass = null;
+ try {
+ carSensors.addCompassListener(CarSensors.UPDATE_RATE_NORMAL, mCarHardwareExecutor,
+ mCompassListener);
+ mHasCompassPermission = true;
+ } catch (SecurityException e) {
+ mHasCompassPermission = false;
+ }
+
+ mGyroscope = null;
+ try {
+ carSensors.addGyroscopeListener(CarSensors.UPDATE_RATE_NORMAL, mCarHardwareExecutor,
+ mGyroscopeListener);
+ mHasGyroscopePermission = true;
+ } catch (SecurityException e) {
+ mHasGyroscopePermission = false;
+ }
+
+ mAccelerometer = null;
+ try {
+ carSensors.addAccelerometerListener(CarSensors.UPDATE_RATE_NORMAL,
+ mCarHardwareExecutor,
+ mAccelerometerListener);
+ mHasAccelerometerPermission = true;
+ } catch (SecurityException e) {
+ mHasAccelerometerPermission = false;
+ }
+
+ mCarHardwareLocation = null;
+ try {
+ carSensors.addCarHardwareLocationListener(CarSensors.UPDATE_RATE_NORMAL,
+ mCarHardwareExecutor, mCarLocationListener);
+ mHasCarHardwareLocationPermission = true;
+ } catch (SecurityException e) {
+ mHasCarHardwareLocationPermission = false;
+ }
+ }
+
+ @Override
+ public void disable() {
+ mRequestRenderRunnable = null;
+ CarHardwareManager carHardwareManager =
+ mCarContext.getCarService(CarHardwareManager.class);
+ CarInfo carInfo = carHardwareManager.getCarInfo();
+ CarSensors carSensors = carHardwareManager.getCarSensors();
+
+ try {
+ // Unsubscribe carinfo
+ carInfo.removeTollListener(mTollListener);
+ mHasTollCardPermission = true;
+ } catch (SecurityException e) {
+ mHasTollCardPermission = false;
+ }
+
+ mTollCard = null;
+ try {
+ carInfo.removeEnergyLevelListener(mEnergyLevelListener);
+ mHasEnergyLevelPermission = true;
+ } catch (SecurityException e) {
+ mHasEnergyLevelPermission = false;
+ }
+
+ mEnergyLevel = null;
+ try {
+ carInfo.removeSpeedListener(mSpeedListener);
+ mHasSpeedPermission = true;
+ } catch (SecurityException e) {
+ mHasSpeedPermission = false;
+ }
+
+ mSpeed = null;
+ try {
+ carInfo.removeMileageListener(mMileageListener);
+ mHasMileagePermission = true;
+ } catch (SecurityException e) {
+ mHasMileagePermission = false;
+ }
+
+ mMileage = null;
+ try {
+ // Unsubscribe sensors
+ carSensors.removeCompassListener(mCompassListener);
+ mHasCompassPermission = true;
+ } catch (SecurityException e) {
+ mHasCompassPermission = false;
+ }
+
+ mCompass = null;
+ try {
+ carSensors.removeGyroscopeListener(mGyroscopeListener);
+ mHasGyroscopePermission = true;
+ } catch (SecurityException e) {
+ mHasGyroscopePermission = false;
+ }
+
+ mGyroscope = null;
+ try {
+ carSensors.removeAccelerometerListener(mAccelerometerListener);
+ mHasAccelerometerPermission = true;
+ } catch (SecurityException e) {
+ mHasAccelerometerPermission = false;
+ }
+
+ mAccelerometer = null;
+ try {
+ carSensors.removeCarHardwareLocationListener(mCarLocationListener);
+ mHasCarHardwareLocationPermission = true;
+ } catch (SecurityException e) {
+ mHasCarHardwareLocationPermission = false;
+ }
+
+ mCarHardwareLocation = null;
+ }
+
+ @Override
+ public void renderFrame(@NonNull Canvas canvas, @Nullable Rect visibleArea,
+ @Nullable Rect stableArea) {
+ if (visibleArea != null) {
+ if (visibleArea.isEmpty()) {
+ // No inset set. The entire area is considered safe to draw.
+ visibleArea.set(0, 0, canvas.getWidth() - 1, canvas.getHeight() - 1);
+ }
+
+ Paint.FontMetrics fm = mCarInfoPaint.getFontMetrics();
+ float height = fm.descent - fm.ascent;
+ float verticalPos = visibleArea.top + VERTICAL_TEXT_MARGIN_FROM_TOP;
+
+ // Prepare text for Make, Model, Year
+ StringBuilder info = new StringBuilder();
+ if (!mHasModelPermission) {
+ info.append("No Model Permission.");
+ } else if (mModel == null) {
+ info.append("Fetching model info.");
+ } else {
+ if (mModel.getManufacturer().getStatus() != CarValue.STATUS_SUCCESS) {
+ info.append("Manufacturer unavailable, ");
+ } else {
+ info.append(mModel.getManufacturer().getValue());
+ info.append(",");
+ }
+ if (mModel.getName().getStatus() != CarValue.STATUS_SUCCESS) {
+ info.append("Model unavailable, ");
+ } else {
+ info.append(mModel.getName());
+ info.append(",");
+ }
+ if (mModel.getYear().getStatus() != CarValue.STATUS_SUCCESS) {
+ info.append("Year unavailable.");
+ } else {
+ info.append(mModel.getYear());
+ }
+ }
+ canvas.drawText(info.toString(), visibleArea.centerX(), verticalPos, mCarInfoPaint);
+ verticalPos += height;
+
+ // Prepare text for Energy Profile
+ info = new StringBuilder();
+ if (!mHasEnergyProfilePermission) {
+ info.append("No EnergyProfile Permission.");
+ } else if (mEnergyProfile == null) {
+ info.append("Fetching EnergyProfile.");
+ } else {
+ if (mEnergyProfile.getFuelTypes().getStatus() != CarValue.STATUS_SUCCESS) {
+ info.append("Fuel Types: Unavailable. ");
+ } else {
+ info.append("Fuel Types: [");
+ for (int fuelType : mEnergyProfile.getFuelTypes().getValue()) {
+ info.append(fuelType);
+ info.append(" ");
+ }
+ info.append("].");
+ }
+ if (mEnergyProfile.getEvConnectorTypes().getStatus() != CarValue.STATUS_SUCCESS) {
+ info.append(" EV Connector Types: Unavailable. ");
+ } else {
+ info.append("EV Connector Types:[");
+ for (int connectorType : mEnergyProfile.getEvConnectorTypes().getValue()) {
+ info.append(connectorType);
+ info.append(" ");
+ }
+ info.append("]");
+ }
+ }
+ canvas.drawText(info.toString(), visibleArea.centerX(), verticalPos, mCarInfoPaint);
+ verticalPos += height;
+
+ // Prepare text for Toll card status
+ info = new StringBuilder();
+ if (!mHasTollCardPermission) {
+ info.append("No TollCard Permission.");
+ } else if (mTollCard == null) {
+ info.append("Fetching Toll information.");
+ } else {
+ if (mTollCard.getCardState().getStatus() != CarValue.STATUS_SUCCESS) {
+ info.append("Toll card state: Unavailable. ");
+ } else {
+ info.append("Toll card state: ");
+ info.append(mTollCard.getCardState().getValue());
+ }
+ }
+ canvas.drawText(info.toString(), visibleArea.centerX(), verticalPos, mCarInfoPaint);
+ verticalPos += height;
+
+ // Prepare text for Energy Level
+ info = new StringBuilder();
+ if (!mHasEnergyLevelPermission) {
+ info.append("No EnergyLevel Permission.");
+ } else if (mEnergyLevel == null) {
+ info.append("Fetching Energy Level.");
+ } else {
+ if (mEnergyLevel.getEnergyIsLow().getStatus() != CarValue.STATUS_SUCCESS) {
+ info.append("Low energy: Unavailable. ");
+ } else {
+ info.append("Low energy: ");
+ info.append(mEnergyLevel.getEnergyIsLow().getValue());
+ info.append(" ");
+ }
+ if (mEnergyLevel.getRangeRemainingMeters().getStatus() != CarValue.STATUS_SUCCESS) {
+ info.append("Range: Unavailable. ");
+ } else {
+ info.append("Range: ");
+ info.append(mEnergyLevel.getRangeRemainingMeters().getValue());
+ info.append(" m. ");
+ }
+ if (mEnergyLevel.getFuelPercent().getStatus() != CarValue.STATUS_SUCCESS) {
+ info.append("Fuel Percent: Unavailable. ");
+ } else {
+ info.append("Fuel Percent: ");
+ info.append(mEnergyLevel.getFuelPercent().getValue());
+ info.append("% ");
+ }
+ if (mEnergyLevel.getBatteryPercent().getStatus() != CarValue.STATUS_SUCCESS) {
+ info.append("Battery Percent: Unavailable. ");
+ } else {
+ info.append("Battery Percent: ");
+ info.append(mEnergyLevel.getBatteryPercent().getValue());
+ info.append("% ");
+ }
+ }
+ canvas.drawText(info.toString(), visibleArea.centerX(), verticalPos, mCarInfoPaint);
+ verticalPos += height;
+
+ // Prepare text for Speed
+ info = new StringBuilder();
+ if (!mHasSpeedPermission) {
+ info.append("No Speed Permission.");
+ } else if (mSpeed == null) {
+ info.append("Fetching Speed.");
+ } else {
+ if (mSpeed.getDisplaySpeedMetersPerSecond().getStatus()
+ != CarValue.STATUS_SUCCESS) {
+ info.append("Display Speed: Unavailable. ");
+ } else {
+ info.append("Display Speed: ");
+ info.append(mSpeed.getDisplaySpeedMetersPerSecond().getValue());
+ info.append(" m/s. ");
+ }
+ if (mSpeed.getRawSpeedMetersPerSecond().getStatus() != CarValue.STATUS_SUCCESS) {
+ info.append("Raw Speed: Unavailable. ");
+ } else {
+ info.append("Raw Speed: ");
+ info.append(mSpeed.getRawSpeedMetersPerSecond().getValue());
+ info.append(" m/s. ");
+ }
+ if (mSpeed.getSpeedDisplayUnit().getStatus() != CarValue.STATUS_SUCCESS) {
+ info.append("Speed Display Unit: Unavailable.");
+ } else {
+ info.append("Speed Display Unit: ");
+ info.append(mSpeed.getSpeedDisplayUnit().getValue());
+ info.append(" ");
+ }
+ }
+ canvas.drawText(info.toString(), visibleArea.centerX(), verticalPos, mCarInfoPaint);
+ verticalPos += height;
+
+ // Prepare text for Odometer
+ info = new StringBuilder();
+ if (!mHasMileagePermission) {
+ info.append("No Mileage Permission.");
+ } else if (mMileage == null) {
+ info.append("Fetching mileage.");
+ } else {
+ if (mMileage.getOdometerMeters().getStatus() != CarValue.STATUS_SUCCESS) {
+ info.append("Odometer: Unavailable. ");
+ } else {
+ info.append("Odometer: ");
+ info.append(mMileage.getOdometerMeters().getValue());
+ info.append(" m. ");
+ }
+ if (mMileage.getDistanceDisplayUnit().getStatus() != CarValue.STATUS_SUCCESS) {
+ info.append("Mileage Display Unit: Unavailable.");
+ } else {
+ info.append("Mileage Display Unit: ");
+ info.append(mMileage.getDistanceDisplayUnit().getValue());
+ info.append(" ");
+ }
+ }
+ canvas.drawText(info.toString(), visibleArea.centerX(), verticalPos, mCarInfoPaint);
+ verticalPos += height;
+
+ // Prepare text for Accelerometer
+ info = new StringBuilder();
+ if (!mHasAccelerometerPermission) {
+ info.append("No Accelerometer Permission.");
+ } else if (mAccelerometer == null) {
+ info.append("Fetching accelerometer");
+ } else {
+ if (mAccelerometer.getForces().getStatus() != CarValue.STATUS_SUCCESS) {
+ info.append("Accelerometer unavailable.");
+ } else {
+ info.append("Accelerometer: ");
+ appendFloatList(info, mAccelerometer.getForces().getValue());
+ }
+ }
+ canvas.drawText(info.toString(), visibleArea.centerX(), verticalPos, mCarInfoPaint);
+ verticalPos += height;
+
+ // Prepare text for Gyroscope
+ info = new StringBuilder();
+ if (!mHasGyroscopePermission) {
+ info.append("No Gyroscope Permission.");
+ } else if (mGyroscope == null) {
+ info.append("Fetching gyroscope");
+ } else {
+ if (mGyroscope.getRotations().getStatus() != CarValue.STATUS_SUCCESS) {
+ info.append("Gyroscope unavailable.");
+ } else {
+ info.append("Gyroscope: ");
+ appendFloatList(info, mGyroscope.getRotations().getValue());
+ }
+ }
+ canvas.drawText(info.toString(), visibleArea.centerX(), verticalPos, mCarInfoPaint);
+ verticalPos += height;
+
+ // Prepare text for Compass
+ info = new StringBuilder();
+ if (!mHasCompassPermission) {
+ info.append("No Compass Permission.");
+ } else if (mCompass == null) {
+ info.append("Fetching compass");
+ } else {
+ if (mCompass.getOrientations().getStatus() != CarValue.STATUS_SUCCESS) {
+ info.append("Compass unavailable.");
+ } else {
+ info.append("Compass: ");
+ appendFloatList(info, mCompass.getOrientations().getValue());
+ }
+ }
+ canvas.drawText(info.toString(), visibleArea.centerX(), verticalPos, mCarInfoPaint);
+ verticalPos += height;
+
+ // Prepare text for Location
+ info = new StringBuilder();
+ if (!mHasCarHardwareLocationPermission) {
+ info.append("No CarHardwareLocation Permission.");
+ } else if (mCarHardwareLocation == null) {
+ info.append("Fetching location");
+ } else {
+ if (mCarHardwareLocation.getLocation().getStatus() != CarValue.STATUS_SUCCESS) {
+ info.append("Car Hardware Location unavailable");
+ } else {
+ info.append("Car Hardware location: ");
+ info.append(mCarHardwareLocation.getLocation().getValue().toString());
+ }
+ }
+ canvas.drawText(info.toString(), visibleArea.centerX(), verticalPos, mCarInfoPaint);
+ }
+ }
+
+ private void requestRenderFrame() {
+ if (mRequestRenderRunnable != null) {
+ mRequestRenderRunnable.run();
+ }
+ }
+
+ private void appendFloatList(StringBuilder builder, List<Float> values) {
+ builder.append("[ ");
+ for (Float value : values) {
+ builder.append(value);
+ builder.append(" ");
+ }
+ builder.append("]");
+ }
+}
diff --git a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/renderer/DefaultRenderer.java b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/renderer/DefaultRenderer.java
new file mode 100644
index 0000000..e3e55d7
--- /dev/null
+++ b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/renderer/DefaultRenderer.java
@@ -0,0 +1,119 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.car.app.sample.showcase.common.renderer;
+
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Paint;
+import android.graphics.Rect;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+/** Simple renderer for the surface templates. */
+public final class DefaultRenderer implements Renderer {
+ private static final String TAG = "showcase";
+
+ private static final int HORIZONTAL_TEXT_MARGIN = 10;
+ private static final int VERTICAL_TEXT_MARGIN_FROM_TOP = 20;
+ private static final int VERTICAL_TEXT_MARGIN_FROM_BOTTOM = 10;
+
+ private final Paint mLeftInsetPaint = new Paint();
+ private final Paint mRightInsetPaint = new Paint();
+ private final Paint mCenterPaint = new Paint();
+
+ public DefaultRenderer() {
+ mLeftInsetPaint.setColor(Color.RED);
+ mLeftInsetPaint.setAntiAlias(true);
+ mLeftInsetPaint.setStyle(Paint.Style.STROKE);
+
+ mRightInsetPaint.setColor(Color.RED);
+ mRightInsetPaint.setAntiAlias(true);
+ mRightInsetPaint.setStyle(Paint.Style.STROKE);
+ mRightInsetPaint.setTextAlign(Paint.Align.RIGHT);
+
+ mCenterPaint.setColor(Color.BLUE);
+ mCenterPaint.setAntiAlias(true);
+ mCenterPaint.setStyle(Paint.Style.STROKE);
+ }
+
+ @Override
+ public void enable(@NonNull Runnable onChangeListener) {
+ // Don't need to do anything here since renderFrame doesn't require any setup.
+ }
+
+ @Override
+ public void disable() {
+ // Don't need to do anything here since renderFrame doesn't require any setup.
+ }
+
+ @Override
+ public void renderFrame(@NonNull Canvas canvas, @Nullable Rect visibleArea,
+ @Nullable Rect stableArea) {
+
+ // Draw a rectangle showing the inset.
+ if (visibleArea != null) {
+ if (visibleArea.isEmpty()) {
+ // No inset set. The entire area is considered safe to draw.
+ visibleArea.set(0, 0, canvas.getWidth() - 1, canvas.getHeight() - 1);
+ }
+
+ canvas.drawRect(visibleArea, mLeftInsetPaint);
+ canvas.drawLine(
+ visibleArea.left,
+ visibleArea.top,
+ visibleArea.right,
+ visibleArea.bottom,
+ mLeftInsetPaint);
+ canvas.drawLine(
+ visibleArea.right,
+ visibleArea.top,
+ visibleArea.left,
+ visibleArea.bottom,
+ mLeftInsetPaint);
+ canvas.drawText(
+ "(" + visibleArea.left + " , " + visibleArea.top + ")",
+ visibleArea.left + HORIZONTAL_TEXT_MARGIN,
+ visibleArea.top + VERTICAL_TEXT_MARGIN_FROM_TOP,
+ mLeftInsetPaint);
+ canvas.drawText(
+ "(" + visibleArea.right + " , " + visibleArea.bottom + ")",
+ visibleArea.right - HORIZONTAL_TEXT_MARGIN,
+ visibleArea.bottom - VERTICAL_TEXT_MARGIN_FROM_BOTTOM,
+ mRightInsetPaint);
+ } else {
+ Log.d(TAG, "Visible area not available.");
+ }
+
+ if (stableArea != null) {
+ // Draw a cross-hairs at the stable center.
+ final int lengthPx = 15;
+ int centerX = stableArea.centerX();
+ int centerY = stableArea.centerY();
+ canvas.drawLine(centerX - lengthPx, centerY, centerX + lengthPx, centerY, mCenterPaint);
+ canvas.drawLine(centerX, centerY - lengthPx, centerX, centerY + lengthPx, mCenterPaint);
+ canvas.drawText(
+ "(" + centerX + ", " + centerY + ")",
+ centerX + HORIZONTAL_TEXT_MARGIN,
+ centerY,
+ mCenterPaint);
+ } else {
+ Log.d(TAG, "Stable area not available.");
+ }
+ }
+}
diff --git a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/renderer/Renderer.java b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/renderer/Renderer.java
new file mode 100644
index 0000000..f6050fe
--- /dev/null
+++ b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/renderer/Renderer.java
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.car.app.sample.showcase.common.renderer;
+
+import android.graphics.Canvas;
+import android.graphics.Rect;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+/** A renderer for use on templates with a surface. */
+public interface Renderer {
+ /**
+ * Informs the renderer that it will receive {@link #renderFrame} calls.
+ *
+ * @param onChangeListener a runnable that will initiate a render pass in the controller
+ */
+ void enable(@NonNull Runnable onChangeListener);
+
+ /** Informs the renderer that it will no longer receive {@link #renderFrame} calls. */
+ void disable();
+
+ /** Request that a frame should be drawn to the supplied canvas. */
+ void renderFrame(@NonNull Canvas canvas, @Nullable Rect visibleArea, @Nullable Rect stableArea);
+}
diff --git a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/renderer/SurfaceController.java b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/renderer/SurfaceController.java
new file mode 100644
index 0000000..079432c
--- /dev/null
+++ b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/renderer/SurfaceController.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.car.app.sample.showcase.common.renderer;
+
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.graphics.Rect;
+import android.util.Log;
+import android.view.Surface;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.car.app.AppManager;
+import androidx.car.app.CarContext;
+import androidx.car.app.SurfaceCallback;
+import androidx.car.app.SurfaceContainer;
+import androidx.lifecycle.DefaultLifecycleObserver;
+import androidx.lifecycle.Lifecycle;
+import androidx.lifecycle.LifecycleOwner;
+
+/** A very simple implementation of a renderer for the app's background surface. */
+public final class SurfaceController implements DefaultLifecycleObserver {
+ private static final String TAG = "showcase";
+
+ private final DefaultRenderer mDefaultRenderer;
+ @Nullable private Renderer mOverrideRenderer;
+
+ private final CarContext mCarContext;
+ @Nullable
+ Surface mSurface;
+ @Nullable
+ Rect mVisibleArea;
+ @Nullable
+ Rect mStableArea;
+ private final SurfaceCallback mSurfaceCallback =
+ new SurfaceCallback() {
+ @Override
+ public void onSurfaceAvailable(@NonNull SurfaceContainer surfaceContainer) {
+ synchronized (SurfaceController.this) {
+ Log.i(TAG, "Surface available " + surfaceContainer);
+ mSurface = surfaceContainer.getSurface();
+ renderFrame();
+ }
+ }
+
+ @Override
+ public void onVisibleAreaChanged(@NonNull Rect visibleArea) {
+ synchronized (SurfaceController.this) {
+ Log.i(TAG, "Visible area changed " + mSurface + ". stableArea: "
+ + mStableArea + " visibleArea:" + visibleArea);
+ mVisibleArea = visibleArea;
+ renderFrame();
+ }
+ }
+
+ @Override
+ public void onStableAreaChanged(@NonNull Rect stableArea) {
+ synchronized (SurfaceController.this) {
+ Log.i(TAG, "Stable area changed " + mSurface + ". stableArea: "
+ + mStableArea + " visibleArea:" + mVisibleArea);
+ mStableArea = stableArea;
+ renderFrame();
+ }
+ }
+
+ @Override
+ public void onSurfaceDestroyed(@NonNull SurfaceContainer surfaceContainer) {
+ synchronized (SurfaceController.this) {
+ mSurface = null;
+ }
+ }
+ };
+
+ public SurfaceController(@NonNull CarContext carContext, @NonNull Lifecycle lifecycle) {
+ mCarContext = carContext;
+ mDefaultRenderer = new DefaultRenderer();
+ lifecycle.addObserver(this);
+ }
+
+ /** Callback called when the car configuration changes. */
+ public void onCarConfigurationChanged() {
+ renderFrame();
+ }
+
+ /** Tells the controller whether to override the default renderer. */
+ public void overrideRenderer(@Nullable Renderer renderer) {
+
+ if (mOverrideRenderer == renderer) {
+ return;
+ }
+
+ if (mOverrideRenderer != null) {
+ mOverrideRenderer.disable();
+ } else {
+ mDefaultRenderer.disable();
+ }
+
+ mOverrideRenderer = renderer;
+
+ if (mOverrideRenderer != null) {
+ mOverrideRenderer.enable(this::renderFrame);
+ } else {
+ mDefaultRenderer.enable(this::renderFrame);
+ }
+ }
+
+ @Override
+ public void onCreate(@NonNull LifecycleOwner owner) {
+ Log.i(TAG, "SurfaceController created");
+ mCarContext.getCarService(AppManager.class).setSurfaceCallback(mSurfaceCallback);
+ }
+
+ void renderFrame() {
+ if (mSurface == null || !mSurface.isValid()) {
+ // Surface is not available, or has been destroyed, skip this frame.
+ return;
+ }
+ Canvas canvas = mSurface.lockCanvas(null);
+
+ // Clear the background.
+ canvas.drawColor(mCarContext.isDarkMode() ? Color.DKGRAY : Color.LTGRAY);
+
+ if (mOverrideRenderer != null) {
+ mOverrideRenderer.renderFrame(canvas, mVisibleArea, mStableArea);
+ } else {
+ mDefaultRenderer.renderFrame(canvas, mVisibleArea, mStableArea);
+ }
+ mSurface.unlockCanvasAndPost(canvas);
+
+ }
+}
diff --git a/car/app/app/build.gradle b/car/app/app/build.gradle
index a017570..b6e0f42 100644
--- a/car/app/app/build.gradle
+++ b/car/app/app/build.gradle
@@ -16,6 +16,22 @@
import androidx.build.LibraryGroups
import androidx.build.LibraryType
+import androidx.build.Release
+import androidx.build.checkapi.LibraryApiTaskConfig
+import androidx.build.metalava.MetalavaRunnerKt
+import androidx.build.uptodatedness.EnableCachingKt
+import androidx.build.Version
+
+import com.android.build.gradle.LibraryExtension
+import com.android.build.gradle.api.AndroidSourceDirectorySet
+import com.android.build.gradle.api.SourceKind
+import com.google.common.io.Files
+import org.apache.commons.io.FileUtils
+
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+
+import static androidx.build.dependencies.DependenciesKt.*
plugins {
id("AndroidXPlugin")
@@ -47,6 +63,10 @@
testImplementation project(path: ':car:app:app-testing')
}
+project.ext {
+ latestCarAppApiLevel = "3"
+}
+
android {
defaultConfig {
minSdkVersion 23
@@ -74,3 +94,353 @@
inceptionYear = "2020"
description = "Build navigation, parking, and charging apps for Android Auto"
}
+
+// Use MetalavaRunnerKt to execute Metalava operations. MetalavaRunnerKt is defined in the buildSrc
+// project and provides convience methods for interacting with Metalava. Configruation required
+// for MetalavaRunnerKt is taken from buildSrc/src/main/kotlin/androidx/build/checkapi/ApiTasks.kt.
+class ProtocolApiTask extends DefaultTask {
+ private final WorkerExecutor workerExecutor
+
+ @Inject
+ ProtocolApiTask(WorkerExecutor workerExecutor) {
+ this.workerExecutor = workerExecutor
+ }
+
+ @Internal
+ def getLibraryVariant() {
+ LibraryExtension extension = project.extensions.getByType(LibraryExtension.class)
+ LibraryApiTaskConfig config = new LibraryApiTaskConfig(extension)
+ return config.library.libraryVariants.find({
+ it.name == Release.DEFAULT_PUBLISH_CONFIG
+ })
+ }
+
+ @Internal
+ def getLibraryExtension() {
+ return project.extensions.getByType(LibraryExtension.class)
+ }
+
+ @Internal
+ def getSourceDirs() {
+ List<File> sourceDirs = new ArrayList<File>()
+ for (ConfigurableFileTree fileTree : getLibraryVariant().getSourceFolders(SourceKind
+ .JAVA)) {
+ if (fileTree.getDir().exists()) {
+ sourceDirs.add(fileTree.getDir())
+ }
+ }
+ return sourceDirs
+ }
+
+ @Internal
+ def runMetalava(List<String> additionalArgs) {
+ FileCollection metalavaClasspath = MetalavaRunnerKt.getMetalavaClasspath(project)
+ FileCollection dependencyClasspath = getLibraryVariant().getCompileClasspath(null).filter {
+ it.exists()
+ }
+
+ List<File> classpath = new ArrayList<File>()
+ classpath.addAll(getLibraryExtension().bootClasspath)
+ classpath.addAll(dependencyClasspath)
+
+ List<String> standardArgs = [
+ "--classpath",
+ classpath.join(File.pathSeparator),
+ '--source-path',
+ sourceDirs.join(File.pathSeparator),
+ '--format=v4',
+ '--output-kotlin-nulls=yes',
+ '--quiet'
+ ]
+ standardArgs.addAll(additionalArgs)
+
+ MetalavaRunnerKt.runMetalavaWithArgs(
+ metalavaClasspath,
+ standardArgs,
+ workerExecutor,
+ )
+ }
+}
+
+// Use Metalava to generate an API signature file that only includes public API that is annotated
+// with @androidx.car.app.annotations.CarProtocol
+class GenerateProtocolApiTask extends ProtocolApiTask {
+ @Inject
+ GenerateProtocolApiTask(WorkerExecutor workerExecutor) {
+ super(workerExecutor)
+ }
+
+ @InputFiles
+ @PathSensitive(PathSensitivity.RELATIVE)
+ FileCollection inputSourceDirs = project.files() // Re-run on source changes
+
+ @OutputFile
+ File generatedApi
+
+ @TaskAction
+ def exec() {
+ List<String> args = [
+ '--api',
+ generatedApi.toString(),
+ "--show-annotation",
+ "@androidx.car.app.annotations.CarProtocol",
+ "--hide",
+ "UnhiddenSystemApi"
+ ]
+
+ runMetalava(args)
+ }
+}
+
+// Compare two files and throw an exception if they are not equivalent. This task is used to check
+// for diffs to generated Metalava API signature files, which would indicate a protocol API change.
+class CheckProtocolApiTask extends DefaultTask {
+ @InputFile
+ @Optional
+ File currentApi
+
+ @InputFile
+ File generatedApi
+
+ def summarizeDiff(File a, File b) {
+ if (!a.exists()) {
+ return "$a does not exist"
+ }
+ if (!b.exists()) {
+ return "$b does not exist"
+ }
+ Process process = new ProcessBuilder(Arrays.asList("diff", a.toString(), b.toString()))
+ .redirectOutput(ProcessBuilder.Redirect.PIPE)
+ .start()
+ process.waitFor(5, TimeUnit.SECONDS)
+ List<String> diffLines = process.inputStream.newReader().readLines()
+ int maxSummaryLines = 50
+ if (diffLines.size() > maxSummaryLines) {
+ diffLines = diffLines.subList(0, maxSummaryLines)
+ diffLines.add("[long diff was truncated]")
+ }
+ return String.join("\n", diffLines)
+ }
+
+ @TaskAction
+ def exec() {
+ if (currentApi == null) {
+ return
+ }
+
+ if (!FileUtils.contentEquals(currentApi, generatedApi)) {
+ String diff = summarizeDiff(currentApi, generatedApi)
+ String message = """API definition has changed
+
+ Declared definition is $currentApi
+ True definition is $generatedApi
+
+ Please run `./gradlew updateProtocolApi` to confirm these changes are
+ intentional by updating the API definition.
+
+ Difference between these files:
+ $diff"""
+
+ throw new GradleException("Protocol changes detected!\n$message")
+ }
+ }
+}
+
+// Check for compatibility breaking changes between two Metalava API signature files. This task is
+// used to detect backward-compatibility breaking changes to protocol API.
+class CheckProtocolApiCompatibilityTask extends ProtocolApiTask {
+ @Inject
+ CheckProtocolApiCompatibilityTask(WorkerExecutor workerExecutor) {
+ super(workerExecutor)
+ }
+
+ @InputFile
+ @Optional
+ File previousApi
+
+ @InputFile
+ @Optional
+ File generatedApi
+
+ @TaskAction
+ def exec() {
+ if (previousApi == null || generatedApi == null) {
+ return
+ }
+
+ List<String> args = [
+ '--source-files',
+ generatedApi.toString(),
+ "--check-compatibility:api:released",
+ previousApi.toString()
+ ]
+ runMetalava(args)
+ }
+}
+
+// Update protocol API signature file for current Car API level to reflect the state of current
+// protocol API in the project.
+class UpdateProtocolApiTask extends DefaultTask {
+ @InputDirectory
+ File protocolDir
+
+ @InputFile
+ File generatedApi
+
+ @InputFile
+ @Optional
+ File currentApi
+
+ @TaskAction
+ def exec() {
+ // The expected Car protocol API signature file for the current Car API level and project
+ // version
+ File updatedApi = new File(protocolDir, String.format(
+ "protocol-%s_%s.txt", project.latestCarAppApiLevel, project.version))
+
+ // Determine whether this API level was previously released by checking whether the project
+ // version matches
+ boolean alreadyReleased = currentApi != updatedApi
+
+ // Determine whether this API level is final (Only snapshot, dev, alpha releases are
+ // non-final)
+ boolean isCurrentApiFinal = false
+ if (currentApi != null) {
+ String currentApiFileName = currentApi.name
+ int versionStart = currentApiFileName.indexOf("_")
+ int versionEnd = currentApiFileName.indexOf(".txt")
+
+ String parsedCurrentVersion = currentApiFileName.substring(versionStart + 1, versionEnd)
+ Version currentVersion = new Version(parsedCurrentVersion)
+ isCurrentApiFinal = currentVersion.isFinalApi()
+ }
+
+ if (currentApi != null && alreadyReleased && isCurrentApiFinal) {
+ throw new GradleException("Version has changed for current Car API level. Increment " +
+ "Car API level before making protocol API changes")
+ }
+
+ // Overwrite protocol API signature file for current Car API level
+ Files.copy(generatedApi, updatedApi)
+ }
+}
+
+class ApiLevelFileWriterTask extends DefaultTask {
+ @Input
+ String carApiLevel = project.latestCarAppApiLevel
+
+ @OutputFile
+ File apiLevelFile
+
+ @TaskAction
+ def exec() {
+ PrintWriter writer = new PrintWriter(apiLevelFile)
+ writer.println(carApiLevel)
+ writer.close()
+ }
+}
+
+// Paths and file locations required for protocol API operations
+class ProtocolLocation {
+ File buildDir
+ File protocolDir
+ File generatedApi
+ File currentApi
+ File previousApi
+
+ def getProtocolApiFile(int carApiLevel) {
+ File[] apiFiles = protocolDir.listFiles(new FilenameFilter() {
+ boolean accept(File dir, String name) {
+ return name.startsWith(String.format("protocol-%d_", carApiLevel))
+ }
+ })
+
+ if (apiFiles == null || apiFiles.size() == 0) {
+ return null
+ } else if (apiFiles.size() > 1) {
+ StringBuilder builder = new StringBuilder()
+ for (File file : currentApis) {
+ builder.append(file.path)
+ builder.append("\n")
+ }
+
+ throw new GradleException(
+ String.format("Multiple API signature files found for Car API level %s\n%s",
+ carApiLevel, builder.toString()))
+ }
+
+ return apiFiles[0]
+ }
+
+ ProtocolLocation(Project project) {
+ buildDir = new File(project.buildDir, "/protocol/")
+ generatedApi = new File(buildDir, "/generated.txt")
+ protocolDir = new File(project.projectDir, "/protocol/")
+ int currentApiLevel = Integer.parseInt(project.latestCarAppApiLevel)
+ currentApi = getProtocolApiFile(currentApiLevel)
+ previousApi = getProtocolApiFile(currentApiLevel - 1)
+ }
+}
+
+def RESOURCE_DIRECTORY = "generatedResources"
+def API_LEVEL_FILE_PATH = "$RESOURCE_DIRECTORY/car-app-api.level"
+
+LibraryExtension library = project.extensions.getByType(LibraryExtension.class)
+
+// afterEvaluate required to read extension properties
+afterEvaluate {
+ task writeCarApiLevelFile(type: ApiLevelFileWriterTask) {
+ File artifactName = new File(buildDir, API_LEVEL_FILE_PATH)
+ apiLevelFile = artifactName
+ }
+
+ AndroidSourceDirectorySet resources = library.sourceSets.getByName("main").resources
+ Set<File> resFiles = new HashSet<>()
+ resFiles.add(resources.srcDirs)
+ resFiles.add(new File(buildDir, RESOURCE_DIRECTORY))
+ resources.srcDirs(resFiles)
+ Set<String> includes = resources.includes
+ if (!includes.isEmpty()) {
+ includes.add("*.level")
+ resources.setIncludes(includes)
+ }
+
+ ProtocolLocation projectProtocolLocation = new ProtocolLocation(project)
+ task generateProtocolApi(type: GenerateProtocolApiTask) {
+ description = "Generate an API signature file for the classes annotated with @CarProtocol"
+ generatedApi = projectProtocolLocation.generatedApi
+ dependsOn(assemble)
+ }
+ task checkProtocolApiCompat(type: CheckProtocolApiCompatibilityTask) {
+ description = "Check for BREAKING changes to the protocol API"
+ if (projectProtocolLocation.previousApi != null) {
+ previousApi = projectProtocolLocation.previousApi
+ }
+ generatedApi = projectProtocolLocation.generatedApi
+ dependsOn(generateProtocolApi)
+ }
+ task checkProtocolApi(type: CheckProtocolApiTask) {
+ description = "Check for changes to the protocol API"
+ generatedApi = projectProtocolLocation.generatedApi
+ currentApi = projectProtocolLocation.currentApi
+ dependsOn(checkProtocolApiCompat)
+ }
+ task updateProtocolApi(type: UpdateProtocolApiTask) {
+ description = "Update protocol API signature file for current Car API level to reflect" +
+ "the current state of the protocol API in the source tree."
+ protocolDir = projectProtocolLocation.protocolDir
+ generatedApi = projectProtocolLocation.generatedApi
+ currentApi = projectProtocolLocation.currentApi
+ dependsOn(checkProtocolApiCompat)
+ }
+ EnableCachingKt.cacheEvenIfNoOutputs(checkProtocolApi)
+ EnableCachingKt.cacheEvenIfNoOutputs(checkProtocolApiCompat)
+ checkApi.dependsOn(checkProtocolApi)
+}
+
+library.libraryVariants.all { variant ->
+ variant.processJavaResourcesProvider.configure {
+ it.dependsOn(writeCarApiLevelFile)
+ }
+}
+
diff --git a/car/app/app/src/main/java/androidx/car/app/hardware/common/CarValue.java b/car/app/app/src/main/java/androidx/car/app/hardware/common/CarValue.java
index b8230d1..c1ca29a 100644
--- a/car/app/app/src/main/java/androidx/car/app/hardware/common/CarValue.java
+++ b/car/app/app/src/main/java/androidx/car/app/hardware/common/CarValue.java
@@ -18,6 +18,7 @@
import static androidx.annotation.RestrictTo.Scope.LIBRARY;
import androidx.annotation.IntDef;
+import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
@@ -85,9 +86,12 @@
@StatusCode
public static final int STATUS_UNAVAILABLE = 3;
+ @Keep
@Nullable
private final T mValue;
+ @Keep
private final long mTimestampMillis;
+ @Keep
@StatusCode
private final int mStatus;
diff --git a/car/app/app/src/main/java/androidx/car/app/versioning/CarAppApiLevels.java b/car/app/app/src/main/java/androidx/car/app/versioning/CarAppApiLevels.java
index 7accc41..bdc8821 100644
--- a/car/app/app/src/main/java/androidx/car/app/versioning/CarAppApiLevels.java
+++ b/car/app/app/src/main/java/androidx/car/app/versioning/CarAppApiLevels.java
@@ -16,9 +16,16 @@
package androidx.car.app.versioning;
+import static java.util.Objects.requireNonNull;
+
import androidx.annotation.RestrictTo;
import androidx.car.app.CarContext;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+
/**
* API levels supported by this library.
*
@@ -65,6 +72,8 @@
@CarAppApiLevel
public static final int UNKNOWN = 0;
+ private static final String CAR_API_LEVEL_FILE = "car-app-api.level";
+
/**
* Returns whether the given integer is a valid {@link CarAppApiLevel}
*
@@ -80,7 +89,37 @@
*/
@CarAppApiLevel
public static int getLatest() {
- return LEVEL_3;
+ // The latest Car API level is defined as java resource, generated via build.gradle. This
+ // has to be read through the class loader because we do not have access to the context
+ // to retrieve an Android resource.
+ ClassLoader classLoader = requireNonNull(CarAppApiLevels.class.getClassLoader());
+ InputStream inputStream = classLoader.getResourceAsStream(CAR_API_LEVEL_FILE);
+
+ if (inputStream == null) {
+ throw new IllegalStateException(String.format("Car API level file %s not found",
+ CAR_API_LEVEL_FILE));
+ }
+
+ try {
+ InputStreamReader streamReader = new InputStreamReader(inputStream);
+ BufferedReader reader = new BufferedReader(streamReader);
+ String line = reader.readLine();
+
+ switch (Integer.parseInt(line)) {
+ case 0:
+ return UNKNOWN;
+ case 1:
+ return LEVEL_1;
+ case 2:
+ return LEVEL_2;
+ case 3:
+ return LEVEL_3;
+ default:
+ throw new IllegalStateException("Undefined Car API level: " + line);
+ }
+ } catch (IOException e) {
+ throw new IllegalStateException("Unable to read Car API level file");
+ }
}
/**
diff --git a/compose/desktop/desktop/src/jvmTest/kotlin/androidx/compose/desktop/InputsTest.kt b/compose/desktop/desktop/src/jvmTest/kotlin/androidx/compose/desktop/InputsTest.kt
index a280d09..937db0c 100644
--- a/compose/desktop/desktop/src/jvmTest/kotlin/androidx/compose/desktop/InputsTest.kt
+++ b/compose/desktop/desktop/src/jvmTest/kotlin/androidx/compose/desktop/InputsTest.kt
@@ -20,7 +20,8 @@
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
-import androidx.compose.ui.test.assertValueEquals
+import androidx.compose.ui.semantics.ProgressBarRangeInfo
+import androidx.compose.ui.test.assertRangeInfoEquals
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import org.junit.Rule
@@ -49,10 +50,10 @@
rule.runOnIdle {
state.value = 2f
}
- rule.onNodeWithTag(tag).assertValueEquals("100 percent")
+ rule.onNodeWithTag(tag).assertRangeInfoEquals(ProgressBarRangeInfo(1f, 0f..1f, 0))
rule.runOnIdle {
state.value = -123145f
}
- rule.onNodeWithTag(tag).assertValueEquals("0 percent")
+ rule.onNodeWithTag(tag).assertRangeInfoEquals(ProgressBarRangeInfo(0f, 0f..1f, 0))
}
}
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/BorderTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/BorderTest.kt
index fcf9836..1a5794f 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/BorderTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/BorderTest.kt
@@ -18,6 +18,7 @@
import android.os.Build
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.shape.CircleShape
@@ -28,10 +29,15 @@
import androidx.compose.runtime.mutableStateOf
import androidx.compose.testutils.assertShape
import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.PathOperation
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
@@ -43,6 +49,7 @@
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.test.filters.MediumTest
@@ -219,19 +226,24 @@
close()
}
rule.setContent {
+ val triangleSizeDp: Dp
+ val borderWidthDp: Dp
+ with(LocalDensity.current) {
+ triangleSizeDp = (100f / density).dp
+ borderWidthDp = (10f / density).dp
+ }
Box(
Modifier.testTag(testTag)
- .requiredSize(100.dp, 100.dp)
+ .requiredSize(triangleSizeDp, triangleSizeDp)
.background(Color.White)
- .border(BorderStroke(10.dp, Color.Red), triangle)
+ .border(BorderStroke(borderWidthDp, Color.Red), triangle)
)
}
- val offsetLeft = 30
- val offsetRight = 15
- val offsetTop = 15
- val offsetBottom = 30
-
+ val offsetLeft = 5
+ val offsetRight = 2
+ val offsetTop = 2
+ val offsetBottom = 5
rule.onNodeWithTag(testTag).captureToImage().apply {
val map = toPixelMap()
assertEquals(Color.Red, map[offsetLeft, offsetTop]) // Top left
@@ -368,6 +380,251 @@
)
}
+ @Test
+ fun border_generic_shape_color_to_brush() {
+ // Verify that rendering with a solid color initially then with a gradient
+ // updates the internal offscreen bitmap config to Argb8888 from Alpha8
+ val gradient = Brush.verticalGradient(
+ 0.0f to Color.Red,
+ 0.5f to Color.Red,
+ 0.5f to Color.Blue,
+ 1.0f to Color.Blue
+ )
+ val testTag = "testTag"
+ val borderStrokeDp = 5.dp
+ var borderStrokePx = 0f
+ var toggle = mutableStateOf(false)
+ rule.setContent {
+ val testShape = GenericShape { size, _ ->
+ addRect(Rect(0f, 0f, size.width, size.height))
+ }
+ with(LocalDensity.current) {
+ borderStrokePx = borderStrokeDp.toPx()
+ }
+ Box(
+ Modifier.testTag(testTag)
+ .requiredSize(20.dp, 20.dp)
+ .background(Color.White)
+ .border(
+ BorderStroke(
+ borderStrokeDp,
+ if (toggle.value) gradient else SolidColor(Color.Green)
+ ),
+ testShape
+ )
+ )
+ }
+
+ val halfBorderStrokePx = (borderStrokePx / 2).toInt()
+ rule.onNodeWithTag(testTag).captureToImage().apply {
+ val pixelMap = toPixelMap()
+ assertEquals(
+ Color.Green,
+ pixelMap[halfBorderStrokePx, halfBorderStrokePx]
+ )
+ assertEquals(
+ Color.Green,
+ pixelMap[width - halfBorderStrokePx, halfBorderStrokePx]
+ )
+ assertEquals(
+ Color.Green,
+ pixelMap[halfBorderStrokePx, height - halfBorderStrokePx]
+ )
+ assertEquals(
+ Color.Green,
+ pixelMap[width - halfBorderStrokePx, height - halfBorderStrokePx]
+ )
+ assertEquals(
+ Color.White,
+ pixelMap[ width / 2, height / 2]
+ )
+ }
+
+ rule.runOnIdle {
+ toggle.value = !toggle.value
+ }
+
+ rule.onNodeWithTag(testTag).captureToImage().apply {
+ val pixelMap = toPixelMap()
+ assertEquals(
+ Color.Red,
+ pixelMap[halfBorderStrokePx, halfBorderStrokePx]
+ )
+ assertEquals(
+ Color.Red,
+ pixelMap[width - halfBorderStrokePx, halfBorderStrokePx]
+ )
+ assertEquals(
+ Color.Blue,
+ pixelMap[halfBorderStrokePx, height - halfBorderStrokePx]
+ )
+ assertEquals(
+ Color.Blue,
+ pixelMap[width - halfBorderStrokePx, height - halfBorderStrokePx]
+ )
+ assertEquals(
+ Color.White,
+ pixelMap[ width / 2, height / 2]
+ )
+ }
+ }
+
+ @Test
+ fun border_test_with_fixed_size_generic_shape() {
+ val testTag = "testTag"
+ val bubbleWidthDp = 80.dp
+ val bubbleHeightDp = 40.dp
+ val borderWidthDp = 10.dp
+ val arrowBaseWidthDp = 24.dp
+ val arrowTipDp: Dp = 56.dp
+ val arrowLengthDp: Dp = 12.dp
+ var offset: Offset = Offset.Zero
+ var arrowTipPx = 0f
+ var arrowBaseWidthPx = 0f
+ var arrowLengthPx = 0f
+ var borderStrokePx = 0f
+ rule.setContent {
+ val bubbleWithArrow = calculateContainerShape(
+ LocalDensity.current,
+ arrowBaseWidthDp,
+ arrowTipDp,
+ arrowLengthDp
+ )
+ with(LocalDensity.current) {
+ val outline = bubbleWithArrow.createOutline(
+ Size(
+ bubbleWidthDp.toPx(),
+ bubbleHeightDp.toPx()
+ ),
+ LayoutDirection.Ltr,
+ this
+ ) as Outline.Generic
+
+ val pathBounds = outline.path.getBounds()
+ offset = Offset(-pathBounds.left, -pathBounds.top)
+
+ arrowTipPx = arrowTipDp.toPx()
+ arrowBaseWidthPx = arrowBaseWidthDp.toPx()
+ arrowLengthPx = arrowLengthDp.toPx()
+ borderStrokePx = borderWidthDp.toPx()
+ }
+ Box(
+ Modifier.testTag(testTag)
+ .requiredSize(bubbleWidthDp, bubbleHeightDp)
+ .background(Color.White)
+ .padding(top = arrowLengthDp)
+ .border(BorderStroke(borderWidthDp, Color.Red), bubbleWithArrow)
+ )
+ }
+
+ val arrowTipX = arrowTipPx / 2
+ rule.onNodeWithTag(testTag).captureToImage().apply {
+ val map = toPixelMap()
+
+ // point along the rounded rect but before the triangle is drawn with the border
+ var currentX = arrowTipX + arrowBaseWidthPx / 2f - arrowBaseWidthPx
+ assertEquals(
+ Color.Red,
+ map[
+ currentX.toInt(),
+ (offset.y + borderStrokePx / 2).toInt()
+ ]
+ )
+
+ // point halfway up the start of the triangle is drawn within the border
+ assertEquals(
+ Color.Red,
+ map[
+ (currentX + arrowBaseWidthPx / 4).toInt(),
+ (offset.y - arrowLengthPx / 2 + borderStrokePx / 2).toInt()
+ ]
+ )
+
+ // Tip of the triangle is drawn within the border
+ currentX += arrowBaseWidthPx / 2f
+ assertEquals(
+ Color.Red,
+ map[
+ currentX.toInt(),
+ (offset.y - arrowLengthPx + borderStrokePx / 2).toInt()
+ ]
+ )
+
+ // rounded rectangle directly below the triangle does not have the border rendered
+ assertEquals(
+ Color.White,
+ map[
+ currentX.toInt(),
+ (offset.y + borderStrokePx / 2).toInt()
+ ]
+ )
+
+ // Midpoint of the end of the triangle being drawn back into the rounded rect
+ // has the border rendered
+ assertEquals(
+ Color.Red,
+ map[
+ (currentX + arrowBaseWidthPx / 4f).toInt(),
+ (offset.y - arrowLengthPx / 4 + borderStrokePx / 2).toInt()
+ ]
+ )
+
+ // Base of the triangle on the rounded rect shape has the border rendered
+ currentX += arrowBaseWidthPx / 2f
+ assertEquals(
+ Color.Red,
+ map[
+ currentX.toInt(),
+ (offset.y + borderStrokePx / 2).toInt()
+ ]
+ )
+ }
+ }
+
+ private fun calculateContainerShape(
+ density: Density,
+ arrowBaseWidthDp: Dp,
+ arrowTipDp: Dp,
+ arrowLengthDp: Dp
+ ): Shape {
+ val cornerRadiusDp: Dp = 8.dp
+ val cornerRadiusPx: Float
+ val arrowBaseWidthPx: Float
+ val arrowLengthPx: Float
+
+ val arrowTipPx: Float
+
+ with(density) {
+ cornerRadiusPx = cornerRadiusDp.toPx()
+ arrowBaseWidthPx = arrowBaseWidthDp.toPx()
+ arrowLengthPx = arrowLengthDp.toPx()
+ arrowTipPx = arrowTipDp.toPx()
+ }
+
+ return GenericShape { size, _ ->
+ val width = size.width
+ val height = size.height
+
+ val boundingRoundRect =
+ Path().apply {
+ addRoundRect(RoundRect(0f, 0f, width, height, cornerRadiusPx, cornerRadiusPx))
+ }
+
+ // The tip of the arrow should be positioned in the middle of the icon above.
+ val arrowTipX = arrowTipPx / 2
+
+ val boundingArrow =
+ Path().apply {
+ moveTo(arrowTipX + arrowBaseWidthPx / 2f, 0f)
+ relativeLineTo(-arrowBaseWidthPx, 0f)
+ relativeLineTo(arrowBaseWidthPx / 2f, -arrowLengthPx)
+ relativeLineTo(arrowBaseWidthPx / 2f, arrowLengthPx)
+ }
+
+ op(boundingRoundRect, boundingArrow, PathOperation.Union)
+ }
+ }
+
@Composable
fun SemanticParent(content: @Composable Density.() -> Unit) {
Box {
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/LazyColumnTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/LazyColumnTest.kt
index 6a6977d..2c5790c 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/LazyColumnTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/LazyColumnTest.kt
@@ -17,245 +17,70 @@
package androidx.compose.foundation.lazy
import android.os.Build
-import androidx.compose.animation.core.snap
-import androidx.compose.foundation.AutoTestFrameClock
import androidx.compose.foundation.background
-import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.requiredSize
-import androidx.compose.foundation.layout.requiredSizeIn
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicText
-import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
-import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
-import androidx.compose.testutils.WithTouchSlop
-import androidx.compose.testutils.assertIsEqualTo
import androidx.compose.testutils.assertPixels
-import androidx.compose.testutils.assertShape
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.testTag
-import androidx.compose.ui.semantics.SemanticsActions
-import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.assertCountEquals
-import androidx.compose.ui.test.assertHeightIsEqualTo
import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.assertIsNotDisplayed
import androidx.compose.ui.test.assertPositionInRootIsEqualTo
-import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo
-import androidx.compose.ui.test.assertWidthIsEqualTo
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.center
import androidx.compose.ui.test.down
import androidx.compose.ui.test.getUnclippedBoundsInRoot
-import androidx.compose.ui.test.junit4.ComposeContentTestRule
-import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.compose.ui.test.moveBy
import androidx.compose.ui.test.onChildren
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performGesture
-import androidx.compose.ui.test.performSemanticsAction
import androidx.compose.ui.test.swipeUp
-import androidx.compose.ui.test.swipeWithVelocity
-import androidx.compose.ui.test.up
-import androidx.compose.ui.unit.Density
-import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.filters.SdkSuppress
-import com.google.common.collect.Range
-import com.google.common.truth.IntegerSubject
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
-import java.util.concurrent.CountDownLatch
@LargeTest
@RunWith(AndroidJUnit4::class)
+/**
+ * This class contains all LazyColumn-specific tests, as well as (by convention) tests that don't
+ * need to be run in both orientations.
+ *
+ * To have a test run in both orientations (LazyRow and LazyColumn), add it to [LazyListTest]
+ */
class LazyColumnTest {
private val LazyListTag = "LazyListTag"
- private val NeverEqualObject = object {
- override fun equals(other: Any?): Boolean {
- return false
- }
- }
-
@get:Rule
val rule = createComposeRule()
@Test
- fun lazyColumnShowsCombinedItems() {
- val itemTestTag = "itemTestTag"
- val items = listOf(1, 2).map { it.toString() }
- val indexedItems = listOf(3, 4, 5)
-
- rule.setContentWithTestViewConfiguration {
- LazyColumn(Modifier.height(200.dp)) {
- item {
- Spacer(
- Modifier.height(40.dp).fillParentMaxWidth().testTag(itemTestTag)
- )
- }
- items(items) {
- Spacer(Modifier.height(40.dp).fillParentMaxWidth().testTag(it))
- }
- itemsIndexed(indexedItems) { index, item ->
- Spacer(
- Modifier.height(41.dp).fillParentMaxWidth()
- .testTag("$index-$item")
- )
- }
- }
- }
-
- rule.onNodeWithTag(itemTestTag)
- .assertIsDisplayed()
-
- rule.onNodeWithTag("1")
- .assertIsDisplayed()
-
- rule.onNodeWithTag("2")
- .assertIsDisplayed()
-
- rule.onNodeWithTag("0-3")
- .assertIsDisplayed()
-
- rule.onNodeWithTag("1-4")
- .assertIsDisplayed()
-
- rule.onNodeWithTag("2-5")
- .assertDoesNotExist()
- }
-
- @Test
- fun lazyColumnAllowEmptyListItems() {
- val itemTag = "itemTag"
-
- rule.setContentWithTestViewConfiguration {
- LazyColumn {
- items(emptyList<Any>()) { }
- item {
- Spacer(Modifier.size(10.dp).testTag(itemTag))
- }
- }
- }
-
- rule.onNodeWithTag(itemTag)
- .assertIsDisplayed()
- }
-
- @Test
- fun lazyColumnAllowsNullableItems() {
- val items = listOf("1", null, "3")
- val nullTestTag = "nullTestTag"
-
- rule.setContentWithTestViewConfiguration {
- LazyColumn(Modifier.height(200.dp)) {
- items(items) {
- if (it != null) {
- Spacer(Modifier.height(101.dp).fillParentMaxWidth().testTag(it))
- } else {
- Spacer(
- Modifier.height(101.dp).fillParentMaxWidth()
- .testTag(nullTestTag)
- )
- }
- }
- }
- }
-
- rule.onNodeWithTag("1")
- .assertIsDisplayed()
-
- rule.onNodeWithTag(nullTestTag)
- .assertIsDisplayed()
-
- rule.onNodeWithTag("3")
- .assertDoesNotExist()
- }
-
- @Test
- fun compositionsAreDisposed_whenNodesAreScrolledOff() {
- var composed: Boolean
- var disposed = false
- // Ten 31dp spacers in a 300dp list
- val latch = CountDownLatch(10)
-
- rule.setContentWithTestViewConfiguration {
- // Fixed height to eliminate device size as a factor
- Box(Modifier.testTag(LazyListTag).height(300.dp)) {
- LazyColumn(Modifier.fillMaxSize()) {
- items(50) {
- DisposableEffect(NeverEqualObject) {
- composed = true
- // Signal when everything is done composing
- latch.countDown()
- onDispose {
- disposed = true
- }
- }
-
- // There will be 10 of these in the 300dp box
- Spacer(Modifier.height(31.dp))
- }
- }
- }
- }
-
- latch.await()
- composed = false
-
- assertWithMessage("Compositions were disposed before we did any scrolling")
- .that(disposed).isFalse()
-
- // Mostly a validity check, this is not part of the behavior under test
- assertWithMessage("Additional composition occurred for no apparent reason")
- .that(composed).isFalse()
-
- rule.onNodeWithTag(LazyListTag)
- .performGesture { swipeUp() }
-
- rule.waitForIdle()
-
- assertWithMessage("No additional items were composed after scroll, scroll didn't work")
- .that(composed).isTrue()
-
- // We may need to modify this test once we prefetch/cache items outside the viewport
- assertWithMessage(
- "No compositions were disposed after scrolling, compositions were leaked"
- ).that(disposed).isTrue()
- }
-
- @Test
fun compositionsAreDisposed_whenDataIsChanged() {
var composed = 0
var disposals = 0
@@ -274,7 +99,7 @@
}
}
- Spacer(Modifier.requiredHeight(50.dp))
+ Spacer(Modifier.height(50.dp))
}
}
}
@@ -300,13 +125,13 @@
}
@Test
- fun compositionsAreDisposed_whenAdapterListIsDisposed() {
- var emitAdapterList by mutableStateOf(true)
+ fun compositionsAreDisposed_whenLazyListIsDisposed() {
+ var emitLazyList by mutableStateOf(true)
var disposeCalledOnFirstItem = false
var disposeCalledOnSecondItem = false
rule.setContentWithTestViewConfiguration {
- if (emitAdapterList) {
+ if (emitLazyList) {
LazyColumn(Modifier.fillMaxSize()) {
items(2) {
Box(Modifier.requiredSize(100.dp))
@@ -325,17 +150,17 @@
}
rule.runOnIdle {
- assertWithMessage("First item is not immediately disposed")
+ assertWithMessage("First item was incorrectly immediately disposed")
.that(disposeCalledOnFirstItem).isFalse()
- assertWithMessage("Second item is not immediately disposed")
+ assertWithMessage("Second item was incorrectly immediately disposed")
.that(disposeCalledOnFirstItem).isFalse()
- emitAdapterList = false
+ emitLazyList = false
}
rule.runOnIdle {
- assertWithMessage("First item is correctly disposed")
+ assertWithMessage("First item was not correctly disposed")
.that(disposeCalledOnFirstItem).isTrue()
- assertWithMessage("Second item is correctly disposed")
+ assertWithMessage("Second item was not correctly disposed")
.that(disposeCalledOnSecondItem).isTrue()
}
}
@@ -411,108 +236,6 @@
}
}
- @Test
- fun whenItemsAreInitiallyCreatedWith0SizeWeCanScrollWhenTheyExpanded() {
- val thirdTag = "third"
- val items = (1..3).toList()
- var thirdHasSize by mutableStateOf(false)
-
- rule.setContentWithTestViewConfiguration {
- LazyColumn(
- Modifier.fillMaxWidth()
- .height(100.dp)
- .testTag(LazyListTag)
- ) {
- items(items) {
- if (it == 3) {
- Spacer(
- Modifier.testTag(thirdTag)
- .fillParentMaxWidth()
- .height(if (thirdHasSize) 60.dp else 0.dp)
- )
- } else {
- Spacer(Modifier.fillParentMaxWidth().height(60.dp))
- }
- }
- }
- }
-
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(y = 21.dp, density = rule.density)
-
- rule.onNodeWithTag(thirdTag)
- .assertExists()
- .assertIsNotDisplayed()
-
- rule.runOnIdle {
- thirdHasSize = true
- }
-
- rule.waitForIdle()
-
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(y = 10.dp, density = rule.density)
-
- rule.onNodeWithTag(thirdTag)
- .assertIsDisplayed()
- }
-
- @Test
- fun lazyColumnWrapsContent() = with(rule.density) {
- val itemInsideLazyColumn = "itemInsideLazyColumn"
- val itemOutsideLazyColumn = "itemOutsideLazyColumn"
- var sameSizeItems by mutableStateOf(true)
-
- rule.setContentWithTestViewConfiguration {
- Row {
- LazyColumn(Modifier.testTag(LazyListTag)) {
- items(listOf(1, 2)) {
- if (it == 1) {
- Spacer(Modifier.size(50.dp).testTag(itemInsideLazyColumn))
- } else {
- Spacer(Modifier.size(if (sameSizeItems) 50.dp else 70.dp))
- }
- }
- }
- Spacer(Modifier.size(50.dp).testTag(itemOutsideLazyColumn))
- }
- }
-
- rule.onNodeWithTag(itemInsideLazyColumn)
- .assertIsDisplayed()
-
- rule.onNodeWithTag(itemOutsideLazyColumn)
- .assertIsDisplayed()
-
- var lazyColumnBounds = rule.onNodeWithTag(LazyListTag)
- .getUnclippedBoundsInRoot()
-
- assertThat(lazyColumnBounds.left.roundToPx()).isWithin1PixelFrom(0.dp.roundToPx())
- assertThat(lazyColumnBounds.right.roundToPx()).isWithin1PixelFrom(50.dp.roundToPx())
- assertThat(lazyColumnBounds.top.roundToPx()).isWithin1PixelFrom(0.dp.roundToPx())
- assertThat(lazyColumnBounds.bottom.roundToPx()).isWithin1PixelFrom(100.dp.roundToPx())
-
- rule.runOnIdle {
- sameSizeItems = false
- }
-
- rule.waitForIdle()
-
- rule.onNodeWithTag(itemInsideLazyColumn)
- .assertIsDisplayed()
-
- rule.onNodeWithTag(itemOutsideLazyColumn)
- .assertIsDisplayed()
-
- lazyColumnBounds = rule.onNodeWithTag(LazyListTag)
- .getUnclippedBoundsInRoot()
-
- assertThat(lazyColumnBounds.left.roundToPx()).isWithin1PixelFrom(0.dp.roundToPx())
- assertThat(lazyColumnBounds.right.roundToPx()).isWithin1PixelFrom(70.dp.roundToPx())
- assertThat(lazyColumnBounds.top.roundToPx()).isWithin1PixelFrom(0.dp.roundToPx())
- assertThat(lazyColumnBounds.bottom.roundToPx()).isWithin1PixelFrom(120.dp.roundToPx())
- }
-
private val firstItemTag = "firstItemTag"
private val secondItemTag = "secondItemTag"
@@ -582,350 +305,6 @@
}
@Test
- fun itemFillingParentWidth() {
- rule.setContentWithTestViewConfiguration {
- LazyColumn(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
- items(listOf(0)) {
- Spacer(
- Modifier.fillParentMaxWidth().requiredHeight(50.dp).testTag(firstItemTag)
- )
- }
- }
- }
-
- rule.onNodeWithTag(firstItemTag)
- .assertWidthIsEqualTo(100.dp)
- .assertHeightIsEqualTo(50.dp)
- }
-
- @Test
- fun itemFillingParentHeight() {
- rule.setContentWithTestViewConfiguration {
- LazyColumn(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
- items(listOf(0)) {
- Spacer(
- Modifier.requiredWidth(50.dp).fillParentMaxHeight().testTag(firstItemTag)
- )
- }
- }
- }
-
- rule.onNodeWithTag(firstItemTag)
- .assertWidthIsEqualTo(50.dp)
- .assertHeightIsEqualTo(150.dp)
- }
-
- @Test
- fun itemFillingParentSize() {
- rule.setContentWithTestViewConfiguration {
- LazyColumn(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
- items(listOf(0)) {
- Spacer(Modifier.fillParentMaxSize().testTag(firstItemTag))
- }
- }
- }
-
- rule.onNodeWithTag(firstItemTag)
- .assertWidthIsEqualTo(100.dp)
- .assertHeightIsEqualTo(150.dp)
- }
-
- @Test
- fun itemFillingParentWidthFraction() {
- rule.setContentWithTestViewConfiguration {
- LazyColumn(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
- items(listOf(0)) {
- Spacer(
- Modifier.fillParentMaxWidth(0.6f)
- .requiredHeight(50.dp)
- .testTag(firstItemTag)
- )
- }
- }
- }
-
- rule.onNodeWithTag(firstItemTag)
- .assertWidthIsEqualTo(60.dp)
- .assertHeightIsEqualTo(50.dp)
- }
-
- @Test
- fun itemFillingParentHeightFraction() {
- rule.setContentWithTestViewConfiguration {
- LazyColumn(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
- items(listOf(0)) {
- Spacer(
- Modifier.requiredWidth(50.dp)
- .fillParentMaxHeight(0.2f)
- .testTag(firstItemTag)
- )
- }
- }
- }
-
- rule.onNodeWithTag(firstItemTag)
- .assertWidthIsEqualTo(50.dp)
- .assertHeightIsEqualTo(30.dp)
- }
-
- @Test
- fun itemFillingParentSizeFraction() {
- rule.setContentWithTestViewConfiguration {
- LazyColumn(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
- items(listOf(0)) {
- Spacer(Modifier.fillParentMaxSize(0.1f).testTag(firstItemTag))
- }
- }
- }
-
- rule.onNodeWithTag(firstItemTag)
- .assertWidthIsEqualTo(10.dp)
- .assertHeightIsEqualTo(15.dp)
- }
-
- @Test
- fun itemFillingParentSizeParentResized() {
- var parentSize by mutableStateOf(100.dp)
- rule.setContentWithTestViewConfiguration {
- LazyColumn(Modifier.requiredSize(parentSize)) {
- items(listOf(0)) {
- Spacer(Modifier.fillParentMaxSize().testTag(firstItemTag))
- }
- }
- }
-
- rule.runOnIdle {
- parentSize = 150.dp
- }
-
- rule.onNodeWithTag(firstItemTag)
- .assertWidthIsEqualTo(150.dp)
- .assertHeightIsEqualTo(150.dp)
- }
-
- @Test
- fun whenNotAnymoreAvailableItemWasDisplayed() {
- var items by mutableStateOf((1..30).toList())
- rule.setContentWithTestViewConfiguration {
- LazyColumn(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
- items(items) {
- Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
- }
- }
- }
-
- // after scroll we will display items 16-20
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(y = 300.dp, density = rule.density)
-
- rule.runOnIdle {
- items = (1..10).toList()
- }
-
- // there is no item 16 anymore so we will just display the last items 6-10
- rule.onNodeWithTag("6")
- .assertTopPositionIsAlmost(0.dp)
- }
-
- @Test
- fun whenFewDisplayedItemsWereRemoved() {
- var items by mutableStateOf((1..10).toList())
- rule.setContentWithTestViewConfiguration {
- LazyColumn(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
- items(items) {
- Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
- }
- }
- }
-
- // after scroll we will display items 6-10
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(y = 100.dp, density = rule.density)
-
- rule.runOnIdle {
- items = (1..8).toList()
- }
-
- // there are no more items 9 and 10, so we have to scroll back
- rule.onNodeWithTag("4")
- .assertTopPositionIsAlmost(0.dp)
- }
-
- @Test
- fun whenItemsBecameEmpty() {
- var items by mutableStateOf((1..10).toList())
- rule.setContentWithTestViewConfiguration {
- LazyColumn(Modifier.requiredSizeIn(maxHeight = 100.dp).testTag(LazyListTag)) {
- items(items) {
- Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
- }
- }
- }
-
- // after scroll we will display items 2-6
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(y = 20.dp, density = rule.density)
-
- rule.runOnIdle {
- items = emptyList()
- }
-
- // there are no more items so the LazyColumn is zero sized
- rule.onNodeWithTag(LazyListTag)
- .assertWidthIsEqualTo(0.dp)
- .assertHeightIsEqualTo(0.dp)
-
- // and has no children
- rule.onNodeWithTag("1")
- .assertDoesNotExist()
- rule.onNodeWithTag("2")
- .assertDoesNotExist()
- }
-
- @Test
- fun scrollBackAndForth() {
- val items by mutableStateOf((1..20).toList())
- rule.setContentWithTestViewConfiguration {
- LazyColumn(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
- items(items) {
- Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
- }
- }
- }
-
- // after scroll we will display items 6-10
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(y = 100.dp, density = rule.density)
-
- // and scroll back
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(y = (-100).dp, density = rule.density)
-
- rule.onNodeWithTag("1")
- .assertTopPositionIsAlmost(0.dp)
- }
-
- @Test
- fun tryToScrollBackwardWhenAlreadyOnTop() {
- val items by mutableStateOf((1..20).toList())
- rule.setContentWithTestViewConfiguration {
- LazyColumn(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
- items(items) {
- Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
- }
- }
- }
-
- // we already displaying the first item, so this should do nothing
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(y = (-50).dp, density = rule.density)
-
- rule.onNodeWithTag("1")
- .assertTopPositionIsAlmost(0.dp)
- rule.onNodeWithTag("5")
- .assertTopPositionIsAlmost(80.dp)
- }
-
- @Test
- fun contentOfNotStableItemsIsNotRecomposedDuringScroll() {
- val items = listOf(NotStable(1), NotStable(2))
- var firstItemRecomposed = 0
- var secondItemRecomposed = 0
- rule.setContentWithTestViewConfiguration {
- LazyColumn(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
- items(items) {
- if (it.count == 1) {
- firstItemRecomposed++
- } else {
- secondItemRecomposed++
- }
- Spacer(Modifier.requiredSize(75.dp))
- }
- }
- }
-
- rule.runOnIdle {
- assertThat(firstItemRecomposed).isEqualTo(1)
- assertThat(secondItemRecomposed).isEqualTo(1)
- }
-
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(y = (50).dp, density = rule.density)
-
- rule.runOnIdle {
- assertThat(firstItemRecomposed).isEqualTo(1)
- assertThat(secondItemRecomposed).isEqualTo(1)
- }
- }
-
- @Test
- fun onlyOneMeasurePassForScrollEvent() {
- val items by mutableStateOf((1..20).toList())
- lateinit var state: LazyListState
- rule.setContentWithTestViewConfiguration {
- state = rememberLazyListState()
- state.prefetchingEnabled = false
- LazyColumn(
- Modifier.requiredSize(100.dp).testTag(LazyListTag),
- state = state
- ) {
- items(items) {
- Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
- }
- }
- }
-
- val initialMeasurePasses = state.numMeasurePasses
-
- rule.runOnIdle {
- with(rule.density) {
- state.onScroll(-110.dp.toPx())
- }
- }
-
- rule.waitForIdle()
-
- assertThat(state.numMeasurePasses).isEqualTo(initialMeasurePasses + 1)
- }
-
- @Test
- fun stateUpdatedAfterScroll() {
- val items by mutableStateOf((1..20).toList())
- lateinit var state: LazyListState
- rule.setContentWithTestViewConfiguration {
- state = rememberLazyListState()
- LazyColumn(
- Modifier.requiredSize(100.dp).testTag(LazyListTag),
- state = state
- ) {
- items(items) {
- Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
- }
- }
- }
-
- rule.runOnIdle {
- assertThat(state.firstVisibleItemIndex).isEqualTo(0)
- assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
- }
-
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(y = 30.dp, density = rule.density)
-
- rule.runOnIdle {
- assertThat(state.firstVisibleItemIndex).isEqualTo(1)
-
- with(rule.density) {
- // TODO(b/169232491): test scrolling doesn't appear to be scrolling exactly the right
- // number of pixels
- val expectedOffset = 10.dp.roundToPx()
- val tolerance = 2.dp.roundToPx()
- assertThat(state.firstVisibleItemScrollOffset).isEqualTo(expectedOffset, tolerance)
- }
- }
- }
-
- @Test
fun flingAnimationStopsOnFingerDown() {
val items by mutableStateOf((1..20).toList())
val state = LazyListState()
@@ -965,309 +344,6 @@
}
@Test
- fun stateUpdatedAfterScrollWithinTheSameItem() {
- val items by mutableStateOf((1..20).toList())
- lateinit var state: LazyListState
- rule.setContentWithTestViewConfiguration {
- state = rememberLazyListState()
- LazyColumn(
- Modifier.requiredSize(100.dp).testTag(LazyListTag),
- state = state
- ) {
- items(items) {
- Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
- }
- }
- }
-
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(y = 10.dp, density = rule.density)
-
- rule.runOnIdle {
- assertThat(state.firstVisibleItemIndex).isEqualTo(0)
- with(rule.density) {
- val expectedOffset = 10.dp.roundToPx()
- val tolerance = 2.dp.roundToPx()
- assertThat(state.firstVisibleItemScrollOffset)
- .isEqualTo(expectedOffset, tolerance)
- }
- }
- }
-
- @Test
- fun initialScrollIsApplied() {
- val items by mutableStateOf((0..20).toList())
- lateinit var state: LazyListState
- val expectedOffset = with(rule.density) { 10.dp.roundToPx() }
- rule.setContentWithTestViewConfiguration {
- state = rememberLazyListState(2, expectedOffset)
- LazyColumn(
- Modifier.requiredSize(100.dp).testTag(LazyListTag),
- state = state
- ) {
- items(items) {
- Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
- }
- }
- }
-
- rule.runOnIdle {
- assertThat(state.firstVisibleItemIndex).isEqualTo(2)
- assertThat(state.firstVisibleItemScrollOffset).isEqualTo(expectedOffset)
- }
-
- rule.onNodeWithTag("2")
- .assertTopPositionInRootIsEqualTo((-10).dp)
- }
-
- @Test
- fun stateIsRestored() {
- val restorationTester = StateRestorationTester(rule)
- var state: LazyListState? = null
- restorationTester.setContent {
- state = rememberLazyListState()
- LazyColumn(
- Modifier.requiredSize(100.dp).testTag(LazyListTag),
- state = state!!
- ) {
- items(20) {
- Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
- }
- }
- }
-
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(y = 30.dp, density = rule.density)
-
- val (index, scrollOffset) = rule.runOnIdle {
- state!!.firstVisibleItemIndex to state!!.firstVisibleItemScrollOffset
- }
-
- state = null
-
- restorationTester.emulateSavedInstanceStateRestore()
-
- rule.runOnIdle {
- assertThat(state!!.firstVisibleItemIndex).isEqualTo(index)
- assertThat(state!!.firstVisibleItemScrollOffset).isEqualTo(scrollOffset)
- }
- }
-
- @Test
- fun scroll_makeListSmaller_scroll() {
- var items by mutableStateOf((1..100).toList())
- rule.setContentWithTestViewConfiguration {
- LazyColumn(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
- items(items) {
- Spacer(Modifier.requiredSize(10.dp).testTag("$it"))
- }
- }
- }
-
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(y = 300.dp, density = rule.density)
-
- rule.runOnIdle {
- items = (1..11).toList()
- }
-
- // try to scroll after the data set has been updated. this was causing a crash previously
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(y = (-10).dp, density = rule.density)
-
- rule.onNodeWithTag("1")
- .assertIsDisplayed()
- }
-
- @Test
- fun snapToItemIndex() {
- lateinit var state: LazyListState
- rule.setContentWithTestViewConfiguration {
- state = rememberLazyListState()
- LazyColumn(
- Modifier.requiredSize(100.dp).testTag(LazyListTag),
- state = state
- ) {
- items(20) {
- Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
- }
- }
- }
-
- rule.runOnIdle {
- runBlocking {
- state.scrollToItem(3, 10)
- }
- assertThat(state.firstVisibleItemIndex).isEqualTo(3)
- assertThat(state.firstVisibleItemScrollOffset).isEqualTo(10)
- }
- }
-
- @Test
- fun itemsAreNotRedrawnDuringScroll() {
- val items = (0..20).toList()
- val redrawCount = Array(6) { 0 }
- rule.setContentWithTestViewConfiguration {
- LazyColumn(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
- items(items) {
- Spacer(
- Modifier.requiredSize(20.dp)
- .drawBehind { redrawCount[it]++ }
- )
- }
- }
- }
-
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(y = 10.dp, density = rule.density)
-
- rule.runOnIdle {
- redrawCount.forEachIndexed { index, i ->
- assertWithMessage("Item with index $index was redrawn $i times")
- .that(i).isEqualTo(1)
- }
- }
- }
-
- @Test
- fun itemInvalidationIsNotCausingAnotherItemToRedraw() {
- val redrawCount = Array(2) { 0 }
- var stateUsedInDrawScope by mutableStateOf(false)
- rule.setContentWithTestViewConfiguration {
- LazyColumn(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
- items(2) {
- Spacer(
- Modifier.requiredSize(50.dp)
- .drawBehind {
- redrawCount[it]++
- if (it == 1) {
- stateUsedInDrawScope.hashCode()
- }
- }
- )
- }
- }
- }
-
- rule.runOnIdle {
- stateUsedInDrawScope = true
- }
-
- rule.runOnIdle {
- assertWithMessage("First items is not expected to be redrawn")
- .that(redrawCount[0]).isEqualTo(1)
- assertWithMessage("Second items is expected to be redrawn")
- .that(redrawCount[1]).isEqualTo(2)
- }
- }
-
- @Test
- fun notVisibleAnymoreItemNotAffectingCrossAxisSize() {
- val itemSize = with(rule.density) { 30.toDp() }
- val itemSizeMinusOne = with(rule.density) { 29.toDp() }
- lateinit var state: LazyListState
- rule.setContentWithTestViewConfiguration {
- LazyColumn(
- Modifier.requiredHeight(itemSizeMinusOne).testTag(LazyListTag),
- state = rememberLazyListState().also { state = it }
- ) {
- items(2) {
- Spacer(
- if (it == 0) {
- Modifier.requiredWidth(30.dp).requiredHeight(itemSizeMinusOne)
- } else {
- Modifier.requiredWidth(20.dp).requiredHeight(itemSize)
- }
- )
- }
- }
- }
-
- state.scrollBy(itemSize)
-
- rule.onNodeWithTag(LazyListTag)
- .assertWidthIsEqualTo(20.dp)
- }
-
- @Test
- fun itemStillVisibleAfterOverscrollIsAffectingCrossAxisSize() {
- val items = (0..2).toList()
- val itemSize = with(rule.density) { 30.toDp() }
- lateinit var state: LazyListState
- rule.setContentWithTestViewConfiguration {
- LazyColumn(
- Modifier.requiredHeight(itemSize * 1.75f).testTag(LazyListTag),
- state = rememberLazyListState().also { state = it }
- ) {
- items(items) {
- Spacer(
- if (it == 0) {
- Modifier.requiredWidth(30.dp).requiredHeight(itemSize / 2)
- } else if (it == 1) {
- Modifier.requiredWidth(20.dp).requiredHeight(itemSize / 2)
- } else {
- Modifier.requiredWidth(20.dp).requiredHeight(itemSize)
- }
- )
- }
- }
- }
-
- state.scrollBy(itemSize)
-
- rule.onNodeWithTag(LazyListTag)
- .assertWidthIsEqualTo(30.dp)
- }
-
- @Test
- fun usedWithArray() {
- val items = arrayOf("1", "2", "3")
-
- val itemSize = with(rule.density) { 15.toDp() }
-
- rule.setContentWithTestViewConfiguration {
- LazyColumn {
- items(items) {
- Spacer(Modifier.requiredSize(itemSize).testTag(it))
- }
- }
- }
-
- rule.onNodeWithTag("1")
- .assertTopPositionInRootIsEqualTo(0.dp)
-
- rule.onNodeWithTag("2")
- .assertTopPositionInRootIsEqualTo(itemSize)
-
- rule.onNodeWithTag("3")
- .assertTopPositionInRootIsEqualTo(itemSize * 2)
- }
-
- @Test
- fun usedWithArrayIndexed() {
- val items = arrayOf("1", "2", "3")
-
- val itemSize = with(rule.density) { 15.toDp() }
-
- rule.setContentWithTestViewConfiguration {
- LazyColumn {
- itemsIndexed(items) { index, item ->
- Spacer(Modifier.requiredSize(itemSize).testTag("$index*$item"))
- }
- }
- }
-
- rule.onNodeWithTag("0*1")
- .assertTopPositionInRootIsEqualTo(0.dp)
-
- rule.onNodeWithTag("1*2")
- .assertTopPositionInRootIsEqualTo(itemSize)
-
- rule.onNodeWithTag("2*3")
- .assertTopPositionInRootIsEqualTo(itemSize * 2)
- }
-
- @Test
fun removalWithMutableStateListOf() {
val items = mutableStateListOf("1", "2", "3")
@@ -1323,34 +399,6 @@
}
}
- @Test
- fun changeItemsCountAndScrollImmediately() {
- lateinit var state: LazyListState
- var count by mutableStateOf(100)
- val composedIndexes = mutableListOf<Int>()
- rule.setContent {
- state = rememberLazyListState()
- LazyColumn(Modifier.fillMaxWidth().height(10.dp), state) {
- items(count) { index ->
- composedIndexes.add(index)
- Box(Modifier.size(20.dp))
- }
- }
- }
-
- rule.runOnIdle {
- composedIndexes.clear()
- count = 10
- runBlocking(AutoTestFrameClock()) {
- state.scrollToItem(50)
- }
- composedIndexes.forEach {
- assertThat(it).isLessThan(count)
- }
- assertThat(state.firstVisibleItemIndex).isEqualTo(9)
- }
- }
-
@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
@Test
fun scrolledAwayItemIsNotDisplayedAnymore() {
@@ -1395,344 +443,8 @@
Color.Blue
}
}
-
- @Test
- fun overscrollingBackwardFromNotTheFirstPosition() {
- val containerTag = "container"
- val itemSizePx = 10
- val itemSizeDp = with(rule.density) { itemSizePx.toDp() }
- val containerSize = itemSizeDp * 5
- rule.setContentWithTestViewConfiguration {
- Box(
- Modifier
- .testTag(containerTag)
- .size(containerSize)
- ) {
- LazyColumn(
- Modifier
- .testTag(LazyListTag)
- .background(Color.Blue),
- state = rememberLazyListState(2, 5)
- ) {
- items(100) {
- Box(
- Modifier
- .fillMaxWidth()
- .height(itemSizeDp)
- .testTag("$it")
- )
- }
- }
- }
- }
-
- rule.onNodeWithTag(LazyListTag)
- .performGesture {
- // we do move manually and not with swipe() utility because we want to have one
- // drag gesture, not multiple smaller ones
- down(center)
- moveBy(Offset(0f, -TestTouchSlop))
- moveBy(
- Offset(
- 0f,
- itemSizePx * 15f // large value which makes us overscroll
- )
- )
- up()
- }
-
- rule.onNodeWithTag(LazyListTag)
- .assertHeightIsEqualTo(containerSize)
-
- rule.onNodeWithTag("0")
- .assertTopPositionInRootIsEqualTo(0.dp)
- rule.onNodeWithTag("4")
- .assertTopPositionInRootIsEqualTo(containerSize - itemSizeDp)
- }
-
- @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
- @Test
- fun lazyColumnDoesNotClipHorizontalOverdraw() {
- rule.setContent {
- Box(Modifier.size(60.dp).testTag("container").background(Color.Gray)) {
- LazyColumn(
- Modifier
- .padding(20.dp)
- .fillMaxSize(),
- rememberLazyListState(1)
- ) {
- items(4) {
- Box(Modifier.size(20.dp).drawOutsideOfBounds())
- }
- }
- }
- }
-
- rule.onNodeWithTag("container")
- .captureToImage()
- .assertShape(
- density = rule.density,
- shape = RectangleShape,
- shapeColor = Color.Red,
- backgroundColor = Color.Gray,
- horizontalPadding = 0.dp,
- verticalPadding = 20.dp
- )
- }
-
- @Test
- fun initialScrollPositionIsCorrectWhenItemsAreLoadedAsynchronously() {
- lateinit var state: LazyListState
- var itemsCount by mutableStateOf(0)
- rule.setContent {
- state = rememberLazyListState(2, 10)
- LazyColumn(Modifier.fillMaxSize(), state) {
- items(itemsCount) {
- Box(Modifier.size(20.dp))
- }
- }
- }
-
- rule.runOnIdle {
- itemsCount = 100
- }
-
- rule.runOnIdle {
- assertThat(state.firstVisibleItemIndex).isEqualTo(2)
- assertThat(state.firstVisibleItemScrollOffset).isEqualTo(10)
- }
- }
-
- @Test
- fun restoredScrollPositionIsCorrectWhenItemsAreLoadedAsynchronously() {
- lateinit var state: LazyListState
- var itemsCount = 100
- val recomposeCounter = mutableStateOf(0)
- val tester = StateRestorationTester(rule)
- tester.setContent {
- state = rememberLazyListState()
- LazyColumn(Modifier.fillMaxSize(), state) {
- recomposeCounter.value
- items(itemsCount) {
- Box(Modifier.size(20.dp))
- }
- }
- }
-
- rule.runOnIdle {
- runBlocking {
- state.scrollToItem(2, 10)
- }
- itemsCount = 0
- }
-
- tester.emulateSavedInstanceStateRestore()
-
- rule.runOnIdle {
- itemsCount = 100
- recomposeCounter.value = 1
- }
-
- rule.runOnIdle {
- assertThat(state.firstVisibleItemIndex).isEqualTo(2)
- assertThat(state.firstVisibleItemScrollOffset).isEqualTo(10)
- }
- }
-
- @Test
- fun animateScrollToItemDoesNotScrollPastItem() {
- lateinit var state: LazyListState
- var target = 0
- var reverse = false
- rule.setContent {
- val listState = rememberLazyListState()
- SideEffect {
- state = listState
- }
- LazyColumn(Modifier.fillMaxSize(), listState) {
- items(2500) { _ ->
- Box(Modifier.size(100.dp))
- }
- }
-
- if (reverse) {
- assertThat(listState.firstVisibleItemIndex).isAtLeast(target)
- } else {
- assertThat(listState.firstVisibleItemIndex).isAtMost(target)
- }
- }
-
- // Try a bunch of different targets with varying spacing
- listOf(500, 800, 1500, 1600, 1800).forEach {
- target = it
- rule.runOnIdle {
- runBlocking(AutoTestFrameClock()) {
- state.animateScrollToItem(target)
- }
- }
-
- rule.runOnIdle {
- assertThat(state.firstVisibleItemIndex).isEqualTo(target)
- assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
- }
- }
-
- reverse = true
-
- listOf(1600, 1500, 800, 500, 0).forEach {
- target = it
- rule.runOnIdle {
- runBlocking(AutoTestFrameClock()) {
- state.animateScrollToItem(target)
- }
- }
-
- rule.runOnIdle {
- assertThat(state.firstVisibleItemIndex).isEqualTo(target)
- assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
- }
- }
- }
-
- @Test
- fun animateScrollToTheLastItemWhenItemsAreLargerThenTheScreen() {
- lateinit var state: LazyListState
- rule.setContent {
- state = rememberLazyListState()
- LazyColumn(Modifier.width(150.dp).height(100.dp), state) {
- items(20) {
- Box(Modifier.size(150.dp))
- }
- }
- }
-
- // Try a bunch of different start indexes
- listOf(0, 5, 12).forEach {
- val startIndex = it
- rule.runOnIdle {
- runBlocking(AutoTestFrameClock()) {
- state.scrollToItem(startIndex)
- state.animateScrollToItem(19)
- }
- }
-
- rule.runOnIdle {
- assertThat(state.firstVisibleItemIndex).isEqualTo(19)
- assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
- }
- }
- }
-
- @Test
- fun recreatingContentLambdaTriggersItemRecomposition() {
- val countState = mutableStateOf(0)
- rule.setContent {
- val count = countState.value
- LazyColumn {
- item {
- BasicText(text = "Count $count")
- }
- }
- }
-
- rule.onNodeWithText("Count 0")
- .assertIsDisplayed()
-
- rule.runOnIdle {
- countState.value++
- }
-
- rule.onNodeWithText("Count 1")
- .assertIsDisplayed()
- }
-
- @Test
- fun semanticsScroll_isAnimated() {
- rule.mainClock.autoAdvance = false
- val state = LazyListState()
-
- rule.setContent {
- LazyColumn(Modifier.testTag(LazyListTag), state = state) {
- items(50) {
- Box(Modifier.height(200.dp))
- }
- }
- }
-
- rule.waitForIdle()
- assertThat(state.firstVisibleItemIndex).isEqualTo(0)
- assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
-
- rule.onNodeWithTag(LazyListTag).performSemanticsAction(SemanticsActions.ScrollBy) {
- it(0f, 100f)
- }
-
- // We haven't advanced time yet, make sure it's still zero
- assertThat(state.firstVisibleItemIndex).isEqualTo(0)
- assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
-
- // Advance and make sure we're partway through
- // Note that we need two frames for the animation to actually happen
- rule.mainClock.advanceTimeByFrame()
- rule.mainClock.advanceTimeByFrame()
-
- // The items are 200dp each, so still the first one, but offset
- assertThat(state.firstVisibleItemIndex).isEqualTo(0)
- assertThat(state.firstVisibleItemScrollOffset).isGreaterThan(0)
- assertThat(state.firstVisibleItemScrollOffset).isLessThan(100)
-
- // Finish the scroll, make sure we're at the target
- rule.mainClock.advanceTimeBy(5000)
- assertThat(state.firstVisibleItemScrollOffset).isEqualTo(100)
- }
-
- private fun SemanticsNodeInteraction.assertTopPositionIsAlmost(expected: Dp) {
- getUnclippedBoundsInRoot().top.assertIsEqualTo(expected, tolerance = 1.dp)
- }
-
- private fun LazyListState.scrollBy(offset: Dp) {
- runBlocking(Dispatchers.Main + AutoTestFrameClock()) {
- animateScrollBy(with(rule.density) { offset.roundToPx().toFloat() }, snap())
- }
- }
}
-data class NotStable(val count: Int)
-
-internal fun IntegerSubject.isWithin1PixelFrom(expected: Int) {
- isEqualTo(expected, 1)
-}
-
-internal fun IntegerSubject.isEqualTo(expected: Int, tolerance: Int) {
- isIn(Range.closed(expected - tolerance, expected + tolerance))
-}
-
-internal const val TestTouchSlop = 18f
-
-internal fun ComposeContentTestRule.setContentWithTestViewConfiguration(
- composable: @Composable () -> Unit
-) {
- this.setContent {
- WithTouchSlop(TestTouchSlop, composable)
- }
-}
-
-internal fun SemanticsNodeInteraction.scrollBy(x: Dp = 0.dp, y: Dp = 0.dp, density: Density) =
- performGesture {
- with(density) {
- val touchSlop = TestTouchSlop.toInt()
- val xPx = x.roundToPx()
- val yPx = y.roundToPx()
- val offsetX = if (xPx > 0) xPx + touchSlop else if (xPx < 0) xPx - touchSlop else 0
- val offsetY = if (yPx > 0) yPx + touchSlop else if (yPx < 0) yPx - touchSlop else 0
- swipeWithVelocity(
- start = center,
- end = Offset(center.x - offsetX, center.y - offsetY),
- endVelocity = 0f
- )
- }
- }
-
internal fun Modifier.drawOutsideOfBounds() = drawBehind {
val inflate = 20.dp.roundToPx().toFloat()
drawRect(
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/LazyGridTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/LazyGridTest.kt
index 5e11763..a4e93b2 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/LazyGridTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/LazyGridTest.kt
@@ -16,13 +16,19 @@
package androidx.compose.foundation.lazy
+import androidx.compose.foundation.AutoTestFrameClock
import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
@@ -34,6 +40,8 @@
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth
+import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -304,4 +312,38 @@
.assertTopPositionInRootIsEqualTo(itemSize)
.assertLeftPositionInRootIsEqualTo(itemSize)
}
+
+ @Test
+ fun changeItemsCountAndScrollImmediately() {
+ lateinit var state: LazyListState
+ var count by mutableStateOf(100)
+ val composedIndexes = mutableListOf<Int>()
+ rule.setContent {
+ state = rememberLazyListState()
+ LazyVerticalGrid(
+ GridCells.Fixed(1),
+ Modifier.fillMaxWidth().height(10.dp),
+ state
+ ) {
+ items(count) { index ->
+ composedIndexes.add(index)
+ Box(Modifier.size(20.dp))
+ }
+ }
+ }
+
+ rule.runOnIdle {
+ composedIndexes.clear()
+ count = 10
+ runBlocking(AutoTestFrameClock()) {
+ // we try to scroll to the index after 10, but we expect that the component will
+ // already be aware there is a new count and not compose items with index > 10
+ state.scrollToItem(50)
+ }
+ composedIndexes.forEach {
+ Truth.assertThat(it).isLessThan(count)
+ }
+ Truth.assertThat(state.firstVisibleItemIndex).isEqualTo(9)
+ }
+ }
}
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/LazyListTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/LazyListTest.kt
new file mode 100644
index 0000000..74a9e25
--- /dev/null
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/LazyListTest.kt
@@ -0,0 +1,1624 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.foundation.lazy
+
+import android.os.Build
+import androidx.compose.animation.core.snap
+import androidx.compose.foundation.AutoTestFrameClock
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.FlingBehavior
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.ScrollableDefaults
+import androidx.compose.foundation.gestures.animateScrollBy
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredHeight
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.requiredSizeIn
+import androidx.compose.foundation.layout.requiredWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.testutils.WithTouchSlop
+import androidx.compose.testutils.assertIsEqualTo
+import androidx.compose.testutils.assertShape
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.SemanticsActions
+import androidx.compose.ui.test.SemanticsNodeInteraction
+import androidx.compose.ui.test.assertHeightIsEqualTo
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertIsNotDisplayed
+import androidx.compose.ui.test.assertLeftPositionInRootIsEqualTo
+import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo
+import androidx.compose.ui.test.assertWidthIsEqualTo
+import androidx.compose.ui.test.captureToImage
+import androidx.compose.ui.test.center
+import androidx.compose.ui.test.down
+import androidx.compose.ui.test.getUnclippedBoundsInRoot
+import androidx.compose.ui.test.junit4.ComposeContentTestRule
+import androidx.compose.ui.test.junit4.StateRestorationTester
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.moveBy
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performGesture
+import androidx.compose.ui.test.performSemanticsAction
+import androidx.compose.ui.test.swipeLeft
+import androidx.compose.ui.test.swipeUp
+import androidx.compose.ui.test.swipeWithVelocity
+import androidx.compose.ui.test.up
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
+import com.google.common.collect.Range
+import com.google.common.truth.IntegerSubject
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import java.util.concurrent.CountDownLatch
+
+@LargeTest
+@RunWith(Parameterized::class)
+class LazyListTest(private val orientation: Orientation) {
+ private val LazyListTag = "LazyListTag"
+ private val firstItemTag = "firstItemTag"
+
+ @get:Rule
+ val rule = createComposeRule()
+
+ private val vertical: Boolean
+ get() = orientation == Orientation.Vertical
+
+ @Test
+ fun lazyListShowsCombinedItems() {
+ val itemTestTag = "itemTestTag"
+ val items = listOf(1, 2).map { it.toString() }
+ val indexedItems = listOf(3, 4, 5)
+
+ rule.setContentWithTestViewConfiguration {
+ LazyColumnOrRow(Modifier.mainAxisSize(200.dp)) {
+ item {
+ Spacer(
+ Modifier.mainAxisSize(40.dp)
+ .then(fillParentMaxCrossAxis())
+ .testTag(itemTestTag)
+ )
+ }
+ items(items) {
+ Spacer(Modifier.mainAxisSize(40.dp).then(fillParentMaxCrossAxis()).testTag(it))
+ }
+ itemsIndexed(indexedItems) { index, item ->
+ Spacer(
+ Modifier.mainAxisSize(41.dp).then(fillParentMaxCrossAxis())
+ .testTag("$index-$item")
+ )
+ }
+ }
+ }
+
+ rule.onNodeWithTag(itemTestTag)
+ .assertIsDisplayed()
+
+ rule.onNodeWithTag("1")
+ .assertIsDisplayed()
+
+ rule.onNodeWithTag("2")
+ .assertIsDisplayed()
+
+ rule.onNodeWithTag("0-3")
+ .assertIsDisplayed()
+
+ rule.onNodeWithTag("1-4")
+ .assertIsDisplayed()
+
+ rule.onNodeWithTag("2-5")
+ .assertDoesNotExist()
+ }
+
+ @Test
+ fun lazyListAllowEmptyListItems() {
+ val itemTag = "itemTag"
+
+ rule.setContentWithTestViewConfiguration {
+ LazyColumnOrRow {
+ items(emptyList<Any>()) { }
+ item {
+ Spacer(Modifier.size(10.dp).testTag(itemTag))
+ }
+ }
+ }
+
+ rule.onNodeWithTag(itemTag)
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun lazyListAllowsNullableItems() {
+ val items = listOf("1", null, "3")
+ val nullTestTag = "nullTestTag"
+
+ rule.setContentWithTestViewConfiguration {
+ LazyColumnOrRow(Modifier.mainAxisSize(200.dp)) {
+ items(items) {
+ if (it != null) {
+ Spacer(
+ Modifier.mainAxisSize(101.dp)
+ .then(fillParentMaxCrossAxis())
+ .testTag(it)
+ )
+ } else {
+ Spacer(
+ Modifier.mainAxisSize(101.dp).then(fillParentMaxCrossAxis())
+ .testTag(nullTestTag)
+ )
+ }
+ }
+ }
+ }
+
+ rule.onNodeWithTag("1")
+ .assertIsDisplayed()
+
+ rule.onNodeWithTag(nullTestTag)
+ .assertIsDisplayed()
+
+ rule.onNodeWithTag("3")
+ .assertDoesNotExist()
+ }
+
+ @Test
+ fun lazyListOnlyVisibleItemsAdded() {
+ val items = (1..4).map { it.toString() }
+
+ rule.setContentWithTestViewConfiguration {
+ Box(Modifier.mainAxisSize(200.dp)) {
+ LazyColumnOrRow {
+ items(items) {
+ Spacer(
+ Modifier.mainAxisSize(101.dp).then(fillParentMaxCrossAxis()).testTag(it)
+ )
+ }
+ }
+ }
+ }
+
+ rule.onNodeWithTag("1")
+ .assertIsDisplayed()
+
+ rule.onNodeWithTag("2")
+ .assertIsDisplayed()
+
+ rule.onNodeWithTag("3")
+ .assertDoesNotExist()
+
+ rule.onNodeWithTag("4")
+ .assertDoesNotExist()
+ }
+
+ @Test
+ fun lazyListScrollToShowItems123() {
+ val items = (1..4).map { it.toString() }
+
+ rule.setContentWithTestViewConfiguration {
+ Box(Modifier.mainAxisSize(200.dp)) {
+ LazyColumnOrRow(Modifier.testTag(LazyListTag)) {
+ items(items) {
+ Spacer(
+ Modifier.mainAxisSize(101.dp).then(fillParentMaxCrossAxis()).testTag(it)
+ )
+ }
+ }
+ }
+ }
+
+ rule.onNodeWithTag(LazyListTag)
+ .scrollMainAxisBy(50.dp)
+
+ rule.onNodeWithTag("1")
+ .assertIsDisplayed()
+
+ rule.onNodeWithTag("2")
+ .assertIsDisplayed()
+
+ rule.onNodeWithTag("3")
+ .assertIsDisplayed()
+
+ rule.onNodeWithTag("4")
+ .assertIsNotDisplayed()
+ }
+
+ @Test
+ fun lazyListScrollToHideFirstItem() {
+ val items = (1..4).map { it.toString() }
+
+ rule.setContentWithTestViewConfiguration {
+ Box(Modifier.mainAxisSize(200.dp)) {
+ LazyColumnOrRow(Modifier.testTag(LazyListTag)) {
+ items(items) {
+ Spacer(
+ Modifier.mainAxisSize(101.dp).then(fillParentMaxCrossAxis()).testTag(it)
+ )
+ }
+ }
+ }
+ }
+
+ rule.onNodeWithTag(LazyListTag)
+ .scrollMainAxisBy(105.dp)
+
+ rule.onNodeWithTag("1")
+ .assertIsNotDisplayed()
+
+ rule.onNodeWithTag("2")
+ .assertIsDisplayed()
+
+ rule.onNodeWithTag("3")
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun lazyListScrollToShowItems234() {
+ val items = (1..4).map { it.toString() }
+
+ rule.setContentWithTestViewConfiguration {
+ Box(Modifier.mainAxisSize(200.dp)) {
+ LazyColumnOrRow(Modifier.testTag(LazyListTag)) {
+ items(items) {
+ Spacer(
+ Modifier.mainAxisSize(101.dp).then(fillParentMaxCrossAxis()).testTag(it)
+ )
+ }
+ }
+ }
+ }
+
+ rule.onNodeWithTag(LazyListTag)
+ .scrollMainAxisBy(150.dp)
+
+ rule.onNodeWithTag("1")
+ .assertIsNotDisplayed()
+
+ rule.onNodeWithTag("2")
+ .assertIsDisplayed()
+
+ rule.onNodeWithTag("3")
+ .assertIsDisplayed()
+
+ rule.onNodeWithTag("4")
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun lazyListWrapsContent() = with(rule.density) {
+ val itemInsideLazyList = "itemInsideLazyList"
+ val itemOutsideLazyList = "itemOutsideLazyList"
+ var sameSizeItems by mutableStateOf(true)
+
+ rule.setContentWithTestViewConfiguration {
+ Column {
+ LazyColumnOrRow(Modifier.testTag(LazyListTag)) {
+ items(listOf(1, 2)) {
+ if (it == 1) {
+ Spacer(Modifier.size(50.dp).testTag(itemInsideLazyList))
+ } else {
+ Spacer(Modifier.size(if (sameSizeItems) 50.dp else 70.dp))
+ }
+ }
+ }
+ Spacer(Modifier.size(50.dp).testTag(itemOutsideLazyList))
+ }
+ }
+
+ rule.onNodeWithTag(itemInsideLazyList)
+ .assertIsDisplayed()
+
+ rule.onNodeWithTag(itemOutsideLazyList)
+ .assertIsDisplayed()
+
+ var lazyListBounds = rule.onNodeWithTag(LazyListTag).getUnclippedBoundsInRoot()
+ var mainAxisEndBound = if (vertical) lazyListBounds.bottom else lazyListBounds.right
+ var crossAxisEndBound = if (vertical) lazyListBounds.right else lazyListBounds.bottom
+
+ assertThat(lazyListBounds.left.roundToPx()).isWithin1PixelFrom(0.dp.roundToPx())
+ assertThat(mainAxisEndBound.roundToPx()).isWithin1PixelFrom(100.dp.roundToPx())
+ assertThat(lazyListBounds.top.roundToPx()).isWithin1PixelFrom(0.dp.roundToPx())
+ assertThat(crossAxisEndBound.roundToPx()).isWithin1PixelFrom(50.dp.roundToPx())
+
+ rule.runOnIdle {
+ sameSizeItems = false
+ }
+
+ rule.waitForIdle()
+
+ rule.onNodeWithTag(itemInsideLazyList)
+ .assertIsDisplayed()
+
+ rule.onNodeWithTag(itemOutsideLazyList)
+ .assertIsDisplayed()
+
+ lazyListBounds = rule.onNodeWithTag(LazyListTag).getUnclippedBoundsInRoot()
+ mainAxisEndBound = if (vertical) lazyListBounds.bottom else lazyListBounds.right
+ crossAxisEndBound = if (vertical) lazyListBounds.right else lazyListBounds.bottom
+
+ assertThat(lazyListBounds.left.roundToPx()).isWithin1PixelFrom(0.dp.roundToPx())
+ assertThat(mainAxisEndBound.roundToPx()).isWithin1PixelFrom(120.dp.roundToPx())
+ assertThat(lazyListBounds.top.roundToPx()).isWithin1PixelFrom(0.dp.roundToPx())
+ assertThat(crossAxisEndBound.roundToPx()).isWithin1PixelFrom(70.dp.roundToPx())
+ }
+
+ @Test
+ fun compositionsAreDisposed_whenNodesAreScrolledOff() {
+ var composed: Boolean
+ var disposed = false
+ // Ten 31dp spacers in a 300dp list
+ val latch = CountDownLatch(10)
+
+ rule.setContentWithTestViewConfiguration {
+ // Fixed size to eliminate device size as a factor
+ Box(Modifier.testTag(LazyListTag).mainAxisSize(300.dp)) {
+ LazyColumnOrRow(Modifier.fillMaxSize()) {
+ items(50) {
+ DisposableEffect(NeverEqualObject) {
+ composed = true
+ // Signal when everything is done composing
+ latch.countDown()
+ onDispose {
+ disposed = true
+ }
+ }
+
+ // There will be 10 of these in the 300dp box
+ Spacer(Modifier.mainAxisSize(31.dp))
+ }
+ }
+ }
+ }
+
+ latch.await()
+ composed = false
+
+ assertWithMessage("Compositions were disposed before we did any scrolling")
+ .that(disposed).isFalse()
+
+ // Mostly a validity check, this is not part of the behavior under test
+ assertWithMessage("Additional composition occurred for no apparent reason")
+ .that(composed).isFalse()
+
+ rule.onNodeWithTag(LazyListTag)
+ .performGesture { if (vertical) swipeUp() else swipeLeft() }
+
+ rule.waitForIdle()
+
+ assertWithMessage("No additional items were composed after scroll, scroll didn't work")
+ .that(composed).isTrue()
+
+ // We may need to modify this test once we prefetch/cache items outside the viewport
+ assertWithMessage(
+ "No compositions were disposed after scrolling, compositions were leaked"
+ ).that(disposed).isTrue()
+ }
+
+ @Test
+ fun whenItemsAreInitiallyCreatedWith0SizeWeCanScrollWhenTheyExpanded() {
+ val thirdTag = "third"
+ val items = (1..3).toList()
+ var thirdHasSize by mutableStateOf(false)
+
+ rule.setContentWithTestViewConfiguration {
+ LazyColumnOrRow(
+ Modifier.fillMaxCrossAxis()
+ .mainAxisSize(100.dp)
+ .testTag(LazyListTag)
+ ) {
+ items(items) {
+ if (it == 3) {
+ Spacer(
+ Modifier.testTag(thirdTag)
+ .then(fillParentMaxCrossAxis())
+ .mainAxisSize(if (thirdHasSize) 60.dp else 0.dp)
+ )
+ } else {
+ Spacer(Modifier.then(fillParentMaxCrossAxis()).mainAxisSize(60.dp))
+ }
+ }
+ }
+ }
+
+ rule.onNodeWithTag(LazyListTag)
+ .scrollMainAxisBy(21.dp)
+
+ rule.onNodeWithTag(thirdTag)
+ .assertExists()
+ .assertIsNotDisplayed()
+
+ rule.runOnIdle {
+ thirdHasSize = true
+ }
+
+ rule.waitForIdle()
+
+ rule.onNodeWithTag(LazyListTag)
+ .scrollMainAxisBy(10.dp)
+
+ rule.onNodeWithTag(thirdTag)
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun itemFillingParentWidth() {
+ rule.setContentWithTestViewConfiguration {
+ LazyColumnOrRow(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
+ items(listOf(0)) {
+ Spacer(
+ Modifier.fillParentMaxWidth().requiredHeight(50.dp).testTag(firstItemTag)
+ )
+ }
+ }
+ }
+
+ rule.onNodeWithTag(firstItemTag)
+ .assertWidthIsEqualTo(100.dp)
+ .assertHeightIsEqualTo(50.dp)
+ }
+
+ @Test
+ fun itemFillingParentHeight() {
+ rule.setContentWithTestViewConfiguration {
+ LazyColumnOrRow(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
+ items(listOf(0)) {
+ Spacer(
+ Modifier.requiredWidth(50.dp).fillParentMaxHeight().testTag(firstItemTag)
+ )
+ }
+ }
+ }
+
+ rule.onNodeWithTag(firstItemTag)
+ .assertWidthIsEqualTo(50.dp)
+ .assertHeightIsEqualTo(150.dp)
+ }
+
+ @Test
+ fun itemFillingParentSize() {
+ rule.setContentWithTestViewConfiguration {
+ LazyColumnOrRow(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
+ items(listOf(0)) {
+ Spacer(Modifier.fillParentMaxSize().testTag(firstItemTag))
+ }
+ }
+ }
+
+ rule.onNodeWithTag(firstItemTag)
+ .assertWidthIsEqualTo(100.dp)
+ .assertHeightIsEqualTo(150.dp)
+ }
+
+ @Test
+ fun itemFillingParentWidthFraction() {
+ rule.setContentWithTestViewConfiguration {
+ LazyColumnOrRow(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
+ items(listOf(0)) {
+ Spacer(
+ Modifier.fillParentMaxWidth(0.7f)
+ .requiredHeight(50.dp)
+ .testTag(firstItemTag)
+ )
+ }
+ }
+ }
+
+ rule.onNodeWithTag(firstItemTag)
+ .assertWidthIsEqualTo(70.dp)
+ .assertHeightIsEqualTo(50.dp)
+ }
+
+ @Test
+ fun itemFillingParentHeightFraction() {
+ rule.setContentWithTestViewConfiguration {
+ LazyColumnOrRow(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
+ items(listOf(0)) {
+ Spacer(
+ Modifier.requiredWidth(50.dp)
+ .fillParentMaxHeight(0.3f)
+ .testTag(firstItemTag)
+ )
+ }
+ }
+ }
+
+ rule.onNodeWithTag(firstItemTag)
+ .assertWidthIsEqualTo(50.dp)
+ .assertHeightIsEqualTo(45.dp)
+ }
+
+ @Test
+ fun itemFillingParentSizeFraction() {
+ rule.setContentWithTestViewConfiguration {
+ LazyColumnOrRow(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
+ items(listOf(0)) {
+ Spacer(Modifier.fillParentMaxSize(0.5f).testTag(firstItemTag))
+ }
+ }
+ }
+
+ rule.onNodeWithTag(firstItemTag)
+ .assertWidthIsEqualTo(50.dp)
+ .assertHeightIsEqualTo(75.dp)
+ }
+
+ @Test
+ fun itemFillingParentSizeParentResized() {
+ var parentSize by mutableStateOf(100.dp)
+ rule.setContentWithTestViewConfiguration {
+ LazyColumnOrRow(Modifier.requiredSize(parentSize)) {
+ items(listOf(0)) {
+ Spacer(Modifier.fillParentMaxSize().testTag(firstItemTag))
+ }
+ }
+ }
+
+ rule.runOnIdle {
+ parentSize = 150.dp
+ }
+
+ rule.onNodeWithTag(firstItemTag)
+ .assertWidthIsEqualTo(150.dp)
+ .assertHeightIsEqualTo(150.dp)
+ }
+
+ @Test
+ fun whenNotAnymoreAvailableItemWasDisplayed() {
+ var items by mutableStateOf((1..30).toList())
+ rule.setContentWithTestViewConfiguration {
+ LazyColumnOrRow(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
+ items(items) {
+ Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
+ }
+ }
+ }
+
+ // after scroll we will display items 16-20
+ rule.onNodeWithTag(LazyListTag)
+ .scrollMainAxisBy(300.dp)
+
+ rule.runOnIdle {
+ items = (1..10).toList()
+ }
+
+ // there is no item 16 anymore so we will just display the last items 6-10
+ rule.onNodeWithTag("6")
+ .assertStartPositionIsAlmost(0.dp)
+ }
+
+ @Test
+ fun whenFewDisplayedItemsWereRemoved() {
+ var items by mutableStateOf((1..10).toList())
+ rule.setContentWithTestViewConfiguration {
+ LazyColumnOrRow(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
+ items(items) {
+ Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
+ }
+ }
+ }
+
+ // after scroll we will display items 6-10
+ rule.onNodeWithTag(LazyListTag)
+ .scrollMainAxisBy(100.dp)
+
+ rule.runOnIdle {
+ items = (1..8).toList()
+ }
+
+ // there are no more items 9 and 10, so we have to scroll back
+ rule.onNodeWithTag("4")
+ .assertStartPositionIsAlmost(0.dp)
+ }
+
+ @Test
+ fun whenItemsBecameEmpty() {
+ var items by mutableStateOf((1..10).toList())
+ rule.setContentWithTestViewConfiguration {
+ LazyColumnOrRow(
+ Modifier.requiredSizeIn(maxHeight = 100.dp, maxWidth = 100.dp)
+ .testTag(LazyListTag)
+ ) {
+ items(items) {
+ Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
+ }
+ }
+ }
+
+ // after scroll we will display items 2-6
+ rule.onNodeWithTag(LazyListTag)
+ .scrollMainAxisBy(20.dp)
+
+ rule.runOnIdle {
+ items = emptyList()
+ }
+
+ // there are no more items so the lazy list is zero sized
+ rule.onNodeWithTag(LazyListTag)
+ .assertWidthIsEqualTo(0.dp)
+ .assertHeightIsEqualTo(0.dp)
+
+ // and has no children
+ rule.onNodeWithTag("1")
+ .assertDoesNotExist()
+ rule.onNodeWithTag("2")
+ .assertDoesNotExist()
+ }
+
+ @Test
+ fun scrollBackAndForth() {
+ val items by mutableStateOf((1..20).toList())
+ rule.setContentWithTestViewConfiguration {
+ LazyColumnOrRow(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
+ items(items) {
+ Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
+ }
+ }
+ }
+
+ // after scroll we will display items 6-10
+ rule.onNodeWithTag(LazyListTag)
+ .scrollMainAxisBy(100.dp)
+
+ // and scroll back
+ rule.onNodeWithTag(LazyListTag)
+ .scrollMainAxisBy((-100).dp)
+
+ rule.onNodeWithTag("1")
+ .assertStartPositionIsAlmost(0.dp)
+ }
+
+ @Test
+ fun tryToScrollBackwardWhenAlreadyOnTop() {
+ val items by mutableStateOf((1..20).toList())
+ rule.setContentWithTestViewConfiguration {
+ LazyColumnOrRow(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
+ items(items) {
+ Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
+ }
+ }
+ }
+
+ // we already displaying the first item, so this should do nothing
+ rule.onNodeWithTag(LazyListTag)
+ .scrollMainAxisBy((-50).dp)
+
+ rule.onNodeWithTag("1")
+ .assertStartPositionIsAlmost(0.dp)
+ rule.onNodeWithTag("5")
+ .assertStartPositionIsAlmost(80.dp)
+ }
+
+ @Test
+ fun contentOfNotStableItemsIsNotRecomposedDuringScroll() {
+ val items = listOf(NotStable(1), NotStable(2))
+ var firstItemRecomposed = 0
+ var secondItemRecomposed = 0
+ rule.setContentWithTestViewConfiguration {
+ LazyColumnOrRow(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
+ items(items) {
+ if (it.count == 1) {
+ firstItemRecomposed++
+ } else {
+ secondItemRecomposed++
+ }
+ Spacer(Modifier.requiredSize(75.dp))
+ }
+ }
+ }
+
+ rule.runOnIdle {
+ assertThat(firstItemRecomposed).isEqualTo(1)
+ assertThat(secondItemRecomposed).isEqualTo(1)
+ }
+
+ rule.onNodeWithTag(LazyListTag)
+ .scrollMainAxisBy(50.dp)
+
+ rule.runOnIdle {
+ assertThat(firstItemRecomposed).isEqualTo(1)
+ assertThat(secondItemRecomposed).isEqualTo(1)
+ }
+ }
+
+ @Test
+ fun onlyOneMeasurePassForScrollEvent() {
+ val items by mutableStateOf((1..20).toList())
+ lateinit var state: LazyListState
+ rule.setContentWithTestViewConfiguration {
+ state = rememberLazyListState()
+ state.prefetchingEnabled = false
+ LazyColumnOrRow(
+ Modifier.requiredSize(100.dp).testTag(LazyListTag),
+ state = state
+ ) {
+ items(items) {
+ Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
+ }
+ }
+ }
+
+ val initialMeasurePasses = state.numMeasurePasses
+
+ rule.runOnIdle {
+ with(rule.density) {
+ state.onScroll(-110.dp.toPx())
+ }
+ }
+
+ rule.waitForIdle()
+
+ assertThat(state.numMeasurePasses).isEqualTo(initialMeasurePasses + 1)
+ }
+
+ @Test
+ fun stateUpdatedAfterScroll() {
+ val items by mutableStateOf((1..20).toList())
+ lateinit var state: LazyListState
+ rule.setContentWithTestViewConfiguration {
+ state = rememberLazyListState()
+ LazyColumnOrRow(
+ Modifier.requiredSize(100.dp).testTag(LazyListTag),
+ state = state
+ ) {
+ items(items) {
+ Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
+ }
+ }
+ }
+
+ rule.runOnIdle {
+ assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+ assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+ }
+
+ rule.onNodeWithTag(LazyListTag)
+ .scrollMainAxisBy(30.dp)
+
+ rule.runOnIdle {
+ assertThat(state.firstVisibleItemIndex).isEqualTo(1)
+
+ with(rule.density) {
+ // TODO(b/169232491): test scrolling doesn't appear to be scrolling exactly the right
+ // number of pixels
+ val expectedOffset = 10.dp.roundToPx()
+ val tolerance = 2.dp.roundToPx()
+ assertThat(state.firstVisibleItemScrollOffset).isEqualTo(expectedOffset, tolerance)
+ }
+ }
+ }
+
+ @Test
+ fun stateUpdatedAfterScrollWithinTheSameItem() {
+ val items by mutableStateOf((1..20).toList())
+ lateinit var state: LazyListState
+ rule.setContentWithTestViewConfiguration {
+ state = rememberLazyListState()
+ LazyColumnOrRow(
+ Modifier.requiredSize(100.dp).testTag(LazyListTag),
+ state = state
+ ) {
+ items(items) {
+ Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
+ }
+ }
+ }
+
+ rule.onNodeWithTag(LazyListTag)
+ .scrollMainAxisBy(10.dp)
+
+ rule.runOnIdle {
+ assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+ with(rule.density) {
+ val expectedOffset = 10.dp.roundToPx()
+ val tolerance = 2.dp.roundToPx()
+ assertThat(state.firstVisibleItemScrollOffset)
+ .isEqualTo(expectedOffset, tolerance)
+ }
+ }
+ }
+
+ @Test
+ fun scroll_makeListSmaller_scroll() {
+ var items by mutableStateOf((1..100).toList())
+ rule.setContentWithTestViewConfiguration {
+ LazyColumnOrRow(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
+ items(items) {
+ Spacer(Modifier.requiredSize(10.dp).testTag("$it"))
+ }
+ }
+ }
+
+ rule.onNodeWithTag(LazyListTag)
+ .scrollMainAxisBy(300.dp)
+
+ rule.runOnIdle {
+ items = (1..11).toList()
+ }
+
+ // try to scroll after the data set has been updated. this was causing a crash previously
+ rule.onNodeWithTag(LazyListTag)
+ .scrollMainAxisBy((-10).dp)
+
+ rule.onNodeWithTag("1")
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun initialScrollIsApplied() {
+ val items by mutableStateOf((0..20).toList())
+ lateinit var state: LazyListState
+ val expectedOffset = with(rule.density) { 10.dp.roundToPx() }
+ rule.setContentWithTestViewConfiguration {
+ state = rememberLazyListState(2, expectedOffset)
+ LazyColumnOrRow(
+ Modifier.requiredSize(100.dp).testTag(LazyListTag),
+ state = state
+ ) {
+ items(items) {
+ Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
+ }
+ }
+ }
+
+ rule.runOnIdle {
+ assertThat(state.firstVisibleItemIndex).isEqualTo(2)
+ assertThat(state.firstVisibleItemScrollOffset).isEqualTo(expectedOffset)
+ }
+
+ rule.onNodeWithTag("2")
+ .assertStartPositionInRootIsEqualTo((-10).dp)
+ }
+
+ @Test
+ fun stateIsRestored() {
+ val restorationTester = StateRestorationTester(rule)
+ var state: LazyListState? = null
+ restorationTester.setContent {
+ state = rememberLazyListState()
+ LazyColumnOrRow(
+ Modifier.requiredSize(100.dp).testTag(LazyListTag),
+ state = state!!
+ ) {
+ items(20) {
+ Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
+ }
+ }
+ }
+
+ rule.onNodeWithTag(LazyListTag)
+ .scrollMainAxisBy(30.dp)
+
+ val (index, scrollOffset) = rule.runOnIdle {
+ state!!.firstVisibleItemIndex to state!!.firstVisibleItemScrollOffset
+ }
+
+ state = null
+
+ restorationTester.emulateSavedInstanceStateRestore()
+
+ rule.runOnIdle {
+ assertThat(state!!.firstVisibleItemIndex).isEqualTo(index)
+ assertThat(state!!.firstVisibleItemScrollOffset).isEqualTo(scrollOffset)
+ }
+ }
+
+ @Test
+ fun snapToItemIndex() {
+ lateinit var state: LazyListState
+ rule.setContentWithTestViewConfiguration {
+ state = rememberLazyListState()
+ LazyColumnOrRow(
+ Modifier.requiredSize(100.dp).testTag(LazyListTag),
+ state = state
+ ) {
+ items(20) {
+ Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
+ }
+ }
+ }
+
+ rule.runOnIdle {
+ runBlocking {
+ state.scrollToItem(3, 10)
+ }
+ assertThat(state.firstVisibleItemIndex).isEqualTo(3)
+ assertThat(state.firstVisibleItemScrollOffset).isEqualTo(10)
+ }
+ }
+
+ @Test
+ fun itemsAreNotRedrawnDuringScroll() {
+ val items = (0..20).toList()
+ val redrawCount = Array(6) { 0 }
+ rule.setContentWithTestViewConfiguration {
+ LazyColumnOrRow(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
+ items(items) {
+ Spacer(
+ Modifier.requiredSize(20.dp)
+ .drawBehind { redrawCount[it]++ }
+ )
+ }
+ }
+ }
+
+ rule.onNodeWithTag(LazyListTag)
+ .scrollMainAxisBy(10.dp)
+
+ rule.runOnIdle {
+ redrawCount.forEachIndexed { index, i ->
+ assertWithMessage("Item with index $index was redrawn $i times")
+ .that(i).isEqualTo(1)
+ }
+ }
+ }
+
+ @Test
+ fun itemInvalidationIsNotCausingAnotherItemToRedraw() {
+ val redrawCount = Array(2) { 0 }
+ var stateUsedInDrawScope by mutableStateOf(false)
+ rule.setContentWithTestViewConfiguration {
+ LazyColumnOrRow(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
+ items(2) {
+ Spacer(
+ Modifier.requiredSize(50.dp)
+ .drawBehind {
+ redrawCount[it]++
+ if (it == 1) {
+ stateUsedInDrawScope.hashCode()
+ }
+ }
+ )
+ }
+ }
+ }
+
+ rule.runOnIdle {
+ stateUsedInDrawScope = true
+ }
+
+ rule.runOnIdle {
+ assertWithMessage("First items is not expected to be redrawn")
+ .that(redrawCount[0]).isEqualTo(1)
+ assertWithMessage("Second items is expected to be redrawn")
+ .that(redrawCount[1]).isEqualTo(2)
+ }
+ }
+
+ @Test
+ fun notVisibleAnymoreItemNotAffectingCrossAxisSize() {
+ val itemSize = with(rule.density) { 30.toDp() }
+ val itemSizeMinusOne = with(rule.density) { 29.toDp() }
+ lateinit var state: LazyListState
+ rule.setContentWithTestViewConfiguration {
+ LazyColumnOrRow(
+ Modifier.mainAxisSize(itemSizeMinusOne).testTag(LazyListTag),
+ state = rememberLazyListState().also { state = it }
+ ) {
+ items(2) {
+ Spacer(
+ if (it == 0) {
+ Modifier.crossAxisSize(30.dp).mainAxisSize(itemSizeMinusOne)
+ } else {
+ Modifier.crossAxisSize(20.dp).mainAxisSize(itemSize)
+ }
+ )
+ }
+ }
+ }
+
+ state.scrollBy(itemSize)
+
+ rule.onNodeWithTag(LazyListTag)
+ .assertCrossAxisSizeIsEqualTo(20.dp)
+ }
+
+ @Test
+ fun itemStillVisibleAfterOverscrollIsAffectingCrossAxisSize() {
+ val items = (0..2).toList()
+ val itemSize = with(rule.density) { 30.toDp() }
+ lateinit var state: LazyListState
+ rule.setContentWithTestViewConfiguration {
+ LazyColumnOrRow(
+ Modifier.mainAxisSize(itemSize * 1.75f).testTag(LazyListTag),
+ state = rememberLazyListState().also { state = it }
+ ) {
+ items(items) {
+ Spacer(
+ if (it == 0) {
+ Modifier.crossAxisSize(30.dp).mainAxisSize(itemSize / 2)
+ } else if (it == 1) {
+ Modifier.crossAxisSize(20.dp).mainAxisSize(itemSize / 2)
+ } else {
+ Modifier.crossAxisSize(20.dp).mainAxisSize(itemSize)
+ }
+ )
+ }
+ }
+ }
+
+ state.scrollBy(itemSize)
+
+ rule.onNodeWithTag(LazyListTag)
+ .assertCrossAxisSizeIsEqualTo(30.dp)
+ }
+
+ @Test
+ fun usedWithArray() {
+ val items = arrayOf("1", "2", "3")
+
+ val itemSize = with(rule.density) { 15.toDp() }
+
+ rule.setContentWithTestViewConfiguration {
+ LazyColumnOrRow {
+ items(items) {
+ Spacer(Modifier.requiredSize(itemSize).testTag(it))
+ }
+ }
+ }
+
+ rule.onNodeWithTag("1")
+ .assertStartPositionInRootIsEqualTo(0.dp)
+
+ rule.onNodeWithTag("2")
+ .assertStartPositionInRootIsEqualTo(itemSize)
+
+ rule.onNodeWithTag("3")
+ .assertStartPositionInRootIsEqualTo(itemSize * 2)
+ }
+
+ @Test
+ fun usedWithArrayIndexed() {
+ val items = arrayOf("1", "2", "3")
+
+ val itemSize = with(rule.density) { 15.toDp() }
+
+ rule.setContentWithTestViewConfiguration {
+ LazyColumnOrRow {
+ itemsIndexed(items) { index, item ->
+ Spacer(Modifier.requiredSize(itemSize).testTag("$index*$item"))
+ }
+ }
+ }
+
+ rule.onNodeWithTag("0*1")
+ .assertStartPositionInRootIsEqualTo(0.dp)
+
+ rule.onNodeWithTag("1*2")
+ .assertStartPositionInRootIsEqualTo(itemSize)
+
+ rule.onNodeWithTag("2*3")
+ .assertStartPositionInRootIsEqualTo(itemSize * 2)
+ }
+
+ @Test
+ fun changeItemsCountAndScrollImmediately() {
+ lateinit var state: LazyListState
+ var count by mutableStateOf(100)
+ val composedIndexes = mutableListOf<Int>()
+ rule.setContent {
+ state = rememberLazyListState()
+ LazyColumnOrRow(Modifier.fillMaxCrossAxis().mainAxisSize(10.dp), state) {
+ items(count) { index ->
+ composedIndexes.add(index)
+ Box(Modifier.size(20.dp))
+ }
+ }
+ }
+
+ rule.runOnIdle {
+ composedIndexes.clear()
+ count = 10
+ runBlocking(AutoTestFrameClock()) {
+ state.scrollToItem(50)
+ }
+ composedIndexes.forEach {
+ assertThat(it).isLessThan(count)
+ }
+ assertThat(state.firstVisibleItemIndex).isEqualTo(9)
+ }
+ }
+
+ @Test
+ fun overscrollingBackwardFromNotTheFirstPosition() {
+ val containerTag = "container"
+ val itemSizePx = 10
+ val itemSizeDp = with(rule.density) { itemSizePx.toDp() }
+ val containerSize = itemSizeDp * 5
+ rule.setContentWithTestViewConfiguration {
+ Box(
+ Modifier
+ .testTag(containerTag)
+ .size(containerSize)
+ ) {
+ LazyColumnOrRow(
+ Modifier
+ .testTag(LazyListTag)
+ .background(Color.Blue),
+ state = rememberLazyListState(2, 5)
+ ) {
+ items(100) {
+ Box(
+ Modifier
+ .fillMaxCrossAxis()
+ .mainAxisSize(itemSizeDp)
+ .testTag("$it")
+ )
+ }
+ }
+ }
+ }
+
+ rule.onNodeWithTag(LazyListTag)
+ .performGesture {
+ // we do move manually and not with swipe() utility because we want to have one
+ // drag gesture, not multiple smaller ones
+ down(center)
+ if (vertical) {
+ moveBy(Offset(0f, -TestTouchSlop))
+ moveBy(
+ Offset(
+ 0f,
+ itemSizePx * 15f // large value which makes us overscroll
+ )
+ )
+ } else {
+ moveBy(Offset(-TestTouchSlop, 0f))
+ moveBy(
+ Offset(
+ itemSizePx * 15f, // large value which makes us overscroll
+ 0f
+ )
+ )
+ }
+ up()
+ }
+
+ rule.onNodeWithTag(LazyListTag)
+ .assertMainAxisSizeIsEqualTo(containerSize)
+
+ rule.onNodeWithTag("0")
+ .assertStartPositionInRootIsEqualTo(0.dp)
+ rule.onNodeWithTag("4")
+ .assertStartPositionInRootIsEqualTo(containerSize - itemSizeDp)
+ }
+
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+ @Test
+ fun doesNotClipHorizontalOverdraw() {
+ rule.setContent {
+ Box(Modifier.size(60.dp).testTag("container").background(Color.Gray)) {
+ LazyColumnOrRow(
+ Modifier
+ .padding(20.dp)
+ .fillMaxSize(),
+ rememberLazyListState(1)
+ ) {
+ items(4) {
+ Box(Modifier.size(20.dp).drawOutsideOfBounds())
+ }
+ }
+ }
+ }
+
+ val horizontalPadding = if (vertical) 0.dp else 20.dp
+ val verticalPadding = if (vertical) 20.dp else 0.dp
+
+ rule.onNodeWithTag("container")
+ .captureToImage()
+ .assertShape(
+ density = rule.density,
+ shape = RectangleShape,
+ shapeColor = Color.Red,
+ backgroundColor = Color.Gray,
+ horizontalPadding = horizontalPadding,
+ verticalPadding = verticalPadding
+ )
+ }
+
+ @Test
+ fun initialScrollPositionIsCorrectWhenItemsAreLoadedAsynchronously() {
+ lateinit var state: LazyListState
+ var itemsCount by mutableStateOf(0)
+ rule.setContent {
+ state = rememberLazyListState(2, 10)
+ LazyColumnOrRow(Modifier.fillMaxSize(), state) {
+ items(itemsCount) {
+ Box(Modifier.size(20.dp))
+ }
+ }
+ }
+
+ rule.runOnIdle {
+ itemsCount = 100
+ }
+
+ rule.runOnIdle {
+ assertThat(state.firstVisibleItemIndex).isEqualTo(2)
+ assertThat(state.firstVisibleItemScrollOffset).isEqualTo(10)
+ }
+ }
+
+ @Test
+ fun restoredScrollPositionIsCorrectWhenItemsAreLoadedAsynchronously() {
+ lateinit var state: LazyListState
+ var itemsCount = 100
+ val recomposeCounter = mutableStateOf(0)
+ val tester = StateRestorationTester(rule)
+ tester.setContent {
+ state = rememberLazyListState()
+ LazyColumnOrRow(Modifier.fillMaxSize(), state) {
+ recomposeCounter.value
+ items(itemsCount) {
+ Box(Modifier.size(20.dp))
+ }
+ }
+ }
+
+ rule.runOnIdle {
+ runBlocking {
+ state.scrollToItem(2, 10)
+ }
+ itemsCount = 0
+ }
+
+ tester.emulateSavedInstanceStateRestore()
+
+ rule.runOnIdle {
+ itemsCount = 100
+ recomposeCounter.value = 1
+ }
+
+ rule.runOnIdle {
+ assertThat(state.firstVisibleItemIndex).isEqualTo(2)
+ assertThat(state.firstVisibleItemScrollOffset).isEqualTo(10)
+ }
+ }
+
+ @Test
+ fun animateScrollToItemDoesNotScrollPastItem() {
+ lateinit var state: LazyListState
+ var target = 0
+ var reverse = false
+ rule.setContent {
+ val listState = rememberLazyListState()
+ SideEffect {
+ state = listState
+ }
+ LazyColumnOrRow(Modifier.fillMaxSize(), listState) {
+ items(2500) { _ ->
+ Box(Modifier.size(100.dp))
+ }
+ }
+
+ if (reverse) {
+ assertThat(listState.firstVisibleItemIndex).isAtLeast(target)
+ } else {
+ assertThat(listState.firstVisibleItemIndex).isAtMost(target)
+ }
+ }
+
+ // Try a bunch of different targets with varying spacing
+ listOf(500, 800, 1500, 1600, 1800).forEach {
+ target = it
+ rule.runOnIdle {
+ runBlocking(AutoTestFrameClock()) {
+ state.animateScrollToItem(target)
+ }
+ }
+
+ rule.runOnIdle {
+ assertThat(state.firstVisibleItemIndex).isEqualTo(target)
+ assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+ }
+ }
+
+ reverse = true
+
+ listOf(1600, 1500, 800, 500, 0).forEach {
+ target = it
+ rule.runOnIdle {
+ runBlocking(AutoTestFrameClock()) {
+ state.animateScrollToItem(target)
+ }
+ }
+
+ rule.runOnIdle {
+ assertThat(state.firstVisibleItemIndex).isEqualTo(target)
+ assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+ }
+ }
+ }
+
+ @Test
+ fun animateScrollToTheLastItemWhenItemsAreLargerThenTheScreen() {
+ lateinit var state: LazyListState
+ rule.setContent {
+ state = rememberLazyListState()
+ LazyColumnOrRow(Modifier.crossAxisSize(150.dp).mainAxisSize(100.dp), state) {
+ items(20) {
+ Box(Modifier.size(150.dp))
+ }
+ }
+ }
+
+ // Try a bunch of different start indexes
+ listOf(0, 5, 12).forEach {
+ val startIndex = it
+ rule.runOnIdle {
+ runBlocking(AutoTestFrameClock()) {
+ state.scrollToItem(startIndex)
+ state.animateScrollToItem(19)
+ }
+ }
+
+ rule.runOnIdle {
+ assertThat(state.firstVisibleItemIndex).isEqualTo(19)
+ assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+ }
+ }
+ }
+
+ @Test
+ fun recreatingContentLambdaTriggersItemRecomposition() {
+ val countState = mutableStateOf(0)
+ rule.setContent {
+ val count = countState.value
+ LazyColumnOrRow {
+ item {
+ BasicText(text = "Count $count")
+ }
+ }
+ }
+
+ rule.onNodeWithText("Count 0")
+ .assertIsDisplayed()
+
+ rule.runOnIdle {
+ countState.value++
+ }
+
+ rule.onNodeWithText("Count 1")
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun semanticsScroll_isAnimated() {
+ rule.mainClock.autoAdvance = false
+ val state = LazyListState()
+
+ rule.setContent {
+ LazyColumnOrRow(Modifier.testTag(LazyListTag), state = state) {
+ items(50) {
+ Box(Modifier.mainAxisSize(200.dp))
+ }
+ }
+ }
+
+ rule.waitForIdle()
+ assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+ assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+
+ rule.onNodeWithTag(LazyListTag).performSemanticsAction(SemanticsActions.ScrollBy) {
+ if (vertical) {
+ it(0f, 100f)
+ } else {
+ it(100f, 0f)
+ }
+ }
+
+ // We haven't advanced time yet, make sure it's still zero
+ assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+ assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
+
+ // Advance and make sure we're partway through
+ // Note that we need two frames for the animation to actually happen
+ rule.mainClock.advanceTimeByFrame()
+ rule.mainClock.advanceTimeByFrame()
+
+ // The items are 200dp each, so still the first one, but offset
+ assertThat(state.firstVisibleItemIndex).isEqualTo(0)
+ assertThat(state.firstVisibleItemScrollOffset).isGreaterThan(0)
+ assertThat(state.firstVisibleItemScrollOffset).isLessThan(100)
+
+ // Finish the scroll, make sure we're at the target
+ rule.mainClock.advanceTimeBy(5000)
+ assertThat(state.firstVisibleItemScrollOffset).isEqualTo(100)
+ }
+
+ // ********************* END OF TESTS *********************
+ // Helper functions, etc. live below here
+
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "{0}")
+ fun params() = arrayOf(Orientation.Vertical, Orientation.Horizontal)
+ }
+
+ private fun Modifier.mainAxisSize(size: Dp) =
+ if (vertical) {
+ this.height(size)
+ } else {
+ this.width(size)
+ }
+
+ private fun Modifier.crossAxisSize(size: Dp) =
+ if (vertical) {
+ this.width(size)
+ } else {
+ this.height(size)
+ }
+
+ private fun Modifier.fillMaxCrossAxis() =
+ if (vertical) {
+ this.fillMaxWidth()
+ } else {
+ this.fillMaxHeight()
+ }
+
+ private fun LazyItemScope.fillParentMaxCrossAxis() =
+ if (vertical) {
+ Modifier.fillParentMaxWidth()
+ } else {
+ Modifier.fillParentMaxHeight()
+ }
+
+ private fun SemanticsNodeInteraction.scrollMainAxisBy(distance: Dp) {
+ if (vertical) {
+ this.scrollBy(y = distance, density = rule.density)
+ } else {
+ this.scrollBy(x = distance, density = rule.density)
+ }
+ }
+
+ private fun SemanticsNodeInteraction.assertMainAxisSizeIsEqualTo(expectedSize: Dp) =
+ if (vertical) {
+ assertHeightIsEqualTo(expectedSize)
+ } else {
+ assertWidthIsEqualTo(expectedSize)
+ }
+
+ private fun SemanticsNodeInteraction.assertCrossAxisSizeIsEqualTo(expectedSize: Dp) =
+ if (vertical) {
+ assertWidthIsEqualTo(expectedSize)
+ } else {
+ assertHeightIsEqualTo(expectedSize)
+ }
+
+ private fun SemanticsNodeInteraction.assertStartPositionIsAlmost(expected: Dp) {
+ val position = if (vertical) {
+ getUnclippedBoundsInRoot().top
+ } else {
+ getUnclippedBoundsInRoot().left
+ }
+ position.assertIsEqualTo(expected, tolerance = 1.dp)
+ }
+
+ private fun SemanticsNodeInteraction.assertStartPositionInRootIsEqualTo(expectedStart: Dp) =
+ if (vertical) {
+ assertTopPositionInRootIsEqualTo(expectedStart)
+ } else {
+ assertLeftPositionInRootIsEqualTo(expectedStart)
+ }
+
+ private fun LazyListState.scrollBy(offset: Dp) {
+ runBlocking(Dispatchers.Main + AutoTestFrameClock()) {
+ animateScrollBy(with(rule.density) { offset.roundToPx().toFloat() }, snap())
+ }
+ }
+
+ @Suppress("NOTHING_TO_INLINE")
+ @Composable
+ private inline fun LazyColumnOrRow(
+ modifier: Modifier = Modifier,
+ state: LazyListState = rememberLazyListState(),
+ contentPadding: PaddingValues = PaddingValues(0.dp),
+ reverseLayout: Boolean = false,
+ flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
+ noinline content: LazyListScope.() -> Unit
+ ) {
+ if (vertical) {
+ LazyColumn(
+ modifier = modifier,
+ state = state,
+ contentPadding = contentPadding,
+ reverseLayout = reverseLayout,
+ flingBehavior = flingBehavior,
+ content = content
+ )
+ } else {
+ LazyRow(
+ modifier = modifier,
+ state = state,
+ contentPadding = contentPadding,
+ reverseLayout = reverseLayout,
+ flingBehavior = flingBehavior,
+ content = content
+ )
+ }
+ }
+}
+
+internal val NeverEqualObject = object {
+ override fun equals(other: Any?): Boolean {
+ return false
+ }
+}
+
+private data class NotStable(val count: Int)
+
+internal const val TestTouchSlop = 18f
+
+internal fun IntegerSubject.isWithin1PixelFrom(expected: Int) {
+ isEqualTo(expected, 1)
+}
+
+internal fun IntegerSubject.isEqualTo(expected: Int, tolerance: Int) {
+ isIn(Range.closed(expected - tolerance, expected + tolerance))
+}
+
+internal fun ComposeContentTestRule.setContentWithTestViewConfiguration(
+ composable: @Composable () -> Unit
+) {
+ this.setContent {
+ WithTouchSlop(TestTouchSlop, composable)
+ }
+}
+
+internal fun SemanticsNodeInteraction.scrollBy(x: Dp = 0.dp, y: Dp = 0.dp, density: Density) =
+ performGesture {
+ with(density) {
+ val touchSlop = TestTouchSlop.toInt()
+ val xPx = x.roundToPx()
+ val yPx = y.roundToPx()
+ val offsetX = if (xPx > 0) xPx + touchSlop else if (xPx < 0) xPx - touchSlop else 0
+ val offsetY = if (yPx > 0) yPx + touchSlop else if (yPx < 0) yPx - touchSlop else 0
+ swipeWithVelocity(
+ start = center,
+ end = Offset(center.x - offsetX, center.y - offsetY),
+ endVelocity = 0f
+ )
+ }
+ }
\ No newline at end of file
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/LazyNestedScrollingTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/LazyNestedScrollingTest.kt
index 9341128..5c4cf93 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/LazyNestedScrollingTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/LazyNestedScrollingTest.kt
@@ -189,9 +189,9 @@
}
}
- // scroll forward
+ // scroll till the end
rule.onNodeWithTag(LazyTag)
- .scrollBy(y = 50.dp, density = rule.density)
+ .scrollBy(y = 55.dp, density = rule.density)
rule.onNodeWithTag(LazyTag)
.performGesture {
@@ -352,9 +352,9 @@
}
}
- // scroll forward
+ // scroll till the end
rule.onNodeWithTag(LazyTag)
- .scrollBy(x = 50.dp, density = rule.density)
+ .scrollBy(x = 55.dp, density = rule.density)
rule.onNodeWithTag(LazyTag)
.performGesture {
diff --git a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/LazyRowTest.kt b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/LazyRowTest.kt
index c2d7acb..d6b11c6 100644
--- a/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/LazyRowTest.kt
+++ b/compose/foundation/foundation/src/androidAndroidTest/kotlin/androidx/compose/foundation/lazy/LazyRowTest.kt
@@ -16,70 +16,26 @@
package androidx.compose.foundation.lazy
-import android.os.Build
-import androidx.compose.animation.core.snap
-import androidx.compose.foundation.AutoTestFrameClock
-import androidx.compose.foundation.background
-import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxHeight
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
-import androidx.compose.foundation.layout.requiredSize
-import androidx.compose.foundation.layout.requiredSizeIn
-import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.SideEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.setValue
-import androidx.compose.testutils.assertIsEqualTo
-import androidx.compose.testutils.assertShape
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.drawBehind
-import androidx.compose.ui.geometry.Offset
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
-import androidx.compose.ui.semantics.SemanticsActions
-import androidx.compose.ui.test.SemanticsNodeInteraction
-import androidx.compose.ui.test.assertHeightIsEqualTo
import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.assertIsNotDisplayed
-import androidx.compose.ui.test.assertLeftPositionInRootIsEqualTo
import androidx.compose.ui.test.assertPositionInRootIsEqualTo
-import androidx.compose.ui.test.assertWidthIsEqualTo
-import androidx.compose.ui.test.captureToImage
-import androidx.compose.ui.test.center
-import androidx.compose.ui.test.down
import androidx.compose.ui.test.getUnclippedBoundsInRoot
-import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.compose.ui.test.moveBy
import androidx.compose.ui.test.onNodeWithTag
-import androidx.compose.ui.test.onNodeWithText
-import androidx.compose.ui.test.performGesture
-import androidx.compose.ui.test.performSemanticsAction
-import androidx.compose.ui.test.up
-import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
-import androidx.test.filters.SdkSuppress
-import com.google.common.truth.Truth
import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -92,267 +48,6 @@
@get:Rule
val rule = createComposeRule()
- @Test
- fun lazyRowShowsCombinedItems() {
- val itemTestTag = "itemTestTag"
- val items = listOf(1, 2).map { it.toString() }
- val indexedItems = listOf(3, 4, 5)
-
- rule.setContentWithTestViewConfiguration {
- LazyRow(Modifier.width(200.dp)) {
- item {
- Spacer(
- Modifier.width(40.dp).fillParentMaxHeight().testTag(itemTestTag)
- )
- }
- items(items) {
- Spacer(Modifier.width(40.dp).fillParentMaxHeight().testTag(it))
- }
- itemsIndexed(indexedItems) { index, item ->
- Spacer(
- Modifier.width(41.dp).fillParentMaxHeight()
- .testTag("$index-$item")
- )
- }
- }
- }
-
- rule.onNodeWithTag(itemTestTag)
- .assertIsDisplayed()
-
- rule.onNodeWithTag("1")
- .assertIsDisplayed()
-
- rule.onNodeWithTag("2")
- .assertIsDisplayed()
-
- rule.onNodeWithTag("0-3")
- .assertIsDisplayed()
-
- rule.onNodeWithTag("1-4")
- .assertIsDisplayed()
-
- rule.onNodeWithTag("2-5")
- .assertDoesNotExist()
- }
-
- @Test
- fun lazyRowAllowEmptyListItems() {
- val itemTag = "itemTag"
-
- rule.setContentWithTestViewConfiguration {
- LazyRow {
- items(emptyList<Any>()) { }
- item {
- Spacer(Modifier.size(10.dp).testTag(itemTag))
- }
- }
- }
-
- rule.onNodeWithTag(itemTag)
- .assertIsDisplayed()
- }
-
- @Test
- fun lazyRowAllowsNullableItems() {
- val items = listOf("1", null, "3")
- val nullTestTag = "nullTestTag"
-
- rule.setContentWithTestViewConfiguration {
- LazyRow(Modifier.width(200.dp)) {
- items(items) {
- if (it != null) {
- Spacer(Modifier.width(101.dp).fillParentMaxHeight().testTag(it))
- } else {
- Spacer(
- Modifier.width(101.dp).fillParentMaxHeight()
- .testTag(nullTestTag)
- )
- }
- }
- }
- }
-
- rule.onNodeWithTag("1")
- .assertIsDisplayed()
-
- rule.onNodeWithTag(nullTestTag)
- .assertIsDisplayed()
-
- rule.onNodeWithTag("3")
- .assertDoesNotExist()
- }
-
- @Test
- fun lazyRowOnlyVisibleItemsAdded() {
- val items = (1..4).map { it.toString() }
-
- rule.setContentWithTestViewConfiguration {
- Box(Modifier.width(200.dp)) {
- LazyRow {
- items(items) {
- Spacer(Modifier.width(101.dp).fillParentMaxHeight().testTag(it))
- }
- }
- }
- }
-
- rule.onNodeWithTag("1")
- .assertIsDisplayed()
-
- rule.onNodeWithTag("2")
- .assertIsDisplayed()
-
- rule.onNodeWithTag("3")
- .assertDoesNotExist()
-
- rule.onNodeWithTag("4")
- .assertDoesNotExist()
- }
-
- @Test
- fun lazyRowScrollToShowItems123() {
- val items = (1..4).map { it.toString() }
-
- rule.setContentWithTestViewConfiguration {
- Box(Modifier.width(200.dp)) {
- LazyRow(Modifier.testTag(LazyListTag)) {
- items(items) {
- Spacer(Modifier.width(101.dp).fillParentMaxHeight().testTag(it))
- }
- }
- }
- }
-
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(x = 50.dp, density = rule.density)
-
- rule.onNodeWithTag("1")
- .assertIsDisplayed()
-
- rule.onNodeWithTag("2")
- .assertIsDisplayed()
-
- rule.onNodeWithTag("3")
- .assertIsDisplayed()
-
- rule.onNodeWithTag("4")
- .assertIsNotDisplayed()
- }
-
- @Test
- fun lazyRowScrollToHideFirstItem() {
- val items = (1..4).map { it.toString() }
-
- rule.setContentWithTestViewConfiguration {
- Box(Modifier.width(200.dp)) {
- LazyRow(Modifier.testTag(LazyListTag)) {
- items(items) {
- Spacer(Modifier.width(101.dp).fillParentMaxHeight().testTag(it))
- }
- }
- }
- }
-
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(x = 105.dp, density = rule.density)
-
- rule.onNodeWithTag("1")
- .assertIsNotDisplayed()
-
- rule.onNodeWithTag("2")
- .assertIsDisplayed()
-
- rule.onNodeWithTag("3")
- .assertIsDisplayed()
- }
-
- @Test
- fun lazyRowScrollToShowItems234() {
- val items = (1..4).map { it.toString() }
-
- rule.setContentWithTestViewConfiguration {
- Box(Modifier.width(200.dp)) {
- LazyRow(Modifier.testTag(LazyListTag)) {
- items(items) {
- Spacer(Modifier.width(101.dp).fillParentMaxHeight().testTag(it))
- }
- }
- }
- }
-
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(x = 150.dp, density = rule.density)
-
- rule.onNodeWithTag("1")
- .assertIsNotDisplayed()
-
- rule.onNodeWithTag("2")
- .assertIsDisplayed()
-
- rule.onNodeWithTag("3")
- .assertIsDisplayed()
-
- rule.onNodeWithTag("4")
- .assertIsDisplayed()
- }
-
- @Test
- fun lazyRowWrapsContent() = with(rule.density) {
- val itemInsideLazyRow = "itemInsideLazyRow"
- val itemOutsideLazyRow = "itemOutsideLazyRow"
- var sameSizeItems by mutableStateOf(true)
-
- rule.setContentWithTestViewConfiguration {
- Column {
- LazyRow(Modifier.testTag(LazyListTag)) {
- items(listOf(1, 2)) {
- if (it == 1) {
- Spacer(Modifier.size(50.dp).testTag(itemInsideLazyRow))
- } else {
- Spacer(Modifier.size(if (sameSizeItems) 50.dp else 70.dp))
- }
- }
- }
- Spacer(Modifier.size(50.dp).testTag(itemOutsideLazyRow))
- }
- }
-
- rule.onNodeWithTag(itemInsideLazyRow)
- .assertIsDisplayed()
-
- rule.onNodeWithTag(itemOutsideLazyRow)
- .assertIsDisplayed()
-
- var lazyRowBounds = rule.onNodeWithTag(LazyListTag)
- .getUnclippedBoundsInRoot()
-
- assertThat(lazyRowBounds.left.roundToPx()).isWithin1PixelFrom(0.dp.roundToPx())
- assertThat(lazyRowBounds.right.roundToPx()).isWithin1PixelFrom(100.dp.roundToPx())
- assertThat(lazyRowBounds.top.roundToPx()).isWithin1PixelFrom(0.dp.roundToPx())
- assertThat(lazyRowBounds.bottom.roundToPx()).isWithin1PixelFrom(50.dp.roundToPx())
-
- rule.runOnIdle {
- sameSizeItems = false
- }
-
- rule.waitForIdle()
-
- rule.onNodeWithTag(itemInsideLazyRow)
- .assertIsDisplayed()
-
- rule.onNodeWithTag(itemOutsideLazyRow)
- .assertIsDisplayed()
-
- lazyRowBounds = rule.onNodeWithTag(LazyListTag)
- .getUnclippedBoundsInRoot()
-
- assertThat(lazyRowBounds.left.roundToPx()).isWithin1PixelFrom(0.dp.roundToPx())
- assertThat(lazyRowBounds.right.roundToPx()).isWithin1PixelFrom(120.dp.roundToPx())
- assertThat(lazyRowBounds.top.roundToPx()).isWithin1PixelFrom(0.dp.roundToPx())
- assertThat(lazyRowBounds.bottom.roundToPx()).isWithin1PixelFrom(70.dp.roundToPx())
- }
-
private val firstItemTag = "firstItemTag"
private val secondItemTag = "secondItemTag"
@@ -422,128 +117,6 @@
}
@Test
- fun itemFillingParentWidth() {
- rule.setContentWithTestViewConfiguration {
- LazyRow(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
- items(listOf(0)) {
- Spacer(
- Modifier.fillParentMaxWidth().requiredHeight(50.dp).testTag(firstItemTag)
- )
- }
- }
- }
-
- rule.onNodeWithTag(firstItemTag)
- .assertWidthIsEqualTo(100.dp)
- .assertHeightIsEqualTo(50.dp)
- }
-
- @Test
- fun itemFillingParentHeight() {
- rule.setContentWithTestViewConfiguration {
- LazyRow(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
- items(listOf(0)) {
- Spacer(
- Modifier.requiredWidth(50.dp).fillParentMaxHeight().testTag(firstItemTag)
- )
- }
- }
- }
-
- rule.onNodeWithTag(firstItemTag)
- .assertWidthIsEqualTo(50.dp)
- .assertHeightIsEqualTo(150.dp)
- }
-
- @Test
- fun itemFillingParentSize() {
- rule.setContentWithTestViewConfiguration {
- LazyRow(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
- items(listOf(0)) {
- Spacer(Modifier.fillParentMaxSize().testTag(firstItemTag))
- }
- }
- }
-
- rule.onNodeWithTag(firstItemTag)
- .assertWidthIsEqualTo(100.dp)
- .assertHeightIsEqualTo(150.dp)
- }
-
- @Test
- fun itemFillingParentWidthFraction() {
- rule.setContentWithTestViewConfiguration {
- LazyRow(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
- items(listOf(0)) {
- Spacer(
- Modifier.fillParentMaxWidth(0.7f)
- .requiredHeight(50.dp)
- .testTag(firstItemTag)
- )
- }
- }
- }
-
- rule.onNodeWithTag(firstItemTag)
- .assertWidthIsEqualTo(70.dp)
- .assertHeightIsEqualTo(50.dp)
- }
-
- @Test
- fun itemFillingParentHeightFraction() {
- rule.setContentWithTestViewConfiguration {
- LazyRow(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
- items(listOf(0)) {
- Spacer(
- Modifier.requiredWidth(50.dp)
- .fillParentMaxHeight(0.3f)
- .testTag(firstItemTag)
- )
- }
- }
- }
-
- rule.onNodeWithTag(firstItemTag)
- .assertWidthIsEqualTo(50.dp)
- .assertHeightIsEqualTo(45.dp)
- }
-
- @Test
- fun itemFillingParentSizeFraction() {
- rule.setContentWithTestViewConfiguration {
- LazyRow(Modifier.requiredSize(width = 100.dp, height = 150.dp)) {
- items(listOf(0)) {
- Spacer(Modifier.fillParentMaxSize(0.5f).testTag(firstItemTag))
- }
- }
- }
-
- rule.onNodeWithTag(firstItemTag)
- .assertWidthIsEqualTo(50.dp)
- .assertHeightIsEqualTo(75.dp)
- }
-
- @Test
- fun itemFillingParentSizeParentResized() {
- var parentSize by mutableStateOf(100.dp)
- rule.setContentWithTestViewConfiguration {
- LazyRow(Modifier.requiredSize(parentSize)) {
- items(listOf(0)) {
- Spacer(Modifier.fillParentMaxSize().testTag(firstItemTag))
- }
- }
- }
-
- rule.runOnIdle {
- parentSize = 150.dp
- }
-
- rule.onNodeWithTag(firstItemTag)
- .assertWidthIsEqualTo(150.dp)
- .assertHeightIsEqualTo(150.dp)
- }
-
- @Test
fun scrollsLeftInRtl() {
lateinit var state: LazyListState
rule.setContentWithTestViewConfiguration {
@@ -569,822 +142,4 @@
assertThat(state.firstVisibleItemScrollOffset).isGreaterThan(0)
}
}
-
- @Test
- fun whenNotAnymoreAvailableItemWasDisplayed() {
- var items by mutableStateOf((1..30).toList())
- rule.setContentWithTestViewConfiguration {
- LazyRow(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
- items(items) {
- Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
- }
- }
- }
-
- // after scroll we will display items 16-20
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(x = 300.dp, density = rule.density)
-
- rule.runOnIdle {
- items = (1..10).toList()
- }
-
- // there is no item 16 anymore so we will just display the last items 6-10
- rule.onNodeWithTag("6")
- .assertLeftPositionIsAlmost(0.dp)
- }
-
- @Test
- fun whenFewDisplayedItemsWereRemoved() {
- var items by mutableStateOf((1..10).toList())
- rule.setContentWithTestViewConfiguration {
- LazyRow(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
- items(items) {
- Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
- }
- }
- }
-
- // after scroll we will display items 6-10
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(x = 100.dp, density = rule.density)
-
- rule.runOnIdle {
- items = (1..8).toList()
- }
-
- // there are no more items 9 and 10, so we have to scroll back
- rule.onNodeWithTag("4")
- .assertLeftPositionIsAlmost(0.dp)
- }
-
- @Test
- fun whenItemsBecameEmpty() {
- var items by mutableStateOf((1..10).toList())
- rule.setContentWithTestViewConfiguration {
- LazyRow(Modifier.requiredSizeIn(maxHeight = 100.dp).testTag(LazyListTag)) {
- items(items) {
- Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
- }
- }
- }
-
- // after scroll we will display items 2-6
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(x = 20.dp, density = rule.density)
-
- rule.runOnIdle {
- items = emptyList()
- }
-
- // there are no more items so the LazyRow is zero sized
- rule.onNodeWithTag(LazyListTag)
- .assertWidthIsEqualTo(0.dp)
- .assertHeightIsEqualTo(0.dp)
-
- // and has no children
- rule.onNodeWithTag("1")
- .assertDoesNotExist()
- rule.onNodeWithTag("2")
- .assertDoesNotExist()
- }
-
- @Test
- fun scrollBackAndForth() {
- val items by mutableStateOf((1..20).toList())
- rule.setContentWithTestViewConfiguration {
- LazyRow(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
- items(items) {
- Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
- }
- }
- }
-
- // after scroll we will display items 6-10
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(x = 100.dp, density = rule.density)
-
- // and scroll back
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(x = (-100).dp, density = rule.density)
-
- rule.onNodeWithTag("1")
- .assertLeftPositionIsAlmost(0.dp)
- }
-
- @Test
- fun tryToScrollBackwardWhenAlreadyOnTop() {
- val items by mutableStateOf((1..20).toList())
- rule.setContentWithTestViewConfiguration {
- LazyRow(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
- items(items) {
- Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
- }
- }
- }
-
- // we already displaying the first item, so this should do nothing
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(x = (-50).dp, density = rule.density)
-
- rule.onNodeWithTag("1")
- .assertLeftPositionIsAlmost(0.dp)
- rule.onNodeWithTag("5")
- .assertLeftPositionIsAlmost(80.dp)
- }
-
- private fun SemanticsNodeInteraction.assertLeftPositionIsAlmost(expected: Dp) {
- getUnclippedBoundsInRoot().left.assertIsEqualTo(expected, tolerance = 1.dp)
- }
-
- @Test
- fun contentOfNotStableItemsIsNotRecomposedDuringScroll() {
- val items = listOf(NotStable(1), NotStable(2))
- var firstItemRecomposed = 0
- var secondItemRecomposed = 0
- rule.setContentWithTestViewConfiguration {
- LazyRow(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
- items(items) {
- if (it.count == 1) {
- firstItemRecomposed++
- } else {
- secondItemRecomposed++
- }
- Spacer(Modifier.requiredSize(75.dp))
- }
- }
- }
-
- rule.runOnIdle {
- assertThat(firstItemRecomposed).isEqualTo(1)
- assertThat(secondItemRecomposed).isEqualTo(1)
- }
-
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(x = (50).dp, density = rule.density)
-
- rule.runOnIdle {
- assertThat(firstItemRecomposed).isEqualTo(1)
- assertThat(secondItemRecomposed).isEqualTo(1)
- }
- }
-
- @Test
- fun onlyOneMeasurePassForScrollEvent() {
- val items by mutableStateOf((1..20).toList())
- lateinit var state: LazyListState
- rule.setContentWithTestViewConfiguration {
- state = rememberLazyListState()
- state.prefetchingEnabled = false
- LazyRow(Modifier.requiredSize(100.dp), state = state) {
- items(items) {
- Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
- }
- }
- }
-
- val initialMeasurePasses = state.numMeasurePasses
-
- rule.runOnIdle {
- with(rule.density) {
- state.onScroll(-110.dp.toPx())
- }
- }
-
- rule.waitForIdle()
-
- assertThat(state.numMeasurePasses).isEqualTo(initialMeasurePasses + 1)
- }
-
- @Test
- fun stateUpdatedAfterScroll() {
- lateinit var state: LazyListState
- rule.setContentWithTestViewConfiguration {
- state = rememberLazyListState()
- LazyRow(
- Modifier.requiredSize(100.dp).testTag(LazyListTag),
- state = state
- ) {
- items(20) {
- Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
- }
- }
- }
-
- rule.runOnIdle {
- assertThat(state.firstVisibleItemIndex).isEqualTo(0)
- assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
- }
-
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(x = 30.dp, density = rule.density)
-
- rule.runOnIdle {
- assertThat(state.firstVisibleItemIndex).isEqualTo(1)
-
- with(rule.density) {
- // TODO(b/169232491): test scrolling doesn't appear to be scrolling exactly the right
- // number of pixels
- val expectedOffset = 10.dp.roundToPx()
- val tolerance = 2.dp.roundToPx()
- assertThat(state.firstVisibleItemScrollOffset).isEqualTo(expectedOffset, tolerance)
- }
- }
- }
-
- @Test
- fun stateUpdatedAfterScrollWithinTheSameItem() {
- lateinit var state: LazyListState
- rule.setContentWithTestViewConfiguration {
- state = rememberLazyListState()
- LazyRow(
- Modifier.requiredSize(100.dp).testTag(LazyListTag),
- state = state
- ) {
- items(20) {
- Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
- }
- }
- }
-
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(x = 10.dp, density = rule.density)
-
- rule.runOnIdle {
- assertThat(state.firstVisibleItemIndex).isEqualTo(0)
- with(rule.density) {
- val expectedOffset = 10.dp.roundToPx()
- val tolerance = 2.dp.roundToPx()
- assertThat(state.firstVisibleItemScrollOffset)
- .isEqualTo(expectedOffset, tolerance)
- }
- }
- }
-
- @Test
- fun initialScrollIsApplied() {
- lateinit var state: LazyListState
- val expectedOffset = with(rule.density) { 10.dp.roundToPx() }
- rule.setContentWithTestViewConfiguration {
- state = rememberLazyListState(2, expectedOffset)
- LazyRow(Modifier.requiredSize(100.dp).testTag(LazyListTag), state = state) {
- items(20) {
- Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
- }
- }
- }
-
- rule.runOnIdle {
- assertThat(state.firstVisibleItemIndex).isEqualTo(2)
- assertThat(state.firstVisibleItemScrollOffset).isEqualTo(expectedOffset)
- }
-
- rule.onNodeWithTag("2")
- .assertLeftPositionInRootIsEqualTo((-10).dp)
- }
-
- @Test
- fun stateIsRestored() {
- val restorationTester = StateRestorationTester(rule)
- var state: LazyListState? = null
- restorationTester.setContent {
- state = rememberLazyListState()
- LazyRow(
- Modifier.requiredSize(100.dp).testTag(LazyListTag),
- state = state!!
- ) {
- items(20) {
- Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
- }
- }
- }
-
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(x = 30.dp, density = rule.density)
-
- val (index, scrollOffset) = rule.runOnIdle {
- state!!.firstVisibleItemIndex to state!!.firstVisibleItemScrollOffset
- }
-
- state = null
-
- restorationTester.emulateSavedInstanceStateRestore()
-
- rule.runOnIdle {
- assertThat(state!!.firstVisibleItemIndex).isEqualTo(index)
- assertThat(state!!.firstVisibleItemScrollOffset).isEqualTo(scrollOffset)
- }
- }
-
- @Test
- fun snapToItemIndex() {
- lateinit var state: LazyListState
- rule.setContentWithTestViewConfiguration {
- state = rememberLazyListState()
- LazyRow(
- Modifier.requiredSize(100.dp).testTag(LazyListTag),
- state = state
- ) {
- items(20) {
- Spacer(Modifier.requiredSize(20.dp).testTag("$it"))
- }
- }
- }
-
- rule.runOnIdle {
- runBlocking {
- state.scrollToItem(3, 10)
- }
- assertThat(state.firstVisibleItemIndex).isEqualTo(3)
- assertThat(state.firstVisibleItemScrollOffset).isEqualTo(10)
- }
- }
-
- @Test
- fun itemsAreNotRedrawnDuringScroll() {
- val redrawCount = Array(6) { 0 }
- rule.setContentWithTestViewConfiguration {
- LazyRow(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
- items(21) {
- Spacer(
- Modifier.requiredSize(20.dp)
- .drawBehind { redrawCount[it]++ }
- )
- }
- }
- }
-
- rule.onNodeWithTag(LazyListTag)
- .scrollBy(x = 10.dp, density = rule.density)
-
- rule.runOnIdle {
- redrawCount.forEachIndexed { index, i ->
- Truth.assertWithMessage("Item with index $index was redrawn $i times")
- .that(i).isEqualTo(1)
- }
- }
- }
-
- @Test
- fun itemInvalidationIsNotCausingAnotherItemToRedraw() {
- val redrawCount = Array(2) { 0 }
- var stateUsedInDrawScope by mutableStateOf(false)
- rule.setContentWithTestViewConfiguration {
- LazyRow(Modifier.requiredSize(100.dp).testTag(LazyListTag)) {
- items(2) {
- Spacer(
- Modifier.requiredSize(50.dp)
- .drawBehind {
- redrawCount[it]++
- if (it == 1) {
- stateUsedInDrawScope.hashCode()
- }
- }
- )
- }
- }
- }
-
- rule.runOnIdle {
- stateUsedInDrawScope = true
- }
-
- rule.runOnIdle {
- Truth.assertWithMessage("First items is not expected to be redrawn")
- .that(redrawCount[0]).isEqualTo(1)
- Truth.assertWithMessage("Second items is expected to be redrawn")
- .that(redrawCount[1]).isEqualTo(2)
- }
- }
-
- @Test
- fun notVisibleAnymoreItemNotAffectingCrossAxisSize() {
- val items = (0..1).toList()
- val itemSize = with(rule.density) { 30.toDp() }
- val itemSizeMinusOne = with(rule.density) { 29.toDp() }
- lateinit var state: LazyListState
- rule.setContentWithTestViewConfiguration {
- LazyRow(
- Modifier.requiredWidth(itemSizeMinusOne).testTag(LazyListTag),
- state = rememberLazyListState().also { state = it }
- ) {
- items(items) {
- Spacer(
- if (it == 0) {
- Modifier.requiredHeight(30.dp).requiredWidth(itemSizeMinusOne)
- } else {
- Modifier.requiredHeight(20.dp).requiredWidth(itemSize)
- }
- )
- }
- }
- }
-
- state.scrollBy(itemSize)
-
- rule.onNodeWithTag(LazyListTag)
- .assertHeightIsEqualTo(20.dp)
- }
-
- @Test
- fun itemStillVisibleAfterOverscrollIsAffectingCrossAxisSize() {
- val items = (0..2).toList()
- val itemSize = with(rule.density) { 30.toDp() }
- lateinit var state: LazyListState
- rule.setContentWithTestViewConfiguration {
- LazyRow(
- Modifier.requiredWidth(itemSize * 1.75f).testTag(LazyListTag),
- state = rememberLazyListState().also { state = it }
- ) {
- items(items) {
- Spacer(
- if (it == 0) {
- Modifier.requiredHeight(30.dp).requiredWidth(itemSize / 2)
- } else if (it == 1) {
- Modifier.requiredHeight(20.dp).requiredWidth(itemSize / 2)
- } else {
- Modifier.requiredHeight(20.dp).requiredWidth(itemSize)
- }
- )
- }
- }
- }
-
- state.scrollBy(itemSize)
-
- rule.onNodeWithTag(LazyListTag)
- .assertHeightIsEqualTo(30.dp)
- }
-
- @Test
- fun usedWithArray() {
- val items = arrayOf("1", "2", "3")
-
- val itemSize = with(rule.density) { 15.toDp() }
-
- rule.setContentWithTestViewConfiguration {
- LazyRow {
- items(items) {
- Spacer(Modifier.requiredSize(itemSize).testTag(it))
- }
- }
- }
-
- rule.onNodeWithTag("1")
- .assertLeftPositionInRootIsEqualTo(0.dp)
-
- rule.onNodeWithTag("2")
- .assertLeftPositionInRootIsEqualTo(itemSize)
-
- rule.onNodeWithTag("3")
- .assertLeftPositionInRootIsEqualTo(itemSize * 2)
- }
-
- @Test
- fun usedWithArrayIndexed() {
- val items = arrayOf("1", "2", "3")
-
- val itemSize = with(rule.density) { 15.toDp() }
-
- rule.setContentWithTestViewConfiguration {
- LazyRow {
- itemsIndexed(items) { index, item ->
- Spacer(Modifier.requiredSize(itemSize).testTag("$index*$item"))
- }
- }
- }
-
- rule.onNodeWithTag("0*1")
- .assertLeftPositionInRootIsEqualTo(0.dp)
-
- rule.onNodeWithTag("1*2")
- .assertLeftPositionInRootIsEqualTo(itemSize)
-
- rule.onNodeWithTag("2*3")
- .assertLeftPositionInRootIsEqualTo(itemSize * 2)
- }
-
- @Test
- fun changeItemsCountAndScrollImmediately() {
- lateinit var state: LazyListState
- var count by mutableStateOf(100)
- val composedIndexes = mutableListOf<Int>()
- rule.setContent {
- state = rememberLazyListState()
- LazyRow(Modifier.fillMaxHeight().width(10.dp), state) {
- items(count) { index ->
- composedIndexes.add(index)
- Box(Modifier.size(20.dp))
- }
- }
- }
-
- rule.runOnIdle {
- composedIndexes.clear()
- count = 10
- runBlocking(AutoTestFrameClock()) {
- state.scrollToItem(50)
- }
- composedIndexes.forEach {
- assertThat(it).isLessThan(count)
- }
- assertThat(state.firstVisibleItemIndex).isEqualTo(9)
- }
- }
-
- @Test
- fun overscrollingBackwardFromNotTheFirstPosition() {
- val containerTag = "container"
- val itemSizePx = 10
- val itemSizeDp = with(rule.density) { itemSizePx.toDp() }
- val containerSize = itemSizeDp * 5
- rule.setContentWithTestViewConfiguration {
- Box(
- Modifier
- .testTag(containerTag)
- .size(containerSize)
- ) {
- LazyRow(
- Modifier
- .testTag(LazyListTag)
- .background(Color.Blue),
- state = rememberLazyListState(2, 5)
- ) {
- items(100) {
- Box(
- Modifier
- .fillMaxHeight()
- .width(itemSizeDp)
- .testTag("$it")
- )
- }
- }
- }
- }
-
- rule.onNodeWithTag(LazyListTag)
- .performGesture {
- // we do move manually and not with swipe() utility because we want to have one
- // drag gesture, not multiple smaller ones
- down(center)
- moveBy(Offset(-TestTouchSlop, 0f))
- moveBy(
- Offset(
- itemSizePx * 15f, // large value which makes us overscroll
- 0f
- )
- )
- up()
- }
-
- rule.onNodeWithTag(LazyListTag)
- .assertWidthIsEqualTo(containerSize)
-
- rule.onNodeWithTag("0")
- .assertLeftPositionInRootIsEqualTo(0.dp)
- rule.onNodeWithTag("4")
- .assertLeftPositionInRootIsEqualTo(containerSize - itemSizeDp)
- }
-
- @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
- @Test
- fun lazyRowDoesNotClipHorizontalOverdraw() {
- rule.setContent {
- Box(Modifier.size(60.dp).testTag("container").background(Color.Gray)) {
- LazyRow(
- Modifier
- .padding(20.dp)
- .fillMaxSize(),
- rememberLazyListState(1)
- ) {
- items(4) {
- Box(Modifier.size(20.dp).drawOutsideOfBounds())
- }
- }
- }
- }
-
- rule.onNodeWithTag("container")
- .captureToImage()
- .assertShape(
- density = rule.density,
- shape = RectangleShape,
- shapeColor = Color.Red,
- backgroundColor = Color.Gray,
- horizontalPadding = 20.dp,
- verticalPadding = 0.dp
- )
- }
-
- @Test
- fun initialScrollPositionIsCorrectWhenItemsAreLoadedAsynchronously() {
- lateinit var state: LazyListState
- var itemsCount by mutableStateOf(0)
- rule.setContent {
- state = rememberLazyListState(2, 10)
- LazyRow(Modifier.fillMaxSize(), state) {
- items(itemsCount) {
- Box(Modifier.size(20.dp))
- }
- }
- }
-
- rule.runOnIdle {
- itemsCount = 100
- }
-
- rule.runOnIdle {
- assertThat(state.firstVisibleItemIndex).isEqualTo(2)
- assertThat(state.firstVisibleItemScrollOffset).isEqualTo(10)
- }
- }
-
- @Test
- fun restoredScrollPositionIsCorrectWhenItemsAreLoadedAsynchronously() {
- lateinit var state: LazyListState
- var itemsCount = 100
- val recomposeCounter = mutableStateOf(0)
- val tester = StateRestorationTester(rule)
- tester.setContent {
- state = rememberLazyListState()
- LazyRow(Modifier.fillMaxSize(), state) {
- recomposeCounter.value
- items(itemsCount) {
- Box(Modifier.size(20.dp))
- }
- }
- }
-
- rule.runOnIdle {
- runBlocking {
- state.scrollToItem(2, 10)
- }
- itemsCount = 0
- }
-
- tester.emulateSavedInstanceStateRestore()
-
- rule.runOnIdle {
- itemsCount = 100
- recomposeCounter.value = 1
- }
-
- rule.runOnIdle {
- assertThat(state.firstVisibleItemIndex).isEqualTo(2)
- assertThat(state.firstVisibleItemScrollOffset).isEqualTo(10)
- }
- }
-
- @Test
- fun animateScrollToItemDoesNotScrollPastItem() {
- lateinit var state: LazyListState
- var target = 0
- var reverse = false
- rule.setContent {
- val listState = rememberLazyListState()
- SideEffect {
- state = listState
- }
- LazyRow(Modifier.fillMaxSize(), listState) {
- items(2500) { _ ->
- Box(Modifier.size(100.dp))
- }
- }
-
- if (reverse) {
- assertThat(listState.firstVisibleItemIndex).isAtLeast(target)
- } else {
- assertThat(listState.firstVisibleItemIndex).isAtMost(target)
- }
- }
-
- // Try a bunch of different targets with varying spacing
- listOf(500, 800, 1500, 1600, 1800).forEach {
- target = it
- rule.runOnIdle {
- runBlocking(AutoTestFrameClock()) {
- state.animateScrollToItem(target)
- }
- }
-
- rule.runOnIdle {
- assertThat(state.firstVisibleItemIndex).isEqualTo(target)
- assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
- }
- }
-
- reverse = true
-
- listOf(1600, 1500, 800, 500, 0).forEach {
- target = it
- rule.runOnIdle {
- runBlocking(AutoTestFrameClock()) {
- state.animateScrollToItem(target)
- }
- }
-
- rule.runOnIdle {
- assertThat(state.firstVisibleItemIndex).isEqualTo(target)
- assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
- }
- }
- }
-
- @Test
- fun animateScrollToTheLastItemWhenItemsAreLargerThenTheScreen() {
- lateinit var state: LazyListState
- rule.setContent {
- state = rememberLazyListState()
- LazyRow(Modifier.height(150.dp).width(100.dp), state) {
- items(20) {
- Box(Modifier.size(150.dp))
- }
- }
- }
-
- // Try a bunch of different start indexes
- listOf(0, 5, 12).forEach {
- val startIndex = it
- rule.runOnIdle {
- runBlocking(AutoTestFrameClock()) {
- state.scrollToItem(startIndex)
- state.animateScrollToItem(19)
- }
- }
-
- rule.runOnIdle {
- assertThat(state.firstVisibleItemIndex).isEqualTo(19)
- assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
- }
- }
- }
-
- @Test
- fun recreatingContentLambdaTriggersItemRecomposition() {
- val countState = mutableStateOf(0)
- rule.setContent {
- val count = countState.value
- LazyRow {
- item {
- BasicText(text = "Count $count")
- }
- }
- }
-
- rule.onNodeWithText("Count 0")
- .assertIsDisplayed()
-
- rule.runOnIdle {
- countState.value++
- }
-
- rule.onNodeWithText("Count 1")
- .assertIsDisplayed()
- }
-
- @Test
- fun semanticsScroll_isAnimated() {
- rule.mainClock.autoAdvance = false
- val state = LazyListState()
-
- rule.setContent {
- LazyRow(Modifier.testTag(LazyListTag), state = state) {
- items(50) {
- Box(Modifier.width(200.dp))
- }
- }
- }
-
- rule.waitForIdle()
- assertThat(state.firstVisibleItemIndex).isEqualTo(0)
- assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
-
- rule.onNodeWithTag(LazyListTag).performSemanticsAction(SemanticsActions.ScrollBy) {
- it(100f, 0f)
- }
-
- // We haven't advanced time yet, make sure it's still zero
- assertThat(state.firstVisibleItemIndex).isEqualTo(0)
- assertThat(state.firstVisibleItemScrollOffset).isEqualTo(0)
-
- // Advance and make sure we're partway through
- // Note that we need two frames for the animation to actually happen
- rule.mainClock.advanceTimeByFrame()
- rule.mainClock.advanceTimeByFrame()
-
- // The items are 200dp each, so still the first one, but offset
- assertThat(state.firstVisibleItemIndex).isEqualTo(0)
- assertThat(state.firstVisibleItemScrollOffset).isGreaterThan(0)
- assertThat(state.firstVisibleItemScrollOffset).isLessThan(100)
-
- // Finish the scroll, make sure we're at the target
- rule.mainClock.advanceTimeBy(5000)
- assertThat(state.firstVisibleItemScrollOffset).isEqualTo(100)
- }
-
- private fun LazyListState.scrollBy(offset: Dp) {
- runBlocking(Dispatchers.Main + AutoTestFrameClock()) {
- animateScrollBy(with(rule.density) { offset.roundToPx().toFloat() }, snap())
- }
- }
}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Border.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Border.kt
index 3953333..bf670c8 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Border.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/Border.kt
@@ -16,31 +16,42 @@
package androidx.compose.foundation
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.CacheDrawScope
+import androidx.compose.ui.draw.DrawResult
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
-import androidx.compose.ui.geometry.isRect
import androidx.compose.ui.geometry.isSimple
+import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.ClipOp
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.ImageBitmapConfig
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathOperation
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
-import androidx.compose.ui.graphics.addOutline
+import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
+import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipRect
+import androidx.compose.ui.graphics.drawscope.scale
+import androidx.compose.ui.graphics.drawscope.translate
+import androidx.compose.ui.node.Ref
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.toSize
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.min
@@ -83,89 +94,55 @@
@Suppress("UnnecessaryComposedModifier")
fun Modifier.border(width: Dp, brush: Brush, shape: Shape): Modifier = composed(
factory = {
+ // BorderCache object that is lazily allocated depending on the type of shape
+ // This object is only used for generic shapes and rounded rectangles with different corner
+ // radius sizes.
+ val borderCacheRef = remember { Ref<BorderCache>() }
this.then(
Modifier.drawWithCache {
- val hasValidBorderParams = width.toPx() > 0f && size.minDimension > 0f
- val outline = shape.createOutline(size, layoutDirection, this)
- val strokeWidthPx = min(
- if (width == Dp.Hairline) 1f else width.toPx(), ceil(size.minDimension / 2)
- )
- val borderStroke = Stroke(strokeWidthPx)
- val halfStroke = strokeWidthPx / 2f
- val topLeft = Offset(halfStroke, halfStroke)
- val borderSize = Size(size.width - strokeWidthPx, size.height - strokeWidthPx)
-
- val borderPath: Path? = if (outline is Outline.Generic) {
- createBorderPath(outline, strokeWidthPx, shape)
- } else if (outline is Outline.Rounded && !outline.roundRect.isSimple) {
- createRoundRectPath(outline.roundRect, strokeWidthPx)
+ val hasValidBorderParams = width.toPx() >= 0f && size.minDimension > 0f
+ if (!hasValidBorderParams) {
+ drawContentWithoutBorder()
} else {
- null // Either a simple rect or rounded rect, no need for path operations
- }
-
- val isRect = outline is Outline.Rectangle ||
- (outline is Outline.Rounded && outline.roundRect.isRect)
-
- val isSimpleRoundRect: Boolean
- val cornerRadius: CornerRadius
- if (outline is Outline.Rounded && outline.roundRect.isSimple) {
- isSimpleRoundRect = true
- cornerRadius = outline.roundRect.topLeftCornerRadius
- } else {
- isSimpleRoundRect = false
- cornerRadius = CornerRadius.Zero
- }
- // The stroke is larger than the drawing area so just draw a full shape instead
- val fillArea = (strokeWidthPx * 2) > size.minDimension
- onDrawWithContent {
- drawContent()
- if (hasValidBorderParams && borderPath != null) {
- // If we have a path, that means we are drawing either a generic shape
- // or a rounded rect with different corner radii across the 4 corners
- drawPath(borderPath, brush)
- } else if (hasValidBorderParams && isRect) {
- // If we are drawing a rectangular stroke, just offset it by half the stroke
- // width as strokes are always drawn centered on their geometry.
- // If the border is larger than the drawing area, just fill the area with a
- // solid rectangle
- drawRect(
- brush = brush,
- topLeft = if (fillArea) Offset.Zero else topLeft,
- size = if (fillArea) size else borderSize,
- style = if (fillArea) Fill else borderStroke
- )
- } else if (hasValidBorderParams && isSimpleRoundRect) {
- if (fillArea) {
- // If the drawing area is smaller than the stroke being drawn
- // drawn all around it just draw a filled in rounded rect
- drawRoundRect(brush, cornerRadius = cornerRadius)
- } else if (cornerRadius.x < halfStroke) {
- // If the corner radius is smaller than half of the stroke width
- // then the interior curvature of the stroke will be a sharp edge
- // In this case just draw a normal filled in rounded rect with the
- // desired corner radius but clipping out the interior rectangle
- clipRect(
- strokeWidthPx,
- strokeWidthPx,
- size.width - strokeWidthPx,
- size.height - strokeWidthPx,
- clipOp = ClipOp.Difference
- ) {
- drawRoundRect(brush, cornerRadius = cornerRadius)
- }
- } else {
- // Otherwise draw a stroked rounded rect with the corner radius
- // shrunk by half of the stroke width. This will ensure that the
- // outer curvature of the rounded rectangle will have the desired
- // corner radius.
- drawRoundRect(
- brush = brush,
- topLeft = topLeft,
- size = borderSize,
- cornerRadius = cornerRadius.shrink(halfStroke),
- style = borderStroke
+ val strokeWidthPx = min(
+ if (width == Dp.Hairline) 1f else ceil(width.toPx()),
+ ceil(size.minDimension / 2)
+ )
+ val halfStroke = strokeWidthPx / 2
+ val topLeft = Offset(halfStroke, halfStroke)
+ val borderSize = Size(
+ size.width - strokeWidthPx,
+ size.height - strokeWidthPx
+ )
+ // The stroke is larger than the drawing area so just draw a full shape instead
+ val fillArea = (strokeWidthPx * 2) > size.minDimension
+ when (val outline = shape.createOutline(size, layoutDirection, this)) {
+ is Outline.Generic ->
+ drawGenericBorder(
+ borderCacheRef,
+ brush,
+ outline,
+ fillArea,
+ strokeWidthPx
)
- }
+ is Outline.Rounded ->
+ drawRoundRectBorder(
+ borderCacheRef,
+ brush,
+ outline,
+ topLeft,
+ borderSize,
+ fillArea,
+ strokeWidthPx
+ )
+ is Outline.Rectangle ->
+ drawRectBorder(
+ brush,
+ topLeft,
+ borderSize,
+ fillArea,
+ strokeWidthPx
+ )
}
}
}
@@ -184,35 +161,274 @@
}
)
+private fun Ref<BorderCache>.obtain(): BorderCache =
+ this.value ?: BorderCache().also { value = it }
+
/**
- * Helper method to create a path with the inner section removed from it based on the
- * stroke size
+ * Helper object that handles lazily allocating and re-using objects
+ * to render the border into an offscreen ImageBitmap
*/
-private fun CacheDrawScope.createBorderPath(outline: Outline, widthPx: Float, shape: Shape): Path =
- // We already have a generic shape that leverages path so create another path that is subtracted
- // from the center that is smaller than the given path by 2 times the stroke width
- Path().apply {
- addOutline(outline)
- // If the stroke width is large enough to fully occupy the bounds we are drawing
- // in, just return the outline path itself, otherwise subtract off a smaller path
- // from the outline minus the stroke width
- if (widthPx * 2 < size.minDimension) {
- val insetSize = Size(size.width - widthPx * 2, size.height - widthPx * 2)
- val insetPath = Path().apply {
- addOutline(shape.createOutline(insetSize, layoutDirection, this@createBorderPath))
- translate(Offset(widthPx, widthPx))
+private data class BorderCache(
+ private var imageBitmap: ImageBitmap? = null,
+ private var canvas: androidx.compose.ui.graphics.Canvas? = null,
+ private var canvasDrawScope: CanvasDrawScope? = null,
+ private var borderPath: Path? = null
+) {
+ inline fun CacheDrawScope.drawBorderCache(
+ borderSize: IntSize,
+ config: ImageBitmapConfig,
+ block: DrawScope.() -> Unit
+ ): ImageBitmap {
+
+ var targetImageBitmap = imageBitmap
+ var targetCanvas = canvas
+ // If we previously had allocated a full Argb888 ImageBitmap but are only requiring
+ // an alpha mask, just re-use the same ImageBitmap instead of allocating a new one
+ val compatibleConfig = targetImageBitmap?.config == ImageBitmapConfig.Argb8888 ||
+ config == targetImageBitmap?.config
+ if (targetImageBitmap == null ||
+ targetCanvas == null ||
+ size.width > targetImageBitmap.width ||
+ size.height > targetImageBitmap.height ||
+ !compatibleConfig
+ ) {
+ targetImageBitmap = ImageBitmap(
+ borderSize.width,
+ borderSize.height,
+ config = config
+ ).also {
+ imageBitmap = it
}
- op(this, insetPath, PathOperation.Difference)
+ targetCanvas = androidx.compose.ui.graphics.Canvas(targetImageBitmap).also {
+ canvas = it
+ }
+ }
+
+ val targetDrawScope = canvasDrawScope ?: CanvasDrawScope().also { canvasDrawScope = it }
+ val drawSize = borderSize.toSize()
+ targetDrawScope.draw(
+ this,
+ layoutDirection,
+ targetCanvas,
+ drawSize
+ ) {
+ // Clear the previously rendered portion within this ImageBitmap as we could
+ // be re-using it
+ drawRect(
+ color = Color.Black,
+ size = drawSize,
+ blendMode = BlendMode.Clear
+ )
+ block()
+ }
+ targetImageBitmap.prepareToDraw()
+ return targetImageBitmap
+ }
+
+ fun obtainPath(): Path =
+ borderPath ?: Path().also { borderPath = it }
+}
+
+/**
+ * Border implementation for invalid parameters that just draws the content
+ * as the given border parameters are infeasible (ex. negative border width)
+ */
+private fun CacheDrawScope.drawContentWithoutBorder(): DrawResult =
+ onDrawWithContent {
+ drawContent()
+ }
+
+/**
+ * Border implementation for generic paths. Note it is possible to be given paths
+ * that do not make sense in the context of a border (ex. a figure 8 path or a non-enclosed
+ * shape) We do not handle that here as we expect developers to give us enclosed, non-overlapping
+ * paths
+ */
+private fun CacheDrawScope.drawGenericBorder(
+ borderCacheRef: Ref<BorderCache>,
+ brush: Brush,
+ outline: Outline.Generic,
+ fillArea: Boolean,
+ strokeWidth: Float
+): DrawResult =
+ if (fillArea) {
+ onDrawWithContent {
+ drawContent()
+ drawPath(outline.path, brush = brush)
+ }
+ } else {
+ // Optimization, if we are only drawing a solid color border, we only need an alpha8 mask
+ // as we can draw the mask with a tint.
+ // Otherwise we need to allocate a full ImageBitmap and draw it normally
+ val config: ImageBitmapConfig
+ val colorFilter: ColorFilter?
+ if (brush is SolidColor) {
+ config = ImageBitmapConfig.Alpha8
+ colorFilter = ColorFilter.tint(brush.value)
+ } else {
+ config = ImageBitmapConfig.Argb8888
+ colorFilter = null
+ }
+
+ val pathBounds = outline.path.getBounds()
+ val borderCache = borderCacheRef.obtain()
+ // Create a mask path that includes a rectangle with the original path cut out of it
+ val maskPath = borderCache.obtainPath().apply {
+ reset()
+ addRect(pathBounds)
+ op(this, outline.path, PathOperation.Difference)
+ }
+
+ val cacheImageBitmap: ImageBitmap
+ val pathBoundsSize = IntSize(
+ ceil(pathBounds.width).toInt(),
+ ceil(pathBounds.height).toInt()
+ )
+ with(borderCache) {
+ // Draw into offscreen bitmap with the size of the path
+ // We need to draw into this intermediate bitmap to act as a layer
+ // and make sure that the clearing logic does not generate underdraw
+ // into the target we are rendering into
+ cacheImageBitmap = drawBorderCache(
+ pathBoundsSize,
+ config
+ ) {
+ // Paths can have offsets, so translate to keep the drawn path
+ // within the bounds of the mask bitmap
+ translate(-pathBounds.left, -pathBounds.top) {
+ // Draw the path with a stroke width twice the provided value.
+ // Because strokes are centered, this will draw both and inner and outer stroke
+ // with the desired stroke width
+ drawPath(path = outline.path, brush = brush, style = Stroke(strokeWidth * 2))
+
+ // Scale the canvas slightly to cover the background that may be visible
+ // after clearing the outer stroke
+ scale(
+ (size.width + 1) / size.width,
+ (size.height + 1) / size.height
+ ) {
+ // Remove the outer stroke by clearing the inverted mask path
+ drawPath(path = maskPath, brush = brush, blendMode = BlendMode.Clear)
+ }
+ }
+ }
+ }
+
+ onDrawWithContent {
+ drawContent()
+ translate(pathBounds.left, pathBounds.top) {
+ drawImage(cacheImageBitmap, srcSize = pathBoundsSize, colorFilter = colorFilter)
+ }
}
}
-private fun CacheDrawScope.createRoundRectPath(
- roundedRect: RoundRect,
+/**
+ * Border implementation for simple rounded rects and those with different corner
+ * radii
+ */
+private fun CacheDrawScope.drawRoundRectBorder(
+ borderCacheRef: Ref<BorderCache>,
+ brush: Brush,
+ outline: Outline.Rounded,
+ topLeft: Offset,
+ borderSize: Size,
+ fillArea: Boolean,
strokeWidth: Float
+): DrawResult {
+ return if (outline.roundRect.isSimple) {
+ val cornerRadius = outline.roundRect.topLeftCornerRadius
+ val halfStroke = strokeWidth / 2
+ val borderStroke = Stroke(strokeWidth)
+ onDrawWithContent {
+ drawContent()
+ when {
+ fillArea -> {
+ // If the drawing area is smaller than the stroke being drawn
+ // drawn all around it just draw a filled in rounded rect
+ drawRoundRect(brush, cornerRadius = cornerRadius)
+ }
+ cornerRadius.x < halfStroke -> {
+ // If the corner radius is smaller than half of the stroke width
+ // then the interior curvature of the stroke will be a sharp edge
+ // In this case just draw a normal filled in rounded rect with the
+ // desired corner radius but clipping out the interior rectangle
+ clipRect(
+ strokeWidth,
+ strokeWidth,
+ size.width - strokeWidth,
+ size.height - strokeWidth,
+ clipOp = ClipOp.Difference
+ ) {
+ drawRoundRect(brush, cornerRadius = cornerRadius)
+ }
+ }
+ else -> {
+ // Otherwise draw a stroked rounded rect with the corner radius
+ // shrunk by half of the stroke width. This will ensure that the
+ // outer curvature of the rounded rectangle will have the desired
+ // corner radius.
+ drawRoundRect(
+ brush = brush,
+ topLeft = topLeft,
+ size = borderSize,
+ cornerRadius = cornerRadius.shrink(halfStroke),
+ style = borderStroke
+ )
+ }
+ }
+ }
+ } else {
+ val path = borderCacheRef.obtain().obtainPath()
+ val roundedRectPath = createRoundRectPath(path, outline.roundRect, strokeWidth, fillArea)
+ onDrawWithContent {
+ drawContent()
+ drawPath(roundedRectPath, brush = brush)
+ }
+ }
+}
+
+/**
+ * Border implementation for rectangular borders
+ */
+private fun CacheDrawScope.drawRectBorder(
+ brush: Brush,
+ topLeft: Offset,
+ borderSize: Size,
+ fillArea: Boolean,
+ strokeWidthPx: Float
+): DrawResult {
+ // If we are drawing a rectangular stroke, just offset it by half the stroke
+ // width as strokes are always drawn centered on their geometry.
+ // If the border is larger than the drawing area, just fill the area with a
+ // solid rectangle
+ val rectTopLeft = if (fillArea) Offset.Zero else topLeft
+ val size = if (fillArea) size else borderSize
+ val style = if (fillArea) Fill else Stroke(strokeWidthPx)
+ return onDrawWithContent {
+ drawContent()
+ drawRect(
+ brush = brush,
+ topLeft = rectTopLeft,
+ size = size,
+ style = style
+ )
+ }
+}
+
+/**
+ * Helper method that creates a round rect with the inner region removed
+ * by the given stroke width
+ */
+private fun createRoundRectPath(
+ targetPath: Path,
+ roundedRect: RoundRect,
+ strokeWidth: Float,
+ fillArea: Boolean
): Path =
- Path().apply {
+ targetPath.apply {
+ reset()
addRoundRect(roundedRect)
- if (strokeWidth * 2 < size.minDimension) {
+ if (!fillArea) {
val insetPath = Path().apply {
addRoundRect(createInsetRoundedRect(strokeWidth, roundedRect))
}
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyGrid.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyGrid.kt
index f0dcd0d..2ec5116 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyGrid.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/lazy/LazyGrid.kt
@@ -47,9 +47,6 @@
contentPadding: PaddingValues = PaddingValues(0.dp),
content: LazyGridScope.() -> Unit
) {
- val scope = LazyGridScopeImpl()
- scope.apply(content)
-
when (cells) {
is GridCells.Fixed ->
FixedLazyGrid(
@@ -57,7 +54,7 @@
modifier = modifier,
state = state,
contentPadding = contentPadding,
- scope = scope
+ content = content
)
is GridCells.Adaptive ->
BoxWithConstraints(
@@ -68,7 +65,7 @@
nColumns = nColumns,
state = state,
contentPadding = contentPadding,
- scope = scope
+ content = content
)
}
}
@@ -185,14 +182,17 @@
modifier: Modifier = Modifier,
state: LazyListState = rememberLazyListState(),
contentPadding: PaddingValues = PaddingValues(0.dp),
- scope: LazyGridScopeImpl
+ content: LazyGridScope.() -> Unit
) {
- val rows = (scope.totalSize + nColumns - 1) / nColumns
LazyColumn(
modifier = modifier,
state = state,
contentPadding = contentPadding
) {
+ val scope = LazyGridScopeImpl()
+ scope.apply(content)
+
+ val rows = (scope.totalSize + nColumns - 1) / nColumns
items(rows) { rowIndex ->
Row {
for (columnIndex in 0 until nColumns) {
diff --git a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreText.kt b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreText.kt
index 1ed3722..2009ff6e 100644
--- a/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreText.kt
+++ b/compose/foundation/foundation/src/commonMain/kotlin/androidx/compose/foundation/text/CoreText.kt
@@ -33,6 +33,7 @@
import androidx.compose.runtime.DisposableEffectScope
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
@@ -127,9 +128,12 @@
// When text is updated, the selection on this CoreText becomes invalid. It can be treated
// as a brand new CoreText.
// When SelectionRegistrar is updated, CoreText have to request a new ID to avoid ID collision.
- val selectableId = rememberSaveable(text, selectionRegistrar) {
- selectionRegistrar?.nextSelectableId() ?: SelectionRegistrar.InvalidSelectableId
- }
+
+ val selectableId =
+ rememberSaveable(text, selectionRegistrar, saver = selectionIdSaver(selectionRegistrar)) {
+ selectionRegistrar?.nextSelectableId() ?: SelectionRegistrar.InvalidSelectableId
+ }
+
val state = remember {
TextState(
TextDelegate(
@@ -639,3 +643,11 @@
}
return Pair(placeholders, inlineComposables)
}
+
+/**
+ * A custom saver that won't save if no selection is active.
+ */
+private fun selectionIdSaver(selectionRegistrar: SelectionRegistrar?) = Saver<Long, Long>(
+ save = { if (selectionRegistrar.hasSelection(it)) it else null },
+ restore = { it }
+)
\ No newline at end of file
diff --git a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/AlertDialogTest.kt b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/AlertDialogTest.kt
index 5ef6c01..e961cd7 100644
--- a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/AlertDialogTest.kt
+++ b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/AlertDialogTest.kt
@@ -18,9 +18,16 @@
import android.os.Build
import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
import androidx.compose.testutils.assertContainsColor
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.isDialog
import androidx.compose.ui.test.junit4.createComposeRule
@@ -30,6 +37,9 @@
import androidx.test.filters.LargeTest
import androidx.test.filters.SdkSuppress
import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -72,4 +82,39 @@
assertThat(contentColor.copy(alpha = 1f)).isEqualTo(Color.Red)
}
}
+
+ /**
+ * Ensure that AlertDialogs don't press up against the edges of the screen.
+ */
+ @Test
+ fun alertDialogDoesNotConsumeFullScreenWidth() {
+ val dialogWidthCh = Channel<Int>(Channel.CONFLATED)
+ var screenWidth by mutableStateOf(0)
+ rule.setContent {
+ val context = LocalContext.current
+ val density = LocalDensity.current
+ val resScreenWidth = context.resources.configuration.screenWidthDp
+ with(density) { screenWidth = resScreenWidth.dp.roundToPx() }
+
+ AlertDialog(
+ modifier = Modifier.onSizeChanged { dialogWidthCh.trySend(it.width) }
+ .fillMaxWidth(),
+ onDismissRequest = {},
+ title = { Text(text = "Title") },
+ text = {
+ Text(
+ "This area typically contains the supportive text " +
+ "which presents the details regarding the Dialog's purpose."
+ )
+ },
+ confirmButton = { TextButton(onClick = {}) { Text("Confirm") } },
+ dismissButton = { TextButton(onClick = {}) { Text("Dismiss") } },
+ )
+ }
+
+ runBlocking {
+ val dialogWidth = withTimeout(5_000) { dialogWidthCh.receive() }
+ assertThat(dialogWidth).isLessThan(screenWidth)
+ }
+ }
}
diff --git a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/FloatingActionButtonScreenshotTest.kt b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/FloatingActionButtonScreenshotTest.kt
new file mode 100644
index 0000000..2efce3a
--- /dev/null
+++ b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/FloatingActionButtonScreenshotTest.kt
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.compose.material
+
+import android.os.Build
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.testutils.assertAgainstGolden
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.captureToImage
+import androidx.compose.ui.test.center
+import androidx.compose.ui.test.down
+import androidx.compose.ui.test.hasClickAction
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onRoot
+import androidx.compose.ui.test.performGesture
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.filters.SdkSuppress
+import androidx.test.screenshot.AndroidXScreenshotTestRule
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+@OptIn(ExperimentalTestApi::class)
+class FloatingActionButtonScreenshotTest {
+
+ @get:Rule
+ val rule = createComposeRule()
+
+ @get:Rule
+ val screenshotRule = AndroidXScreenshotTestRule(GOLDEN_MATERIAL)
+
+ @Test
+ fun icon() {
+ rule.setMaterialContent {
+ FloatingActionButton(onClick = { }) {
+ Icon(Icons.Filled.Favorite, contentDescription = null)
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "fab_icon")
+ }
+
+ @Test
+ fun text() {
+ rule.setMaterialContent {
+ ExtendedFloatingActionButton(
+ text = { Text("EXTENDED") },
+ onClick = {}
+ )
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "fab_text")
+ }
+
+ @Test
+ fun textAndIcon() {
+ rule.setMaterialContent {
+ ExtendedFloatingActionButton(
+ text = { Text("EXTENDED") },
+ icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
+ onClick = {}
+ )
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "fab_textAndIcon")
+ }
+
+ @Test
+ fun ripple() {
+ rule.mainClock.autoAdvance = false
+
+ rule.setMaterialContent {
+ Box(Modifier.requiredSize(100.dp, 100.dp).wrapContentSize()) {
+ FloatingActionButton(onClick = { }) {
+ Icon(Icons.Filled.Favorite, contentDescription = null)
+ }
+ }
+ }
+
+ // Start ripple
+ rule.onNode(hasClickAction())
+ .performGesture { down(center) }
+
+ // Advance past the tap timeout
+ rule.mainClock.advanceTimeBy(100)
+
+ rule.waitForIdle()
+ // Ripples are drawn on the RenderThread, not the main (UI) thread, so we can't
+ // properly wait for synchronization. Instead just wait until after the ripples are
+ // finished animating.
+ Thread.sleep(300)
+
+ rule.onRoot()
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "fab_ripple")
+ }
+}
diff --git a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/SwipeableTest.kt b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/SwipeableTest.kt
index ec819db..cde992f 100644
--- a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/SwipeableTest.kt
+++ b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/SwipeableTest.kt
@@ -1511,6 +1511,38 @@
}
/**
+ * Tests that the new [SwipeableState] `onValueChange` is set up if the anchors didn't change
+ */
+ @Test
+ fun swipeable_newStateIsInitialized_afterRecomposingWithOldAnchors() {
+ lateinit var swipeableState: MutableState<SwipeableState<String>>
+ val anchors = mapOf(0f to "A")
+ setSwipeableContent {
+ swipeableState = remember { mutableStateOf(SwipeableState("A")) }
+ Modifier.swipeable(
+ state = swipeableState.value,
+ anchors = anchors,
+ thresholds = { _, _ -> FractionalThreshold(0.5f) },
+ orientation = Orientation.Horizontal
+ )
+ }
+
+ rule.runOnIdle {
+ assertThat(swipeableState.value.currentValue).isEqualTo("A")
+ assertThat(swipeableState.value.offset.value).isEqualTo(0f)
+ assertThat(swipeableState.value.anchors).isEqualTo(anchors)
+ }
+
+ swipeableState.value = SwipeableState("A")
+
+ rule.runOnIdle {
+ assertThat(swipeableState.value.currentValue).isEqualTo("A")
+ assertThat(swipeableState.value.offset.value).isEqualTo(0f)
+ assertThat(swipeableState.value.anchors).isEqualTo(anchors)
+ }
+ }
+
+ /**
* Tests that the [SwipeableState] is updated if the anchors change.
*/
@Test
diff --git a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/textfield/OutlinedTextFieldScreenshotTest.kt b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/textfield/OutlinedTextFieldScreenshotTest.kt
index 1fda1bc..071c5f2 100644
--- a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/textfield/OutlinedTextFieldScreenshotTest.kt
+++ b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/textfield/OutlinedTextFieldScreenshotTest.kt
@@ -44,6 +44,7 @@
import androidx.compose.ui.test.performGesture
import androidx.compose.ui.test.swipeLeft
import androidx.compose.ui.test.up
+import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
@@ -82,8 +83,9 @@
fun outlinedTextField_withInput() {
rule.setMaterialContent {
Box(Modifier.semantics(mergeDescendants = true) {}.testTag(TextFieldTag)) {
+ val text = "Text"
OutlinedTextField(
- value = "Text",
+ value = TextFieldValue(text = text, selection = TextRange(text.length)),
onValueChange = {},
label = { Text("Label") },
modifier = Modifier.requiredWidth(280.dp)
@@ -152,8 +154,9 @@
fun outlinedTextField_error_focused() {
rule.setMaterialContent {
Box(Modifier.semantics(mergeDescendants = true) {}.testTag(TextFieldTag)) {
+ val text = "Input"
OutlinedTextField(
- value = "Input",
+ value = TextFieldValue(text = text, selection = TextRange(text.length)),
onValueChange = {},
label = { Text("Label") },
isError = true,
@@ -188,8 +191,9 @@
fun outlinedTextField_textColor_fallbackToContentColor() {
rule.setMaterialContent {
CompositionLocalProvider(LocalContentColor provides Color.Magenta) {
+ val text = "Hello, world!"
OutlinedTextField(
- value = "Hello, world!",
+ value = TextFieldValue(text = text, selection = TextRange(text.length)),
onValueChange = {},
modifier = Modifier.testTag(TextFieldTag).requiredWidth(280.dp)
)
@@ -202,8 +206,9 @@
@Test
fun outlinedTextField_multiLine_withLabel_textAlignedToTop() {
rule.setMaterialContent {
+ val text = "Text"
OutlinedTextField(
- value = "Text",
+ value = TextFieldValue(text = text, selection = TextRange(text.length)),
onValueChange = {},
label = { Text("Label") },
modifier = Modifier.requiredHeight(300.dp)
@@ -218,8 +223,9 @@
@Test
fun outlinedTextField_multiLine_withoutLabel_textAlignedToTop() {
rule.setMaterialContent {
+ val text = "Text"
OutlinedTextField(
- value = "Text",
+ value = TextFieldValue(text = text, selection = TextRange(text.length)),
onValueChange = {},
modifier = Modifier.requiredHeight(300.dp)
.requiredWidth(280.dp)
@@ -286,8 +292,9 @@
@Test
fun outlinedTextField_singleLine_withLabel_textAlignedToTop() {
rule.setMaterialContent {
+ val text = "Text"
OutlinedTextField(
- value = "Text",
+ value = TextFieldValue(text = text, selection = TextRange(text.length)),
onValueChange = {},
singleLine = true,
label = { Text("Label") },
@@ -301,8 +308,9 @@
@Test
fun outlinedTextField_singleLine_withoutLabel_textCenteredVertically() {
rule.setMaterialContent {
+ val text = "Text"
OutlinedTextField(
- value = "Text",
+ value = TextFieldValue(text = text, selection = TextRange(text.length)),
onValueChange = {},
singleLine = true,
modifier = Modifier.testTag(TextFieldTag).requiredWidth(280.dp)
@@ -484,8 +492,9 @@
@Test
fun outlinedTextField_textCenterAligned() {
rule.setMaterialContent {
+ val text = "Hello world"
OutlinedTextField(
- value = "Hello world",
+ value = TextFieldValue(text = text, selection = TextRange(text.length)),
onValueChange = {},
modifier = Modifier.width(300.dp).testTag(TextFieldTag),
textStyle = TextStyle(textAlign = TextAlign.Center),
@@ -499,8 +508,9 @@
@Test
fun outlinedTextField_textAlignedToEnd() {
rule.setMaterialContent {
+ val text = "Hello world"
OutlinedTextField(
- value = "Hello world",
+ value = TextFieldValue(text = text, selection = TextRange(text.length)),
onValueChange = {},
modifier = Modifier.fillMaxWidth().testTag(TextFieldTag),
textStyle = TextStyle(textAlign = TextAlign.End),
diff --git a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/textfield/TextFieldScreenshotTest.kt b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/textfield/TextFieldScreenshotTest.kt
index ee34435..42a0998 100644
--- a/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/textfield/TextFieldScreenshotTest.kt
+++ b/compose/material/material/src/androidAndroidTest/kotlin/androidx/compose/material/textfield/TextFieldScreenshotTest.kt
@@ -44,6 +44,7 @@
import androidx.compose.ui.test.performGesture
import androidx.compose.ui.test.swipeLeft
import androidx.compose.ui.test.up
+import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign
@@ -81,8 +82,9 @@
fun textField_withInput() {
rule.setMaterialContent {
Box(Modifier.semantics(mergeDescendants = true) {}.testTag(TextFieldTag)) {
+ val text = "Text"
TextField(
- value = "Text",
+ value = TextFieldValue(text = text, selection = TextRange(text.length)),
onValueChange = {},
label = { Text("Label") },
modifier = Modifier.requiredWidth(280.dp)
@@ -150,8 +152,9 @@
@Test
fun textField_error_focused() {
rule.setMaterialContent {
+ val text = "Input"
TextField(
- value = "Input",
+ value = TextFieldValue(text = text, selection = TextRange(text.length)),
onValueChange = {},
label = { Text("Label") },
isError = true,
@@ -183,8 +186,9 @@
fun textField_textColor_fallbackToContentColor() {
rule.setMaterialContent {
CompositionLocalProvider(LocalContentColor provides Color.Green) {
+ val text = "Hello, world!"
TextField(
- value = "Hello, world!",
+ value = TextFieldValue(text = text, selection = TextRange(text.length)),
onValueChange = {},
modifier = Modifier.requiredWidth(280.dp).testTag(TextFieldTag)
)
@@ -197,8 +201,9 @@
@Test
fun textField_multiLine_withLabel_textAlignedToTop() {
rule.setMaterialContent {
+ val text = "Text"
TextField(
- value = "Text",
+ value = TextFieldValue(text = text, selection = TextRange(text.length)),
onValueChange = {},
label = { Text("Label") },
modifier = Modifier.requiredHeight(300.dp)
@@ -213,8 +218,9 @@
@Test
fun textField_multiLine_withoutLabel_textAlignedToTop() {
rule.setMaterialContent {
+ val text = "Text"
TextField(
- value = "Text",
+ value = TextFieldValue(text = text, selection = TextRange(text.length)),
onValueChange = {},
modifier = Modifier.requiredHeight(300.dp)
.requiredWidth(280.dp)
@@ -281,8 +287,9 @@
@Test
fun textField_singleLine_withLabel_textAlignedToTop() {
rule.setMaterialContent {
+ val text = "Text"
TextField(
- value = "Text",
+ value = TextFieldValue(text = text, selection = TextRange(text.length)),
onValueChange = {},
singleLine = true,
label = { Text("Label") },
@@ -296,8 +303,9 @@
@Test
fun textField_singleLine_withoutLabel_textCenteredVertically() {
rule.setMaterialContent {
+ val text = "Text"
TextField(
- value = "Text",
+ value = TextFieldValue(text = text, selection = TextRange(text.length)),
onValueChange = {},
singleLine = true,
modifier = Modifier.requiredWidth(280.dp).testTag(TextFieldTag)
@@ -472,8 +480,9 @@
@Test
fun textField_textCenterAligned() {
rule.setMaterialContent {
+ val text = "Hello world"
TextField(
- value = "Hello world",
+ value = TextFieldValue(text = text, selection = TextRange(text.length)),
onValueChange = {},
modifier = Modifier.width(300.dp).testTag(TextFieldTag),
textStyle = TextStyle(textAlign = TextAlign.Center),
@@ -487,8 +496,9 @@
@Test
fun textField_textAlignedToEnd() {
rule.setMaterialContent {
+ val text = "Hello world"
TextField(
- value = "Hello world",
+ value = TextFieldValue(text = text, selection = TextRange(text.length)),
onValueChange = {},
modifier = Modifier.fillMaxWidth().testTag(TextFieldTag),
textStyle = TextStyle(textAlign = TextAlign.End),
diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/FloatingActionButton.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/FloatingActionButton.kt
index 50d21a7..9dffc43 100644
--- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/FloatingActionButton.kt
+++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/FloatingActionButton.kt
@@ -168,22 +168,19 @@
contentColor = contentColor,
elevation = elevation
) {
- Box(
+ val startPadding = if (icon == null) ExtendedFabTextPadding else ExtendedFabIconPadding
+ Row(
modifier = Modifier.padding(
- start = ExtendedFabTextPadding,
+ start = startPadding,
end = ExtendedFabTextPadding
),
- contentAlignment = Alignment.Center
+ verticalAlignment = Alignment.CenterVertically
) {
- if (icon == null) {
- text()
- } else {
- Row(verticalAlignment = Alignment.CenterVertically) {
- icon()
- Spacer(Modifier.width(ExtendedFabIconPadding))
- text()
- }
+ if (icon != null) {
+ icon()
+ Spacer(Modifier.width(ExtendedFabIconPadding))
}
+ text()
}
}
}
diff --git a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Swipeable.kt b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Swipeable.kt
index a9d5d1d..b7cc4a1 100644
--- a/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Swipeable.kt
+++ b/compose/material/material/src/commonMain/kotlin/androidx/compose/material/Swipeable.kt
@@ -592,7 +592,7 @@
}
val density = LocalDensity.current
state.ensureInit(anchors)
- LaunchedEffect(anchors) {
+ LaunchedEffect(anchors, state) {
val oldAnchors = state.anchors
state.anchors = anchors
state.resistance = resistance
diff --git a/compose/runtime/runtime-saveable/src/commonMain/kotlin/androidx/compose/runtime/saveable/ListSaver.kt b/compose/runtime/runtime-saveable/src/commonMain/kotlin/androidx/compose/runtime/saveable/ListSaver.kt
index bcdf4be..761fe64 100644
--- a/compose/runtime/runtime-saveable/src/commonMain/kotlin/androidx/compose/runtime/saveable/ListSaver.kt
+++ b/compose/runtime/runtime-saveable/src/commonMain/kotlin/androidx/compose/runtime/saveable/ListSaver.kt
@@ -27,7 +27,7 @@
*
* @sample androidx.compose.runtime.saveable.samples.ListSaverSample
*/
-fun <Original : Any, Saveable : Any> listSaver(
+fun <Original, Saveable> listSaver(
save: SaverScope.(value: Original) -> List<Saveable>,
restore: (list: List<Saveable>) -> Original?
): Saver<Original, Any> = @Suppress("UNCHECKED_CAST") Saver(
@@ -35,7 +35,9 @@
val list = save(it)
for (index in list.indices) {
val item = list[index]
- require(canBeSaved(item))
+ if (item != null) {
+ require(canBeSaved(item))
+ }
}
if (list.isNotEmpty()) ArrayList(list) else null
},
diff --git a/compose/runtime/runtime-saveable/src/commonMain/kotlin/androidx/compose/runtime/saveable/MapSaver.kt b/compose/runtime/runtime-saveable/src/commonMain/kotlin/androidx/compose/runtime/saveable/MapSaver.kt
index 6ad0912..9bfa09b 100644
--- a/compose/runtime/runtime-saveable/src/commonMain/kotlin/androidx/compose/runtime/saveable/MapSaver.kt
+++ b/compose/runtime/runtime-saveable/src/commonMain/kotlin/androidx/compose/runtime/saveable/MapSaver.kt
@@ -27,12 +27,12 @@
*
* @sample androidx.compose.runtime.saveable.samples.MapSaverSample
*/
-fun <T : Any> mapSaver(
- save: SaverScope.(value: T) -> Map<String, Any>,
- restore: (Map<String, Any>) -> T?
-) = listSaver<T, Any>(
+fun <T> mapSaver(
+ save: SaverScope.(value: T) -> Map<String, Any?>,
+ restore: (Map<String, Any?>) -> T?
+) = listSaver<T, Any?>(
save = {
- mutableListOf<Any>().apply {
+ mutableListOf<Any?>().apply {
save(it).forEach { entry ->
add(entry.key)
add(entry.value)
@@ -40,7 +40,7 @@
}
},
restore = { list ->
- val map = mutableMapOf<String, Any>()
+ val map = mutableMapOf<String, Any?>()
check(list.size.rem(2) == 0)
var index = 0
while (index < list.size) {
diff --git a/compose/runtime/runtime-saveable/src/test/java/androidx/compose/runtime/saveable/ListSaverTest.kt b/compose/runtime/runtime-saveable/src/test/java/androidx/compose/runtime/saveable/ListSaverTest.kt
index ffd8d9a..99d6d4d 100644
--- a/compose/runtime/runtime-saveable/src/test/java/androidx/compose/runtime/saveable/ListSaverTest.kt
+++ b/compose/runtime/runtime-saveable/src/test/java/androidx/compose/runtime/saveable/ListSaverTest.kt
@@ -58,11 +58,41 @@
assertThat(savedList).isInstanceOf(ArrayList::class.java)
assertThat(savedList).isEqualTo(listOf("One", "Two"))
}
+
+ @Test
+ fun nullableListItemsAreSupported() {
+ val original = NullableSize(null, 3)
+ val saved = with(NullableSizeSaver) {
+ allowingScope.save(original)
+ }
+
+ assertThat(saved).isNotNull()
+ assertThat(NullableSizeSaver.restore(saved!!))
+ .isEqualTo(original)
+ }
+
+ @Test
+ fun nullableTypeIsSupported() {
+ val saved = with(NullableSizeSaver) {
+ allowingScope.save(null)
+ }
+
+ assertThat(saved).isNotNull()
+ assertThat(NullableSizeSaver.restore(saved!!))
+ .isEqualTo(NullableSize(null, null))
+ }
}
private data class Size(val x: Int, val y: Int)
+private data class NullableSize(val x: Int?, val y: Int?)
+
private val SizeSaver = listSaver<Size, Int>(
save = { listOf(it.x, it.y) },
restore = { Size(it[0], it[1]) }
)
+
+private val NullableSizeSaver = listSaver<NullableSize?, Int?>(
+ save = { listOf(it?.x, it?.y) },
+ restore = { NullableSize(it[0], it[1]) }
+)
diff --git a/compose/runtime/runtime-saveable/src/test/java/androidx/compose/runtime/saveable/MapSaverTest.kt b/compose/runtime/runtime-saveable/src/test/java/androidx/compose/runtime/saveable/MapSaverTest.kt
index 8c939d2..375b6b9 100644
--- a/compose/runtime/runtime-saveable/src/test/java/androidx/compose/runtime/saveable/MapSaverTest.kt
+++ b/compose/runtime/runtime-saveable/src/test/java/androidx/compose/runtime/saveable/MapSaverTest.kt
@@ -53,6 +53,29 @@
onlyInts.save(User("John", 30))
}
}
+
+ @Test
+ fun nullableMapItemsAreSupported() {
+ val original = NullableUser(null, 30)
+ val saved = with(NullableUserSaver) {
+ allowingScope.save(original)
+ }
+
+ assertThat(saved).isNotNull()
+ assertThat(NullableUserSaver.restore(saved!!))
+ .isEqualTo(original)
+ }
+
+ @Test
+ fun nullableTypeIsSupported() {
+ val saved = with(NullableUserSaver) {
+ allowingScope.save(null)
+ }
+
+ assertThat(saved).isNotNull()
+ assertThat(NullableUserSaver.restore(saved!!))
+ .isEqualTo(NullableUser(null, null))
+ }
}
private data class User(val name: String, val age: Int)
@@ -64,4 +87,15 @@
save = { mapOf(nameKey to it.name, ageKey to it.age) },
restore = { User(it[nameKey] as String, it[ageKey] as Int) }
)
+}
+
+private data class NullableUser(val name: String?, val age: Int?)
+
+private val NullableUserSaver = run {
+ val nameKey = "Name"
+ val ageKey = "Age"
+ mapSaver<NullableUser?>(
+ save = { mapOf(nameKey to it?.name, ageKey to it?.age) },
+ restore = { NullableUser(it[nameKey] as String?, it[ageKey] as Int?) }
+ )
}
\ No newline at end of file
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/Snapshot.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/Snapshot.kt
index d676337..8095df7 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/Snapshot.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/Snapshot.kt
@@ -1441,18 +1441,6 @@
}
}
- // Update the transparent snapshot if necessary
- // This doesn't need to take the sync because it is updating thread local state.
- (threadSnapshot.get() as? TransparentObserverMutableSnapshot)?.let {
- threadSnapshot.set(
- TransparentObserverMutableSnapshot(
- currentGlobalSnapshot.get(),
- it.specifiedReadObserver,
- it.specifiedWriteObserver
- )
- )
- it.dispose()
- }
return result
}
diff --git a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserver.kt b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserver.kt
index 3448a42..aec9166 100644
--- a/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserver.kt
+++ b/compose/runtime/runtime/src/commonMain/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserver.kt
@@ -113,8 +113,10 @@
if (!isObserving) {
isObserving = true
try {
- applyMap.map.removeValueIf {
- it === scope
+ synchronized(applyMaps) {
+ applyMap.map.removeValueIf {
+ it === scope
+ }
}
Snapshot.observe(readObserver, null, block)
} finally {
diff --git a/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserverTests.kt b/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserverTests.kt
index 2d7e1ec..66b607b 100644
--- a/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserverTests.kt
+++ b/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/snapshots/SnapshotStateObserverTests.kt
@@ -17,9 +17,13 @@
package androidx.compose.runtime.snapshots
import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import kotlin.concurrent.thread
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertNull
class SnapshotStateObserverTests {
@@ -282,6 +286,44 @@
assertEquals(1, changes2)
}
+ @Test // regression test for 192677711
+ fun tryToReproduceRaceCondition() {
+ var running = true
+ var threadException: Exception? = null
+ try {
+ thread {
+ try {
+ while (running) {
+ Snapshot.sendApplyNotifications()
+ }
+ } catch (e: Exception) {
+ threadException = e
+ }
+ }
+
+ for (i in 1..10000) {
+ val state1 by mutableStateOf(0)
+ var state2 by mutableStateOf(true)
+ val observer = SnapshotStateObserver({}).apply {
+ start()
+ }
+ repeat(1000) {
+ observer.observeReads(Unit, {}) {
+ @Suppress("UNUSED_EXPRESSION")
+ state1
+ if (state2) {
+ state2 = false
+ }
+ }
+ }
+ assertNull(threadException)
+ }
+ } finally {
+ running = false
+ }
+ assertNull(threadException)
+ }
+
private fun runSimpleTest(
block: (modelObserver: SnapshotStateObserver, data: MutableState<Int>) -> Unit
) {
diff --git a/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/snapshots/SnapshotTests.kt b/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/snapshots/SnapshotTests.kt
index 5ce7ca3..6ec7593 100644
--- a/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/snapshots/SnapshotTests.kt
+++ b/compose/runtime/runtime/src/test/kotlin/androidx/compose/runtime/snapshots/SnapshotTests.kt
@@ -703,6 +703,27 @@
nestedChild.dispose()
}
+ @Test // Regression test for b/193006595
+ fun transparentSnapshotAdvancesCorrectly() {
+ val state = Snapshot.observe({}) {
+ // In a transparent snapshot, advance the global snapshot
+ Snapshot.notifyObjectsInitialized()
+
+ // Create an apply an object in a snapshot
+ val state = atomic {
+ mutableStateOf(0)
+ }
+
+ // Ensure that the object can be accessed in the observer
+ assertEquals(0, state.value)
+
+ state
+ }
+
+ // Ensure that the object can be accessed globally.
+ assertEquals(0, state.value)
+ }
+
private var count = 0
@BeforeTest
diff --git a/compose/ui/ui-test/src/androidAndroidTest/kotlin/androidx/compose/ui/test/gesturescope/LocalToRootTest.kt b/compose/ui/ui-test/src/androidAndroidTest/kotlin/androidx/compose/ui/test/gesturescope/LocalToRootTest.kt
new file mode 100644
index 0000000..d7cd9fc
--- /dev/null
+++ b/compose/ui/ui-test/src/androidAndroidTest/kotlin/androidx/compose/ui/test/gesturescope/LocalToRootTest.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.ui.test.gesturescope
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredSize
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.center
+import androidx.compose.ui.test.click
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performGesture
+import androidx.compose.ui.test.util.ClickableTestBox
+import androidx.compose.ui.test.util.SinglePointerInputRecorder
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+
+class LocalToRootTest {
+ @get:Rule
+ val rule = createComposeRule()
+
+ private val recorder = SinglePointerInputRecorder()
+
+ @Test
+ fun test() {
+ rule.setContent {
+ with(LocalDensity.current) {
+ Column(
+ Modifier.requiredSize(100.toDp())
+ .testTag("viewport")
+ .verticalScroll(rememberScrollState())
+ .padding(top = 20.toDp())
+ ) {
+ ClickableTestBox(recorder, width = 100f, height = 200f)
+ }
+ }
+ }
+
+ rule.onNodeWithTag("viewport").performGesture { click(center) }
+
+ val expectedClickLocation = Offset(50f, 30f)
+ recorder.events.forEach {
+ assertThat(it.position).isEqualTo(expectedClickLocation)
+ }
+ }
+}
diff --git a/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/GestureScope.kt b/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/GestureScope.kt
index 7cf6995..b175dcf 100644
--- a/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/GestureScope.kt
+++ b/compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/GestureScope.kt
@@ -17,8 +17,8 @@
package androidx.compose.ui.test
import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.lerp
-import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.semantics.SemanticsNode
import androidx.compose.ui.test.InputDispatcher.Companion.eventPeriodMillis
import androidx.compose.ui.unit.IntSize
@@ -119,12 +119,18 @@
}
/**
+ * Returns and stores the visible bounds of the [semanticsNode] we're interacting with. This
+ * applies clipping, which is almost always the correct thing to do when injecting gestures,
+ * as gestures operate on visible UI.
+ */
+ internal val boundsInRoot: Rect by lazy { semanticsNode.boundsInRoot }
+
+ /**
* Returns the size of the visible part of the node we're interacting with. This is contrary
* to [SemanticsNode.size], which returns the unclipped size of the node.
*/
val visibleSize: IntSize by lazy {
- val bounds = semanticsNode.boundsInRoot
- IntSize(bounds.width.roundToInt(), bounds.height.roundToInt())
+ IntSize(boundsInRoot.width.roundToInt(), boundsInRoot.height.roundToInt())
}
internal fun dispose() {
@@ -289,7 +295,7 @@
* @return [position] transformed to coordinates relative to the containing root.
*/
private fun GestureScope.localToRoot(position: Offset): Offset {
- return position + semanticsNode.layoutInfo.coordinates.boundsInRoot().topLeft
+ return position + boundsInRoot.topLeft
}
/**
diff --git a/compose/ui/ui-text/src/desktopMain/kotlin/androidx/compose/ui/text/platform/DesktopParagraph.desktop.kt b/compose/ui/ui-text/src/desktopMain/kotlin/androidx/compose/ui/text/platform/DesktopParagraph.desktop.kt
index f32b3af..6f1759e 100644
--- a/compose/ui/ui-text/src/desktopMain/kotlin/androidx/compose/ui/text/platform/DesktopParagraph.desktop.kt
+++ b/compose/ui/ui-text/src/desktopMain/kotlin/androidx/compose/ui/text/platform/DesktopParagraph.desktop.kt
@@ -123,19 +123,21 @@
override val width: Float
) : Paragraph {
- val paragraphIntrinsics = intrinsics as DesktopParagraphIntrinsics
+ private val ellipsisChar = if (ellipsis) "\u2026" else ""
+
+ private val paragraphIntrinsics = intrinsics as DesktopParagraphIntrinsics
/**
* Paragraph isn't always immutable, it could be changed via [paint] method without
* rerunning layout
*/
- val para: SkParagraph
- get() = paragraphIntrinsics.para
+ private var para = paragraphIntrinsics.layoutParagraph(
+ width = width,
+ maxLines = maxLines,
+ ellipsis = ellipsisChar
+ )
init {
- if (resetMaxLinesIfNeeded()) {
- rebuildParagraph()
- }
para.layout(width)
}
@@ -288,7 +290,7 @@
// workaround for https://bugs.chromium.org/p/skia/issues/detail?id=11321 :(
private val lineMetrics: Array<LineMetrics>
get() = if (text == "") {
- val height = paragraphIntrinsics.builder.defaultHeight.toDouble()
+ val height = paragraphIntrinsics.defaultHeight.toDouble()
arrayOf(
LineMetrics(
0, 0, 0, 0, true,
@@ -372,55 +374,17 @@
shadow: Shadow?,
textDecoration: TextDecoration?
) {
- var toRebuild = false
- var currentColor = paragraphIntrinsics.builder.textStyle.color
- var currentShadow = paragraphIntrinsics.builder.textStyle.shadow
- var currentTextDecoration = paragraphIntrinsics.builder.textStyle.textDecoration
- if (color.isSpecified && color != currentColor) {
- toRebuild = true
- currentColor = color
- }
+ para = paragraphIntrinsics.layoutParagraph(
+ width = width,
+ maxLines = maxLines,
+ ellipsis = ellipsisChar,
+ color = color,
+ shadow = shadow,
+ textDecoration = textDecoration
+ )
- if (shadow != currentShadow) {
- toRebuild = true
- currentShadow = shadow
- }
-
- if (textDecoration != currentTextDecoration) {
- toRebuild = true
- currentTextDecoration = textDecoration
- }
-
- if (resetMaxLinesIfNeeded()) {
- toRebuild = true
- }
-
- if (toRebuild) {
- paragraphIntrinsics.builder.textStyle =
- paragraphIntrinsics.builder.textStyle.copy(
- color = currentColor,
- shadow = currentShadow,
- textDecoration = currentTextDecoration
- )
- rebuildParagraph()
- para.layout(width)
- }
para.paint(canvas.nativeCanvas, 0.0f, 0.0f)
}
-
- fun resetMaxLinesIfNeeded(): Boolean {
- if (maxLines != paragraphIntrinsics.builder.maxLines) {
- paragraphIntrinsics.builder.maxLines = maxLines
- paragraphIntrinsics.builder.ellipsis = if (ellipsis) "\u2026" else ""
- return true
- } else {
- return false
- }
- }
-
- fun rebuildParagraph() {
- paragraphIntrinsics.para = paragraphIntrinsics.builder.build()
- }
}
private fun fontSizeInHierarchy(density: Density, base: Float, other: TextUnit): Float {
diff --git a/compose/ui/ui-text/src/desktopMain/kotlin/androidx/compose/ui/text/platform/DesktopParagraphIntrinsics.desktop.kt b/compose/ui/ui-text/src/desktopMain/kotlin/androidx/compose/ui/text/platform/DesktopParagraphIntrinsics.desktop.kt
index eacd64d..b5fe1ca 100644
--- a/compose/ui/ui-text/src/desktopMain/kotlin/androidx/compose/ui/text/platform/DesktopParagraphIntrinsics.desktop.kt
+++ b/compose/ui/ui-text/src/desktopMain/kotlin/androidx/compose/ui/text/platform/DesktopParagraphIntrinsics.desktop.kt
@@ -15,14 +15,17 @@
*/
package androidx.compose.ui.text.platform
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shadow
+import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.AnnotatedString.Range
import androidx.compose.ui.text.ParagraphIntrinsics
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
-import androidx.compose.ui.text.resolveTextDirection
import androidx.compose.ui.text.style.ResolvedTextDirection
+import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.unit.Density
import org.jetbrains.skija.paragraph.Paragraph
@@ -54,27 +57,59 @@
resourceLoader: Font.ResourceLoader
) : ParagraphIntrinsics {
- val fontLoader = resourceLoader as FontLoader
+ private val fontLoader = resourceLoader as FontLoader
val textDirection = resolveTextDirection(style.textDirection)
- val builder: ParagraphBuilder
- var para: Paragraph
- init {
- builder = ParagraphBuilder(
- fontLoader = fontLoader,
- text = text,
- textStyle = style,
- spanStyles = spanStyles,
- placeholders = placeholders,
- density = density,
- textDirection = textDirection
- )
- para = builder.build()
+ val defaultHeight get() = builder.defaultHeight
+ private val builder = ParagraphBuilder(
+ fontLoader = fontLoader,
+ text = text,
+ textStyle = style,
+ spanStyles = spanStyles,
+ placeholders = placeholders,
+ density = density,
+ textDirection = textDirection
+ )
+ private var para = builder.build()
+ private var width = Float.POSITIVE_INFINITY
- para.layout(Float.POSITIVE_INFINITY)
+ init {
+ para.layout(width)
}
- override val minIntrinsicWidth = ceil(para.getMinIntrinsicWidth())
- override val maxIntrinsicWidth = ceil(para.getMaxIntrinsicWidth())
+ fun layoutParagraph(
+ width: Float = this.width,
+ maxLines: Int = builder.maxLines,
+ ellipsis: String = builder.ellipsis,
+ color: Color = builder.textStyle.color,
+ shadow: Shadow? = builder.textStyle.shadow,
+ textDecoration: TextDecoration? = builder.textStyle.textDecoration,
+ ): Paragraph {
+ if (
+ builder.maxLines != maxLines ||
+ builder.ellipsis != ellipsis ||
+ (builder.textStyle.color != color && color.isSpecified) ||
+ builder.textStyle.shadow != shadow ||
+ builder.textStyle.textDecoration != textDecoration
+ ) {
+ this.width = width
+ builder.maxLines = maxLines
+ builder.ellipsis = ellipsis
+ builder.textStyle = builder.textStyle.copy(
+ color = color,
+ shadow = shadow,
+ textDecoration = textDecoration
+ )
+ para = builder.build()
+ para.layout(width)
+ } else if (this.width != width) {
+ this.width = width
+ para.layout(width)
+ }
+ return para
+ }
+
+ override val minIntrinsicWidth = ceil(para.minIntrinsicWidth)
+ override val maxIntrinsicWidth = ceil(para.maxIntrinsicWidth)
private fun resolveTextDirection(direction: TextDirection?): ResolvedTextDirection {
return when (direction) {
diff --git a/compose/ui/ui/src/androidAndroidTest/AndroidManifest.xml b/compose/ui/ui/src/androidAndroidTest/AndroidManifest.xml
index 5a120c7..d62742c 100644
--- a/compose/ui/ui/src/androidAndroidTest/AndroidManifest.xml
+++ b/compose/ui/ui/src/androidAndroidTest/AndroidManifest.xml
@@ -29,5 +29,8 @@
<activity android:name="androidx.fragment.app.FragmentActivity"/>
<activity android:name="androidx.compose.ui.window.ActivityWithFlagSecure"/>
<activity android:name="androidx.compose.ui.RecyclerViewActivity" />
+ <activity
+ android:name="androidx.compose.ui.draw.NotHardwareAcceleratedActivity"
+ android:hardwareAccelerated="false" />
</application>
</manifest>
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/draw/DrawModifierTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/draw/DrawModifierTest.kt
index 7939afd..920300b 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/draw/DrawModifierTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/draw/DrawModifierTest.kt
@@ -52,7 +52,6 @@
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.filters.FlakyTest
import androidx.test.filters.LargeTest
import androidx.test.filters.SdkSuppress
import com.google.common.truth.Truth.assertThat
@@ -278,10 +277,8 @@
}
}
- @FlakyTest(bugId = 182512695)
@Test
fun testCacheInvalidatedAfterLayoutDirectionChange() {
- var cacheBuildCount = 0
var layoutDirection by mutableStateOf(LayoutDirection.Ltr)
var realLayoutDirection: LayoutDirection? = null
rule.setContent {
@@ -290,7 +287,6 @@
size = 10,
modifier = Modifier.drawWithCache {
realLayoutDirection = layoutDirection
- cacheBuildCount++
onDrawBehind {}
}
) { }
@@ -298,13 +294,11 @@
}
rule.runOnIdle {
- assertEquals(1, cacheBuildCount)
assertEquals(LayoutDirection.Ltr, realLayoutDirection)
layoutDirection = LayoutDirection.Rtl
}
rule.runOnIdle {
- assertEquals(2, cacheBuildCount)
assertEquals(LayoutDirection.Rtl, realLayoutDirection)
}
}
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/draw/NotHardwareAcceleratedActivityTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/draw/NotHardwareAcceleratedActivityTest.kt
new file mode 100644
index 0000000..7657dec2
--- /dev/null
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/draw/NotHardwareAcceleratedActivityTest.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.ui.draw
+
+import android.os.Build
+import androidx.activity.ComponentActivity
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.testutils.assertAgainstGolden
+import androidx.compose.ui.GOLDEN_UI
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.background
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.captureToImage
+import androidx.compose.ui.test.junit4.createAndroidComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.filters.SdkSuppress
+import androidx.test.screenshot.AndroidXScreenshotTestRule
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@MediumTest
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+@RunWith(AndroidJUnit4::class)
+class NotHardwareAcceleratedActivityTest {
+
+ @get:Rule
+ val composeTestRule = createAndroidComposeRule<NotHardwareAcceleratedActivity>()
+
+ @get:Rule
+ val screenshotRule = AndroidXScreenshotTestRule(GOLDEN_UI)
+
+ @Test
+ fun layerTransformationsApplied() {
+ composeTestRule.setContent {
+ Box(Modifier.size(200.dp).background(Color.White).testTag("box")) {
+ Box(
+ Modifier
+ .layout { measurable, constraints ->
+ val placeable = measurable.measure(constraints)
+ layout(placeable.width, placeable.height) {
+ val offset = 50.dp.roundToPx()
+ placeable.placeWithLayer(offset, offset) {
+ alpha = 0.5f
+ scaleX = 0.5f
+ scaleY = 0.5f
+ }
+ }
+ }
+ .size(100.dp)
+ .background(Color.Red)
+ )
+ }
+ }
+
+ composeTestRule.onNodeWithTag("box")
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "not_hardware_accelerated_activity")
+ }
+}
+
+class NotHardwareAcceleratedActivity : ComponentActivity()
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/OnGloballyPositionedTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/OnGloballyPositionedTest.kt
index 5ffcbb1..acb37cc 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/OnGloballyPositionedTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/layout/OnGloballyPositionedTest.kt
@@ -51,7 +51,6 @@
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.filters.FlakyTest
import androidx.test.filters.MediumTest
import androidx.test.filters.SmallTest
import com.google.common.truth.Truth.assertThat
@@ -506,7 +505,6 @@
assertThat(childCoordinates!!.positionInParent().x).isEqualTo(thirdPaddingPx)
}
- @FlakyTest(bugId = 187962859)
@Test
fun globalCoordinatesAreInActivityCoordinates() {
val padding = 30
@@ -522,17 +520,18 @@
composeView.setPadding(padding, padding, padding, padding)
rule.activity.setContentView(composeView)
- val position = IntArray(2)
- composeView.getLocationOnScreen(position)
- frameGlobalPosition = Offset(position[0].toFloat(), position[1].toFloat())
-
composeView.setContent {
Box(
Modifier.fillMaxSize().onGloballyPositioned {
+ val position = IntArray(2)
+ composeView.getLocationInWindow(position)
+ frameGlobalPosition = Offset(position[0].toFloat(), position[1].toFloat())
+
realGlobalPosition = it.localToWindow(localPosition)
realLocalPosition = it.windowToLocal(
framePadding + frameGlobalPosition!!
)
+
positionedLatch.countDown()
}
)
diff --git a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/window/DialogTest.kt b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/window/DialogTest.kt
index 282ec372..d5ceaf8 100644
--- a/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/window/DialogTest.kt
+++ b/compose/ui/ui/src/androidAndroidTest/kotlin/androidx/compose/ui/window/DialogTest.kt
@@ -286,13 +286,13 @@
var box1Width = 0
var box2Width = 0
rule.setContent {
- Dialog(onDismissRequest = {}) {
- Box(Modifier.fillMaxSize().onSizeChanged { box1Width = it.width })
- }
Dialog(
onDismissRequest = {},
- properties = DialogProperties(usePlatformDefaultWidth = true)
+ properties = DialogProperties(usePlatformDefaultWidth = false)
) {
+ Box(Modifier.fillMaxSize().onSizeChanged { box1Width = it.width })
+ }
+ Dialog(onDismissRequest = {}) {
Box(Modifier.fillMaxSize().onSizeChanged { box2Width = it.width })
}
}
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt
index 748621d..ed7fb62 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeView.android.kt
@@ -614,7 +614,10 @@
// We can't be confident that RenderNode is supported, so we try and fail over to
// the ViewLayer implementation. We'll try even on on P devices, but it will fail
// until ART allows things on the unsupported list on P.
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && isRenderNodeCompatible) {
+ if (isHardwareAccelerated &&
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
+ isRenderNodeCompatible
+ ) {
try {
return RenderNodeLayer(
this,
diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/window/AndroidDialog.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/window/AndroidDialog.android.kt
index 5def9d1..336e5de 100644
--- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/window/AndroidDialog.android.kt
+++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/window/AndroidDialog.android.kt
@@ -79,7 +79,7 @@
val dismissOnClickOutside: Boolean = true,
val securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit,
@get:ExperimentalComposeUiApi
- val usePlatformDefaultWidth: Boolean = false
+ val usePlatformDefaultWidth: Boolean = true
) {
@OptIn(ExperimentalComposeUiApi::class)
constructor(
@@ -90,7 +90,7 @@
dismissOnBackPress = dismissOnBackPress,
dismissOnClickOutside = dismissOnClickOutside,
securePolicy = securePolicy,
- usePlatformDefaultWidth = false
+ usePlatformDefaultWidth = true
)
@OptIn(ExperimentalComposeUiApi::class)
diff --git a/contentpager/contentpager/src/main/java/androidx/contentpager/content/ContentPager.java b/contentpager/contentpager/src/main/java/androidx/contentpager/content/ContentPager.java
index bf4cf49..2882853 100644
--- a/contentpager/contentpager/src/main/java/androidx/contentpager/content/ContentPager.java
+++ b/contentpager/contentpager/src/main/java/androidx/contentpager/content/ContentPager.java
@@ -75,6 +75,7 @@
* <p>Data will be loaded from a content uri in one of two ways, depending on the runtime
* environment and if the provider supports paging.
*
+ * <ul>
* <li>If the system is Android O and greater and the provider supports paging, the Cursor
* will be returned, effectively unmodified, to a {@link ContentCallback} supplied by
* your application.
@@ -83,6 +84,7 @@
* loader will fetch an unpaged Cursor from the provider. The unpaged Cursor will be held
* by the ContentPager, and data will be copied into a new cursor in a background thread.
* The new cursor will be returned to a {@link ContentCallback} supplied by your application.
+ *</ul>
*
* <p>In either cases, when an application employs this library it can generally assume
* that there will be no CursorWindow swap. But picking the right limit for records can
@@ -96,10 +98,12 @@
* projection they'll use in their app. The total number of records that will fit into shared
* memory varies depending on multiple factors.
*
+ * <ul>
* <li>The number of columns being requested in the cursor projection. Limit the number
* of columns, to reduce the size of each row.
* <li>The size of the data in each column.
* <li>the Cursor type.
+ * </ul>
*
* <p>If the cursor is running in-process, there may be no need for paging. Depending on
* the Cursor implementation chosen there may be no shared memory/CursorWindow in use.
diff --git a/core/core/src/main/java/androidx/core/content/FileProvider.java b/core/core/src/main/java/androidx/core/content/FileProvider.java
index cac2e31..d882a0a 100644
--- a/core/core/src/main/java/androidx/core/content/FileProvider.java
+++ b/core/core/src/main/java/androidx/core/content/FileProvider.java
@@ -261,7 +261,7 @@
* <code>default_image.jpg</code> in the <code>images/</code> subdirectory of your internal storage
* add the following code:
* <pre class="prettyprint">
- *File imagePath = new File(Context.getFilesDir(), "images");
+ *File imagePath = new File(Context.getFilesDir(), "my_images");
*File newFile = new File(imagePath, "default_image.jpg");
*Uri contentUri = getUriForFile(getContext(), "com.mydomain.fileprovider", newFile);
*</pre>
diff --git a/datastore/datastore-rxjava2/src/main/java/androidx/datastore/rxjava2/RxSharedPreferencesMigration.kt b/datastore/datastore-rxjava2/src/main/java/androidx/datastore/rxjava2/RxSharedPreferencesMigration.kt
index 3882a04..a24bcfa 100644
--- a/datastore/datastore-rxjava2/src/main/java/androidx/datastore/rxjava2/RxSharedPreferencesMigration.kt
+++ b/datastore/datastore-rxjava2/src/main/java/androidx/datastore/rxjava2/RxSharedPreferencesMigration.kt
@@ -46,6 +46,9 @@
* SharedPreferences to migrate from (limited to [keysToMigrate] and a T which represent
* the current data. The function must return the migrated data.
*
+ * If SharedPreferences is empty or does not contain any keys which you specified, this
+ * callback will not run.
+ *
* @param sharedPreferencesView the current state of the SharedPreferences
* @param currentData the most recently persisted data
* @return a Single of the updated data
diff --git a/datastore/datastore-rxjava3/src/main/java/androidx/datastore/rxjava3/RxSharedPreferencesMigration.kt b/datastore/datastore-rxjava3/src/main/java/androidx/datastore/rxjava3/RxSharedPreferencesMigration.kt
index 6b83eaa..0c64b55 100644
--- a/datastore/datastore-rxjava3/src/main/java/androidx/datastore/rxjava3/RxSharedPreferencesMigration.kt
+++ b/datastore/datastore-rxjava3/src/main/java/androidx/datastore/rxjava3/RxSharedPreferencesMigration.kt
@@ -46,6 +46,9 @@
* SharedPreferences to migrate from (limited to [keysToMigrate]) and a T which represent
* the current data. The function must return the migrated data.
*
+ * If SharedPreferences is empty or does not contain any keys which you specified, this
+ * callback will not run.
+ *
* @param sharedPreferencesView the current state of the SharedPreferences
* @param currentData the most recently persisted data
* @return a Single of the updated data
diff --git a/datastore/datastore/src/androidTest/java/androidx/datastore/migrations/SharedPreferencesMigrationTest.kt b/datastore/datastore/src/androidTest/java/androidx/datastore/migrations/SharedPreferencesMigrationTest.kt
index 52604f60..0387583 100644
--- a/datastore/datastore/src/androidTest/java/androidx/datastore/migrations/SharedPreferencesMigrationTest.kt
+++ b/datastore/datastore/src/androidTest/java/androidx/datastore/migrations/SharedPreferencesMigrationTest.kt
@@ -154,6 +154,21 @@
}
@Test
+ fun testSharedPrefsViewWithAllKeysSpecified_doesntThrowErrorWhenKeyDoesntExist() =
+ runBlockingTest {
+ assertThat(sharedPrefs.edit().putInt("unrelated_key", -123).commit()).isTrue()
+
+ val migration = SharedPreferencesMigration(
+ produceSharedPreferences = { sharedPrefs },
+ ) { prefs: SharedPreferencesView, _: Byte ->
+ prefs.getInt("this_key_doesnt_exist_yet", 123).toByte()
+ }
+
+ val dataStore = getDataStoreWithMigrations(listOf(migration))
+ assertThat(dataStore.data.first()).isEqualTo(123)
+ }
+
+ @Test
fun producedSharedPreferencesIsUsed() = runBlockingTest {
assertThat(sharedPrefs.edit().putInt("integer_key", 123).commit()).isTrue()
diff --git a/datastore/datastore/src/main/java/androidx/datastore/migrations/SharedPreferencesMigration.kt b/datastore/datastore/src/main/java/androidx/datastore/migrations/SharedPreferencesMigration.kt
index 76cfae9..4fc7b7c 100644
--- a/datastore/datastore/src/main/java/androidx/datastore/migrations/SharedPreferencesMigration.kt
+++ b/datastore/datastore/src/main/java/androidx/datastore/migrations/SharedPreferencesMigration.kt
@@ -66,7 +66,8 @@
* since this may be called multiple times. See [DataMigration.migrate] for more
* information. The lambda accepts a SharedPreferencesView which is the view of the
* SharedPreferences to migrate from (limited to [keysToMigrate] and a T which represent
- * the current data. The function must return the migrated data.
+ * the current data. The function must return the migrated data. If SharedPreferences is
+ * empty or does not contain any keys which you specified, this callback will not run.
*/
@JvmOverloads // Generate constructors for default params for java users.
public constructor(
@@ -111,7 +112,8 @@
* since this may be called multiple times. See [DataMigration.migrate] for more
* information. The lambda accepts a SharedPreferencesView which is the view of the
* SharedPreferences to migrate from (limited to [keysToMigrate] and a T which represent
- * the current data. The function must return the migrated data.
+ * the current data. The function must return the migrated data. If SharedPreferences is
+ * empty or does not contain any keys which you specified, this callback will not run.
*/
@JvmOverloads // Generate constructors for default params for java users.
public constructor(
@@ -131,20 +133,26 @@
private val sharedPrefs: SharedPreferences by lazy(produceSharedPreferences)
- private val keySet: MutableSet<String> by lazy {
+ /**
+ * keySet is null if the user specified [MIGRATE_ALL_KEYS].
+ */
+ private val keySet: MutableSet<String>? =
if (keysToMigrate === MIGRATE_ALL_KEYS) {
- sharedPrefs.all.keys
+ null
} else {
- keysToMigrate
- }.toMutableSet()
- }
+ keysToMigrate.toMutableSet()
+ }
override suspend fun shouldMigrate(currentData: T): Boolean {
if (!shouldRunMigration(currentData)) {
return false
}
- return keySet.any(sharedPrefs::contains)
+ return if (keySet == null) {
+ sharedPrefs.all.isNotEmpty()
+ } else {
+ keySet.any(sharedPrefs::contains)
+ }
}
override suspend fun migrate(currentData: T): T =
@@ -160,8 +168,12 @@
override suspend fun cleanUp() {
val sharedPrefsEditor = sharedPrefs.edit()
- for (key in keySet) {
- sharedPrefsEditor.remove(key)
+ if (keySet == null) {
+ sharedPrefsEditor.clear()
+ } else {
+ keySet.forEach { key ->
+ sharedPrefsEditor.remove(key)
+ }
}
if (!sharedPrefsEditor.commit()) {
@@ -172,7 +184,7 @@
deleteSharedPreferences(context, name)
}
- keySet.clear()
+ keySet?.clear()
}
private fun deleteSharedPreferences(context: Context, name: String) {
@@ -211,12 +223,11 @@
}
/**
- * Read-only wrapper around SharedPreferences. This will be passed in to your migration. The
- * constructor is public to enable easier testing of migrations.
+ * Read-only wrapper around SharedPreferences. This will be passed in to your migration.
*/
public class SharedPreferencesView internal constructor(
private val prefs: SharedPreferences,
- private val keySet: Set<String>
+ private val keySet: Set<String>?
) {
/**
* Checks whether the preferences contains a preference.
@@ -285,18 +296,22 @@
prefs.getStringSet(checkKey(key), defValues)?.toMutableSet()
/** Retrieve all values from the preferences that are in the specified keySet. */
- public fun getAll(): Map<String, Any?> = prefs.all.filter { (key, _) ->
- key in keySet
- }.mapValues { (_, value) ->
- if (value is Set<*>) {
- value.toSet()
- } else {
- value
+ public fun getAll(): Map<String, Any?> =
+ prefs.all.filter { (key, _) ->
+ keySet?.contains(key) ?: true
+ }.mapValues { (_, value) ->
+ if (value is Set<*>) {
+ value.toSet()
+ } else {
+ value
+ }
}
- }
private fun checkKey(key: String): String {
- check(key in keySet) { "Can't access key outside migration: $key" }
+ keySet?.let {
+ check(key in it) { "Can't access key outside migration: $key" }
+ }
+
return key
}
}
diff --git a/development/build_log_simplifier/gc.sh b/development/build_log_simplifier/gc.sh
index 8cfb3f8..a40d066 100755
--- a/development/build_log_simplifier/gc.sh
+++ b/development/build_log_simplifier/gc.sh
@@ -54,7 +54,7 @@
BUILD_SCRIPTS_DIR="$SUPPORT_ROOT/busytown"
# find the .sh files that enable build log validation
-target_scripts="$(cd $BUILD_SCRIPTS_DIR && find -name "*.sh" -type f | xargs grep -l "impl/build.sh" | sed 's|^./||')"
+target_scripts="$(cd $BUILD_SCRIPTS_DIR && find -maxdepth 1 -name "*.sh" -type f | grep -v androidx-studio-integration | sed 's|^./||')"
# find the target names that enable build log validation
targets="$(echo $target_scripts | sed 's/\.sh//g')"
@@ -69,7 +69,6 @@
shift
done
-
# process all of the logs
logs="$(echo */*.log)"
echo
diff --git a/development/build_log_simplifier/messages.ignore b/development/build_log_simplifier/messages.ignore
index 88d5612..9a03ae4 100644
--- a/development/build_log_simplifier/messages.ignore
+++ b/development/build_log_simplifier/messages.ignore
@@ -733,6 +733,7 @@
PROGRESS: Validity check
PROGRESS: Creating documentation models
ERROR: An attempt to write .*
+WARN: Unable to find what is referred to by
Generation completed with.*
# > Task :docs-tip-of-tree:dackkaDocs
Conflicting documentation for .*
diff --git a/docs-public/build.gradle b/docs-public/build.gradle
index 587c7cc..2383267 100644
--- a/docs-public/build.gradle
+++ b/docs-public/build.gradle
@@ -125,6 +125,7 @@
docs("androidx.hilt:hilt-common:1.0.0-beta01")
docs("androidx.hilt:hilt-navigation:1.0.0-beta01")
docs("androidx.hilt:hilt-navigation-compose:1.0.0-alpha03")
+ samples("androidx.hilt:hilt-navigation-compose-samples:1.0.0-alpha04")
docs("androidx.hilt:hilt-navigation-fragment:1.0.0-beta01")
docs("androidx.hilt:hilt-work:1.0.0-beta01")
docs("androidx.interpolator:interpolator:1.0.0")
diff --git a/docs-tip-of-tree/build.gradle b/docs-tip-of-tree/build.gradle
index 54895cd..1f398f4 100644
--- a/docs-tip-of-tree/build.gradle
+++ b/docs-tip-of-tree/build.gradle
@@ -134,6 +134,7 @@
docs(project(":hilt:hilt-common"))
docs(project(":hilt:hilt-navigation"))
docs(project(":hilt:hilt-navigation-compose"))
+ samples(project(":hilt:hilt-navigation-compose-samples"))
docs(project(":hilt:hilt-navigation-fragment"))
docs(project(":hilt:hilt-work"))
docs(project(":interpolator:interpolator"))
@@ -249,9 +250,10 @@
docs(project(":wear:wear"))
stubs(fileTree(dir: "../wear/wear_stubs/", include: ["com.google.android.wearable-stubs.jar"]))
docs(project(":wear:wear-complications-data"))
- docs(project(":wear:wear-complications-provider"))
- samples(project(":wear:wear-complications-provider-samples"))
+ docs(project(":wear:wear-complications-data-source"))
+ samples(project(":wear:wear-complications-data-source-samples"))
docs(project(":wear:compose:compose-foundation"))
+ samples(project(":wear:compose:compose-foundation-samples"))
docs(project(":wear:compose:compose-material"))
docs(project(":wear:wear-input"))
docs(project(":wear:wear-input-testing"))
diff --git a/fragment/fragment-lint/src/main/java/androidx/fragment/lint/FragmentIssueRegistry.kt b/fragment/fragment-lint/src/main/java/androidx/fragment/lint/FragmentIssueRegistry.kt
index ec305a3..bba9973 100644
--- a/fragment/fragment-lint/src/main/java/androidx/fragment/lint/FragmentIssueRegistry.kt
+++ b/fragment/fragment-lint/src/main/java/androidx/fragment/lint/FragmentIssueRegistry.kt
@@ -14,8 +14,6 @@
* limitations under the License.
*/
-@file:Suppress("UnstableApiUsage")
-
package androidx.fragment.lint
import com.android.tools.lint.client.api.IssueRegistry
diff --git a/fragment/fragment-lint/src/test/java/androidx/fragment/lint/ApiLintVersionsTest.kt b/fragment/fragment-lint/src/test/java/androidx/fragment/lint/ApiLintVersionsTest.kt
index 5ffc19a..c5e38ba 100644
--- a/fragment/fragment-lint/src/test/java/androidx/fragment/lint/ApiLintVersionsTest.kt
+++ b/fragment/fragment-lint/src/test/java/androidx/fragment/lint/ApiLintVersionsTest.kt
@@ -35,6 +35,6 @@
assertThat(registry.api).isEqualTo(CURRENT_API)
// Intentionally fails in IDE, because we use different API version in
// studio and command line
- assertThat(registry.minApi).isEqualTo(10)
+ assertThat(registry.minApi).isEqualTo(8)
}
}
diff --git a/fragment/fragment-testing-lint/build.gradle b/fragment/fragment-testing-lint/build.gradle
index 1e70d24..e4170a6 100644
--- a/fragment/fragment-testing-lint/build.gradle
+++ b/fragment/fragment-testing-lint/build.gradle
@@ -23,7 +23,7 @@
}
dependencies {
- compileOnly(libs.androidLintApi)
+ compileOnly(libs.androidLintMinApi)
compileOnly(libs.kotlinStdlib)
testImplementation(libs.kotlinStdlib)
diff --git a/fragment/fragment-testing-lint/src/main/java/androidx/fragment/testing/lint/FragmentTestingIssueRegistry.kt b/fragment/fragment-testing-lint/src/main/java/androidx/fragment/testing/lint/FragmentTestingIssueRegistry.kt
index 60fb3c1..8303419 100644
--- a/fragment/fragment-testing-lint/src/main/java/androidx/fragment/testing/lint/FragmentTestingIssueRegistry.kt
+++ b/fragment/fragment-testing-lint/src/main/java/androidx/fragment/testing/lint/FragmentTestingIssueRegistry.kt
@@ -14,20 +14,13 @@
* limitations under the License.
*/
-@file:Suppress("UnstableApiUsage")
-
package androidx.fragment.testing.lint
import com.android.tools.lint.client.api.IssueRegistry
-import com.android.tools.lint.client.api.Vendor
import com.android.tools.lint.detector.api.CURRENT_API
class FragmentTestingIssueRegistry : IssueRegistry() {
override val api = 10
override val minApi = CURRENT_API
override val issues get() = listOf(GradleConfigurationDetector.ISSUE)
- override val vendor = Vendor(
- vendorName = "Android Open Source Project",
- feedbackUrl = "https://issuetracker.google.com/issues/new?component=460964"
- )
}
diff --git a/fragment/fragment-testing-lint/src/test/java/androidx/fragment/testing/lint/ApiLintVersionsTest.kt b/fragment/fragment-testing-lint/src/test/java/androidx/fragment/testing/lint/ApiLintVersionsTest.kt
index 82745c6..3b659c7 100644
--- a/fragment/fragment-testing-lint/src/test/java/androidx/fragment/testing/lint/ApiLintVersionsTest.kt
+++ b/fragment/fragment-testing-lint/src/test/java/androidx/fragment/testing/lint/ApiLintVersionsTest.kt
@@ -36,6 +36,6 @@
assertThat(registry.api).isEqualTo(CURRENT_API)
// Intentionally fails in IDE, because we use different API version in
// studio and command line
- assertThat(registry.minApi).isEqualTo(10)
+ assertThat(registry.minApi).isEqualTo(8)
}
}
diff --git a/fragment/fragment/src/main/java/androidx/fragment/app/FragmentActivity.java b/fragment/fragment/src/main/java/androidx/fragment/app/FragmentActivity.java
index 5ce276d..45093a9 100644
--- a/fragment/fragment/src/main/java/androidx/fragment/app/FragmentActivity.java
+++ b/fragment/fragment/src/main/java/androidx/fragment/app/FragmentActivity.java
@@ -234,8 +234,8 @@
*/
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
- super.onConfigurationChanged(newConfig);
mFragments.noteStateNotSaved();
+ super.onConfigurationChanged(newConfig);
mFragments.dispatchConfigurationChanged(newConfig);
}
@@ -382,8 +382,8 @@
@Override
@CallSuper
protected void onNewIntent(@SuppressLint("UnknownNullness") Intent intent) {
- super.onNewIntent(intent);
mFragments.noteStateNotSaved();
+ super.onNewIntent(intent);
}
/**
@@ -406,9 +406,9 @@
*/
@Override
protected void onResume() {
+ mFragments.noteStateNotSaved();
super.onResume();
mResumed = true;
- mFragments.noteStateNotSaved();
mFragments.execPendingActions();
}
@@ -468,6 +468,7 @@
*/
@Override
protected void onStart() {
+ mFragments.noteStateNotSaved();
super.onStart();
mStopped = false;
@@ -477,7 +478,6 @@
mFragments.dispatchActivityCreated();
}
- mFragments.noteStateNotSaved();
mFragments.execPendingActions();
// NOTE: HC onStart goes here.
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 2e3da26..caf3af8 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -38,7 +38,6 @@
wire = "3.6.0"
[libraries]
-androidBuilderModel = { module = "com.android.tools.build:builder-model", version.ref = "androidGradlePlugin" }
androidGradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "androidGradlePlugin" }
androidLint = { module = "com.android.tools.lint:lint", version.ref = "androidLint" }
androidLintMin = { module = "com.android.tools.lint:lint", version.ref = "androidLintMin" }
diff --git a/hilt/hilt-navigation-compose/samples/build.gradle b/hilt/hilt-navigation-compose/samples/build.gradle
index 0a3b526..a048a87 100644
--- a/hilt/hilt-navigation-compose/samples/build.gradle
+++ b/hilt/hilt-navigation-compose/samples/build.gradle
@@ -17,6 +17,7 @@
import androidx.build.LibraryGroups
import androidx.build.LibraryVersions
import androidx.build.LibraryType
+import androidx.build.Publish
plugins {
id("AndroidXPlugin")
@@ -35,6 +36,7 @@
androidx {
name = "Navigation Compose Hilt Extension Samples"
+ publish = Publish.SNAPSHOT_AND_RELEASE
type = LibraryType.SAMPLES
mavenGroup = LibraryGroups.HILT
mavenVersion = LibraryVersions.HILT_NAVIGATION_COMPOSE
diff --git a/lifecycle/lifecycle-livedata-core-ktx-lint/build.gradle b/lifecycle/lifecycle-livedata-core-ktx-lint/build.gradle
index 32a876f..12b0831 100644
--- a/lifecycle/lifecycle-livedata-core-ktx-lint/build.gradle
+++ b/lifecycle/lifecycle-livedata-core-ktx-lint/build.gradle
@@ -24,7 +24,7 @@
}
dependencies {
- compileOnly(libs.androidLintApi)
+ compileOnly(libs.androidLintMinApi)
compileOnly(libs.androidLintMin)
compileOnly(libs.kotlinStdlib)
diff --git a/lifecycle/lifecycle-livedata-core-ktx-lint/src/main/java/androidx/lifecycle/lint/LiveDataCoreIssueRegistry.kt b/lifecycle/lifecycle-livedata-core-ktx-lint/src/main/java/androidx/lifecycle/lint/LiveDataCoreIssueRegistry.kt
index efa7fda..23ad130 100644
--- a/lifecycle/lifecycle-livedata-core-ktx-lint/src/main/java/androidx/lifecycle/lint/LiveDataCoreIssueRegistry.kt
+++ b/lifecycle/lifecycle-livedata-core-ktx-lint/src/main/java/androidx/lifecycle/lint/LiveDataCoreIssueRegistry.kt
@@ -14,20 +14,13 @@
* limitations under the License.
*/
-@file:Suppress("UnstableApiUsage")
-
package androidx.lifecycle.lint
import com.android.tools.lint.client.api.IssueRegistry
-import com.android.tools.lint.client.api.Vendor
import com.android.tools.lint.detector.api.CURRENT_API
class LiveDataCoreIssueRegistry : IssueRegistry() {
override val minApi = CURRENT_API
override val api = 10
override val issues get() = listOf(NonNullableMutableLiveDataDetector.ISSUE)
- override val vendor = Vendor(
- vendorName = "Android Open Source Project",
- feedbackUrl = "https://issuetracker.google.com/issues/new?component=413132"
- )
}
diff --git a/lifecycle/lifecycle-runtime-ktx-lint/src/main/java/androidx/lifecycle/lint/LifecycleRuntimeIssueRegistry.kt b/lifecycle/lifecycle-runtime-ktx-lint/src/main/java/androidx/lifecycle/lint/LifecycleRuntimeIssueRegistry.kt
index d153816..d107f47 100644
--- a/lifecycle/lifecycle-runtime-ktx-lint/src/main/java/androidx/lifecycle/lint/LifecycleRuntimeIssueRegistry.kt
+++ b/lifecycle/lifecycle-runtime-ktx-lint/src/main/java/androidx/lifecycle/lint/LifecycleRuntimeIssueRegistry.kt
@@ -14,8 +14,6 @@
* limitations under the License.
*/
-@file:Suppress("UnstableApiUsage")
-
package androidx.lifecycle.lint
import com.android.tools.lint.client.api.IssueRegistry
diff --git a/lint-checks/src/main/java/androidx/build/lint/AndroidXIssueRegistry.kt b/lint-checks/src/main/java/androidx/build/lint/AndroidXIssueRegistry.kt
index 19ced00..6f93b40 100644
--- a/lint-checks/src/main/java/androidx/build/lint/AndroidXIssueRegistry.kt
+++ b/lint-checks/src/main/java/androidx/build/lint/AndroidXIssueRegistry.kt
@@ -19,7 +19,6 @@
package androidx.build.lint
import com.android.tools.lint.client.api.IssueRegistry
-import com.android.tools.lint.client.api.Vendor
import com.android.tools.lint.detector.api.CURRENT_API
import com.android.tools.lint.detector.api.Issue
@@ -29,10 +28,6 @@
override val issues get(): List<Issue> {
return Issues
}
- override val vendor = Vendor(
- vendorName = "Android Open Source Project",
- feedbackUrl = "https://issuetracker.google.com/issues/new?component=192731"
- )
companion object {
val Issues get(): List<Issue> {
diff --git a/paging/paging-compose/api/current.txt b/paging/paging-compose/api/current.txt
index 5859668..99d803e 100644
--- a/paging/paging-compose/api/current.txt
+++ b/paging/paging-compose/api/current.txt
@@ -16,8 +16,8 @@
public final class LazyPagingItemsKt {
method @androidx.compose.runtime.Composable public static <T> androidx.paging.compose.LazyPagingItems<T> collectAsLazyPagingItems(kotlinx.coroutines.flow.Flow<androidx.paging.PagingData<T>>);
- method public static <T> void items(androidx.compose.foundation.lazy.LazyListScope, androidx.paging.compose.LazyPagingItems<T> lazyPagingItems, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.lazy.LazyItemScope,? super T,kotlin.Unit> itemContent);
- method public static <T> void itemsIndexed(androidx.compose.foundation.lazy.LazyListScope, androidx.paging.compose.LazyPagingItems<T> lazyPagingItems, kotlin.jvm.functions.Function3<? super androidx.compose.foundation.lazy.LazyItemScope,? super java.lang.Integer,? super T,kotlin.Unit> itemContent);
+ method public static <T> void items(androidx.compose.foundation.lazy.LazyListScope, androidx.paging.compose.LazyPagingItems<T> items, optional kotlin.jvm.functions.Function1<? super T,?>? key, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.lazy.LazyItemScope,? super T,kotlin.Unit> itemContent);
+ method public static <T> void itemsIndexed(androidx.compose.foundation.lazy.LazyListScope, androidx.paging.compose.LazyPagingItems<T> items, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?>? key, kotlin.jvm.functions.Function3<? super androidx.compose.foundation.lazy.LazyItemScope,? super java.lang.Integer,? super T,kotlin.Unit> itemContent);
}
}
diff --git a/paging/paging-compose/api/public_plus_experimental_current.txt b/paging/paging-compose/api/public_plus_experimental_current.txt
index 5859668..99d803e 100644
--- a/paging/paging-compose/api/public_plus_experimental_current.txt
+++ b/paging/paging-compose/api/public_plus_experimental_current.txt
@@ -16,8 +16,8 @@
public final class LazyPagingItemsKt {
method @androidx.compose.runtime.Composable public static <T> androidx.paging.compose.LazyPagingItems<T> collectAsLazyPagingItems(kotlinx.coroutines.flow.Flow<androidx.paging.PagingData<T>>);
- method public static <T> void items(androidx.compose.foundation.lazy.LazyListScope, androidx.paging.compose.LazyPagingItems<T> lazyPagingItems, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.lazy.LazyItemScope,? super T,kotlin.Unit> itemContent);
- method public static <T> void itemsIndexed(androidx.compose.foundation.lazy.LazyListScope, androidx.paging.compose.LazyPagingItems<T> lazyPagingItems, kotlin.jvm.functions.Function3<? super androidx.compose.foundation.lazy.LazyItemScope,? super java.lang.Integer,? super T,kotlin.Unit> itemContent);
+ method public static <T> void items(androidx.compose.foundation.lazy.LazyListScope, androidx.paging.compose.LazyPagingItems<T> items, optional kotlin.jvm.functions.Function1<? super T,?>? key, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.lazy.LazyItemScope,? super T,kotlin.Unit> itemContent);
+ method public static <T> void itemsIndexed(androidx.compose.foundation.lazy.LazyListScope, androidx.paging.compose.LazyPagingItems<T> items, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?>? key, kotlin.jvm.functions.Function3<? super androidx.compose.foundation.lazy.LazyItemScope,? super java.lang.Integer,? super T,kotlin.Unit> itemContent);
}
}
diff --git a/paging/paging-compose/api/restricted_current.txt b/paging/paging-compose/api/restricted_current.txt
index 5859668..99d803e 100644
--- a/paging/paging-compose/api/restricted_current.txt
+++ b/paging/paging-compose/api/restricted_current.txt
@@ -16,8 +16,8 @@
public final class LazyPagingItemsKt {
method @androidx.compose.runtime.Composable public static <T> androidx.paging.compose.LazyPagingItems<T> collectAsLazyPagingItems(kotlinx.coroutines.flow.Flow<androidx.paging.PagingData<T>>);
- method public static <T> void items(androidx.compose.foundation.lazy.LazyListScope, androidx.paging.compose.LazyPagingItems<T> lazyPagingItems, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.lazy.LazyItemScope,? super T,kotlin.Unit> itemContent);
- method public static <T> void itemsIndexed(androidx.compose.foundation.lazy.LazyListScope, androidx.paging.compose.LazyPagingItems<T> lazyPagingItems, kotlin.jvm.functions.Function3<? super androidx.compose.foundation.lazy.LazyItemScope,? super java.lang.Integer,? super T,kotlin.Unit> itemContent);
+ method public static <T> void items(androidx.compose.foundation.lazy.LazyListScope, androidx.paging.compose.LazyPagingItems<T> items, optional kotlin.jvm.functions.Function1<? super T,?>? key, kotlin.jvm.functions.Function2<? super androidx.compose.foundation.lazy.LazyItemScope,? super T,kotlin.Unit> itemContent);
+ method public static <T> void itemsIndexed(androidx.compose.foundation.lazy.LazyListScope, androidx.paging.compose.LazyPagingItems<T> items, optional kotlin.jvm.functions.Function2<? super java.lang.Integer,? super T,?>? key, kotlin.jvm.functions.Function3<? super androidx.compose.foundation.lazy.LazyItemScope,? super java.lang.Integer,? super T,kotlin.Unit> itemContent);
}
}
diff --git a/paging/paging-compose/integration-tests/paging-demos/src/main/java/androidx/paging/compose/demos/room/PagingRoomSample.kt b/paging/paging-compose/integration-tests/paging-demos/src/main/java/androidx/paging/compose/demos/room/PagingRoomSample.kt
index 780a5ac..27ebfb1 100644
--- a/paging/paging-compose/integration-tests/paging-demos/src/main/java/androidx/paging/compose/demos/room/PagingRoomSample.kt
+++ b/paging/paging-compose/integration-tests/paging-demos/src/main/java/androidx/paging/compose/demos/room/PagingRoomSample.kt
@@ -16,13 +16,19 @@
package androidx.paging.compose.demos.room
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.sp
import androidx.paging.Pager
@@ -92,9 +98,10 @@
scope.launch(Dispatchers.IO) {
val randomUser = dao.getRandomUser()
if (randomUser != null) {
+ val newName = Names[Random.nextInt(Names.size)]
val updatedUser = User(
randomUser.id,
- randomUser.name + " updated"
+ newName
)
dao.update(updatedUser)
}
@@ -106,8 +113,16 @@
val lazyPagingItems = pager.flow.collectAsLazyPagingItems()
LazyColumn {
- itemsIndexed(lazyPagingItems) { index, user ->
- Text("$index " + user?.name, fontSize = 50.sp)
+ itemsIndexed(
+ items = lazyPagingItems,
+ key = { _, user -> user.id }
+ ) { index, user ->
+ var counter by rememberSaveable { mutableStateOf(0) }
+ Text(
+ text = "counter=$counter index=$index ${user?.name} ${user?.id}",
+ fontSize = 50.sp,
+ modifier = Modifier.clickable { counter++ }
+ )
}
}
}
diff --git a/paging/paging-compose/src/androidTest/java/androidx/paging/compose/LazyPagingItemsTest.kt b/paging/paging-compose/src/androidTest/java/androidx/paging/compose/LazyPagingItemsTest.kt
index 17d2e46..18af614 100644
--- a/paging/paging-compose/src/androidTest/java/androidx/paging/compose/LazyPagingItemsTest.kt
+++ b/paging/paging-compose/src/androidTest/java/androidx/paging/compose/LazyPagingItemsTest.kt
@@ -21,13 +21,16 @@
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTopPositionInRootIsEqualTo
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.unit.dp
import androidx.paging.Pager
import androidx.paging.PagingConfig
@@ -497,4 +500,69 @@
rule.onNodeWithTag("1")
.assertDoesNotExist()
}
+
+ @Test
+ fun stateIsMovedWithItemWithCustomKey_items() {
+ val items = mutableListOf(1)
+ val pager = createPager {
+ TestPagingSource(items = items, loadDelay = 0)
+ }
+
+ lateinit var lazyPagingItems: LazyPagingItems<Int>
+ var counter = 0
+ rule.setContent {
+ lazyPagingItems = pager.flow.collectAsLazyPagingItems()
+ LazyColumn {
+ items(lazyPagingItems, key = { it }) {
+ BasicText(
+ "Item=$it. counter=${remember { counter++ }}"
+ )
+ }
+ }
+ }
+
+ rule.runOnIdle {
+ items.clear()
+ items.addAll(listOf(0, 1))
+ lazyPagingItems.refresh()
+ }
+
+ rule.onNodeWithText("Item=0. counter=1")
+ .assertExists()
+
+ rule.onNodeWithText("Item=1. counter=0")
+ .assertExists()
+ }
+
+ @Test
+ fun stateIsMovedWithItemWithCustomKey_itemsIndexed() {
+ val items = mutableListOf(1)
+ val pager = createPager {
+ TestPagingSource(items = items, loadDelay = 0)
+ }
+
+ lateinit var lazyPagingItems: LazyPagingItems<Int>
+ rule.setContent {
+ lazyPagingItems = pager.flow.collectAsLazyPagingItems()
+ LazyColumn {
+ itemsIndexed(lazyPagingItems, key = { _, item -> item }) { index, item ->
+ BasicText(
+ "Item=$item. index=$index. remembered index=${remember { index }}"
+ )
+ }
+ }
+ }
+
+ rule.runOnIdle {
+ items.clear()
+ items.addAll(listOf(0, 1))
+ lazyPagingItems.refresh()
+ }
+
+ rule.onNodeWithText("Item=0. index=0. remembered index=0")
+ .assertExists()
+
+ rule.onNodeWithText("Item=1. index=1. remembered index=0")
+ .assertExists()
+ }
}
diff --git a/paging/paging-compose/src/main/java/androidx/paging/compose/LazyPagingItems.kt b/paging/paging-compose/src/main/java/androidx/paging/compose/LazyPagingItems.kt
index b53790f..ae13eab 100644
--- a/paging/paging-compose/src/main/java/androidx/paging/compose/LazyPagingItems.kt
+++ b/paging/paging-compose/src/main/java/androidx/paging/compose/LazyPagingItems.kt
@@ -17,6 +17,8 @@
package androidx.paging.compose
import android.annotation.SuppressLint
+import android.os.Parcel
+import android.os.Parcelable
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.Composable
@@ -253,17 +255,34 @@
*
* @sample androidx.paging.compose.samples.ItemsDemo
*
- * @param lazyPagingItems the items received from a [Flow] of [PagingData].
+ * @param items the items received from a [Flow] of [PagingData].
+ * @param key a factory of stable and unique keys representing the item. Using the same key
+ * for multiple items in the list is not allowed. Type of the key should be saveable
+ * via Bundle on Android. If null is passed the position in the list will represent the key.
+ * When you specify the key the scroll position will be maintained based on the key, which
+ * means if you add/remove items before the current visible item the item with the given key
+ * will be kept as the first visible one.
* @param itemContent the content displayed by a single item. In case the item is `null`, the
* [itemContent] method should handle the logic of displaying a placeholder instead of the main
* content displayed by an item which is not `null`.
*/
public fun <T : Any> LazyListScope.items(
- lazyPagingItems: LazyPagingItems<T>,
+ items: LazyPagingItems<T>,
+ key: ((item: T) -> Any)? = null,
itemContent: @Composable LazyItemScope.(value: T?) -> Unit
) {
- items(lazyPagingItems.itemCount) { index ->
- itemContent(lazyPagingItems[index])
+ items(
+ count = items.itemCount,
+ key = if (key == null) null else { index ->
+ val item = items.peek(index)
+ if (item == null) {
+ PagingPlaceholderKey(index)
+ } else {
+ key(item)
+ }
+ }
+ ) { index ->
+ itemContent(items[index])
}
}
@@ -275,16 +294,56 @@
*
* @sample androidx.paging.compose.samples.ItemsIndexedDemo
*
- * @param lazyPagingItems the items received from a [Flow] of [PagingData].
+ * @param items the items received from a [Flow] of [PagingData].
+ * @param key a factory of stable and unique keys representing the item. Using the same key
+ * for multiple items in the list is not allowed. Type of the key should be saveable
+ * via Bundle on Android. If null is passed the position in the list will represent the key.
+ * When you specify the key the scroll position will be maintained based on the key, which
+ * means if you add/remove items before the current visible item the item with the given key
+ * will be kept as the first visible one.
* @param itemContent the content displayed by a single item. In case the item is `null`, the
* [itemContent] method should handle the logic of displaying a placeholder instead of the main
* content displayed by an item which is not `null`.
*/
public fun <T : Any> LazyListScope.itemsIndexed(
- lazyPagingItems: LazyPagingItems<T>,
+ items: LazyPagingItems<T>,
+ key: ((index: Int, item: T) -> Any)? = null,
itemContent: @Composable LazyItemScope.(index: Int, value: T?) -> Unit
) {
- items(lazyPagingItems.itemCount) { index ->
- itemContent(index, lazyPagingItems[index])
+ items(
+ count = items.itemCount,
+ key = if (key == null) null else { index ->
+ val item = items.peek(index)
+ if (item == null) {
+ PagingPlaceholderKey(index)
+ } else {
+ key(index, item)
+ }
+ }
+ ) { index ->
+ itemContent(index, items[index])
+ }
+}
+
+@SuppressLint("BanParcelableUsage")
+private data class PagingPlaceholderKey(private val index: Int) : Parcelable {
+ override fun writeToParcel(parcel: Parcel, flags: Int) {
+ parcel.writeInt(index)
+ }
+
+ override fun describeContents(): Int {
+ return 0
+ }
+
+ companion object {
+ @Suppress("unused")
+ @JvmField
+ val CREATOR: Parcelable.Creator<PagingPlaceholderKey> =
+ object : Parcelable.Creator<PagingPlaceholderKey> {
+ override fun createFromParcel(parcel: Parcel) =
+ PagingPlaceholderKey(parcel.readInt())
+
+ override fun newArray(size: Int) = arrayOfNulls<PagingPlaceholderKey?>(size)
+ }
}
}
diff --git a/profileinstaller/profileinstaller/src/main/java/androidx/profileinstaller/Encoding.java b/profileinstaller/profileinstaller/src/main/java/androidx/profileinstaller/Encoding.java
index 84ff365..c2d5a35 100644
--- a/profileinstaller/profileinstaller/src/main/java/androidx/profileinstaller/Encoding.java
+++ b/profileinstaller/profileinstaller/src/main/java/androidx/profileinstaller/Encoding.java
@@ -118,41 +118,49 @@
) throws IOException {
// Read the expected compressed data size.
Inflater inf = new Inflater();
- byte[] result = new byte[uncompressedDataSize];
- int totalBytesRead = 0;
- int totalBytesInflated = 0;
- byte[] input = new byte[2048]; // 2KB read window size;
- while (!inf.finished() && !inf.needsDictionary() && totalBytesRead < compressedDataSize) {
- int bytesRead = is.read(input);
- if (bytesRead < 0) {
+ try {
+ byte[] result = new byte[uncompressedDataSize];
+ int totalBytesRead = 0;
+ int totalBytesInflated = 0;
+ byte[] input = new byte[2048]; // 2KB read window size;
+ while (
+ !inf.finished() &&
+ !inf.needsDictionary() &&
+ totalBytesRead < compressedDataSize
+ ) {
+ int bytesRead = is.read(input);
+ if (bytesRead < 0) {
+ throw error(
+ "Invalid zip data. Stream ended after $totalBytesRead bytes. " +
+ "Expected " + compressedDataSize + " bytes"
+ );
+ }
+ inf.setInput(input, 0, bytesRead);
+ try {
+ totalBytesInflated += inf.inflate(
+ result,
+ totalBytesInflated,
+ uncompressedDataSize - totalBytesInflated
+ );
+ } catch (DataFormatException e) {
+ throw error(e.getMessage());
+ }
+ totalBytesRead += bytesRead;
+ }
+ if (totalBytesRead != compressedDataSize) {
throw error(
- "Invalid zip data. Stream ended after $totalBytesRead bytes. " +
- "Expected " + compressedDataSize + " bytes"
+ "Didn't read enough bytes during decompression." +
+ " expected=" + compressedDataSize +
+ " actual=" + totalBytesRead
);
}
- inf.setInput(input, 0, bytesRead);
- try {
- totalBytesInflated += inf.inflate(
- result,
- totalBytesInflated,
- uncompressedDataSize - totalBytesInflated
- );
- } catch (DataFormatException e) {
- throw error(e.getMessage());
+ if (!inf.finished()) {
+ throw error("Inflater did not finish");
}
- totalBytesRead += bytesRead;
+ return result;
+ } finally {
+ inf.end();
}
- if (totalBytesRead != compressedDataSize) {
- throw error(
- "Didn't read enough bytes during decompression." +
- " expected=" + compressedDataSize +
- " actual=" + totalBytesRead
- );
- }
- if (!inf.finished()) {
- throw error("Inflater did not finish");
- }
- return result;
}
static void writeAll(@NonNull InputStream is, @NonNull OutputStream os) throws IOException {
diff --git a/recyclerview/recyclerview-lint/build.gradle b/recyclerview/recyclerview-lint/build.gradle
index 4835b0b..a5d4b72 100644
--- a/recyclerview/recyclerview-lint/build.gradle
+++ b/recyclerview/recyclerview-lint/build.gradle
@@ -23,7 +23,7 @@
}
dependencies {
- compileOnly(libs.androidLintApi)
+ compileOnly(libs.androidLintMinApi)
compileOnly(libs.kotlinStdlib)
testImplementation(libs.kotlinStdlib)
diff --git a/recyclerview/recyclerview-lint/src/main/java/androidx/recyclerview/lint/RecyclerViewIssueRegistry.kt b/recyclerview/recyclerview-lint/src/main/java/androidx/recyclerview/lint/RecyclerViewIssueRegistry.kt
index 93d382c..5290049 100644
--- a/recyclerview/recyclerview-lint/src/main/java/androidx/recyclerview/lint/RecyclerViewIssueRegistry.kt
+++ b/recyclerview/recyclerview-lint/src/main/java/androidx/recyclerview/lint/RecyclerViewIssueRegistry.kt
@@ -19,7 +19,6 @@
package androidx.recyclerview.lint
import com.android.tools.lint.client.api.IssueRegistry
-import com.android.tools.lint.client.api.Vendor
import com.android.tools.lint.detector.api.CURRENT_API
import com.android.tools.lint.detector.api.Issue
@@ -30,8 +29,4 @@
get() = listOf(
InvalidSetHasFixedSizeDetector.ISSUE
)
- override val vendor = Vendor(
- vendorName = "Android Open Source Project",
- feedbackUrl = "https://issuetracker.google.com/issues/new?component=460887"
- )
}
diff --git a/recyclerview/recyclerview-lint/src/test/java/androidx/recyclerview/lint/ApiLintVersionsTest.kt b/recyclerview/recyclerview-lint/src/test/java/androidx/recyclerview/lint/ApiLintVersionsTest.kt
index cae6708..3478bd2 100644
--- a/recyclerview/recyclerview-lint/src/test/java/androidx/recyclerview/lint/ApiLintVersionsTest.kt
+++ b/recyclerview/recyclerview-lint/src/test/java/androidx/recyclerview/lint/ApiLintVersionsTest.kt
@@ -36,6 +36,6 @@
// We hardcode version registry.api to the version that is used to run tests.
assertEquals("registry.api matches version used to run tests", CURRENT_API, registry.api)
// Intentionally fails in IDE, because we use different API version in Studio and CLI.
- assertEquals("registry.minApi is set to minimum level of 10", 10, registry.minApi)
+ assertEquals("registry.minApi is set to minimum level of 8", 8, registry.minApi)
}
}
diff --git a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/dao/MusicDao.java b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/dao/MusicDao.java
index 488c1f8..49d8e86 100644
--- a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/dao/MusicDao.java
+++ b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/dao/MusicDao.java
@@ -35,8 +35,13 @@
import androidx.room.integration.testapp.vo.Song;
import androidx.sqlite.db.SupportSQLiteQuery;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSetMultimap;
+
import java.util.List;
import java.util.Map;
+import java.util.Set;
import io.reactivex.Flowable;
@@ -74,24 +79,104 @@
@Query("SELECT * FROM Playlist")
List<MultiSongPlaylistWithSongs> getAllMultiSongPlaylistWithSongs();
+ /* Map of Object to Object */
@Query("SELECT * FROM Artist JOIN Song ON Artist.mArtistName = Song.mArtist")
- Map<Artist, List<Song>> getAllArtistAndTheirSongs();
+ Map<Artist, Song> getArtistAndFirstSongMap();
+
+ @Query("SELECT * FROM Song JOIN Artist ON Song.mArtist = Artist.mArtistName")
+ Map<Song, Artist> getSongAndArtist();
@Transaction
@Query("SELECT * FROM Artist JOIN Album ON Artist.mArtistName = Album.mAlbumArtist")
- Map<Artist, List<AlbumWithSongs>> getAllArtistAndTheirAlbumsWithSongs();
+ Map<Artist, AlbumWithSongs> getAllArtistAndTheirAlbumsWithSongs();
@RawQuery
- Map<Artist, List<Song>> getAllArtistAndTheirSongsRawQuery(SupportSQLiteQuery query);
+ Map<Artist, Song> getAllArtistAndTheirSongsRawQuery(SupportSQLiteQuery query);
@Query("SELECT * FROM Artist JOIN Song ON Artist.mArtistName = Song.mArtist")
- LiveData<Map<Artist, List<Song>>> getAllArtistAndTheirSongsAsLiveData();
+ LiveData<Map<Artist, Song>> getAllArtistAndTheirSongsAsLiveData();
+
+ /* Map of Object to List */
+ @Query("SELECT * FROM Artist JOIN Song ON Artist.mArtistName = Song.mArtist")
+ Map<Artist, List<Song>> getAllArtistAndTheirSongsList();
+
+ @Transaction
+ @Query("SELECT * FROM Artist JOIN Album ON Artist.mArtistName = Album.mAlbumArtist")
+ Map<Artist, List<AlbumWithSongs>> getAllArtistAndTheirAlbumsWithSongsList();
+
+ @RawQuery
+ Map<Artist, List<Song>> getAllArtistAndTheirSongsRawQueryList(SupportSQLiteQuery query);
@Query("SELECT * FROM Artist JOIN Song ON Artist.mArtistName = Song.mArtist")
- Flowable<Map<Artist, List<Song>>> getAllArtistAndTheirSongsAsFlowable();
+ LiveData<Map<Artist, List<Song>>> getAllArtistAndTheirSongsAsLiveDataList();
+
+ @Query("SELECT * FROM Artist JOIN Song ON Artist.mArtistName = Song.mArtist")
+ Flowable<Map<Artist, List<Song>>> getAllArtistAndTheirSongsAsFlowableList();
@Query("SELECT Album.mAlbumReleaseYear as mReleaseYear, Album.mAlbumName, Album.mAlbumArtist "
- + "as mBandName from Album JOIN Song ON Album.mAlbumArtist = Song.mArtist AND "
- + "Album.mAlbumName = Song.mAlbum")
- Map<ReleasedAlbum, List<AlbumNameAndBandName>> getReleaseYearToAlbumsAndBands();
+ + "as mBandName"
+ + " from Album "
+ + "JOIN Song "
+ + "ON Album.mAlbumArtist = Song.mArtist AND Album.mAlbumName = Song.mAlbum")
+ Map<ReleasedAlbum, List<AlbumNameAndBandName>> getReleaseYearToAlbumsAndBandsList();
+
+ /* Map of Object to Set */
+ @Query("SELECT * FROM Artist JOIN Song ON Artist.mArtistName = Song.mArtist")
+ Map<Artist, Set<Song>> getAllArtistAndTheirSongsSet();
+
+ @RawQuery
+ Map<Artist, Set<Song>> getAllArtistAndTheirSongsRawQuerySet(SupportSQLiteQuery query);
+
+ @Query("SELECT * FROM Artist JOIN Song ON Artist.mArtistName = Song.mArtist")
+ LiveData<Map<Artist, Set<Song>>> getAllArtistAndTheirSongsAsLiveDataSet();
+
+ @Query("SELECT * FROM Artist JOIN Song ON Artist.mArtistName = Song.mArtist")
+ Flowable<Map<Artist, Set<Song>>> getAllArtistAndTheirSongsAsFlowableSet();
+
+ /* Guava ImmutableMap */
+ @Query("SELECT * FROM Artist JOIN Song ON Artist.mArtistName = Song.mArtist")
+ ImmutableMap<Artist, List<Song>> getAllArtistAndTheirSongsImmutableMap();
+
+ @RawQuery
+ ImmutableMap<Artist, List<Song>> getAllArtistAndTheirSongsRawQueryImmutableMap(
+ SupportSQLiteQuery query
+ );
+
+ @Query("SELECT * FROM Artist JOIN Song ON Artist.mArtistName = Song.mArtist")
+ LiveData<ImmutableMap<Artist, Set<Song>>> getAllArtistAndTheirSongsAsLiveDataImmutableMap();
+
+ /* Guava Multimap */
+ @Query("SELECT * FROM Artist JOIN Song ON Artist.mArtistName = Song.mArtist")
+ ImmutableSetMultimap<Artist, Song> getAllArtistAndTheirSongsGuavaImmutableSetMultimap();
+
+ @Query("SELECT * FROM Artist JOIN Song ON Artist.mArtistName = Song.mArtist")
+ ImmutableListMultimap<Artist, Song> getAllArtistAndTheirSongsGuavaImmutableListMultimap();
+
+ @Transaction
+ @Query("SELECT * FROM Artist JOIN Album ON Artist.mArtistName = Album.mAlbumArtist")
+ ImmutableSetMultimap<Artist, AlbumWithSongs>
+ getAllArtistAndTheirAlbumsWithSongsGuavaImmutableSetMultimap();
+
+ @Transaction
+ @Query("SELECT * FROM Artist JOIN Album ON Artist.mArtistName = Album.mAlbumArtist")
+ ImmutableListMultimap<Artist, AlbumWithSongs>
+ getAllArtistAndTheirAlbumsWithSongsGuavaImmutableListMultimap();
+
+ @RawQuery
+ ImmutableSetMultimap<Artist, Song> getAllArtistAndTheirSongsRawQueryGuavaImmutableSetMultimap(
+ SupportSQLiteQuery query
+ );
+
+ @RawQuery
+ ImmutableListMultimap<Artist, Song> getAllArtistAndTheirSongsRawQueryGuavaImmutableListMultimap(
+ SupportSQLiteQuery query
+ );
+
+ @Query("SELECT * FROM Artist JOIN Song ON Artist.mArtistName = Song.mArtist")
+ LiveData<ImmutableSetMultimap<Artist, Song>>
+ getAllArtistAndTheirSongsAsLiveDataGuavaImmutableSetMultimap();
+
+ @Query("SELECT * FROM Artist JOIN Song ON Artist.mArtistName = Song.mArtist")
+ LiveData<ImmutableListMultimap<Artist, Song>>
+ getAllArtistAndTheirSongsAsLiveDataGuavaImmutableListMultimap();
}
diff --git a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/MultimapQueryTest.java b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/MultimapQueryTest.java
index b96e7b4..e6606a0 100644
--- a/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/MultimapQueryTest.java
+++ b/room/integration-tests/testapp/src/androidTest/java/androidx/room/integration/testapp/test/MultimapQueryTest.java
@@ -41,6 +41,13 @@
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.filters.MediumTest;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+
import org.hamcrest.MatcherAssert;
import org.junit.Before;
import org.junit.Rule;
@@ -66,28 +73,28 @@
// TODO: (b/191265082) Handle duplicate column names in JOINs
private MusicDao mMusicDao;
- private final Song mSong1 = new Song(
+ private final Song mRhcpSong1 = new Song(
1,
"Dani California",
"Red Hot Chili Peppers",
"Stadium Arcadium",
442,
2006);
- private final Song mSong2 = new Song(
+ private final Song mRhcpSong2 = new Song(
2,
"Snow (Hey Oh)",
"Red Hot Chili Peppers",
"Stadium Arcadium",
514,
2006);
- private final Song mSong3 = new Song(
+ private final Song mAcdcSong1 = new Song(
3,
"Highway to Hell",
"AC/DC",
"Highway to Hell",
328,
1979);
- private final Song mSong4 = new Song(
+ private final Song mPinkFloydSong1 = new Song(
4,
"The Great Gig in the Sky",
"Pink Floyd",
@@ -95,7 +102,6 @@
443,
1973);
-
private final Artist mRhcp = new Artist(
1,
"Red Hot Chili Peppers"
@@ -168,41 +174,117 @@
* Tests a simple JOIN query between two tables.
*/
@Test
- public void testJoinByArtistName() {
- mMusicDao.addSongs(mSong1, mSong2, mSong3, mSong4);
+ public void testGetFirstSongForArtist() {
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
- Map<Artist, List<Song>> artistToSongsMap = mMusicDao.getAllArtistAndTheirSongs();
- assertContentsOfResultMap(artistToSongsMap);
+ Map<Artist, Song> artistToSongsMap = mMusicDao.getArtistAndFirstSongMap();
+ assertThat(artistToSongsMap.get(mAcDc)).isEqualTo(mAcdcSong1);
+ assertThat(artistToSongsMap.get(mRhcp)).isEqualTo(mRhcpSong1);
+ }
+
+ @Test
+ public void testGetSongToArtistMapping() {
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
+ mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+
+ Map<Song, Artist> songToArtistMap = mMusicDao.getSongAndArtist();
+ assertThat(songToArtistMap.get(mAcdcSong1)).isEqualTo(mAcDc);
+ assertThat(songToArtistMap.get(mPinkFloydSong1)).isEqualTo(mPinkFloyd);
+ assertThat(songToArtistMap.get(mRhcpSong1)).isEqualTo(mRhcp);
+ assertThat(songToArtistMap.get(mRhcpSong2)).isEqualTo(mRhcp);
+ }
+
+ @Test
+ public void testJoinByArtistNameList() {
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
+ mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+
+ Map<Artist, List<Song>> artistToSongsMap = mMusicDao.getAllArtistAndTheirSongsList();
+ assertContentsOfResultMapWithList(artistToSongsMap);
+ }
+
+ @Test
+ public void testJoinByArtistNameSet() {
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
+ mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+
+ Map<Artist, Set<Song>> artistToSongsSet = mMusicDao.getAllArtistAndTheirSongsSet();
+ assertContentsOfResultMapWithSet(artistToSongsSet);
}
/**
- * Tests a JOIN {@link androidx.room.RawQuery} between two tables.
+ * Tests a JOIN using {@link androidx.room.RawQuery} between two tables.
*/
@Test
public void testJoinByArtistNameRawQuery() {
- mMusicDao.addSongs(mSong1, mSong2, mSong3, mSong4);
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
- Map<Artist, List<Song>> artistToSongsMap = mMusicDao.getAllArtistAndTheirSongsRawQuery(
+ Map<Artist, Song> artistToSongsMap = mMusicDao.getAllArtistAndTheirSongsRawQuery(
new SimpleSQLiteQuery(
"SELECT * FROM Artist JOIN Song ON Artist.mArtistName = Song.mArtist"
)
);
- assertContentsOfResultMap(artistToSongsMap);
+ assertThat(artistToSongsMap.get(mAcDc)).isEqualTo(mAcdcSong1);
+ }
+
+ @Test
+ public void testJoinByArtistNameRawQueryList() {
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
+ mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+
+ Map<Artist, List<Song>> artistToSongsMap = mMusicDao.getAllArtistAndTheirSongsRawQueryList(
+ new SimpleSQLiteQuery(
+ "SELECT * FROM Artist JOIN Song ON Artist.mArtistName = Song.mArtist"
+ )
+ );
+ assertContentsOfResultMapWithList(artistToSongsMap);
+ }
+
+ @Test
+ public void testJoinByArtistNameRawQuerySet() {
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
+ mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+
+ Map<Artist, Set<Song>> artistToSongsMap = mMusicDao.getAllArtistAndTheirSongsRawQuerySet(
+ new SimpleSQLiteQuery(
+ "SELECT * FROM Artist JOIN Song ON Artist.mArtistName = Song.mArtist"
+ )
+ );
+ assertContentsOfResultMapWithSet(artistToSongsMap);
}
/**
- * Tests a simple JOIN query between two tables with a {@link LiveData} return type.
+ * Tests a simple JOIN query between two tables with a {@link LiveData} map return type.
*/
@Test
public void testJoinByArtistNameLiveData()
throws ExecutionException, InterruptedException, TimeoutException {
- mMusicDao.addSongs(mSong1, mSong2, mSong3, mSong4);
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
+ mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+
+ LiveData<Map<Artist, Song>> artistToSongsMapLiveData =
+ mMusicDao.getAllArtistAndTheirSongsAsLiveData();
+ final TestLifecycleOwner testOwner = new TestLifecycleOwner(Lifecycle.State.CREATED);
+ final TestObserver<Map<Artist, Song>> observer = new MyTestObserver<>();
+ TestUtil.observeOnMainThread(artistToSongsMapLiveData, testOwner, observer);
+ MatcherAssert.assertThat(observer.hasValue(), is(false));
+ observer.reset();
+ testOwner.handleLifecycleEvent(Lifecycle.Event.ON_START);
+
+ assertThat(observer.get()).isNotNull();
+ assertThat(observer.get().get(mAcDc)).isEqualTo(mAcdcSong1);
+ }
+
+ @Test
+ public void testJoinByArtistNameLiveDataList()
+ throws ExecutionException, InterruptedException, TimeoutException {
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
LiveData<Map<Artist, List<Song>>> artistToSongsMapLiveData =
- mMusicDao.getAllArtistAndTheirSongsAsLiveData();
+ mMusicDao.getAllArtistAndTheirSongsAsLiveDataList();
final TestLifecycleOwner testOwner = new TestLifecycleOwner(Lifecycle.State.CREATED);
final TestObserver<Map<Artist, List<Song>>> observer = new MyTestObserver<>();
TestUtil.observeOnMainThread(artistToSongsMapLiveData, testOwner, observer);
@@ -211,20 +293,89 @@
testOwner.handleLifecycleEvent(Lifecycle.Event.ON_START);
assertThat(observer.get()).isNotNull();
- assertContentsOfResultMap(observer.get());
+ assertContentsOfResultMapWithList(observer.get());
+ }
+
+ @Test
+ public void testJoinByArtistNameLiveDataSet()
+ throws ExecutionException, InterruptedException, TimeoutException {
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
+ mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+
+ LiveData<Map<Artist, Set<Song>>> artistToSongsMapLiveData =
+ mMusicDao.getAllArtistAndTheirSongsAsLiveDataSet();
+ final TestLifecycleOwner testOwner = new TestLifecycleOwner(Lifecycle.State.CREATED);
+ final TestObserver<Map<Artist, Set<Song>>> observer = new MyTestObserver<>();
+ TestUtil.observeOnMainThread(artistToSongsMapLiveData, testOwner, observer);
+ MatcherAssert.assertThat(observer.hasValue(), is(false));
+ observer.reset();
+ testOwner.handleLifecycleEvent(Lifecycle.Event.ON_START);
+
+ assertThat(observer.get()).isNotNull();
+ assertContentsOfResultMapWithSet(observer.get());
}
/**
- * Tests a simple JOIN query between two tables with a {@link Flowable} return type.
+ * Tests a simple JOIN query between two tables with a {@link Flowable} map return type.
*/
@Test
- public void testJoinByArtistNameFlowable() {
- mMusicDao.addSongs(mSong1, mSong2, mSong3, mSong4);
+ public void testJoinByArtistNameFlowableList() {
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
Flowable<Map<Artist, List<Song>>> artistToSongsMapFlowable =
- mMusicDao.getAllArtistAndTheirSongsAsFlowable();
- assertContentsOfResultMap(artistToSongsMapFlowable.blockingFirst());
+ mMusicDao.getAllArtistAndTheirSongsAsFlowableList();
+ assertContentsOfResultMapWithList(artistToSongsMapFlowable.blockingFirst());
+ }
+
+ @Test
+ public void testJoinByArtistNameFlowableSet() {
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
+ mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+
+ Flowable<Map<Artist, Set<Song>>> artistToSongsMapFlowable =
+ mMusicDao.getAllArtistAndTheirSongsAsFlowableSet();
+ assertContentsOfResultMapWithSet(artistToSongsMapFlowable.blockingFirst());
+ }
+
+ /**
+ * Tests a simple JOIN query between two tables with a return type of a map with a key that
+ * is an entity {@link Artist} and a POJO {@link AlbumWithSongs} that use
+ * {@link androidx.room.Embedded} and {@link androidx.room.Relation}.
+ */
+ @Test
+ public void testPojoWithEmbeddedAndRelation() {
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
+ mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+ mMusicDao.addAlbums(
+ mStadiumArcadium,
+ mCalifornication,
+ mTheDarkSideOfTheMoon,
+ mHighwayToHell
+ );
+
+ Map<Artist, AlbumWithSongs> artistToAlbumsWithSongsMap =
+ mMusicDao.getAllArtistAndTheirAlbumsWithSongs();
+ AlbumWithSongs rhcpAlbum = artistToAlbumsWithSongsMap.get(mRhcp);
+
+ assertThat(artistToAlbumsWithSongsMap.keySet()).containsExactlyElementsIn(
+ Arrays.asList(mRhcp, mAcDc, mPinkFloyd));
+ assertThat(artistToAlbumsWithSongsMap.containsKey(mTheClash)).isFalse();
+ assertThat(artistToAlbumsWithSongsMap.get(mPinkFloyd).getAlbum())
+ .isEqualTo(mTheDarkSideOfTheMoon);
+ assertThat(artistToAlbumsWithSongsMap.get(mAcDc).getAlbum())
+ .isEqualTo(mHighwayToHell);
+ assertThat(artistToAlbumsWithSongsMap.get(mAcDc).getSongs().get(0)).isEqualTo(mAcdcSong1);
+
+ if (rhcpAlbum.getAlbum().equals(mStadiumArcadium)) {
+ assertThat(rhcpAlbum.getSongs()).containsExactlyElementsIn(
+ Arrays.asList(mRhcpSong1, mRhcpSong2)
+ );
+ } else if (rhcpAlbum.getAlbum().equals(mCalifornication)) {
+ assertThat(rhcpAlbum.getSongs()).isEmpty();
+ } else {
+ fail();
+ }
}
/**
@@ -233,8 +384,8 @@
* {@link androidx.room.Embedded} and {@link androidx.room.Relation}.
*/
@Test
- public void testPojoWithEmbeddedAndRelation() {
- mMusicDao.addSongs(mSong1, mSong2, mSong3, mSong4);
+ public void testPojoWithEmbeddedAndRelationList() {
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
mMusicDao.addAlbums(
mStadiumArcadium,
@@ -244,7 +395,8 @@
);
Map<Artist, List<AlbumWithSongs>> artistToAlbumsWithSongsMap =
- mMusicDao.getAllArtistAndTheirAlbumsWithSongs();
+ mMusicDao.getAllArtistAndTheirAlbumsWithSongsList();
+ mMusicDao.getAllArtistAndTheirAlbumsWithSongs();
List<AlbumWithSongs> rhcpList = artistToAlbumsWithSongsMap.get(mRhcp);
assertThat(artistToAlbumsWithSongsMap.keySet()).containsExactlyElementsIn(
@@ -255,12 +407,12 @@
assertThat(artistToAlbumsWithSongsMap.get(mAcDc).get(0).getAlbum())
.isEqualTo(mHighwayToHell);
assertThat(artistToAlbumsWithSongsMap.get(mAcDc).get(0).getSongs().get(0))
- .isEqualTo(mSong3);
+ .isEqualTo(mAcdcSong1);
for (AlbumWithSongs albumAndSong : rhcpList) {
if (albumAndSong.getAlbum().equals(mStadiumArcadium)) {
assertThat(albumAndSong.getSongs()).containsExactlyElementsIn(
- Arrays.asList(mSong1, mSong2)
+ Arrays.asList(mRhcpSong1, mRhcpSong2)
);
} else if (albumAndSong.getAlbum().equals(mCalifornication)) {
assertThat(albumAndSong.getSongs()).isEmpty();
@@ -276,8 +428,8 @@
* POJOs.
*/
@Test
- public void testNonEntityPojos() {
- mMusicDao.addSongs(mSong1, mSong2, mSong3, mSong4);
+ public void testNonEntityPojosList() {
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
mMusicDao.addAlbums(
mStadiumArcadium,
@@ -287,14 +439,15 @@
);
Map<ReleasedAlbum, List<AlbumNameAndBandName>> map =
- mMusicDao.getReleaseYearToAlbumsAndBands();
+ mMusicDao.getReleaseYearToAlbumsAndBandsList();
Set<ReleasedAlbum> allReleasedAlbums = map.keySet();
assertThat(allReleasedAlbums.size()).isEqualTo(3);
for (ReleasedAlbum album : allReleasedAlbums) {
if (album.getAlbumName().equals(mStadiumArcadium.mAlbumName)) {
- assertThat(album.getReleaseYear()).isEqualTo(mStadiumArcadium.mAlbumReleaseYear);
+ assertThat(album.getReleaseYear()).isEqualTo(
+ mStadiumArcadium.mAlbumReleaseYear);
assertThat(map.get(album).size()).isEqualTo(2);
assertThat(map.get(album).get(0).getBandName()).isEqualTo(mRhcp.mArtistName);
assertThat(map.get(album).get(0).getAlbumName())
@@ -326,19 +479,260 @@
}
}
+ @Test
+ public void testJoinByArtistNameGuavaImmutableListMultimap() {
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
+ mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+
+ ImmutableListMultimap<Artist, Song> artistToSongs =
+ mMusicDao.getAllArtistAndTheirSongsGuavaImmutableListMultimap();
+ assertContentsOfResultMultimap(artistToSongs);
+ }
+
+ @Test
+ public void testJoinByArtistNameGuavaImmutableSetMultimap() {
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
+ mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+
+ ImmutableSetMultimap<Artist, Song> artistToSongs =
+ mMusicDao.getAllArtistAndTheirSongsGuavaImmutableSetMultimap();
+ assertContentsOfResultMultimap(artistToSongs);
+ }
+
+ @Test
+ public void testJoinByArtistNameRawQueryGuavaImmutableListMultimap() {
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
+ mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+
+ ImmutableListMultimap<Artist, Song> artistToSongsMap =
+ mMusicDao.getAllArtistAndTheirSongsRawQueryGuavaImmutableListMultimap(
+ new SimpleSQLiteQuery(
+ "SELECT * FROM Artist JOIN Song ON Artist.mArtistName = Song"
+ + ".mArtist"
+ )
+ );
+ assertThat(artistToSongsMap.get(mAcDc)).containsExactly(mAcdcSong1);
+ }
+
+ @Test
+ public void testJoinByArtistNameRawQueryGuavaImmutableSetMultimap() {
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
+ mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+
+ ImmutableSetMultimap<Artist, Song> artistToSongsMap =
+ mMusicDao.getAllArtistAndTheirSongsRawQueryGuavaImmutableSetMultimap(
+ new SimpleSQLiteQuery(
+ "SELECT * FROM Artist JOIN Song ON Artist.mArtistName = Song"
+ + ".mArtist"
+ )
+ );
+ assertThat(artistToSongsMap.get(mAcDc)).containsExactly(mAcdcSong1);
+ }
+
+ @Test
+ public void testJoinByArtistNameLiveDataGuavaImmutableListMultimap()
+ throws ExecutionException, InterruptedException, TimeoutException {
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
+ mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+
+ LiveData<ImmutableListMultimap<Artist, Song>> artistToSongsMapLiveData =
+ mMusicDao.getAllArtistAndTheirSongsAsLiveDataGuavaImmutableListMultimap();
+ final TestLifecycleOwner testOwner = new TestLifecycleOwner(Lifecycle.State.CREATED);
+ final TestObserver<ImmutableListMultimap<Artist, Song>> observer = new MyTestObserver<>();
+ TestUtil.observeOnMainThread(artistToSongsMapLiveData, testOwner, observer);
+ MatcherAssert.assertThat(observer.hasValue(), is(false));
+ observer.reset();
+ testOwner.handleLifecycleEvent(Lifecycle.Event.ON_START);
+
+ assertThat(observer.get()).isNotNull();
+ assertContentsOfResultMultimap(observer.get());
+ }
+
+ @Test
+ public void testJoinByArtistNameLiveDataGuavaImmutableSetMultimap()
+ throws ExecutionException, InterruptedException, TimeoutException {
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
+ mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+
+ LiveData<ImmutableSetMultimap<Artist, Song>> artistToSongsMapLiveData =
+ mMusicDao.getAllArtistAndTheirSongsAsLiveDataGuavaImmutableSetMultimap();
+ final TestLifecycleOwner testOwner = new TestLifecycleOwner(Lifecycle.State.CREATED);
+ final TestObserver<ImmutableSetMultimap<Artist, Song>> observer = new MyTestObserver<>();
+ TestUtil.observeOnMainThread(artistToSongsMapLiveData, testOwner, observer);
+ MatcherAssert.assertThat(observer.hasValue(), is(false));
+ observer.reset();
+ testOwner.handleLifecycleEvent(Lifecycle.Event.ON_START);
+
+ assertThat(observer.get()).isNotNull();
+ assertContentsOfResultMultimap(observer.get());
+ }
+
+ @Test
+ public void testPojoWithEmbeddedAndRelationGuavaImmutableListMultimap() {
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
+ mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+ mMusicDao.addAlbums(
+ mStadiumArcadium,
+ mCalifornication,
+ mTheDarkSideOfTheMoon,
+ mHighwayToHell
+ );
+
+ ImmutableListMultimap<Artist, AlbumWithSongs> artistToAlbumsWithSongsMap =
+ mMusicDao.getAllArtistAndTheirAlbumsWithSongsGuavaImmutableListMultimap();
+ ImmutableList<AlbumWithSongs> rhcpList = artistToAlbumsWithSongsMap.get(mRhcp);
+
+ assertThat(artistToAlbumsWithSongsMap.keySet()).containsExactlyElementsIn(
+ Arrays.asList(mRhcp, mAcDc, mPinkFloyd));
+ assertThat(artistToAlbumsWithSongsMap.containsKey(mTheClash)).isFalse();
+ assertThat(artistToAlbumsWithSongsMap.get(
+ mPinkFloyd).get(0).getAlbum())
+ .isEqualTo(mTheDarkSideOfTheMoon);
+ assertThat(artistToAlbumsWithSongsMap.get(mAcDc).get(0).getAlbum())
+ .isEqualTo(mHighwayToHell);
+ assertThat(artistToAlbumsWithSongsMap.get(mAcDc).get(0).getSongs()
+ .get(0)).isEqualTo(mAcdcSong1);
+
+ for (AlbumWithSongs albumAndSong : rhcpList) {
+ if (albumAndSong.getAlbum().equals(mStadiumArcadium)) {
+ assertThat(albumAndSong.getSongs()).containsExactlyElementsIn(
+ Arrays.asList(mRhcpSong1, mRhcpSong2)
+ );
+ } else if (albumAndSong.getAlbum().equals(mCalifornication)) {
+ assertThat(albumAndSong.getSongs()).isEmpty();
+ } else {
+ fail();
+ }
+ }
+ }
+
+ @Test
+ public void testPojoWithEmbeddedAndRelationGuavaImmutableSetMultimap() {
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
+ mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+ mMusicDao.addAlbums(
+ mStadiumArcadium,
+ mCalifornication,
+ mTheDarkSideOfTheMoon,
+ mHighwayToHell
+ );
+
+ ImmutableSetMultimap<Artist, AlbumWithSongs> artistToAlbumsWithSongsMap =
+ mMusicDao.getAllArtistAndTheirAlbumsWithSongsGuavaImmutableSetMultimap();
+ ImmutableSet<AlbumWithSongs> rhcpList = artistToAlbumsWithSongsMap.get(mRhcp);
+
+ assertThat(artistToAlbumsWithSongsMap.keySet()).containsExactlyElementsIn(
+ Arrays.asList(mRhcp, mAcDc, mPinkFloyd));
+ assertThat(artistToAlbumsWithSongsMap.containsKey(mTheClash)).isFalse();
+ assertThat(artistToAlbumsWithSongsMap.get(
+ mPinkFloyd).asList().get(0).getAlbum())
+ .isEqualTo(mTheDarkSideOfTheMoon);
+ assertThat(artistToAlbumsWithSongsMap.get(mAcDc).asList().get(0).getAlbum())
+ .isEqualTo(mHighwayToHell);
+ assertThat(artistToAlbumsWithSongsMap.get(mAcDc).asList().get(0).getSongs()
+ .get(0)).isEqualTo(mAcdcSong1);
+
+ for (AlbumWithSongs albumAndSong : rhcpList) {
+ if (albumAndSong.getAlbum().equals(mStadiumArcadium)) {
+ assertThat(albumAndSong.getSongs()).containsExactlyElementsIn(
+ Arrays.asList(mRhcpSong1, mRhcpSong2)
+ );
+ } else if (albumAndSong.getAlbum().equals(mCalifornication)) {
+ assertThat(albumAndSong.getSongs()).isEmpty();
+ } else {
+ fail();
+ }
+ }
+ }
+
+ @Test
+ public void testJoinByArtistNameImmutableMap() {
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
+ mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+
+ ImmutableMap<Artist, List<Song>> artistToSongsMap =
+ mMusicDao.getAllArtistAndTheirSongsImmutableMap();
+ assertContentsOfResultMapWithList(artistToSongsMap);
+ }
+
+ @Test
+ public void testJoinByArtistNameRawQueryImmutableMap() {
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
+ mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+ ImmutableMap<Artist, List<Song>> artistToSongsMap =
+ mMusicDao.getAllArtistAndTheirSongsRawQueryImmutableMap(
+ new SimpleSQLiteQuery(
+ "SELECT * FROM Artist JOIN Song ON Artist.mArtistName = Song"
+ + ".mArtist"
+ )
+ );
+ assertContentsOfResultMapWithList(artistToSongsMap);
+ }
+
+ @Test
+ public void testJoinByArtistNameImmutableMapWithSet()
+ throws ExecutionException, InterruptedException, TimeoutException {
+ mMusicDao.addSongs(mRhcpSong1, mRhcpSong2, mAcdcSong1, mPinkFloydSong1);
+ mMusicDao.addArtists(mRhcp, mAcDc, mTheClash, mPinkFloyd);
+
+ LiveData<ImmutableMap<Artist, Set<Song>>> artistToSongsMapLiveData =
+ mMusicDao.getAllArtistAndTheirSongsAsLiveDataImmutableMap();
+ final TestLifecycleOwner testOwner = new TestLifecycleOwner(Lifecycle.State.CREATED);
+ final TestObserver<Map<Artist, Set<Song>>> observer = new MyTestObserver<>();
+ TestUtil.observeOnMainThread(artistToSongsMapLiveData, testOwner, observer);
+ MatcherAssert.assertThat(observer.hasValue(), is(false));
+ observer.reset();
+ testOwner.handleLifecycleEvent(Lifecycle.Event.ON_START);
+
+ assertThat(observer.get()).isNotNull();
+ assertContentsOfResultMapWithSet(observer.get());
+ }
+
/**
* Checks that the contents of the map are as expected.
*
- * @param artistToSongsMap Map of Artists to Songs joined by the artist name
+ * @param artistToSongsMap Map of Artists to list of Songs joined by the artist name
*/
- private void assertContentsOfResultMap(Map<Artist, List<Song>> artistToSongsMap) {
+ private void assertContentsOfResultMapWithList(Map<Artist, List<Song>> artistToSongsMap) {
assertThat(artistToSongsMap.keySet()).containsExactlyElementsIn(
Arrays.asList(mRhcp, mAcDc, mPinkFloyd));
assertThat(artistToSongsMap.containsKey(mTheClash)).isFalse();
- assertThat(artistToSongsMap.get(mPinkFloyd)).containsExactly(mSong4);
+ assertThat(artistToSongsMap.get(mPinkFloyd)).containsExactly(mPinkFloydSong1);
assertThat(artistToSongsMap.get(mRhcp)).containsExactlyElementsIn(
- Arrays.asList(mSong1, mSong2)
+ Arrays.asList(mRhcpSong1, mRhcpSong2)
);
- assertThat(artistToSongsMap.get(mAcDc)).containsExactly(mSong3);
+ assertThat(artistToSongsMap.get(mAcDc)).containsExactly(mAcdcSong1);
}
-}
+
+ /**
+ * Checks that the contents of the map are as expected.
+ *
+ * @param artistToSongsMap Map of Artists to set of Songs joined by the artist name
+ */
+ private void assertContentsOfResultMapWithSet(Map<Artist, Set<Song>> artistToSongsMap) {
+ assertThat(artistToSongsMap.keySet()).containsExactlyElementsIn(
+ Arrays.asList(mRhcp, mAcDc, mPinkFloyd));
+ assertThat(artistToSongsMap.containsKey(mTheClash)).isFalse();
+ assertThat(artistToSongsMap.get(mPinkFloyd)).containsExactly(mPinkFloydSong1);
+ assertThat(artistToSongsMap.get(mRhcp)).containsExactlyElementsIn(
+ Arrays.asList(mRhcpSong1, mRhcpSong2)
+ );
+ assertThat(artistToSongsMap.get(mAcDc)).containsExactly(mAcdcSong1);
+ }
+
+ /**
+ * Checks that the contents of the map are as expected.
+ *
+ * @param artistToSongsMap Map of Artists to Collection of Songs joined by the artist name
+ */
+ private void assertContentsOfResultMultimap(ImmutableMultimap<Artist, Song> artistToSongsMap) {
+ assertThat(artistToSongsMap.keySet()).containsExactlyElementsIn(
+ Arrays.asList(mRhcp, mAcDc, mPinkFloyd));
+ assertThat(artistToSongsMap.containsKey(mTheClash)).isFalse();
+ assertThat(artistToSongsMap.get(mPinkFloyd)).containsExactly(mPinkFloydSong1);
+ assertThat(artistToSongsMap.get(mRhcp)).containsExactlyElementsIn(
+ Arrays.asList(mRhcpSong1, mRhcpSong2)
+ );
+ assertThat(artistToSongsMap.get(mAcDc)).containsExactly(mAcdcSong1);
+ }
+}
\ No newline at end of file
diff --git a/room/room-compiler-processing/build.gradle b/room/room-compiler-processing/build.gradle
index 5a91131..f1c4286 100644
--- a/room/room-compiler-processing/build.gradle
+++ b/room/room-compiler-processing/build.gradle
@@ -57,8 +57,7 @@
}
tasks.withType(Test).configureEach {
- // TODO: re-enable once b/177660733 is fixed.
- it.systemProperty("androidx.room.compiler.processing.strict", "false")
+ it.systemProperty("androidx.room.compiler.processing.strict", "true")
}
androidx {
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/DeclarationCollector.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/DeclarationCollector.kt
index af1a65c..b87d357 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/DeclarationCollector.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/DeclarationCollector.kt
@@ -158,7 +158,13 @@
(candidate.enclosingElement as? XTypeElement)?.isInterface() == true ->
false
// accept if not overridden
- else -> existing.none { it.overrides(candidate, xTypeElement) }
+ else -> existing.none {
+ // we might see the same method twice due to diamond inheritance so we need
+ // check for equals in addition to overrides
+ // note that this is OK to check because you cannot implement the same interface
+ // twice with different type parameters (due to erasure).
+ it == candidate || it.overrides(candidate, xTypeElement)
+ }
}
},
getCandidateDeclarations = XTypeElement::getAllMethods,
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/XType.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/XType.kt
index 52388ed..bec9add 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/XType.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/XType.kt
@@ -71,7 +71,8 @@
fun isAssignableFrom(other: XType): Boolean
/**
- * Returns `true` if this type can be assigned from [other] while ignoring the type variance.
+ * Returns `true` if this can be assigned from an instance of [other] without checking for
+ * variance.
*/
fun isAssignableFromWithoutVariance(other: XType): Boolean {
return isAssignableWithoutVariance(other, this)
@@ -123,14 +124,6 @@
fun extendsBoundOrSelf(): XType = extendsBound() ?: this
/**
- * Returns `true` if this can be assigned from an instance of [other] without checking for
- * variance.
- */
- fun isAssignableWithoutVariance(other: XType): Boolean {
- return isAssignableWithoutVariance(other, this)
- }
-
- /**
* If this is a wildcard with an extends bound, returns that bounded typed.
*/
fun extendsBound(): XType?
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KSDeclarationExt.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KSDeclarationExt.kt
index 2b59a34..b6d549f 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KSDeclarationExt.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KSDeclarationExt.kt
@@ -16,6 +16,8 @@
package androidx.room.compiler.processing.ksp
+import androidx.room.compiler.processing.ksp.synthetic.KspSyntheticFileMemberContainer
+import com.google.devtools.ksp.KspExperimental
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.KSDeclaration
import com.google.devtools.ksp.symbol.KSFunctionDeclaration
@@ -42,14 +44,33 @@
* Node that this is not necessarily the parent declaration. e.g. when a property is declared in
* a constructor, its containing type is actual two levels up.
*/
+@OptIn(KspExperimental::class)
internal fun KSDeclaration.findEnclosingMemberContainer(
env: KspProcessingEnv
): KspMemberContainer? {
- return findEnclosingAncestorClassDeclaration()?.let {
+ val memberContainer = findEnclosingAncestorClassDeclaration()?.let {
env.wrapClassDeclaration(it)
} ?: this.containingFile?.let {
env.wrapKSFile(it)
}
+ memberContainer?.let {
+ return it
+ }
+ // in compiled files, we may not find it. Try using the binary name
+
+ val ownerJvmClassName = when (this) {
+ is KSPropertyDeclaration -> env.resolver.getOwnerJvmClassName(this)
+ is KSFunctionDeclaration -> env.resolver.getOwnerJvmClassName(this)
+ else -> null
+ } ?: return null
+ // Binary name of a top level type is its canonical name. So we just load it directly by
+ // that value
+ env.findTypeElement(ownerJvmClassName)?.let {
+ return it
+ }
+ // When a top level function/property is compiled, its containing class does not exist in KSP,
+ // neither the file. So instead, we synthesize one
+ return KspSyntheticFileMemberContainer(ownerJvmClassName)
}
private fun KSDeclaration.findEnclosingAncestorClassDeclaration(): KSClassDeclaration? {
@@ -62,6 +83,9 @@
internal fun KSDeclaration.isStatic(): Boolean {
return modifiers.contains(Modifier.JAVA_STATIC) || hasJvmStaticAnnotation() ||
+ // declarations in the companion object move into the enclosing class as statics.
+ // https://kotlinlang.org/docs/java-to-kotlin-interop.html#static-fields
+ this.findEnclosingAncestorClassDeclaration()?.isCompanionObject == true ||
when (this) {
is KSPropertyAccessor -> this.receiver.findEnclosingAncestorClassDeclaration() == null
is KSPropertyDeclaration -> this.findEnclosingAncestorClassDeclaration() == null
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KSFunctionExt.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KSFunctionExt.kt
index 76e7323..1e646c6 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KSFunctionExt.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KSFunctionExt.kt
@@ -17,6 +17,7 @@
package androidx.room.compiler.processing.ksp
import androidx.room.compiler.processing.XType
+import androidx.room.compiler.processing.util.ISSUE_TRACKER_LINK
import com.google.devtools.ksp.symbol.KSFunctionDeclaration
import com.google.devtools.ksp.symbol.KSPropertyDeclaration
@@ -43,8 +44,7 @@
else -> error(
"""
Unexpected overridee type for $this ($overridee).
- Please file a bug with steps to reproduce.
- https://issuetracker.google.com/issues/new?component=413107
+ Please file a bug at $ISSUE_TRACKER_LINK.
""".trimIndent()
)
} ?: returnType
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KSTypeExt.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KSTypeExt.kt
index dd6e2bd..f8c7623 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KSTypeExt.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KSTypeExt.kt
@@ -19,6 +19,7 @@
import androidx.room.compiler.processing.XNullability
import androidx.room.compiler.processing.javac.kotlin.typeNameFromJvmSignature
import androidx.room.compiler.processing.tryBox
+import androidx.room.compiler.processing.util.ISSUE_TRACKER_LINK
import com.google.devtools.ksp.KspExperimental
import com.google.devtools.ksp.processing.Resolver
import com.google.devtools.ksp.symbol.KSDeclaration
@@ -255,8 +256,8 @@
} catch (ex: NoSuchMethodException) {
throw IllegalStateException(
"""
- Room couldn't find the constructor it is looking for in JavaPoet. Please file a bug at
- https://issuetracker.google.com/issues/new?component=413107
+ Room couldn't find the constructor it is looking for in JavaPoet.
+ Please file a bug at $ISSUE_TRACKER_LINK.
""".trimIndent(),
ex
)
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspExecutableElement.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspExecutableElement.kt
index cb4ee5f..f2a52bb 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspExecutableElement.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspExecutableElement.kt
@@ -21,6 +21,7 @@
import androidx.room.compiler.processing.XExecutableParameterElement
import androidx.room.compiler.processing.XHasModifiers
import androidx.room.compiler.processing.ksp.KspAnnotated.UseSiteFilter.Companion.NO_USE_SITE
+import androidx.room.compiler.processing.util.ISSUE_TRACKER_LINK
import com.google.devtools.ksp.isConstructor
import com.google.devtools.ksp.symbol.KSFunctionDeclaration
import com.google.devtools.ksp.symbol.Modifier
@@ -76,8 +77,10 @@
val enclosingContainer = declaration.findEnclosingMemberContainer(env)
checkNotNull(enclosingContainer) {
- "XProcessing does not currently support annotations on top level " +
- "functions with KSP. Cannot process $declaration."
+ """
+ Couldn't find the container element for $declaration.
+ Please file a bug at $ISSUE_TRACKER_LINK.
+ """
}
return when {
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspFiler.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspFiler.kt
index ae3b1f7..e2a0758 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspFiler.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspFiler.kt
@@ -18,6 +18,7 @@
import androidx.room.compiler.processing.XFiler
import androidx.room.compiler.processing.XMessager
+import androidx.room.compiler.processing.util.ISSUE_TRACKER_LINK
import com.google.devtools.ksp.processing.CodeGenerator
import com.google.devtools.ksp.processing.Dependencies
import com.google.devtools.ksp.symbol.KSFile
@@ -87,8 +88,8 @@
Diagnostic.Kind.WARNING,
"""
No dependencies are reported for $fileName which will prevent
- incremental compilation. Please file a bug at:
- https://issuetracker.google.com/issues/new?component=413107
+ incremental compilation.
+ Please file a bug at $ISSUE_TRACKER_LINK.
""".trimIndent()
)
Dependencies.ALL_FILES
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspProcessingEnv.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspProcessingEnv.kt
index 46889e0..1726c96 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspProcessingEnv.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspProcessingEnv.kt
@@ -79,7 +79,7 @@
)
}
- override fun findTypeElement(qName: String): XTypeElement? {
+ override fun findTypeElement(qName: String): KspTypeElement? {
return typeElementStore[qName]
}
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspTypeElement.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspTypeElement.kt
index a9f8d83..3f3e58a 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspTypeElement.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/KspTypeElement.kt
@@ -37,7 +37,6 @@
import com.google.devtools.ksp.symbol.ClassKind
import com.google.devtools.ksp.symbol.KSClassDeclaration
import com.google.devtools.ksp.symbol.Modifier
-import com.google.devtools.ksp.symbol.Origin
import com.squareup.javapoet.ClassName
internal sealed class KspTypeElement(
@@ -111,8 +110,12 @@
containing = this
)
}.let {
- // only order instance fields, we don't care about the order of companion fields.
- KspClassFileUtility.orderFields(declaration, it.toList())
+ // only order instance properties with backing fields, we don't care about the order
+ // of companion properties or properties without backing fields.
+ val (withBackingField, withoutBackingField) = it.partition {
+ it.declaration.hasBackingField
+ }
+ KspClassFileUtility.orderFields(declaration, withBackingField) + withoutBackingField
}
val companionProperties = declaration
@@ -131,63 +134,54 @@
declaredProperties + companionProperties
}
- private val syntheticGetterSetterMethods: List<XMethodElement> by lazy {
- _declaredProperties.flatMap {
- if (it.type.ksType.isInline()) {
- // KAPT does not generate getters/setters for inlines, we'll hide them as well
- // until room generates kotlin code
- return@flatMap emptyList()
- }
+ private val _declaredFields by lazy {
+ _declaredProperties.filter {
+ it.declaration.hasBackingField
+ }
+ }
- val setter = it.declaration.setter
- val needsSetter = when {
- it.declaration.hasJvmFieldAnnotation() -> {
- // jvm fields cannot have accessors but KSP generates synthetic accessors for
- // them. We check for JVM field first before checking the setter
- false
+ private val syntheticGetterSetterMethods: List<XMethodElement> by lazy {
+ _declaredProperties.flatMap { field ->
+ when {
+ field.type.ksType.isInline() -> {
+ // KAPT does not generate getters/setters for inlines, we'll hide them as well
+ // until room generates kotlin code
+ emptyList()
}
- it.declaration.isPrivate() -> false
- setter != null -> !setter.modifiers.contains(Modifier.PRIVATE)
- it.declaration.origin != Origin.KOTLIN -> {
- // no reason to generate synthetics non kotlin code. If it had a setter, that
- // would show up as a setter
- false
- }
- else -> it.declaration.isMutable
- }
- val getter = it.declaration.getter
- val needsGetter = when {
- it.declaration.hasJvmFieldAnnotation() -> {
+ field.declaration.hasJvmFieldAnnotation() -> {
// jvm fields cannot have accessors but KSP generates synthetic accessors for
// them. We check for JVM field first before checking the getter
- false
+ emptyList()
}
- it.declaration.isPrivate() -> false
- getter != null -> !getter.modifiers.contains(Modifier.PRIVATE)
- it.declaration.origin != Origin.KOTLIN -> {
- // no reason to generate synthetics non kotlin code. If it had a getter, that
- // would show up as a getter
- false
- }
- else -> true
+ field.declaration.isPrivate() -> emptyList()
+
+ else ->
+ sequenceOf(field.declaration.getter, field.declaration.setter)
+ .filterNotNull()
+ .filterNot {
+ // KAPT does not generate methods for privates, KSP does so we filter
+ // them out.
+ it.modifiers.contains(Modifier.PRIVATE)
+ }
+ .filter {
+ if (field.isStatic()) {
+ // static fields are the properties that are coming from the
+ // companion. Whether we'll generate method for it or not depends on
+ // the JVMStatic annotation
+ it.hasJvmStaticAnnotation() ||
+ field.declaration.hasJvmStaticAnnotation()
+ } else {
+ true
+ }
+ }
+ .map { accessor ->
+ KspSyntheticPropertyMethodElement.create(
+ env = env,
+ field = field,
+ accessor = accessor
+ )
+ }.toList()
}
- val setterElm = if (needsSetter) {
- KspSyntheticPropertyMethodElement.Setter(
- env = env,
- field = it
- )
- } else {
- null
- }
- val getterElm = if (needsGetter) {
- KspSyntheticPropertyMethodElement.Getter(
- env = env,
- field = it
- )
- } else {
- null
- }
- listOfNotNull(getterElm, setterElm)
}
}
@@ -232,14 +226,6 @@
return !isInterface() && !declaration.isOpen()
}
- private val _declaredFields by lazy {
- if (declaration.classKind == ClassKind.INTERFACE) {
- _declaredProperties.filter { it.isStatic() }
- } else {
- _declaredProperties.filter { !it.isAbstract() }
- }
- }
-
override fun getDeclaredFields(): List<XFieldElement> {
return _declaredFields
}
@@ -259,9 +245,11 @@
.filterNot { it.isConstructor() }
val companionMethods = declaration.findCompanionObject()
?.getDeclaredFunctions()
- ?.asSequence()
+ ?.filterNot {
+ it.isConstructor()
+ }
?.filter {
- it.isStatic()
+ it.hasJvmStaticAnnotation()
} ?: emptySequence()
val declaredMethods = (instanceMethods + companionMethods)
.filterNot {
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/OverrideVarianceResolver.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/OverrideVarianceResolver.kt
index 96cade3..b2f3cf8 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/OverrideVarianceResolver.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/OverrideVarianceResolver.kt
@@ -169,8 +169,7 @@
when (val decl = myType.declaration) {
is KSClassDeclaration -> {
decl.isOpen() ||
- decl.classKind == ClassKind.ENUM_CLASS ||
- decl.classKind == ClassKind.OBJECT
+ decl.classKind == ClassKind.ENUM_CLASS
}
else -> true
}
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/synthetic/KspSyntheticFileMemberContainer.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/synthetic/KspSyntheticFileMemberContainer.kt
new file mode 100644
index 0000000..35dc043
--- /dev/null
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/synthetic/KspSyntheticFileMemberContainer.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.room.compiler.processing.ksp.synthetic
+
+import androidx.room.compiler.processing.XAnnotation
+import androidx.room.compiler.processing.XAnnotationBox
+import androidx.room.compiler.processing.XEquality
+import androidx.room.compiler.processing.ksp.KspMemberContainer
+import androidx.room.compiler.processing.ksp.KspType
+import com.google.devtools.ksp.symbol.KSDeclaration
+import com.squareup.javapoet.ClassName
+import kotlin.reflect.KClass
+
+/**
+ * When a top level function/member is compiled, the generated Java class does not exist in KSP.
+ *
+ * This wrapper synthesizes one from the JVM binary name
+ *
+ * https://docs.oracle.com/javase/specs/jls/se7/html/jls-13.html#jls-13.1
+ */
+internal class KspSyntheticFileMemberContainer(
+ private val binaryName: String
+) : KspMemberContainer, XEquality {
+ override val equalityItems: Array<out Any?> by lazy {
+ arrayOf(binaryName)
+ }
+
+ override val type: KspType?
+ get() = null
+
+ override val declaration: KSDeclaration?
+ get() = null
+
+ override val className: ClassName by lazy {
+ val packageName = binaryName.substringBeforeLast(
+ delimiter = '.',
+ missingDelimiterValue = ""
+ )
+ val shortNames = if (packageName == "") {
+ binaryName
+ } else {
+ binaryName.substring(packageName.length + 1)
+ }.split('$')
+ ClassName.get(
+ packageName,
+ shortNames.first(),
+ *shortNames.drop(1).toTypedArray()
+ )
+ }
+
+ override fun kindName(): String {
+ return "synthethic top level file"
+ }
+
+ override val fallbackLocationText: String
+ get() = binaryName
+
+ override val docComment: String?
+ get() = null
+
+ override fun <T : Annotation> getAnnotations(annotation: KClass<T>): List<XAnnotationBox<T>> {
+ return emptyList()
+ }
+
+ override fun getAllAnnotations(): List<XAnnotation> {
+ return emptyList()
+ }
+
+ override fun hasAnnotation(annotation: KClass<out Annotation>): Boolean {
+ return false
+ }
+
+ override fun hasAnnotationWithPackage(pkg: String): Boolean {
+ return false
+ }
+}
\ No newline at end of file
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/synthetic/KspSyntheticPropertyMethodElement.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/synthetic/KspSyntheticPropertyMethodElement.kt
index b5636fa..4c0226b 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/synthetic/KspSyntheticPropertyMethodElement.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/synthetic/KspSyntheticPropertyMethodElement.kt
@@ -40,7 +40,6 @@
import com.google.devtools.ksp.symbol.KSPropertyAccessor
import com.google.devtools.ksp.symbol.KSPropertyGetter
import com.google.devtools.ksp.symbol.KSPropertySetter
-import java.util.Locale
/**
* Kotlin properties don't have getters/setters in KSP. As Room expects Java code, we synthesize
@@ -53,13 +52,23 @@
internal sealed class KspSyntheticPropertyMethodElement(
val env: KspProcessingEnv,
val field: KspFieldElement,
- accessor: KSPropertyAccessor?
+ open val accessor: KSPropertyAccessor
) : XMethodElement,
XEquality,
XHasModifiers by KspHasModifiers.createForSyntheticAccessor(
field.declaration,
accessor
) {
+
+ @OptIn(KspExperimental::class)
+ override val name: String by lazy {
+ env.resolver.getJvmName(accessor) ?: error("Cannot find the name for accessor $accessor")
+ }
+
+ override val equalityItems: Array<out Any?> by lazy {
+ arrayOf(field, accessor)
+ }
+
// NOTE: modifiers of the property are not necessarily my modifiers.
// that being said, it only matters if it is private in which case KAPT does not generate the
// synthetic hence we don't either.
@@ -103,29 +112,29 @@
return env.resolver.overrides(this, other)
}
- internal class Getter(
+ override fun copyTo(newContainer: XTypeElement): XMethodElement {
+ check(newContainer is KspTypeElement)
+ return create(
+ env = env,
+ field = field.copyTo(newContainer),
+ accessor = accessor
+ )
+ }
+
+ private class Getter(
env: KspProcessingEnv,
- field: KspFieldElement
+ field: KspFieldElement,
+ override val accessor: KSPropertyGetter
) : KspSyntheticPropertyMethodElement(
env = env,
field = field,
- accessor = field.declaration.getter
+ accessor = accessor
),
XAnnotated by KspAnnotated.create(
env = env,
- delegate = field.declaration.getter,
+ delegate = accessor,
filter = NO_USE_SITE_OR_GETTER
) {
- override val equalityItems: Array<out Any?> by lazy {
- arrayOf(field, "getter")
- }
-
- @OptIn(KspExperimental::class)
- override val name: String by lazy {
- field.declaration.getter?.let {
- env.resolver.getJvmName(it)
- } ?: computeGetterName(field.name)
- }
override val returnType: XType by lazy {
field.type
@@ -137,55 +146,22 @@
override fun kindName(): String {
return "synthetic property getter"
}
-
- override fun copyTo(newContainer: XTypeElement): XMethodElement {
- check(newContainer is KspTypeElement)
- return Getter(
- env = env,
- field = field.copyTo(newContainer)
- )
- }
-
- companion object {
- private fun computeGetterName(propName: String): String {
- // see https://kotlinlang.org/docs/reference/java-to-kotlin-interop.html#properties
- return if (propName.startsWith("is")) {
- propName
- } else {
- val capitalizedName = propName.replaceFirstChar {
- if (it.isLowerCase()) it.titlecase(
- Locale.US
- ) else it.toString()
- }
- "get$capitalizedName"
- }
- }
- }
}
- internal class Setter(
+ private class Setter(
env: KspProcessingEnv,
- field: KspFieldElement
+ field: KspFieldElement,
+ override val accessor: KSPropertySetter
) : KspSyntheticPropertyMethodElement(
env = env,
field = field,
- accessor = field.declaration.setter
+ accessor = accessor
),
XAnnotated by KspAnnotated.create(
env = env,
delegate = field.declaration.setter,
filter = NO_USE_SITE_OR_SETTER
) {
- override val equalityItems: Array<out Any?> by lazy {
- arrayOf(field, "setter")
- }
-
- @OptIn(KspExperimental::class)
- override val name: String by lazy {
- field.declaration.setter?.let {
- env.resolver.getJvmName(it)
- } ?: computeSetterName(field.name)
- }
override val returnType: XType by lazy {
env.voidType
@@ -204,14 +180,6 @@
return "synthetic property getter"
}
- override fun copyTo(newContainer: XTypeElement): XMethodElement {
- check(newContainer is KspTypeElement)
- return Setter(
- env = env,
- field = field.copyTo(newContainer)
- )
- }
-
private class SyntheticExecutableParameterElement(
env: KspProcessingEnv,
private val origin: Setter
@@ -223,7 +191,7 @@
) {
override val name: String by lazy {
- val originalName = origin.field.declaration.setter?.parameter?.name?.asString()
+ val originalName = origin.accessor.parameter.name?.asString()
originalName.sanitizeAsJavaParameterName(0)
}
override val type: XType
@@ -243,51 +211,53 @@
return "method parameter"
}
}
-
- companion object {
- private fun computeSetterName(propName: String): String {
- // see https://kotlinlang.org/docs/reference/java-to-kotlin-interop.html#properties
- return if (propName.startsWith("is")) {
- "set${propName.substring(2)}"
- } else {
- val capitalizedName = propName.replaceFirstChar {
- if (it.isLowerCase()) it.titlecase(
- Locale.US
- ) else it.toString()
- }
- "set$capitalizedName"
- }
- }
- }
}
companion object {
-
fun create(
env: KspProcessingEnv,
- propertyAccessor: KSPropertyAccessor
+ accessor: KSPropertyAccessor
): KspSyntheticPropertyMethodElement {
- val enclosingType = propertyAccessor.receiver.findEnclosingMemberContainer(env)
+ val enclosingType = accessor.receiver.findEnclosingMemberContainer(env)
checkNotNull(enclosingType) {
"XProcessing does not currently support annotations on top level " +
- "properties with KSP. Cannot process $propertyAccessor."
+ "properties with KSP. Cannot process $accessor."
}
val field = KspFieldElement(
env,
- propertyAccessor.receiver,
+ accessor.receiver,
enclosingType
)
+ return create(
+ env = env,
+ field = field,
+ accessor = accessor
+ )
+ }
- return when (propertyAccessor) {
+ fun create(
+ env: KspProcessingEnv,
+ field: KspFieldElement,
+ accessor: KSPropertyAccessor
+ ): KspSyntheticPropertyMethodElement {
+ return when (accessor) {
is KSPropertyGetter -> {
- Getter(env, field)
+ Getter(
+ env = env,
+ field = field,
+ accessor = accessor
+ )
}
is KSPropertySetter -> {
- Setter(env, field)
+ Setter(
+ env = env,
+ field = field,
+ accessor = accessor
+ )
}
- else -> error("Unsupported property accessor $propertyAccessor")
+ else -> error("Unsupported property accessor $accessor")
}
}
}
diff --git a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/synthetic/KspSyntheticPropertyMethodType.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/synthetic/KspSyntheticPropertyMethodType.kt
index 8d442b5..e45e505 100644
--- a/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/synthetic/KspSyntheticPropertyMethodType.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/ksp/synthetic/KspSyntheticPropertyMethodType.kt
@@ -18,6 +18,8 @@
import androidx.room.compiler.processing.XMethodType
import androidx.room.compiler.processing.XType
+import com.google.devtools.ksp.symbol.KSPropertyGetter
+import com.google.devtools.ksp.symbol.KSPropertySetter
import com.squareup.javapoet.TypeVariableName
/**
@@ -48,23 +50,24 @@
element: KspSyntheticPropertyMethodElement,
container: XType?
): XMethodType {
- return when (element) {
- is KspSyntheticPropertyMethodElement.Getter ->
+ return when (element.accessor) {
+ is KSPropertyGetter ->
Getter(
origin = element,
containingType = container
)
- is KspSyntheticPropertyMethodElement.Setter ->
+ is KSPropertySetter ->
Setter(
origin = element,
containingType = container
)
+ else -> error("Unexpected accessor type for $element (${element.accessor})")
}
}
}
private class Getter(
- origin: KspSyntheticPropertyMethodElement.Getter,
+ origin: KspSyntheticPropertyMethodElement,
containingType: XType?
) : KspSyntheticPropertyMethodType(
origin = origin,
@@ -80,7 +83,7 @@
}
private class Setter(
- origin: KspSyntheticPropertyMethodElement.Setter,
+ origin: KspSyntheticPropertyMethodElement,
containingType: XType?
) : KspSyntheticPropertyMethodType(
origin = origin,
@@ -90,4 +93,4 @@
// setters always return Unit, no need to get it as type of
get() = origin.returnType
}
-}
+}
\ No newline at end of file
diff --git a/benchmark/integration-tests/crystalball-experiment/src/androidTest/java/androidx/benchmark/macro/BenchmarkClass.kt b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/util/ErrorMessages.kt
similarity index 69%
rename from benchmark/integration-tests/crystalball-experiment/src/androidTest/java/androidx/benchmark/macro/BenchmarkClass.kt
rename to room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/util/ErrorMessages.kt
index 88234c7..1386a08 100644
--- a/benchmark/integration-tests/crystalball-experiment/src/androidTest/java/androidx/benchmark/macro/BenchmarkClass.kt
+++ b/room/room-compiler-processing/src/main/java/androidx/room/compiler/processing/util/ErrorMessages.kt
@@ -1,5 +1,5 @@
/*
- * Copyright 2020 The Android Open Source Project
+ * Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,10 +14,9 @@
* limitations under the License.
*/
-package androidx.benchmark.macro
+package androidx.room.compiler.processing.util
-import android.platform.test.rule.DropCachesRule
-
-class BenchmarkClass {
- val rule = DropCachesRule()
-}
+/**
+ * Link to the issue tracker to report bugs.
+ */
+internal val ISSUE_TRACKER_LINK = "https://issuetracker.google.com/issues/new?component=413107"
\ No newline at end of file
diff --git a/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/InternalModifierTest.kt b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/InternalModifierTest.kt
index 900a9b3..f5098cd 100644
--- a/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/InternalModifierTest.kt
+++ b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/InternalModifierTest.kt
@@ -20,7 +20,7 @@
import androidx.room.compiler.processing.util.Source
import androidx.room.compiler.processing.util.runKaptTest
import androidx.room.compiler.processing.util.runKspTest
-import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
import org.junit.Test
class InternalModifierTest {
@@ -38,24 +38,23 @@
internal class InternalClass(val value: String)
inline class InlineClass(val value:String)
abstract class Subject {
- var normalProp: String = TODO()
- var inlineProp: InlineClass = TODO()
+ var normalProp: String = ""
+ var inlineProp: InlineClass = InlineClass("")
internal abstract var internalAbstractProp: String
- internal var internalProp: String = TODO()
- internal var internalInlineProp: InlineClass = TODO()
- private var internalTypeProp : InternalClass = TODO()
+ internal var internalProp: String = ""
+ internal var internalInlineProp: InlineClass = InlineClass("")
+ private var internalTypeProp : InternalClass = InternalClass("")
@get:JvmName("explicitGetterName")
@set:JvmName("explicitSetterName")
- var jvmNameProp:String
+ var jvmNameProp:String = ""
fun normalFun() {}
@JvmName("explicitJvmName")
fun hasJvmName() {}
fun inlineReceivingFun(value: InlineClass) {}
- fun inlineReturningFun(): InlineClass = TODO()
+ fun inlineReturningFun(): InlineClass = InlineClass("")
internal fun internalInlineReceivingFun(value: InlineClass) {}
- internal fun internalInlineReturningFun(): InlineClass = TODO()
+ internal fun internalInlineReturningFun(): InlineClass = InlineClass("")
inline fun inlineFun() {
- TODO()
}
}
""".trimIndent()
@@ -101,7 +100,7 @@
) { invocation ->
kspResult = traverse(invocation.processingEnv)
}
-
- assertThat(kspResult).isEqualTo(kaptResult)
+ assertWithMessage("$kspResult\n--\n$kaptResult")
+ .that(kspResult).isEqualTo(kaptResult)
}
}
\ No newline at end of file
diff --git a/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/MethodSpecHelperTest.kt b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/MethodSpecHelperTest.kt
index cfd143b..2032454 100644
--- a/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/MethodSpecHelperTest.kt
+++ b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/MethodSpecHelperTest.kt
@@ -93,6 +93,7 @@
package foo.bar;
import androidx.room.compiler.processing.testcode.OtherAnnotation;
+ object MyObject
abstract class Baz {
open fun method1() {
}
@@ -124,6 +125,18 @@
protected open fun listArg(r:List<String>) {
}
+ protected open fun listOfUnitArg(r:List<Unit>) {
+ }
+
+ protected open fun listOfCustomObjectArg(r:List<MyObject>) {
+ }
+
+ protected open fun listOfAnyArg(r:List<Any>) {
+ }
+
+ protected open fun listOfVoidArg(r:List<Void>) {
+ }
+
open suspend fun suspendUnitFun() {
}
@@ -155,6 +168,35 @@
}
@Test
+ fun kotlinParametersAsFunction() {
+ val source = Source.kotlin(
+ "Foo.kt",
+ """
+ package foo.bar;
+ interface MyInterface
+ interface Baz {
+ fun noArg_returnsUnit(operation: () -> Unit) {
+ }
+ fun singleArg_returnsUnit(operation: (Int) -> Unit) {
+ }
+ fun singleInterfaceArg_returnsUnit(operation: (MyInterface) -> Unit) {
+ }
+ fun singleReceiverArg_returnsUnit(operation: Int.() -> Unit) {
+ }
+ fun singleInterfaceReceiverArg_returnsUnit(operation: MyInterface.() -> Unit) {
+ }
+
+ fun noArg_returnsInt(operation: () -> Int) {
+ }
+ fun singleArg_returnsInterface(operation: (Int) -> MyInterface) {
+ }
+ }
+ """.trimIndent()
+ )
+ overridesCheck(source)
+ }
+
+ @Test
fun variance() {
// check our override impl matches javapoet
val source = Source.kotlin(
diff --git a/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/TopLevelMembersTest.kt b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/TopLevelMembersTest.kt
index 5582ac2..4054f04 100644
--- a/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/TopLevelMembersTest.kt
+++ b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/TopLevelMembersTest.kt
@@ -23,7 +23,7 @@
import androidx.room.compiler.processing.util.kspProcessingEnv
import androidx.room.compiler.processing.util.kspResolver
import androidx.room.compiler.processing.util.runKspTest
-import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
import com.google.devtools.ksp.KspExperimental
import com.google.devtools.ksp.symbol.KSFunctionDeclaration
import com.google.devtools.ksp.symbol.KSPropertyDeclaration
@@ -61,34 +61,37 @@
sources = listOf(appSrc),
classpath = classpath
) { invocation ->
- // b/188822146
- // TODO add lib package here once Room updates to a version that includes the
- // https://github.com/google/ksp/issues/396 fix (1.5.0-1.0.0-alpha09)
- val declarations = invocation.kspResolver.getDeclarationsFromPackage("app")
- declarations.filterIsInstance<KSFunctionDeclaration>()
- .toList().let { methods ->
- assertThat(methods).hasSize(1)
- methods.forEach { method ->
- val element = KspExecutableElement.create(
- env = invocation.kspProcessingEnv,
- declaration = method
- )
- assertThat(element.containing.isTypeElement()).isFalse()
- assertThat(element.isStatic()).isTrue()
+ listOf("lib", "app").forEach { pkg ->
+ val declarations = invocation.kspResolver.getDeclarationsFromPackage(pkg)
+ declarations.filterIsInstance<KSFunctionDeclaration>()
+ .toList().let { methods ->
+ assertWithMessage(pkg).that(methods).hasSize(1)
+ methods.forEach { method ->
+ val element = KspExecutableElement.create(
+ env = invocation.kspProcessingEnv,
+ declaration = method
+ )
+ assertWithMessage(pkg).that(
+ element.containing.isTypeElement()
+ ).isFalse()
+ assertWithMessage(pkg).that(element.isStatic()).isTrue()
+ }
}
- }
- declarations.filterIsInstance<KSPropertyDeclaration>()
- .toList().let { properties ->
- assertThat(properties).hasSize(2)
- properties.forEach {
- val element = KspFieldElement.create(
- env = invocation.kspProcessingEnv,
- declaration = it
- )
- assertThat(element.containing.isTypeElement()).isFalse()
- assertThat(element.isStatic()).isTrue()
+ declarations.filterIsInstance<KSPropertyDeclaration>()
+ .toList().let { properties ->
+ assertWithMessage(pkg).that(properties).hasSize(2)
+ properties.forEach {
+ val element = KspFieldElement.create(
+ env = invocation.kspProcessingEnv,
+ declaration = it
+ )
+ assertWithMessage(pkg).that(
+ element.containing.isTypeElement()
+ ).isFalse()
+ assertWithMessage(pkg).that(element.isStatic()).isTrue()
+ }
}
- }
+ }
}
}
}
\ No newline at end of file
diff --git a/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XTypeElementTest.kt b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XTypeElementTest.kt
index ac777c9..857e85c 100644
--- a/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XTypeElementTest.kt
+++ b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/XTypeElementTest.kt
@@ -22,7 +22,6 @@
import androidx.room.compiler.processing.util.getAllFieldNames
import androidx.room.compiler.processing.util.getField
import androidx.room.compiler.processing.util.getMethod
-import androidx.room.compiler.processing.util.runKspTest
import androidx.room.compiler.processing.util.runProcessorTest
import com.google.common.truth.Truth.assertThat
import com.google.common.truth.Truth.assertWithMessage
@@ -397,6 +396,112 @@
}
@Test
+ fun fieldsMethodsWithoutBacking() {
+ fun buildSrc(pkg: String) = Source.kotlin(
+ "Foo.kt",
+ """
+ package $pkg;
+ class Subject {
+ val realField: String = ""
+ get() = field
+ val noBackingVal: String
+ get() = ""
+ var noBackingVar: String
+ get() = ""
+ set(value) {}
+
+ companion object {
+ @JvmStatic
+ val staticRealField: String = ""
+ get() = field
+ @JvmStatic
+ val staticNoBackingVal: String
+ get() = ""
+ @JvmStatic
+ var staticNoBackingVar: String
+ get() = ""
+ set(value) {}
+ }
+ }
+ """.trimIndent()
+ )
+ val lib = compileFiles(listOf(buildSrc("lib")))
+ runProcessorTest(
+ sources = listOf(buildSrc("main")),
+ classpath = lib
+ ) { invocation ->
+ listOf("lib", "main").forEach { pkg ->
+ val subject = invocation.processingEnv.requireTypeElement("$pkg.Subject")
+ val declaredFields = subject.getDeclaredFields().map { it.name } -
+ listOf("Companion") // skip Companion, KAPT generates it
+ val expectedFields = if (invocation.isKsp && pkg == "lib") {
+ // TODO https://github.com/google/ksp/issues/491
+ // KSP returns false for companions in compiled code
+ listOf("realField")
+ } else {
+ listOf("realField", "staticRealField")
+ }
+ assertWithMessage(subject.qualifiedName)
+ .that(declaredFields)
+ .containsExactlyElementsIn(expectedFields)
+ val allFields = subject.getAllFieldsIncludingPrivateSupers().map { it.name } -
+ listOf("Companion") // skip Companion, KAPT generates it
+ assertWithMessage(subject.qualifiedName)
+ .that(allFields.toList())
+ .containsExactlyElementsIn(expectedFields)
+ val methodNames = subject.getDeclaredMethods().map { it.name }
+ assertWithMessage(subject.qualifiedName)
+ .that(methodNames)
+ .containsAtLeast("getNoBackingVal", "getNoBackingVar", "setNoBackingVar")
+ assertWithMessage(subject.qualifiedName)
+ .that(methodNames)
+ .doesNotContain("setNoBackingVal")
+ }
+ }
+ }
+
+ @Test
+ fun abstractFields() {
+ fun buildSource(pkg: String) = Source.kotlin(
+ "Foo.kt",
+ """
+ package $pkg;
+ abstract class Subject {
+ val value: String = ""
+ abstract val abstractValue: String
+ companion object {
+ var realCompanion: String = ""
+ @JvmStatic
+ var jvmStatic: String = ""
+ }
+ }
+ """.trimIndent()
+ )
+
+ val lib = compileFiles(listOf(buildSource("lib")))
+ runProcessorTest(
+ sources = listOf(buildSource("main")),
+ classpath = lib
+ ) { invocation ->
+ listOf("lib", "main").forEach { pkg ->
+ val subject = invocation.processingEnv.requireTypeElement("$pkg.Subject")
+ val declaredFields = subject.getDeclaredFields().map { it.name } -
+ listOf("Companion")
+ val expectedFields = if (invocation.isKsp && pkg == "lib") {
+ // TODO https://github.com/google/ksp/issues/491
+ // KSP returns false for companions in compiled code
+ listOf("value")
+ } else {
+ listOf("value", "realCompanion", "jvmStatic")
+ }
+ assertWithMessage(subject.qualifiedName)
+ .that(declaredFields)
+ .containsExactlyElementsIn(expectedFields)
+ }
+ }
+ }
+
+ @Test
fun fieldsInInterfaces() {
val src = Source.kotlin(
"Foo.kt",
@@ -581,6 +686,82 @@
}
@Test
+ fun diamondOverride() {
+ fun buildSrc(pkg: String) = Source.kotlin(
+ "Foo.kt",
+ """
+ package $pkg;
+ interface Parent<T> {
+ fun parent(t: T)
+ }
+
+ interface Child1<T> : Parent<T> {
+ fun child1(t: T)
+ }
+
+ interface Child2<T> : Parent<T> {
+ fun child2(t: T)
+ }
+
+ abstract class Subject1 : Child1<String>, Child2<String>, Parent<String>
+ abstract class Subject2 : Child1<String>, Parent<String>
+ abstract class Subject3 : Child1<String>, Parent<String> {
+ abstract override fun parent(t: String)
+ }
+ """.trimIndent()
+ )
+
+ runProcessorTest(
+ sources = listOf(buildSrc("app")),
+ classpath = compileFiles(listOf(buildSrc("lib")))
+ ) { invocation ->
+ listOf("lib", "app").forEach { pkg ->
+ val objectMethodNames = invocation.processingEnv.requireTypeElement(Any::class)
+ .getAllMethods().names()
+
+ fun XMethodElement.signature(
+ owner: XType
+ ): String {
+ val methodType = this.asMemberOf(owner)
+ val params = methodType.parameterTypes.joinToString(",") {
+ it.typeName.toString()
+ }
+ return "$name($params):${returnType.typeName}"
+ }
+
+ fun XTypeElement.allMethodSignatures(): List<String> = getAllMethods().filterNot {
+ it.name in objectMethodNames
+ }.map { it.signature(this.type) }.toList()
+ invocation.processingEnv.requireTypeElement("$pkg.Subject1").let { subject ->
+ assertWithMessage(subject.qualifiedName).that(
+ subject.allMethodSignatures()
+ ).containsExactly(
+ "child1(java.lang.String):void",
+ "child2(java.lang.String):void",
+ "parent(java.lang.String):void",
+ )
+ }
+ invocation.processingEnv.requireTypeElement("$pkg.Subject2").let { subject ->
+ assertWithMessage(subject.qualifiedName).that(
+ subject.allMethodSignatures()
+ ).containsExactly(
+ "child1(java.lang.String):void",
+ "parent(java.lang.String):void",
+ )
+ }
+ invocation.processingEnv.requireTypeElement("$pkg.Subject3").let { subject ->
+ assertWithMessage(subject.qualifiedName).that(
+ subject.allMethodSignatures()
+ ).containsExactly(
+ "child1(java.lang.String):void",
+ "parent(java.lang.String):void",
+ )
+ }
+ }
+ }
+ }
+
+ @Test
fun allMethods() {
val src = Source.kotlin(
"Foo.kt",
@@ -677,7 +858,7 @@
}
@Test
- fun gettersSetters_companion() {
+ fun companion() {
val src = Source.kotlin(
"Foo.kt",
"""
@@ -688,30 +869,80 @@
@JvmStatic
val immutableStatic: String = "bar"
val companionProp: Int = 3
+ @get:JvmStatic
+ var companionProp_getterJvmStatic:Int =3
+ @set:JvmStatic
+ var companionProp_setterJvmStatic:Int =3
+
+ fun companionMethod() {
+ }
+
+ @JvmStatic
+ fun companionMethodWithJvmStatic() {}
}
}
class SubClass : CompanionSubject()
""".trimIndent()
)
- // KAPT is a bit aggressive in adding fields, specifically, it adds companionProp and
- // Companion as static fields which are not really fields from room's perspective.
- runKspTest(sources = listOf(src)) { invocation ->
+ runProcessorTest(sources = listOf(src)) { invocation ->
+ val objectMethodNames = invocation.processingEnv.requireTypeElement(
+ Any::class
+ ).getAllMethods().names()
val subject = invocation.processingEnv.requireTypeElement("CompanionSubject")
- assertThat(subject.getAllFieldNames()).containsExactly(
- "mutableStatic", "immutableStatic"
+ assertThat(subject.getAllFieldNames() - "Companion").containsExactly(
+ "mutableStatic", "immutableStatic", "companionProp",
+ "companionProp_getterJvmStatic", "companionProp_setterJvmStatic"
)
- assertThat(subject.getDeclaredMethods().names()).containsExactly(
- "getMutableStatic", "setMutableStatic", "getImmutableStatic"
+ val expectedMethodNames = listOf(
+ "getMutableStatic", "setMutableStatic", "getImmutableStatic",
+ "getCompanionProp_getterJvmStatic", "setCompanionProp_setterJvmStatic",
+ "companionMethodWithJvmStatic"
)
- assertThat(subject.getAllMethods().names()).containsExactly(
- "getMutableStatic", "setMutableStatic", "getImmutableStatic"
+ assertThat(
+ subject.getDeclaredMethods().names()
+ ).containsExactlyElementsIn(
+ expectedMethodNames
)
- assertThat(subject.getAllNonPrivateInstanceMethods().names()).isEmpty()
+ assertThat(
+ subject.getAllMethods().names() - objectMethodNames
+ ).containsExactlyElementsIn(
+ expectedMethodNames
+ )
+ assertThat(
+ subject.getAllNonPrivateInstanceMethods().names() - objectMethodNames
+ ).isEmpty()
val subClass = invocation.processingEnv.requireTypeElement("SubClass")
assertThat(subClass.getDeclaredMethods()).isEmpty()
- assertThat(subClass.getAllMethods().names()).containsExactly(
- "getMutableStatic", "setMutableStatic", "getImmutableStatic"
+ assertThat(
+ subClass.getAllMethods().names() - objectMethodNames
+ ).containsExactlyElementsIn(
+ expectedMethodNames
)
+
+ // make sure everything coming from companion is marked as static
+ subject.getDeclaredFields().forEach {
+ assertWithMessage(it.name).that(it.isStatic()).isTrue()
+ }
+ subject.getDeclaredMethods().forEach {
+ assertWithMessage(it.name).that(it.isStatic()).isTrue()
+ }
+
+ // make sure asMemberOf works fine for statics
+ val subClassType = subClass.type
+ subject.getDeclaredFields().forEach {
+ try {
+ it.asMemberOf(subClassType)
+ } catch (th: Throwable) {
+ throw AssertionError("Couldn't run asMemberOf for ${it.name}")
+ }
+ }
+ subject.getDeclaredMethods().forEach {
+ try {
+ it.asMemberOf(subClassType)
+ } catch (th: Throwable) {
+ throw AssertionError("Couldn't run asMemberOf for ${it.name}")
+ }
+ }
}
}
diff --git a/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/ksp/synthetic/KspSyntheticFileMemberContainerTest.kt b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/ksp/synthetic/KspSyntheticFileMemberContainerTest.kt
new file mode 100644
index 0000000..1ef355a
--- /dev/null
+++ b/room/room-compiler-processing/src/test/java/androidx/room/compiler/processing/ksp/synthetic/KspSyntheticFileMemberContainerTest.kt
@@ -0,0 +1,155 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.room.compiler.processing.ksp.synthetic
+
+import androidx.room.compiler.processing.ksp.KspFieldElement
+import androidx.room.compiler.processing.util.Source
+import androidx.room.compiler.processing.util.compileFiles
+import androidx.room.compiler.processing.util.getField
+import androidx.room.compiler.processing.util.kspResolver
+import androidx.room.compiler.processing.util.runKspTest
+import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
+import com.google.devtools.ksp.KspExperimental
+import com.google.devtools.ksp.symbol.KSPropertyDeclaration
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+@OptIn(KspExperimental::class)
+class KspSyntheticFileMemberContainerTest {
+ @Test
+ fun topLevel_noPackage() {
+ val annotation = Source.kotlin(
+ "MyAnnotation.kt",
+ """
+ annotation class MyAnnotation
+ """.trimIndent()
+ )
+ val appSrc = Source.kotlin(
+ "App.kt",
+ """
+ @MyAnnotation
+ val appMember = 1
+ """.trimIndent()
+ )
+ runKspTest(
+ sources = listOf(annotation, appSrc)
+ ) { invocation ->
+ val elements = invocation.kspResolver.getSymbolsWithAnnotation("MyAnnotation").toList()
+ assertThat(elements).hasSize(1)
+ val className = elements.map {
+ val owner = invocation.kspResolver.getOwnerJvmClassName(it as KSPropertyDeclaration)
+ assertWithMessage(it.toString()).that(owner).isNotNull()
+ KspSyntheticFileMemberContainer(owner!!).className
+ }.first()
+ assertThat(className.packageName()).isEmpty()
+ assertThat(className.simpleNames()).containsExactly("AppKt")
+ }
+ }
+
+ @Test
+ fun nestedClassNames() {
+ fun buildSources(pkg: String) = listOf(
+ Source.java(
+ "$pkg.JavaClass",
+ """
+ package $pkg;
+ public class JavaClass {
+ int member;
+ public static class NestedClass {
+ int member;
+ }
+ public class InnerClass {
+ int member;
+ }
+ }
+ """.trimIndent()
+ ),
+ Source.java(
+ "${pkg}JavaClass",
+ """
+ public class ${pkg}JavaClass {
+ int member;
+ public static class NestedClass {
+ int member;
+ }
+ public class InnerClass {
+ int member;
+ }
+ }
+ """.trimIndent()
+ ),
+ Source.kotlin(
+ "$pkg/KotlinClass.kt",
+ """
+ package $pkg
+ class KotlinClass {
+ val member = 1
+ class NestedClass {
+ val member = 1
+ }
+ inner class InnerClass {
+ val member = 1
+ }
+ }
+ """.trimIndent()
+ ),
+ Source.kotlin(
+ "KotlinClass.kt",
+ """
+ class ${pkg}KotlinClass {
+ val member = 1
+ class NestedClass {
+ val member = 1
+ }
+ inner class InnerClass {
+ val member = 1
+ }
+ }
+ """.trimIndent()
+ )
+ )
+ val lib = compileFiles(buildSources("lib"))
+ runKspTest(
+ sources = buildSources("app"),
+ classpath = lib
+ ) { invocation ->
+ fun runTest(qName: String) {
+ invocation.processingEnv.requireTypeElement(qName).let { target ->
+ val field = target.getField("member") as KspFieldElement
+ val owner = invocation.kspResolver.getOwnerJvmClassName(field.declaration)
+ assertWithMessage(qName).that(owner).isNotNull()
+ val synthetic = KspSyntheticFileMemberContainer(owner!!)
+ assertWithMessage(qName).that(target.className).isEqualTo(synthetic.className)
+ }
+ }
+ listOf("lib", "app").forEach { pkg ->
+ // test both top level and in package cases
+ listOf(pkg, "$pkg.").forEach { prefix ->
+ runTest("${prefix}JavaClass")
+ runTest("${prefix}JavaClass.NestedClass")
+ runTest("${prefix}JavaClass.InnerClass")
+ runTest("${prefix}KotlinClass")
+ runTest("${prefix}KotlinClass.NestedClass")
+ runTest("${prefix}KotlinClass.InnerClass")
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/processor/ProcessorErrors.kt b/room/room-compiler/src/main/kotlin/androidx/room/processor/ProcessorErrors.kt
index c9b955e..eecf7b1 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/processor/ProcessorErrors.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/processor/ProcessorErrors.kt
@@ -67,6 +67,10 @@
val AUTO_INCREMENT_EMBEDDED_HAS_MULTIPLE_FIELDS = "When @PrimaryKey annotation is used on a" +
" field annotated with @Embedded, the embedded class should have only 1 field."
+ val DO_NOT_USE_GENERIC_IMMUTABLE_MULTIMAP = "Do not use ImmutableMultimap as a type (as with" +
+ " Multimap itself). Instead use the subtypes such as ImmutableSetMultimap or " +
+ "ImmutableListMultimap."
+
fun multiplePrimaryKeyAnnotations(primaryKeys: List<String>): String {
return """
You cannot have multiple primary keys defined in an Entity. If you
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/solver/TypeAdapterStore.kt b/room/room-compiler/src/main/kotlin/androidx/room/solver/TypeAdapterStore.kt
index ecad9834..e42de73 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/solver/TypeAdapterStore.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/solver/TypeAdapterStore.kt
@@ -32,6 +32,7 @@
import androidx.room.processor.EntityProcessor
import androidx.room.processor.FieldProcessor
import androidx.room.processor.PojoProcessor
+import androidx.room.processor.ProcessorErrors.DO_NOT_USE_GENERIC_IMMUTABLE_MULTIMAP
import androidx.room.solver.binderprovider.CoroutineFlowResultBinderProvider
import androidx.room.solver.binderprovider.CursorQueryResultBinderProvider
import androidx.room.solver.binderprovider.DataSourceFactoryQueryResultBinderProvider
@@ -54,8 +55,10 @@
import androidx.room.solver.query.parameter.QueryParameterAdapter
import androidx.room.solver.query.result.ArrayQueryResultAdapter
import androidx.room.solver.query.result.EntityRowAdapter
+import androidx.room.solver.query.result.GuavaImmutableMultimapQueryResultAdapter
import androidx.room.solver.query.result.GuavaOptionalQueryResultAdapter
import androidx.room.solver.query.result.ImmutableListQueryResultAdapter
+import androidx.room.solver.query.result.ImmutableMapQueryResultAdapter
import androidx.room.solver.query.result.ListQueryResultAdapter
import androidx.room.solver.query.result.MapQueryResultAdapter
import androidx.room.solver.query.result.OptionalQueryResultAdapter
@@ -95,6 +98,11 @@
import androidx.room.vo.ShortcutQueryParameter
import com.google.common.annotations.VisibleForTesting
import com.google.common.collect.ImmutableList
+import com.google.common.collect.ImmutableListMultimap
+import com.google.common.collect.ImmutableMultimap
+import com.google.common.collect.ImmutableSetMultimap
+import com.squareup.javapoet.ClassName
+import com.google.common.collect.ImmutableMap
import java.util.LinkedList
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN")
@@ -330,8 +338,8 @@
val targetTypes = targetTypeMirrorsFor(affinity)
val intoStatement = findTypeConverter(out, targetTypes) ?: return null
// ok found a converter, try the reverse now
- val fromCursor = reverse(intoStatement) ?: findTypeConverter(intoStatement.to, out)
- ?: return null
+ val fromCursor = reverse(intoStatement)
+ ?: findTypeConverter(intoStatement.to, out) ?: return null
return CompositeAdapter(
out, getAllColumnAdapters(intoStatement.to).first(), intoStatement, fromCursor
)
@@ -419,6 +427,7 @@
if (typeMirror.isError()) {
return null
}
+ // TODO: (b/192068912) Refactor the following since this if-else cascade has gotten large
if (typeMirror.isArray() && typeMirror.componentType.isNotByte()) {
val rowAdapter =
findRowAdapter(typeMirror.componentType, query) ?: return null
@@ -461,26 +470,104 @@
typeArg = typeArg,
rowAdapter = rowAdapter
)
- } else if (typeMirror.isTypeOf(java.util.Map::class)) {
- val keyArg = typeMirror.typeArguments[0].extendsBoundOrSelf()
- val secondTypeArg = typeMirror.typeArguments[1].extendsBoundOrSelf()
+ } else if (typeMirror.isTypeOf(ImmutableMap::class)) {
+ val keyTypeArg = typeMirror.typeArguments[0].extendsBoundOrSelf()
+ val valueTypeArg = typeMirror.typeArguments[1].extendsBoundOrSelf()
- // TODO: Support Set::class here as well.
- if (!secondTypeArg.isTypeOf(java.util.List::class)) {
- context.logger.e("Only supporting Map<Key, List<Value>> for now.")
+ // Create a type mirror for a regular Map in order to use MapQueryResultAdapter. This
+ // avoids code duplication as Immutable Map can be initialized by creating an immutable
+ // copy of a regular map.
+ val mapType = context.processingEnv.getDeclaredType(
+ context.processingEnv.requireTypeElement(Map::class),
+ keyTypeArg,
+ valueTypeArg
+ )
+ val resultAdapter = findQueryResultAdapter(mapType, query = query) ?: return null
+ return ImmutableMapQueryResultAdapter(
+ keyTypeArg = keyTypeArg,
+ valueTypeArg = valueTypeArg,
+ resultAdapter = resultAdapter
+ )
+ } else if (typeMirror.isTypeOf(ImmutableSetMultimap::class) ||
+ typeMirror.isTypeOf(ImmutableListMultimap::class) ||
+ typeMirror.isTypeOf(ImmutableMultimap::class)
+ ) {
+ val keyTypeArg = typeMirror.typeArguments[0].extendsBoundOrSelf()
+ val valueTypeArg = typeMirror.typeArguments[1].extendsBoundOrSelf()
+
+ if (valueTypeArg.typeElement == null) {
+ context.logger.e(
+ "Guava multimap 'value' type argument does not represent a class. " +
+ "Found $valueTypeArg."
+ )
return null
}
- val valueArg = secondTypeArg.typeArguments.first().extendsBoundOrSelf()
- val keyRowAdapter = findRowAdapter(keyArg, query) ?: return null
- val valueRowAdapter = findRowAdapter(valueArg, query) ?: return null
+ val immutableClassName = if (typeMirror.isTypeOf(ImmutableListMultimap::class)) {
+ ClassName.get(ImmutableListMultimap::class.java)
+ } else if (typeMirror.isTypeOf(ImmutableSetMultimap::class)) {
+ ClassName.get(ImmutableSetMultimap::class.java)
+ } else {
+ // Return type is base class ImmutableMultimap which is not recommended.
+ context.logger.e(DO_NOT_USE_GENERIC_IMMUTABLE_MULTIMAP)
+ return null
+ }
- return MapQueryResultAdapter(
- keyTypeArg = keyArg,
- valueTypeArg = valueArg,
- keyRowAdapter = keyRowAdapter,
- valueRowAdapter = valueRowAdapter
+ return GuavaImmutableMultimapQueryResultAdapter(
+ keyTypeArg = keyTypeArg,
+ valueTypeArg = valueTypeArg,
+ keyRowAdapter = findRowAdapter(keyTypeArg, query) ?: return null,
+ valueRowAdapter = findRowAdapter(valueTypeArg, query) ?: return null,
+ immutableClassName = immutableClassName
)
+ } else if (typeMirror.isTypeOf(java.util.Map::class)) {
+ // TODO: Handle nested collection values in the map
+ // TODO: Verify that hashCode() and equals() are declared by the keyTypeArg
+
+ val keyTypeArg = typeMirror.typeArguments[0].extendsBoundOrSelf()
+ val mapValueTypeArg = typeMirror.typeArguments[1].extendsBoundOrSelf()
+
+ if (mapValueTypeArg.typeElement == null) {
+ context.logger.e(
+ "Multimap 'value' collection type argument does not represent a class. " +
+ "Found $mapValueTypeArg."
+ )
+ return null
+ }
+
+ val collectionTypeRaw = context.COMMON_TYPES.READONLY_COLLECTION.rawType
+
+ if (collectionTypeRaw.isAssignableFrom(mapValueTypeArg.rawType)) {
+ // The Map's value type argument is assignable to a Collection, we need to make
+ // sure it is either a list or a set.
+ if (
+ mapValueTypeArg.isTypeOf(java.util.List::class) ||
+ mapValueTypeArg.isTypeOf(java.util.Set::class)
+ ) {
+ val valueTypeArg =
+ mapValueTypeArg.typeArguments.single().extendsBoundOrSelf()
+ return MapQueryResultAdapter(
+ keyTypeArg = keyTypeArg,
+ valueTypeArg = valueTypeArg,
+ keyRowAdapter = findRowAdapter(keyTypeArg, query) ?: return null,
+ valueRowAdapter = findRowAdapter(valueTypeArg, query) ?: return null,
+ valueCollectionType = mapValueTypeArg
+ )
+ } else {
+ context.logger.e(
+ "Multimap 'value' collection type must be a List or Set. Found " +
+ "${mapValueTypeArg.typeName}."
+ )
+ }
+ } else {
+ return MapQueryResultAdapter(
+ keyTypeArg = keyTypeArg,
+ valueTypeArg = mapValueTypeArg,
+ keyRowAdapter = findRowAdapter(keyTypeArg, query) ?: return null,
+ valueRowAdapter = findRowAdapter(mapValueTypeArg, query) ?: return null,
+ valueCollectionType = null
+ )
+ }
}
return null
}
@@ -697,20 +784,19 @@
* The returned list is ordered by priority such that if we have an exact match, it is
* prioritized.
*/
- private fun getAllTypeConverters(input: XType, excludes: List<XType>):
- List<TypeConverter> {
- // for input, check assignability because it defines whether we can use the method or not.
- // for excludes, use exact match
- return typeConverters.filter { converter ->
- converter.from.isAssignableFrom(input) &&
- !excludes.any { it.isSameType(converter.to) }
- }.sortedByDescending {
- // if it is the same, prioritize
- if (it.from.isSameType(input)) {
- 2
- } else {
- 1
- }
+ private fun getAllTypeConverters(input: XType, excludes: List<XType>): List<TypeConverter> {
+ // for input, check assignability because it defines whether we can use the method or not.
+ // for excludes, use exact match
+ return typeConverters.filter { converter ->
+ converter.from.isAssignableFrom(input) &&
+ !excludes.any { it.isSameType(converter.to) }
+ }.sortedByDescending {
+ // if it is the same, prioritize
+ if (it.from.isSameType(input)) {
+ 2
+ } else {
+ 1
}
}
+ }
}
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/solver/query/result/GuavaImmutableMultimapQueryResultAdapter.kt b/room/room-compiler/src/main/kotlin/androidx/room/solver/query/result/GuavaImmutableMultimapQueryResultAdapter.kt
new file mode 100644
index 0000000..612d276
--- /dev/null
+++ b/room/room-compiler/src/main/kotlin/androidx/room/solver/query/result/GuavaImmutableMultimapQueryResultAdapter.kt
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.room.solver.query.result
+
+import androidx.room.compiler.processing.XType
+import androidx.room.ext.L
+import androidx.room.ext.T
+import androidx.room.solver.CodeGenScope
+import com.squareup.javapoet.ClassName
+import com.squareup.javapoet.ParameterizedTypeName
+
+class GuavaImmutableMultimapQueryResultAdapter(
+ private val keyTypeArg: XType,
+ private val valueTypeArg: XType,
+ private val keyRowAdapter: RowAdapter,
+ private val valueRowAdapter: RowAdapter,
+ private val immutableClassName: ClassName
+) : QueryResultAdapter(listOf(keyRowAdapter, valueRowAdapter)) {
+ private val mapType = ParameterizedTypeName.get(
+ immutableClassName,
+ keyTypeArg.typeName,
+ valueTypeArg.typeName
+ )
+
+ override fun convert(outVarName: String, cursorVarName: String, scope: CodeGenScope) {
+ val mapVarName = scope.getTmpVar("_mapBuilder")
+
+ scope.builder().apply {
+ keyRowAdapter.onCursorReady(cursorVarName, scope)
+ valueRowAdapter.onCursorReady(cursorVarName, scope)
+ addStatement(
+ "final $T.Builder<$T, $T> $L = $T.builder()",
+ immutableClassName,
+ keyTypeArg.typeName,
+ valueTypeArg.typeName,
+ mapVarName,
+ immutableClassName
+ )
+ val tmpKeyVarName = scope.getTmpVar("_key")
+ val tmpValueVarName = scope.getTmpVar("_value")
+ beginControlFlow("while ($L.moveToNext())", cursorVarName).apply {
+ addStatement("final $T $L", keyTypeArg.typeName, tmpKeyVarName)
+ keyRowAdapter.convert(tmpKeyVarName, cursorVarName, scope)
+ addStatement("final $T $L", valueTypeArg.typeName, tmpValueVarName)
+ valueRowAdapter.convert(tmpValueVarName, cursorVarName, scope)
+ addStatement("$L.put($L, $L)", mapVarName, tmpKeyVarName, tmpValueVarName)
+ }
+ endControlFlow()
+ addStatement("final $T $L = $L.build()", mapType, outVarName, mapVarName)
+ keyRowAdapter.onCursorFinished()?.invoke(scope)
+ valueRowAdapter.onCursorFinished()?.invoke(scope)
+ }
+ }
+}
\ No newline at end of file
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/solver/query/result/ImmutableMapQueryResultAdapter.kt b/room/room-compiler/src/main/kotlin/androidx/room/solver/query/result/ImmutableMapQueryResultAdapter.kt
new file mode 100644
index 0000000..c28b189
--- /dev/null
+++ b/room/room-compiler/src/main/kotlin/androidx/room/solver/query/result/ImmutableMapQueryResultAdapter.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.room.solver.query.result
+
+import androidx.room.compiler.processing.XType
+import androidx.room.ext.L
+import androidx.room.ext.T
+import androidx.room.solver.CodeGenScope
+import com.google.common.collect.ImmutableMap
+import com.squareup.javapoet.ClassName
+import com.squareup.javapoet.ParameterizedTypeName
+
+class ImmutableMapQueryResultAdapter(
+ private val keyTypeArg: XType,
+ private val valueTypeArg: XType,
+ private val resultAdapter: QueryResultAdapter
+) : QueryResultAdapter(resultAdapter.rowAdapters) {
+ override fun convert(outVarName: String, cursorVarName: String, scope: CodeGenScope) {
+ scope.builder().apply {
+ val mapVarName = scope.getTmpVar("_mapResult")
+ resultAdapter.convert(mapVarName, cursorVarName, scope)
+ addStatement(
+ "final $T $L = $T.copyOf($L)",
+ ParameterizedTypeName.get(
+ ClassName.get(ImmutableMap::class.java),
+ keyTypeArg.typeName,
+ valueTypeArg.typeName
+ ),
+ outVarName,
+ ClassName.get(ImmutableMap::class.java),
+ mapVarName
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/room/room-compiler/src/main/kotlin/androidx/room/solver/query/result/MapQueryResultAdapter.kt b/room/room-compiler/src/main/kotlin/androidx/room/solver/query/result/MapQueryResultAdapter.kt
index 124d9da..62ccb49 100644
--- a/room/room-compiler/src/main/kotlin/androidx/room/solver/query/result/MapQueryResultAdapter.kt
+++ b/room/room-compiler/src/main/kotlin/androidx/room/solver/query/result/MapQueryResultAdapter.kt
@@ -28,59 +28,103 @@
private val valueTypeArg: XType,
private val keyRowAdapter: RowAdapter,
private val valueRowAdapter: RowAdapter,
+ private val valueCollectionType: XType?
) : QueryResultAdapter(listOf(keyRowAdapter, valueRowAdapter)) {
- private val listType = ParameterizedTypeName.get(
- ClassName.get(List::class.java),
- valueTypeArg.typeName
+ private val declaredToConcreteCollection = mapOf<ClassName, ClassName>(
+ ClassName.get(List::class.java) to ClassName.get(ArrayList::class.java),
+ ClassName.get(Set::class.java) to ClassName.get(HashSet::class.java)
)
- private val arrayListType = ParameterizedTypeName
- .get(ClassName.get(ArrayList::class.java), valueTypeArg.typeName)
+ private val declaredValueType = if (valueCollectionType != null) {
+ ParameterizedTypeName.get(
+ valueCollectionType.typeElement?.className,
+ valueTypeArg.typeName
+ )
+ } else {
+ valueTypeArg.typeName
+ }
+
+ private val concreteValueType = if (valueCollectionType != null) {
+ ParameterizedTypeName.get(
+ declaredToConcreteCollection[valueCollectionType.typeElement?.className],
+ valueTypeArg.typeName
+ )
+ } else {
+ valueTypeArg.typeName
+ }
private val mapType = ParameterizedTypeName.get(
ClassName.get(Map::class.java),
keyTypeArg.typeName,
- listType
+ declaredValueType
)
private val hashMapType = ParameterizedTypeName.get(
ClassName.get(HashMap::class.java),
keyTypeArg.typeName,
- listType
+ declaredValueType
)
override fun convert(outVarName: String, cursorVarName: String, scope: CodeGenScope) {
scope.builder().apply {
keyRowAdapter.onCursorReady(cursorVarName, scope)
valueRowAdapter.onCursorReady(cursorVarName, scope)
- addStatement(
- "final $T $L = new $T()",
- mapType, outVarName, hashMapType
- )
+
+ val mapVarName = outVarName
+ addStatement("final $T $L = new $T()", mapType, mapVarName, hashMapType)
+
val tmpKeyVarName = scope.getTmpVar("_key")
val tmpValueVarName = scope.getTmpVar("_value")
beginControlFlow("while ($L.moveToNext())", cursorVarName).apply {
addStatement("final $T $L", keyTypeArg.typeName, tmpKeyVarName)
keyRowAdapter.convert(tmpKeyVarName, cursorVarName, scope)
- addStatement("final $T $L", valueTypeArg.typeName, tmpValueVarName)
- valueRowAdapter.convert(tmpValueVarName, cursorVarName, scope)
+ // If valueCollectionType is null, this means that we have a 1-to-1 mapping, as
+ // opposed to a 1-to-many mapping.
+ if (valueCollectionType != null) {
+ addStatement("final $T $L", valueTypeArg.typeName, tmpValueVarName)
+ valueRowAdapter.convert(tmpValueVarName, cursorVarName, scope)
+ val tmpCollectionVarName = scope.getTmpVar("_values")
+ addStatement("$T $L", declaredValueType, tmpCollectionVarName)
+ beginControlFlow("if ($L.containsKey($L))", mapVarName, tmpKeyVarName).apply {
+ addStatement(
+ "$L = $L.get($L)",
+ tmpCollectionVarName,
+ mapVarName,
+ tmpKeyVarName
+ )
+ }
+ nextControlFlow("else").apply {
+ addStatement("$L = new $T()", tmpCollectionVarName, concreteValueType)
+ addStatement(
+ "$L.put($L, $L)",
+ mapVarName,
+ tmpKeyVarName,
+ tmpCollectionVarName
+ )
+ }
+ endControlFlow()
+ addStatement("$L.add($L)", tmpCollectionVarName, tmpValueVarName)
+ } else {
+ addStatement(
+ "final $T $L",
+ valueTypeArg.typeElement?.className,
+ tmpValueVarName
+ )
+ valueRowAdapter.convert(tmpValueVarName, cursorVarName, scope)
- val tmpListVarName = scope.getTmpVar("_values")
- addStatement("$T $L", listType, tmpListVarName)
- beginControlFlow("if ($L.containsKey($L))", outVarName, tmpKeyVarName).apply {
- addStatement("$L = $L.get($L)", tmpListVarName, outVarName, tmpKeyVarName)
+ // For consistency purposes, in the one-to-one object mapping case, if
+ // multiple values are encountered for the same key, we will only consider
+ // the first ever encountered mapping.
+ beginControlFlow("if (!$L.containsKey($L))", mapVarName, tmpKeyVarName).apply {
+ addStatement("$L.put($L, $L)", mapVarName, tmpKeyVarName, tmpValueVarName)
+ }
+ endControlFlow()
}
- nextControlFlow("else").apply {
- addStatement("$L = new $T()", tmpListVarName, arrayListType)
- addStatement("$L.put($L, $L)", outVarName, tmpKeyVarName, tmpListVarName)
- }
- endControlFlow()
- addStatement("$L.add($L)", tmpListVarName, tmpValueVarName)
}
endControlFlow()
keyRowAdapter.onCursorFinished()?.invoke(scope)
valueRowAdapter.onCursorFinished()?.invoke(scope)
}
}
-}
\ No newline at end of file
+}
diff --git a/room/room-compiler/src/test/kotlin/androidx/room/processor/BaseDaoTest.kt b/room/room-compiler/src/test/kotlin/androidx/room/processor/BaseDaoTest.kt
index 148e40d..a87d4b5 100644
--- a/room/room-compiler/src/test/kotlin/androidx/room/processor/BaseDaoTest.kt
+++ b/room/room-compiler/src/test/kotlin/androidx/room/processor/BaseDaoTest.kt
@@ -161,6 +161,69 @@
}
}
+ @Test
+ fun extendSameInterfaceTwice() {
+ val source = Source.kotlin(
+ "Foo.kt",
+ """
+ import androidx.room.*
+
+ interface Parent<T> {
+ @Delete
+ fun delete(t: T)
+ }
+
+ interface Child1<T> : Parent<T> {
+ @Insert
+ fun insert(t: T)
+ }
+
+ interface Child2<T> : Parent<T> {
+ @Update
+ fun update(t: T)
+ }
+
+ @Entity
+ data class Data(
+ @PrimaryKey(autoGenerate = false)
+ val id: Long,
+ val data: String
+ )
+
+ @Dao
+ abstract class Dao1 : Child1<Data>, Child2<Data>, Parent<Data>
+
+ @Dao
+ abstract class Dao2 : Child1<Data>, Parent<Data>
+
+ @Dao
+ abstract class Dao3 : Child1<Data>, Parent<Data> {
+ @Delete
+ abstract override fun delete(t: Data)
+ }
+
+ abstract class MyDb : RoomDatabase() {
+ }
+ """.trimIndent()
+ )
+ runProcessorTest(
+ sources = listOf(source)
+ ) { invocation ->
+ val dbElm = invocation.context.processingEnv
+ .requireTypeElement("MyDb")
+ val dbType = dbElm.type
+ // if we could create valid code, it is good, no need for assertions.
+ listOf("Dao1", "Dao2", "Dao3").forEach { name ->
+ val dao = invocation.processingEnv.requireTypeElement(name)
+ val processed = DaoProcessor(
+ invocation.context, dao, dbType, null
+ ).process()
+ DaoWriter(processed, dbElm, invocation.processingEnv)
+ .write(invocation.processingEnv)
+ }
+ }
+ }
+
fun baseDao(code: String, handler: (Dao) -> Unit) {
val baseClass = Source.java(
"foo.bar.BaseDao",
diff --git a/room/room-compiler/src/test/kotlin/androidx/room/processor/QueryMethodProcessorTest.kt b/room/room-compiler/src/test/kotlin/androidx/room/processor/QueryMethodProcessorTest.kt
index 2574779..8659c89 100644
--- a/room/room-compiler/src/test/kotlin/androidx/room/processor/QueryMethodProcessorTest.kt
+++ b/room/room-compiler/src/test/kotlin/androidx/room/processor/QueryMethodProcessorTest.kt
@@ -31,6 +31,7 @@
import androidx.room.ext.typeName
import androidx.room.parser.QueryType
import androidx.room.parser.Table
+import androidx.room.processor.ProcessorErrors.DO_NOT_USE_GENERIC_IMMUTABLE_MULTIMAP
import androidx.room.processor.ProcessorErrors.cannotFindQueryResultAdapter
import androidx.room.solver.query.result.DataSourceFactoryQueryResultBinder
import androidx.room.solver.query.result.ListQueryResultAdapter
@@ -73,8 +74,7 @@
package foo.bar;
import androidx.annotation.NonNull;
import androidx.room.*;
- import java.util.Map;
- import java.util.List;
+ import java.util.*;
@Dao
abstract class MyClass {
"""
@@ -1326,4 +1326,37 @@
handler(parsedQuery as T, invocation)
}
}
+
+ @Test
+ fun testInvalidLinkedListCollectionInMultimapJoin() {
+ singleQueryMethod<ReadQueryMethod>(
+ """
+ @Query("select * from User u JOIN Book b ON u.uid == b.uid")
+ abstract Map<User, LinkedList<Book>> getInvalidCollectionMultimap();
+ """
+ ) { _, invocation ->
+ invocation.assertCompilationResult {
+ hasErrorCount(2)
+ hasErrorContaining("Multimap 'value' collection type must be a List or Set.")
+ hasErrorContaining("Not sure how to convert a Cursor to this method's return type")
+ }
+ }
+ }
+
+ @Test
+ fun testInvalidGenericMultimapJoin() {
+ singleQueryMethod<ReadQueryMethod>(
+ """
+ @Query("select * from User u JOIN Book b ON u.uid == b.uid")
+ abstract com.google.common.collect.ImmutableMultimap<User, Book>
+ getInvalidCollectionMultimap();
+ """
+ ) { _, invocation ->
+ invocation.assertCompilationResult {
+ hasErrorCount(2)
+ hasError(DO_NOT_USE_GENERIC_IMMUTABLE_MULTIMAP)
+ hasErrorContaining("Not sure how to convert a Cursor to this method's return type")
+ }
+ }
+ }
}
diff --git a/settings.gradle b/settings.gradle
index 94a7f99..9904a62 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -216,7 +216,6 @@
includeProject(":benchmark:benchmark-junit4", "benchmark/junit4")
includeProject(":benchmark:benchmark-macro", "benchmark/macro", [BuildType.MAIN, BuildType.COMPOSE])
includeProject(":benchmark:benchmark-macro-junit4", "benchmark/macro-junit4", [BuildType.MAIN, BuildType.COMPOSE])
-includeProject(":benchmark:integration-tests:crystalball-experiment", "benchmark/integration-tests/crystalball-experiment", [BuildType.MAIN])
includeProject(":benchmark:integration-tests:dry-run-benchmark", "benchmark/integration-tests/dry-run-benchmark", [BuildType.MAIN])
includeProject(":benchmark:integration-tests:macrobenchmark", "benchmark/integration-tests/macrobenchmark", [BuildType.MAIN, BuildType.COMPOSE])
includeProject(":benchmark:integration-tests:macrobenchmark-target", "benchmark/integration-tests/macrobenchmark-target", [BuildType.MAIN, BuildType.COMPOSE])
@@ -621,9 +620,12 @@
includeProject(":viewpager:viewpager", "viewpager/viewpager", [BuildType.MAIN])
includeProject(":wear:wear", "wear/wear", [BuildType.MAIN, BuildType.WEAR])
includeProject(":wear:wear-complications-data", "wear/wear-complications-data", [BuildType.MAIN, BuildType.WEAR])
-includeProject(":wear:wear-complications-provider", "wear/wear-complications-provider", [BuildType.MAIN, BuildType.WEAR])
-includeProject(":wear:wear-complications-provider-samples", "wear/wear-complications-provider/samples", [BuildType.MAIN, BuildType.WEAR])
+includeProject(":wear:wear-complications-data-source", "wear/wear-complications-data-source", [BuildType.MAIN, BuildType.WEAR])
+includeProject(":wear:wear-complications-data-source-samples", "wear/wear-complications-data-source-samples", [BuildType.MAIN, BuildType.WEAR])
+includeProject(":wear:benchmark:integration-tests:macrobenchmark-target", "wear/benchmark/integration-tests/macrobenchmark-target", [BuildType.MAIN, BuildType.COMPOSE])
+includeProject(":wear:benchmark:integration-tests:macrobenchmark", "wear/benchmark/integration-tests/macrobenchmark", [BuildType.MAIN, BuildType.COMPOSE])
includeProject(":wear:compose:compose-foundation", "wear/compose/foundation", [BuildType.COMPOSE])
+includeProject(":wear:compose:compose-foundation-samples", "wear/compose/foundation/samples", [BuildType.COMPOSE])
includeProject(":wear:compose:compose-material", "wear/compose/material", [BuildType.COMPOSE])
includeProject(":wear:compose:compose-material-benchmark", "wear/compose/material/benchmark", [BuildType.COMPOSE])
includeProject(":wear:compose:integration-tests:demos", "wear/compose/integration-tests/demos", [BuildType.COMPOSE])
@@ -655,7 +657,7 @@
includeProject(":webkit:webkit", "webkit/webkit", [BuildType.MAIN])
includeProject(":window:window", "window/window", [BuildType.MAIN, BuildType.FLAN])
includeProject(":window:window-extensions", "window/window-extensions", [BuildType.MAIN, BuildType.FLAN])
-includeProject(":window:window-java", "window/window-java", [BuildType.MAIN, BuildType.FLAN])
+includeProject(":window:window-java", "window/window-java", [BuildType.MAIN])
includeProject(":window:window-rxjava2", "window/window-rxjava2", [BuildType.MAIN])
includeProject(":window:window-rxjava3", "window/window-rxjava3", [BuildType.MAIN])
includeProject(":window:window-samples", "window/window-samples", [BuildType.MAIN])
diff --git a/slidingpanelayout/slidingpanelayout/build.gradle b/slidingpanelayout/slidingpanelayout/build.gradle
index 39d7795..a55b4ec 100644
--- a/slidingpanelayout/slidingpanelayout/build.gradle
+++ b/slidingpanelayout/slidingpanelayout/build.gradle
@@ -13,7 +13,6 @@
implementation("androidx.core:core:1.1.0")
api("androidx.customview:customview:1.1.0")
implementation(project(":window:window"))
- implementation(project(":window:window-java"))
androidTestImplementation(libs.testExtJunit)
androidTestImplementation(libs.testRunner)
diff --git a/slidingpanelayout/slidingpanelayout/src/androidTest/java/androidx/slidingpanelayout/widget/FoldingFeatureObserverTest.kt b/slidingpanelayout/slidingpanelayout/src/androidTest/java/androidx/slidingpanelayout/widget/FoldingFeatureObserverTest.kt
new file mode 100644
index 0000000..abe8864
--- /dev/null
+++ b/slidingpanelayout/slidingpanelayout/src/androidTest/java/androidx/slidingpanelayout/widget/FoldingFeatureObserverTest.kt
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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 androidx.slidingpanelayout.widget.helpers.TestActivity
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.window.FoldingFeature
+import androidx.window.WindowLayoutInfo
+import androidx.window.testing.FoldingFeature
+import androidx.window.testing.WindowLayoutInfoPublisherRule
+import androidx.window.windowInfoRepository
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+
+class FoldingFeatureObserverTest {
+ @get:Rule
+ val windowInfoPublisherRule: WindowLayoutInfoPublisherRule = WindowLayoutInfoPublisherRule()
+
+ @get:Rule
+ val activityScenarioRule: ActivityScenarioRule<TestActivity> =
+ ActivityScenarioRule(TestActivity::class.java)
+
+ @Test
+ fun testNoValuesBeforeSubscribe() {
+ val listener = TestListener()
+ activityScenarioRule.scenario.onActivity { activity ->
+ val observer = FoldingFeatureObserver(activity.windowInfoRepository(), Runnable::run)
+ val expected = FoldingFeature(activity = activity)
+ val info = WindowLayoutInfo.Builder().setDisplayFeatures(listOf(expected)).build()
+
+ observer.setOnFoldingFeatureChangeListener(listener)
+ windowInfoPublisherRule.overrideWindowLayoutInfo(info)
+
+ listener.assertCount(0)
+ }
+ }
+
+ @Test
+ fun testRelaysValuesFromWindowInfoRepo() {
+ val listener = TestListener()
+ activityScenarioRule.scenario.onActivity { activity ->
+ val observer = FoldingFeatureObserver(activity.windowInfoRepository(), Runnable::run)
+ val expected = FoldingFeature(activity = activity)
+ val info = WindowLayoutInfo.Builder().setDisplayFeatures(listOf(expected)).build()
+
+ observer.setOnFoldingFeatureChangeListener(listener)
+ observer.registerLayoutStateChangeCallback()
+ windowInfoPublisherRule.overrideWindowLayoutInfo(info)
+
+ listener.assertValue(expected)
+ }
+ }
+
+ @Test
+ fun testRelaysValuesNotRelayedAfterUnsubscribed() {
+ val listener = TestListener()
+ activityScenarioRule.scenario.onActivity { activity ->
+ val observer = FoldingFeatureObserver(activity.windowInfoRepository(), Runnable::run)
+ val expected = FoldingFeature(activity = activity)
+ val info = WindowLayoutInfo.Builder().setDisplayFeatures(listOf(expected)).build()
+
+ observer.setOnFoldingFeatureChangeListener(listener)
+ observer.registerLayoutStateChangeCallback()
+ observer.unregisterLayoutStateChangeCallback()
+ windowInfoPublisherRule.overrideWindowLayoutInfo(info)
+
+ listener.assertCount(0)
+ }
+ }
+
+ private class TestListener : FoldingFeatureObserver.OnFoldingFeatureChangeListener {
+ private val features = mutableListOf<FoldingFeature>()
+
+ override fun onFoldingFeatureChange(foldingFeature: FoldingFeature) {
+ features.add(foldingFeature)
+ }
+
+ fun assertCount(count: Int) {
+ assertEquals(count, features.size)
+ }
+
+ fun assertValue(expected: FoldingFeature) {
+ assertCount(1)
+ assertEquals(expected, features.first())
+ }
+ }
+}
\ No newline at end of file
diff --git a/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/FoldingFeatureObserver.kt b/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/FoldingFeatureObserver.kt
new file mode 100644
index 0000000..4d216a4
--- /dev/null
+++ b/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/FoldingFeatureObserver.kt
@@ -0,0 +1,91 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.app.Activity
+import androidx.window.FoldingFeature
+import androidx.window.WindowInfoRepo
+import androidx.window.WindowLayoutInfo
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.asCoroutineDispatcher
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.mapNotNull
+import kotlinx.coroutines.launch
+import java.util.concurrent.Executor
+
+/**
+ * A device folding feature observer is used to notify listener when there is a folding feature
+ * change.
+ */
+internal class FoldingFeatureObserver(
+ private val windowInfoRepo: WindowInfoRepo,
+ private val executor: Executor
+) {
+ private var job: Job? = null
+ private var onFoldingFeatureChangeListener: OnFoldingFeatureChangeListener? = null
+
+ /**
+ * Interface definition for a callback to be invoked when there is a folding feature change
+ */
+ internal interface OnFoldingFeatureChangeListener {
+ /**
+ * Callback method to update window layout when there is a folding feature change
+ */
+ fun onFoldingFeatureChange(foldingFeature: FoldingFeature)
+ }
+
+ /**
+ * Register a listener that can be notified when there is a folding feature change.
+ *
+ * @param onFoldingFeatureChangeListener The listener to be added
+ */
+ fun setOnFoldingFeatureChangeListener(
+ onFoldingFeatureChangeListener: OnFoldingFeatureChangeListener
+ ) {
+ this.onFoldingFeatureChangeListener = onFoldingFeatureChangeListener
+ }
+
+ /**
+ * Registers a callback for layout changes of the window for the supplied [Activity].
+ * Must be called only after the it is attached to the window.
+ */
+ fun registerLayoutStateChangeCallback() {
+ job?.cancel()
+ job = CoroutineScope(executor.asCoroutineDispatcher()).launch {
+ windowInfoRepo.windowLayoutInfo
+ .mapNotNull { info -> getFoldingFeature(info) }
+ .distinctUntilChanged()
+ .collect { nextFeature ->
+ onFoldingFeatureChangeListener?.onFoldingFeatureChange(nextFeature)
+ }
+ }
+ }
+
+ /**
+ * Unregisters a callback for window layout changes of the [Activity] window.
+ */
+ fun unregisterLayoutStateChangeCallback() {
+ job?.cancel()
+ }
+
+ private fun getFoldingFeature(windowLayoutInfo: WindowLayoutInfo): FoldingFeature? {
+ return windowLayoutInfo.displayFeatures
+ .firstOrNull { feature -> feature is FoldingFeature } as? FoldingFeature
+ }
+}
\ No newline at end of file
diff --git a/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/SlidingPaneLayout.java b/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/SlidingPaneLayout.java
index 6b45a92..8d1e1c1d 100644
--- a/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/SlidingPaneLayout.java
+++ b/slidingpanelayout/slidingpanelayout/src/main/java/androidx/slidingpanelayout/widget/SlidingPaneLayout.java
@@ -45,7 +45,6 @@
import androidx.annotation.VisibleForTesting;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.Insets;
-import androidx.core.util.Consumer;
import androidx.core.view.AccessibilityDelegateCompat;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
@@ -53,11 +52,8 @@
import androidx.customview.view.AbsSavedState;
import androidx.customview.widget.Openable;
import androidx.customview.widget.ViewDragHelper;
-import androidx.window.DisplayFeature;
import androidx.window.FoldingFeature;
import androidx.window.WindowInfoRepo;
-import androidx.window.WindowLayoutInfo;
-import androidx.window.java.WindowInfoRepoCallbackAdapter;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@@ -331,7 +327,11 @@
mDragHelper.setMinVelocity(MIN_FLING_VELOCITY * density);
try {
- FoldingFeatureObserver foldingFeatureObserver = new FoldingFeatureObserver(context);
+ Activity activity = requireActivity(context);
+ WindowInfoRepo repo = WindowInfoRepo.create(activity);
+ Executor mainExecutor = ContextCompat.getMainExecutor(activity);
+ FoldingFeatureObserver foldingFeatureObserver = new FoldingFeatureObserver(repo,
+ mainExecutor);
setFoldingFeatureObserver(foldingFeatureObserver);
} catch (IllegalArgumentException exception) {
// Disable fold detection.
@@ -1880,108 +1880,17 @@
return foldRectInView;
}
- /**
- * A device folding feature observer is used to notify listener when there is a folding feature
- * change.
- */
- @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
- static class FoldingFeatureObserver {
- /**
- * Interface definition for a callback to be invoked when there is a folding feature change
- */
- private interface OnFoldingFeatureChangeListener {
- /**
- * Callback method to update window layout when there is a folding feature change
- */
- void onFoldingFeatureChange(@NonNull FoldingFeature foldingFeature);
- }
-
- class LayoutStateChangeCallback implements Consumer<WindowLayoutInfo> {
- private FoldingFeature mLastFoldingFeature;
-
- @Override
- public void accept(WindowLayoutInfo windowLayoutInfo) {
- final FoldingFeature currentFoldingFeature = getFoldingFeature(windowLayoutInfo);
- if (currentFoldingFeature != null) {
- // Update window layout when folding feature changed
- if (!currentFoldingFeature.equals(mLastFoldingFeature)) {
- dispatchOnFoldingFeatureChange(currentFoldingFeature);
- }
- mLastFoldingFeature = currentFoldingFeature;
- }
+ private static Activity requireActivity(Context context) {
+ Context iterator = context;
+ while (iterator instanceof ContextWrapper) {
+ if (iterator instanceof Activity) {
+ return (Activity) iterator;
}
-
- private FoldingFeature getFoldingFeature(WindowLayoutInfo windowLayoutInfo) {
- for (DisplayFeature displayFeature : windowLayoutInfo.getDisplayFeatures()) {
- if (displayFeature instanceof FoldingFeature) {
- return (FoldingFeature) displayFeature;
- }
- }
- return null;
- }
+ iterator = ((ContextWrapper) iterator).getBaseContext();
}
-
- private final WindowInfoRepoCallbackAdapter mWindowInfoRepo;
- private final Executor mExecutor;
- private OnFoldingFeatureChangeListener mOnFoldingFeatureChangeListener;
- private final LayoutStateChangeCallback mLayoutStateChangeCallback =
- new LayoutStateChangeCallback();
-
- /**
- * Create an instance of a folding feature observer
- *
- * @param context A visual context, such as an {@link Activity} or a {@link ContextWrapper}
- */
- FoldingFeatureObserver(@NonNull Context context) {
- Activity activity = requireActivity(context);
- mWindowInfoRepo = new WindowInfoRepoCallbackAdapter(WindowInfoRepo.create(activity));
- mExecutor = ContextCompat.getMainExecutor(context);
- }
-
- /**
- * Register a listener that can be notified when there is a folding feature change.
- *
- * @param onFoldingFeatureChangeListener The listener to be added
- */
- void setOnFoldingFeatureChangeListener(
- @NonNull OnFoldingFeatureChangeListener onFoldingFeatureChangeListener) {
- mOnFoldingFeatureChangeListener = onFoldingFeatureChangeListener;
- }
-
- void dispatchOnFoldingFeatureChange(@NonNull FoldingFeature foldingFeature) {
- if (mOnFoldingFeatureChangeListener == null) {
- return;
- }
- mOnFoldingFeatureChangeListener.onFoldingFeatureChange(foldingFeature);
- }
-
- /**
- * Registers a callback for layout changes of the window for the supplied {@link Activity}.
- * Must be called only after the it is attached to the window.
- */
- void registerLayoutStateChangeCallback() {
- mWindowInfoRepo.addWindowLayoutInfoListener(mExecutor, mLayoutStateChangeCallback);
- }
-
- /**
- * Unregisters a callback for window layout changes of the {@link Activity} window.
- */
- void unregisterLayoutStateChangeCallback() {
- mWindowInfoRepo.removeWindowLayoutInfoListener(mLayoutStateChangeCallback);
- }
-
- private static Activity requireActivity(Context context) {
- Context iterator = context;
- while (iterator instanceof ContextWrapper) {
- if (iterator instanceof Activity) {
- return (Activity) iterator;
- }
- iterator = ((ContextWrapper) iterator).getBaseContext();
- }
- throw new IllegalArgumentException("Used non-visual Context to obtain an instance of "
- + "WindowManager. Please use an Activity or a ContextWrapper around one "
- + "instead."
- );
- }
+ throw new IllegalArgumentException("Used non-visual Context to obtain an instance of "
+ + "WindowManager. Please use an Activity or a ContextWrapper around one "
+ + "instead."
+ );
}
}
diff --git a/startup/startup-runtime-lint/build.gradle b/startup/startup-runtime-lint/build.gradle
index 85ddbf3..a5e584b 100644
--- a/startup/startup-runtime-lint/build.gradle
+++ b/startup/startup-runtime-lint/build.gradle
@@ -23,7 +23,7 @@
}
dependencies {
- compileOnly(libs.androidLintApi)
+ compileOnly(libs.androidLintMinApi)
compileOnly(libs.kotlinStdlib)
testImplementation(libs.kotlinStdlib)
diff --git a/startup/startup-runtime-lint/src/main/java/androidx/startup/lint/StartupRuntimeIssueRegistry.kt b/startup/startup-runtime-lint/src/main/java/androidx/startup/lint/StartupRuntimeIssueRegistry.kt
index 8bcf291..9cbccb3 100644
--- a/startup/startup-runtime-lint/src/main/java/androidx/startup/lint/StartupRuntimeIssueRegistry.kt
+++ b/startup/startup-runtime-lint/src/main/java/androidx/startup/lint/StartupRuntimeIssueRegistry.kt
@@ -19,7 +19,6 @@
package androidx.startup.lint
import com.android.tools.lint.client.api.IssueRegistry
-import com.android.tools.lint.client.api.Vendor
import com.android.tools.lint.detector.api.CURRENT_API
import com.android.tools.lint.detector.api.Issue
@@ -34,8 +33,4 @@
InitializerConstructorDetector.ISSUE,
EnsureInitializerMetadataDetector.ISSUE
)
- override val vendor = Vendor(
- vendorName = "Android Open Source Project",
- feedbackUrl = "https://issuetracker.google.com/issues/new?component=823348"
- )
}
diff --git a/startup/startup-runtime-lint/src/test/java/androidx/startup/lint/ApiLintVersionsTest.kt b/startup/startup-runtime-lint/src/test/java/androidx/startup/lint/ApiLintVersionsTest.kt
index 596f449..561f972 100644
--- a/startup/startup-runtime-lint/src/test/java/androidx/startup/lint/ApiLintVersionsTest.kt
+++ b/startup/startup-runtime-lint/src/test/java/androidx/startup/lint/ApiLintVersionsTest.kt
@@ -36,6 +36,6 @@
// We hardcode version registry.api to the version that is used to run tests.
assertEquals("registry.api matches version used to run tests", CURRENT_API, registry.api)
// Intentionally fails in IDE, because we use different API version in Studio and CLI.
- assertEquals("registry.minApi is set to minimum level of 10", 10, registry.minApi)
+ assertEquals("registry.minApi is set to minimum level of 8", 8, registry.minApi)
}
}
diff --git a/wear/benchmark/integration-tests/macrobenchmark-target/build.gradle b/wear/benchmark/integration-tests/macrobenchmark-target/build.gradle
new file mode 100644
index 0000000..76ae77a
--- /dev/null
+++ b/wear/benchmark/integration-tests/macrobenchmark-target/build.gradle
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.
+ */
+
+plugins {
+ id("AndroidXPlugin")
+ id 'com.android.application'
+ id 'kotlin-android'
+}
+
+android {
+ buildTypes {
+ release {
+ minifyEnabled true
+ shrinkResources true
+ proguardFiles getDefaultProguardFile("proguard-android-optimize.txt")
+ }
+ }
+}
+
+dependencies {
+ implementation(libs.kotlinStdlib)
+ implementation(libs.constraintLayout)
+ implementation projectOrArtifact(":activity:activity-ktx")
+ implementation 'androidx.core:core-ktx'
+ implementation(libs.material)
+}
diff --git a/wear/benchmark/integration-tests/macrobenchmark-target/src/main/AndroidManifest.xml b/wear/benchmark/integration-tests/macrobenchmark-target/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..0aaac14
--- /dev/null
+++ b/wear/benchmark/integration-tests/macrobenchmark-target/src/main/AndroidManifest.xml
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright 2021 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ 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.
+ -->
+
+<manifest
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ package="androidx.wear.benchmark.integration.macrobenchmark.target">
+
+ <application
+ android:label="wear Macrobenchmark Target"
+ android:allowBackup="false"
+ android:supportsRtl="true"
+ android:theme="@android:style/Theme.DeviceDefault"
+ tools:ignore="MissingApplicationIcon">
+
+ <!-- Profileable to enable macrobenchmark profiling -->
+ <!--suppress AndroidElementNotAllowed -->
+ <profileable android:shell="true"/>
+
+ <!--
+ Activities need to be exported so the macrobenchmark can discover them
+ under the new package visibility changes for Android 11.
+ -->
+ <activity
+ android:name=".StartActivity"
+ android:exported="true">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.LAUNCHER" />
+ </intent-filter>
+ <intent-filter>
+ <action
+ android:name=
+ "androidx.wear.benchmark.integration.macrobenchmark.target.STARTUP_ACTIVITY" />
+ <category android:name="android.intent.category.DEFAULT" />
+ </intent-filter>
+ </activity>
+ </application>
+</manifest>
\ No newline at end of file
diff --git a/wear/benchmark/integration-tests/macrobenchmark-target/src/main/java/androidx/wear/benchmark/integration/macrobenchmark/target/StartActivity.kt b/wear/benchmark/integration-tests/macrobenchmark-target/src/main/java/androidx/wear/benchmark/integration/macrobenchmark/target/StartActivity.kt
new file mode 100644
index 0000000..d6e4c18
--- /dev/null
+++ b/wear/benchmark/integration-tests/macrobenchmark-target/src/main/java/androidx/wear/benchmark/integration/macrobenchmark/target/StartActivity.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.benchmark.integration.macrobenchmark.target
+
+import android.app.Activity
+import android.os.Bundle
+import android.widget.TextView
+
+class StartActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_start)
+
+ val txt = findViewById<TextView>(R.id.text)
+ txt.setText("Wear Macrobenchmark Target")
+ }
+}
\ No newline at end of file
diff --git a/wear/benchmark/integration-tests/macrobenchmark-target/src/main/res/layout/activity_start.xml b/wear/benchmark/integration-tests/macrobenchmark-target/src/main/res/layout/activity_start.xml
new file mode 100644
index 0000000..8ca9e47
--- /dev/null
+++ b/wear/benchmark/integration-tests/macrobenchmark-target/src/main/res/layout/activity_start.xml
@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright 2021 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ 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.
+ -->
+
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:padding="0dp"
+ tools:context=".StartActivity"
+ tools:deviceIds="wear">
+
+ <TextView
+ android:id="@+id/text"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_gravity="center"
+ android:text="Preview text" />
+
+</FrameLayout>
\ No newline at end of file
diff --git a/wear/benchmark/integration-tests/macrobenchmark/build.gradle b/wear/benchmark/integration-tests/macrobenchmark/build.gradle
new file mode 100644
index 0000000..67fa2c2
--- /dev/null
+++ b/wear/benchmark/integration-tests/macrobenchmark/build.gradle
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.
+ */
+
+plugins {
+ id("AndroidXPlugin")
+ id 'com.android.library'
+ id 'kotlin-android'
+}
+
+android {
+ defaultConfig {
+ minSdkVersion 29
+ }
+}
+
+dependencies {
+ androidTestImplementation(project(":benchmark:benchmark-junit4"))
+ androidTestImplementation(project(":benchmark:benchmark-macro-junit4"))
+ androidTestImplementation(project(":internal-testutils-macrobenchmark"))
+ androidTestImplementation(libs.testRules)
+ androidTestImplementation(libs.testExtJunit)
+ androidTestImplementation(libs.testCore)
+ androidTestImplementation(libs.testRunner)
+ androidTestImplementation(libs.testUiautomator)
+}
+
+// Define a task dependency so the app is installed before we run macro benchmarks.
+tasks.getByPath(":wear:benchmark:integration-tests:macrobenchmark:connectedCheck")
+ .dependsOn(tasks.getByPath(
+ ":wear:benchmark:integration-tests:macrobenchmark-target:installRelease"))
\ No newline at end of file
diff --git a/wear/benchmark/integration-tests/macrobenchmark/src/androidTest/AndroidManifest.xml b/wear/benchmark/integration-tests/macrobenchmark/src/androidTest/AndroidManifest.xml
new file mode 100644
index 0000000..c9becec4
--- /dev/null
+++ b/wear/benchmark/integration-tests/macrobenchmark/src/androidTest/AndroidManifest.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright 2021 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ 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.
+ -->
+
+<manifest package="androidx.wear.benchmark.integration.macrobenchmark.test"/>
diff --git a/wear/benchmark/integration-tests/macrobenchmark/src/androidTest/java/androidx/wear/benchmark/integration/macrobenchmark/StartupBenchmark.kt b/wear/benchmark/integration-tests/macrobenchmark/src/androidTest/java/androidx/wear/benchmark/integration/macrobenchmark/StartupBenchmark.kt
new file mode 100644
index 0000000..87063f6
--- /dev/null
+++ b/wear/benchmark/integration-tests/macrobenchmark/src/androidTest/java/androidx/wear/benchmark/integration/macrobenchmark/StartupBenchmark.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.benchmark.integration.macrobenchmark
+
+import androidx.benchmark.macro.CompilationMode
+import androidx.benchmark.macro.StartupMode
+import androidx.benchmark.macro.junit4.MacrobenchmarkRule
+import androidx.test.filters.LargeTest
+import androidx.testutils.createStartupCompilationParams
+import androidx.testutils.measureStartup
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@LargeTest
+@RunWith(Parameterized::class)
+class StartupBenchmark(
+ private val startupMode: StartupMode,
+ private val compilationMode: CompilationMode
+) {
+ @get:Rule
+ val benchmarkRule = MacrobenchmarkRule()
+
+ @Test
+ fun startup() = benchmarkRule.measureStartup(
+ compilationMode = compilationMode,
+ startupMode = startupMode,
+ packageName = "androidx.wear.benchmark.integration.macrobenchmark.target"
+ ) {
+ action = "androidx.wear.benchmark.integration.macrobenchmark.target" + ".STARTUP_ACTIVITY"
+ }
+
+ companion object {
+ @Parameterized.Parameters(name = "startup={0},compilation={1}")
+ @JvmStatic
+ fun parameters() = createStartupCompilationParams()
+ }
+}
\ No newline at end of file
diff --git a/wear/benchmark/integration-tests/macrobenchmark/src/main/AndroidManifest.xml b/wear/benchmark/integration-tests/macrobenchmark/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..86fc508
--- /dev/null
+++ b/wear/benchmark/integration-tests/macrobenchmark/src/main/AndroidManifest.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright 2021 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ 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.
+ -->
+
+<manifest package="androidx.wear.benchmark.integration.macrobenchmark" />
\ No newline at end of file
diff --git a/wear/compose/foundation/api/current.txt b/wear/compose/foundation/api/current.txt
index e6f50d0..f7ec156 100644
--- a/wear/compose/foundation/api/current.txt
+++ b/wear/compose/foundation/api/current.txt
@@ -1 +1,22 @@
// Signature format: 4.0
+package androidx.wear.compose.foundation {
+
+ public final inline class AnchorType {
+ ctor public AnchorType();
+ }
+
+ public static final class AnchorType.Companion {
+ method public float getCenter();
+ method public float getEnd();
+ method public float getStart();
+ property public final float Center;
+ property public final float End;
+ property public final float Start;
+ }
+
+ public final class CurvedRowKt {
+ method @androidx.compose.runtime.Composable public static void CurvedRow(optional androidx.compose.ui.Modifier modifier, optional float anchor, optional float anchorType, optional boolean clockwise, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+ }
+
+}
+
diff --git a/wear/compose/foundation/api/public_plus_experimental_current.txt b/wear/compose/foundation/api/public_plus_experimental_current.txt
index e6f50d0..f7ec156 100644
--- a/wear/compose/foundation/api/public_plus_experimental_current.txt
+++ b/wear/compose/foundation/api/public_plus_experimental_current.txt
@@ -1 +1,22 @@
// Signature format: 4.0
+package androidx.wear.compose.foundation {
+
+ public final inline class AnchorType {
+ ctor public AnchorType();
+ }
+
+ public static final class AnchorType.Companion {
+ method public float getCenter();
+ method public float getEnd();
+ method public float getStart();
+ property public final float Center;
+ property public final float End;
+ property public final float Start;
+ }
+
+ public final class CurvedRowKt {
+ method @androidx.compose.runtime.Composable public static void CurvedRow(optional androidx.compose.ui.Modifier modifier, optional float anchor, optional float anchorType, optional boolean clockwise, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+ }
+
+}
+
diff --git a/wear/compose/foundation/api/restricted_current.txt b/wear/compose/foundation/api/restricted_current.txt
index e6f50d0..f7ec156 100644
--- a/wear/compose/foundation/api/restricted_current.txt
+++ b/wear/compose/foundation/api/restricted_current.txt
@@ -1 +1,22 @@
// Signature format: 4.0
+package androidx.wear.compose.foundation {
+
+ public final inline class AnchorType {
+ ctor public AnchorType();
+ }
+
+ public static final class AnchorType.Companion {
+ method public float getCenter();
+ method public float getEnd();
+ method public float getStart();
+ property public final float Center;
+ property public final float End;
+ property public final float Start;
+ }
+
+ public final class CurvedRowKt {
+ method @androidx.compose.runtime.Composable public static void CurvedRow(optional androidx.compose.ui.Modifier modifier, optional float anchor, optional float anchorType, optional boolean clockwise, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+ }
+
+}
+
diff --git a/wear/compose/foundation/build.gradle b/wear/compose/foundation/build.gradle
index ead85ed..21cdf26 100644
--- a/wear/compose/foundation/build.gradle
+++ b/wear/compose/foundation/build.gradle
@@ -30,9 +30,21 @@
dependencies {
kotlinPlugin("androidx.compose.compiler:compiler:1.0.0-rc01")
- implementation(libs.kotlinStdlib)
-}
+ if(!AndroidXComposePlugin.isMultiplatformEnabled(project)) {
+ api(project(":compose:foundation:foundation"))
+ api(project(":compose:ui:ui"))
+ api(project(":compose:ui:ui-text"))
+ api(project(":compose:runtime:runtime"))
+ implementation(libs.kotlinStdlib)
+ implementation(project(":compose:foundation:foundation-layout"))
+
+ androidTestImplementation project(path: ':compose:ui:ui-test')
+ androidTestImplementation project(path: ':compose:ui:ui-test-junit4')
+ androidTestImplementation project(path: ':compose:test-utils')
+ androidTestImplementation(libs.testRunner)
+ }
+}
if(AndroidXComposePlugin.isMultiplatformEnabled(project)) {
kotlin {
@@ -45,7 +57,13 @@
*/
sourceSets {
commonMain.dependencies {
+ api(project(":compose:foundation:foundation"))
+ api(project(":compose:ui:ui"))
+ api(project(":compose:ui:ui-text"))
+ api(project(":compose:runtime:runtime"))
+
implementation(libs.kotlinStdlibCommon)
+ implementation(project(":compose:foundation:foundation-layout"))
}
jvmMain.dependencies {
implementation(libs.kotlinStdlib)
diff --git a/wear/compose/foundation/samples/build.gradle b/wear/compose/foundation/samples/build.gradle
new file mode 100644
index 0000000..fc01a79
--- /dev/null
+++ b/wear/compose/foundation/samples/build.gradle
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.
+ */
+
+import androidx.build.LibraryGroups
+import androidx.build.LibraryType
+import androidx.build.LibraryVersions
+
+plugins {
+ id("AndroidXPlugin")
+ id("com.android.library")
+ id("AndroidXComposePlugin")
+ id("org.jetbrains.kotlin.android")
+}
+
+dependencies {
+ kotlinPlugin(project(":compose:compiler:compiler"))
+
+ implementation(libs.kotlinStdlib)
+
+ compileOnly(project(":annotation:annotation-sampled"))
+ implementation(project(":compose:foundation:foundation"))
+ implementation(project(":compose:foundation:foundation-layout"))
+ implementation(project(":compose:runtime:runtime"))
+ implementation(project(":compose:ui:ui"))
+ implementation(project(":compose:ui:ui-text"))
+ implementation(project(":wear:compose:compose-foundation"))
+}
+
+androidx {
+ name = "Android Wear Compose Foundation Samples"
+ type = LibraryType.SAMPLES
+ mavenGroup = LibraryGroups.WEAR_COMPOSE
+ inceptionYear = "2021"
+ description = "Contains the sample code for the Android Wear Compose Foundation Classes"
+}
diff --git a/wear/compose/foundation/samples/src/main/AndroidManifest.xml b/wear/compose/foundation/samples/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..12512aa
--- /dev/null
+++ b/wear/compose/foundation/samples/src/main/AndroidManifest.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ Copyright 2021 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ 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.
+ -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ package="androidx.wear.compose.foundation.samples">
+ <uses-sdk android:minSdkVersion="25"
+ tools:overrideLibrary="androidx.wear.compose.foundation"/>
+</manifest>
diff --git a/wear/compose/foundation/samples/src/main/java/androidx/wear/compose/foundation/samples/CurvedRowSample.kt b/wear/compose/foundation/samples/src/main/java/androidx/wear/compose/foundation/samples/CurvedRowSample.kt
new file mode 100644
index 0000000..0a1baba
--- /dev/null
+++ b/wear/compose/foundation/samples/src/main/java/androidx/wear/compose/foundation/samples/CurvedRowSample.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.samples
+
+import androidx.annotation.Sampled
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.wear.compose.foundation.CurvedRow
+
+@Sampled
+@Composable
+fun SimpleCurvedRow() {
+ CurvedRow(
+ modifier = Modifier.fillMaxSize()
+ ) {
+ BasicText(
+ "Simple",
+ Modifier.background(Color.White).padding(2.dp),
+ TextStyle(
+ color = Color.Black,
+ fontSize = 16.sp,
+ )
+ )
+ Box(
+ modifier = Modifier
+ .size(20.dp)
+ .background(Color.Gray)
+ )
+ BasicText(
+ "CurvedRow",
+ Modifier.background(Color.White).padding(2.dp),
+ TextStyle(
+ color = Color.Black,
+ fontSize = 16.sp,
+ )
+ )
+ }
+}
\ No newline at end of file
diff --git a/wear/compose/foundation/src/commonMain/kotlin/androidx/wear/compose/foundation/CurvedRow.kt b/wear/compose/foundation/src/commonMain/kotlin/androidx/wear/compose/foundation/CurvedRow.kt
new file mode 100644
index 0000000..a18bcc8
--- /dev/null
+++ b/wear/compose/foundation/src/commonMain/kotlin/androidx/wear/compose/foundation/CurvedRow.kt
@@ -0,0 +1,213 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.TransformOrigin
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.layout.Measurable
+import androidx.compose.ui.layout.Placeable
+import androidx.compose.ui.unit.Constraints
+import kotlin.math.PI
+import kotlin.math.asin
+import kotlin.math.cos
+import kotlin.math.min
+import kotlin.math.sin
+import kotlin.math.sqrt
+
+/**
+ * Specifies how components will be laid down with respect to the anchor.
+ */
+@Suppress("INLINE_CLASS_DEPRECATED")
+inline class AnchorType internal constructor(internal val ratio: Float) {
+ companion object {
+ // Start the content of the CurvedRow on the anchor
+ val Start = AnchorType(0f)
+ // Center the content of the CurvedRow around the anchor
+ val Center = AnchorType(0.5f)
+ // End the content of the CurvedRow on the anchor
+ val End = AnchorType(1f)
+ }
+
+ override fun toString(): String {
+ return when (this) {
+ Center -> "AnchorType.Center"
+ Start -> "AnchorType.Start"
+ else -> "AnchorType.End"
+ }
+ }
+}
+
+/**
+ * A layout composable that places its children in an arc, rotating them as needed. This is
+ * similar to a [Row] layout, that it's curved into a segment of an annulus.
+ *
+ * The thickness of the layout (the difference between the outer and inner radius) will be the
+ * same as the thickest child, and the total angle taken is the sum of the children's angles.
+ *
+ * Example usage:
+ * @sample androidx.wear.compose.foundation.samples.SimpleCurvedRow
+ *
+ * @param modifier The modifier to be applied to the CurvedRow.
+ * @param anchor The angle at which children are laid out relative to, in degrees. An angle of 0
+ * corresponds to the right (3 o'clock on a watch), 90 degrees is bottom (6 o'clock), and so on.
+ * Default is 270 degrees (top of the screen)
+ * @param anchorType Specify how the content is drawn with respect to the anchor. Default is to
+ * center the content on the anchor.
+ * @param clockwise Specify if the children are laid out clockwise (the default) or
+ * counter-clockwise
+ */
+@Composable
+fun CurvedRow(
+ modifier: Modifier = Modifier,
+ anchor: Float = 270f,
+ anchorType: AnchorType = AnchorType.Center,
+ clockwise: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ // Note that all angles in the function are in radians, and the anchor parameter is in degrees
+ Box(modifier = modifier) {
+ Layout(
+ content = content
+ ) { measurables, constraints ->
+ require(constraints.hasBoundedHeight || constraints.hasBoundedWidth)
+ // We take as much room as possible, the same in both dimensions, within the constraints
+ val diameter = min(
+ if (constraints.hasBoundedWidth) constraints.maxWidth else 0,
+ if (constraints.hasBoundedHeight) constraints.maxHeight else 0,
+ )
+
+ val measuredChildren = measurables.map { m ->
+ NormalMeasuredChild(m)
+ }
+
+ // Measure the children, we only need an upper bound for the thickness of each element.
+ measuredChildren.forEach {
+ it.initialMeasurePass(diameter / 2f)
+ }
+
+ // Compute to total angle all children take and where we need to start laying them out.
+ val totalSweep = measuredChildren.map { it.sweep }.sum()
+ var childAngleStart = -anchorType.ratio * totalSweep
+
+ val clockwiseFactor = if (clockwise) 1 else -1
+
+ layout(diameter, diameter) {
+ measuredChildren.forEach { child ->
+ // Angle of the vector from the centre of the CurvedRow to the center of the child.
+ val centerAngle = anchor.toRadians() + clockwiseFactor *
+ (childAngleStart + child.sweep / 2)
+
+ child.place(diameter / 2f, scope = this, centerAngle, clockwise)
+
+ childAngleStart += child.sweep
+ }
+ }
+ }
+ }
+}
+
+private abstract class MeasuredChild(
+ val measurable: Measurable
+) {
+ lateinit var placeable: Placeable
+ var width: Int = 0
+ var height: Int = 0
+ var thickness: Float = 0f
+ var sweep: Float = 0f
+
+ abstract fun initialMeasurePass(radius: Float)
+ abstract fun place(
+ radius: Float,
+ scope: Placeable.PlacementScope,
+ centerAngle: Float,
+ clockwise: Boolean
+ )
+
+ internal fun place(
+ scope: Placeable.PlacementScope,
+ positionX: Float,
+ positionY: Float,
+ rotation: Float
+ ) {
+ with(scope) {
+ placeable.placeRelativeWithLayer(
+ x = positionX.toInt(),
+ y = positionY.toInt(),
+ layerBlock = {
+ rotationZ = rotation.toDegrees() - 270f
+ transformOrigin = TransformOrigin(0.5f, 0.5f)
+ }
+ )
+ }
+ }
+}
+
+private class NormalMeasuredChild(measurable: Measurable) : MeasuredChild(measurable) {
+
+ override fun initialMeasurePass(radius: Float) {
+ // This is the size biggest square box that fits in half a circle
+ val biggestSize = (radius * sqrt(2f)).toInt()
+ val actualConstraint = Constraints(maxWidth = biggestSize, maxHeight = biggestSize)
+ placeable = measurable.measure(actualConstraint)
+ width = placeable.width
+ height = placeable.height
+
+ // Distance we want from the center of the CurvedRow to the Top Center of the child's
+ // containing box.
+ val radiusInBox = sqrt(sqr(radius) - sqr(width / 2f))
+
+ val innerRadius = sqrt(sqr(width / 2f) + sqr(radiusInBox - height))
+ thickness = radius - innerRadius
+
+ sweep = 2 * asin(width / 2 / innerRadius)
+ }
+
+ override fun place(
+ radius: Float,
+ scope: Placeable.PlacementScope,
+ centerAngle: Float,
+ clockwise: Boolean
+ ) {
+ // Distance from the center of the CurvedRow to the top left of the component.
+ val radiusToTopLeft = radius
+
+ // Distance from the center of the CurvedRow to the top center of the component.
+ val radiusToTopCenter = sqrt(sqr(radiusToTopLeft) - sqr(width / 2f))
+
+ // To position this child, we move its center rotating it around the CurvedRow's center.
+ val radiusToCenter = radiusToTopCenter - height / 2f
+ val childCenterX = radius + radiusToCenter * cos(centerAngle)
+ val childCenterY = radius / 2f + radiusToCenter * sin(centerAngle)
+
+ // Then compute the position of the top left corner given that center.
+ val positionX = childCenterX - width / 2f
+ val positionY = childCenterY - height / 2f
+
+ val rotationAngle = if (clockwise) centerAngle else centerAngle + PI.toFloat()
+
+ place(scope, positionX, positionY, rotationAngle)
+ }
+
+ fun sqr(x: Float): Float = x * x
+}
+
+private fun Float.toRadians() = this * PI.toFloat() / 180f
+private fun Float.toDegrees() = this * 180f / PI.toFloat()
diff --git a/wear/compose/integration-tests/demos/build.gradle b/wear/compose/integration-tests/demos/build.gradle
index a82173a..84713d9 100644
--- a/wear/compose/integration-tests/demos/build.gradle
+++ b/wear/compose/integration-tests/demos/build.gradle
@@ -49,11 +49,14 @@
implementation 'androidx.wear:wear:1.1.0'
implementation(project(":compose:ui:ui"))
- implementation project(path: ':compose:integration-tests:demos:common')
- implementation project(path: ':wear:compose:compose-material')
+ implementation(project(':compose:integration-tests:demos:common'))
implementation(project(":compose:foundation:foundation"))
implementation(project(":compose:foundation:foundation-layout"))
+
implementation(project(":compose:runtime:runtime"))
+ implementation(project(':wear:compose:compose-material'))
+ implementation(project(':wear:compose:compose-foundation'))
+ implementation(project(":wear:compose:compose-foundation-samples"))
androidTestImplementation(project(":compose:ui:ui-test-junit4"))
androidTestImplementation(libs.testCore)
diff --git a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/ButtonDemo.kt b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/ButtonDemo.kt
index 757d704..e763c45 100644
--- a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/ButtonDemo.kt
+++ b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/ButtonDemo.kt
@@ -16,10 +16,10 @@
package androidx.wear.compose.integration.demos
+import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
@@ -30,6 +30,7 @@
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.wear.compose.material.Button
import androidx.wear.compose.material.ButtonDefaults
@@ -39,12 +40,12 @@
import androidx.wear.compose.material.ToggleButton
@Composable
-fun ButtonDemo() {
+fun ButtonSizes() {
var enabled by remember { mutableStateOf(true) }
Column(
modifier = Modifier.fillMaxSize(),
- verticalArrangement = Arrangement.Center,
+ verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
@@ -79,9 +80,8 @@
Text("XS")
}
}
- Spacer(modifier = Modifier.size(4.dp))
Row(
- horizontalArrangement = Arrangement.Center,
+ horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
Text(
@@ -89,7 +89,6 @@
style = MaterialTheme.typography.caption2,
color = Color.White
)
- Spacer(modifier = Modifier.size(4.dp))
ToggleButton(
checked = enabled,
onCheckedChange = {
@@ -101,4 +100,116 @@
}
}
}
-}
\ No newline at end of file
+}
+
+@Composable
+fun ButtonStyles() {
+ var enabled by remember { mutableStateOf(true) }
+ val context = LocalContext.current
+
+ Column(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Button(
+ onClick = {
+ Toast.makeText(
+ context,
+ "Button: Override primary colors", Toast.LENGTH_LONG
+ ).show()
+ },
+ colors = ButtonDefaults.primaryButtonColors(
+ backgroundColor = Color.Yellow,
+ contentColor = Color.Red
+ ),
+ enabled = enabled,
+ ) {
+ DemoIcon(R.drawable.ic_accessibility_24px)
+ }
+ Button(
+ onClick = {
+ Toast.makeText(
+ context,
+ "Button: Primary colors", Toast.LENGTH_LONG
+ ).show()
+ },
+ colors = ButtonDefaults.primaryButtonColors(),
+ enabled = enabled,
+ ) {
+ DemoIcon(R.drawable.ic_accessibility_24px)
+ }
+ }
+ Text(
+ text = "Styles (Click for details)",
+ style = MaterialTheme.typography.body2,
+ color = Color.White
+ )
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Button(
+ onClick = {
+ Toast.makeText(
+ context,
+ "Button: Secondary, $enabled", Toast.LENGTH_LONG
+ ).show()
+ },
+ colors = ButtonDefaults.secondaryButtonColors(),
+ enabled = enabled,
+ ) {
+ DemoIcon(R.drawable.ic_accessibility_24px)
+ }
+ Button(
+ onClick = {
+ Toast.makeText(
+ context,
+ "Button: Small, icon only, $enabled", Toast.LENGTH_LONG
+ ).show()
+ },
+ colors = ButtonDefaults.iconButtonColors(),
+ modifier = Modifier.size(ButtonDefaults.SmallButtonSize),
+ enabled = enabled
+ ) {
+ DemoIcon(R.drawable.ic_accessibility_24px)
+ }
+ Button(
+ onClick = {
+ Toast.makeText(
+ context,
+ "Button: Large, icon only, $enabled", Toast.LENGTH_LONG
+ ).show()
+ },
+ colors = ButtonDefaults.iconButtonColors(),
+ modifier = Modifier.size(ButtonDefaults.LargeButtonSize),
+ enabled = enabled
+ ) {
+ DemoIcon(R.drawable.ic_accessibility_24px)
+ }
+ }
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Buttons Enabled",
+ style = MaterialTheme.typography.caption2,
+ color = Color.White
+ )
+ ToggleButton(
+ checked = enabled,
+ onCheckedChange = {
+ enabled = it
+ },
+ modifier = Modifier.size(ButtonDefaults.SmallButtonSize),
+ ) {
+ DemoIcon(R.drawable.ic_check_24px)
+ }
+ }
+ }
+}
diff --git a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/CardDemo.kt b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/CardDemo.kt
new file mode 100644
index 0000000..a480cb27
--- /dev/null
+++ b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/CardDemo.kt
@@ -0,0 +1,141 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.integration.demos
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.material.AppCard
+import androidx.wear.compose.material.Card
+import androidx.wear.compose.material.CardDefaults
+import androidx.wear.compose.material.MaterialTheme
+import androidx.wear.compose.material.Text
+import androidx.wear.compose.material.TitleCard
+
+@Composable
+fun CardDemo() {
+ Column(
+ modifier = Modifier.fillMaxSize()
+ .padding(start = 8.dp, end = 8.dp)
+ .verticalScroll(
+ rememberScrollState()
+ ),
+ verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Spacer(modifier = Modifier.size(30.dp))
+ Card(
+ onClick = {},
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text("Basic unopinionated chip")
+ Text("Sets the shape")
+ Text("and the background")
+ }
+ }
+ Spacer(modifier = Modifier.size(4.dp))
+ AppCard(
+ onClick = {},
+ appName = { Text("AppName") },
+ title = { Text("AppCard") },
+ time = { Text("now") },
+ body = {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text("Some body content")
+ Text("and some more body content")
+ }
+ },
+ )
+ Spacer(modifier = Modifier.size(4.dp))
+ TitleCard(
+ onClick = {},
+ title = { Text("TitleCard") },
+ time = { Text("now") },
+ body = {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text("Some body content")
+ Text("and some more body content")
+ }
+ },
+ )
+ Spacer(modifier = Modifier.size(4.dp))
+ TitleCard(
+ onClick = {},
+ title = { Text("TitleCard") },
+ body = {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text("This title card doesn't show time")
+ }
+ }
+ )
+ Spacer(modifier = Modifier.size(4.dp))
+ TitleCard(
+ onClick = {},
+ title = { Text("Custom TitleCard") },
+ body = {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text("This title card emphasises the title with custom color")
+ }
+ },
+ titleColor = Color.Yellow
+ )
+ Spacer(modifier = Modifier.size(4.dp))
+ TitleCard(
+ onClick = {},
+ title = {
+ Column {
+ Text("Custom TitleCard")
+ Text("With a Coloured Secondary Label", color = Color.Yellow)
+ }
+ },
+ body = {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text("This title card emphasises the title with custom color")
+ }
+ },
+ )
+ Spacer(modifier = Modifier.size(4.dp))
+ TitleCard(
+ onClick = {},
+ title = { Text("TitleCard With an ImageBackground") },
+ body = {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text("Text coloured to stand out on the image")
+ }
+ },
+ backgroundPainter = CardDefaults.imageBackgroundPainter(
+ backgroundImagePainter = painterResource(id = R.drawable.backgroundimage1)
+ ),
+ bodyColor = MaterialTheme.colors.onSurface,
+ titleColor = MaterialTheme.colors.onSurface,
+ )
+ Spacer(modifier = Modifier.size(30.dp))
+ }
+}
\ No newline at end of file
diff --git a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/CurvedRowDemo.kt b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/CurvedRowDemo.kt
new file mode 100644
index 0000000..d7d1e48
--- /dev/null
+++ b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/CurvedRowDemo.kt
@@ -0,0 +1,105 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.integration.demos
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.wear.compose.foundation.AnchorType
+import androidx.wear.compose.foundation.CurvedRow
+import androidx.wear.compose.material.Text
+
+@Composable
+fun CurvedRowDemo() {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ CurvedRow() {
+ Box(
+ modifier = Modifier
+ .size(20.dp)
+ .background(Color.Red)
+ )
+ Column(
+ modifier = Modifier
+ .background(Color.Gray)
+ .padding(3.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "A", color = Color.Black,
+ fontSize = 16.sp,
+ modifier = Modifier.background(Color.Blue)
+ )
+ Row {
+ Text(
+ text = "B",
+ color = Color.Black,
+ fontSize = 16.sp,
+ modifier = Modifier.background(Color.Green).padding(2.dp)
+ )
+ Text(
+ text = "C",
+ color = Color.Black,
+ fontSize = 16.sp,
+ modifier = Modifier.background(Color.Red)
+ )
+ }
+ }
+ Box(
+ modifier = Modifier
+ .size(20.dp)
+ .background(Color.Red)
+ )
+ }
+ CurvedRow(
+ anchor = 90F,
+ anchorType = AnchorType.Start,
+ clockwise = false
+ ) {
+ Text(
+ text = "Start",
+ color = Color.Black,
+ fontSize = 30.sp,
+ modifier = Modifier.background(Color.White).padding(horizontal = 10.dp)
+ )
+ }
+ CurvedRow(
+ anchor = 90F,
+ anchorType = AnchorType.End,
+ clockwise = false
+ ) {
+ Text(
+ text = "End",
+ color = Color.Black,
+ fontSize = 30.sp,
+ modifier = Modifier.background(Color.White).padding(horizontal = 10.dp)
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/DemoApp.kt b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/DemoApp.kt
index 4e8f1e6..a44e241 100644
--- a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/DemoApp.kt
+++ b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/DemoApp.kt
@@ -20,8 +20,8 @@
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.integration.demos.common.ActivityDemo
@@ -90,7 +90,11 @@
modifier = Modifier.align(Alignment.CenterHorizontally)
)
},
- modifier = Modifier.width(100.dp)
+ modifier = Modifier.fillMaxWidth().padding(
+ start = 10.dp,
+ end = 10.dp,
+ bottom = 4.dp
+ )
)
}
}
diff --git a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/DemoComponents.kt b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/DemoComponents.kt
new file mode 100644
index 0000000..1842460
--- /dev/null
+++ b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/DemoComponents.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.integration.demos
+
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.material.Icon
+
+/**
+ * A simple [Icon] with default size
+ */
+@Composable
+fun DemoIcon(
+ resourceId: Int,
+ modifier: Modifier = Modifier,
+ size: Dp = 24.dp,
+) {
+ Icon(
+ painter = painterResource(id = resourceId),
+ contentDescription = null,
+ modifier = modifier
+ .size(size)
+ .wrapContentSize(align = Alignment.Center),
+ )
+}
\ No newline at end of file
diff --git a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/Demos.kt b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/Demos.kt
index 125bb33..e58290a 100644
--- a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/Demos.kt
+++ b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/Demos.kt
@@ -24,6 +24,7 @@
val WearComposeDemos = DemoCategory(
"Wear Compose Demos",
listOf(
+ WearFoundationDemos,
WearMaterialDemos,
)
)
\ No newline at end of file
diff --git a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/FoundationDemos.kt b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/FoundationDemos.kt
new file mode 100644
index 0000000..7f5a536
--- /dev/null
+++ b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/FoundationDemos.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.integration.demos
+
+import androidx.compose.integration.demos.common.ComposableDemo
+import androidx.compose.integration.demos.common.DemoCategory
+import androidx.wear.compose.foundation.samples.SimpleCurvedRow
+
+val WearFoundationDemos = DemoCategory(
+ "Foundation",
+ listOf(
+ ComposableDemo("CurvedRow") { CurvedRowDemo() },
+ ComposableDemo("Simple CurvedRow") { SimpleCurvedRow() },
+ ),
+)
diff --git a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/MaterialDemos.kt b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/MaterialDemos.kt
index 34fedc1..3dc7725 100644
--- a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/MaterialDemos.kt
+++ b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/MaterialDemos.kt
@@ -22,6 +22,14 @@
val WearMaterialDemos = DemoCategory(
"Material",
listOf(
- ComposableDemo("Button") { ButtonDemo() },
+ DemoCategory(
+ "Button",
+ listOf(
+ ComposableDemo("Button Sizes") { ButtonSizes() },
+ ComposableDemo("Button Styles") { ButtonStyles() },
+ )
+ ),
+ ComposableDemo("Toggle Button") { ToggleButtons() },
+ ComposableDemo("Card") { CardDemo() },
),
)
diff --git a/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/ToggleButtonDemo.kt b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/ToggleButtonDemo.kt
new file mode 100644
index 0000000..e75eaa0
--- /dev/null
+++ b/wear/compose/integration-tests/demos/src/main/java/androidx/wear/compose/integration/demos/ToggleButtonDemo.kt
@@ -0,0 +1,150 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.integration.demos
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+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.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.wear.compose.material.ButtonDefaults
+import androidx.wear.compose.material.MaterialTheme
+import androidx.wear.compose.material.Text
+import androidx.wear.compose.material.ToggleButton
+import androidx.wear.compose.material.ToggleButtonDefaults
+
+@Composable
+fun ToggleButtons() {
+ var toggleButtonsEnabled by remember { mutableStateOf(true) }
+ var singularButton1Enabled by remember { mutableStateOf(true) }
+ var singularButton2Enabled by remember { mutableStateOf(true) }
+ var groupButtonState by remember { mutableStateOf(true) }
+
+ Column(
+ modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center,
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Singular",
+ style = MaterialTheme.typography.body2,
+ color = Color.White
+ )
+ Spacer(modifier = Modifier.size(4.dp))
+ ToggleButton(
+ checked = singularButton1Enabled,
+ onCheckedChange = {
+ singularButton1Enabled = it
+ },
+ enabled = toggleButtonsEnabled,
+ colors = ToggleButtonDefaults.toggleButtonColors(
+ checkedBackgroundColor = Color.Yellow,
+ checkedContentColor = Color.Black
+ ),
+ modifier = Modifier.size(ButtonDefaults.SmallButtonSize)
+ ) {
+ if (singularButton1Enabled) {
+ DemoIcon(R.drawable.ic_volume_up_24px)
+ } else {
+ DemoIcon(R.drawable.ic_volume_off_24px)
+ }
+ }
+ Spacer(modifier = Modifier.size(4.dp))
+ ToggleButton(
+ checked = singularButton2Enabled,
+ onCheckedChange = {
+ singularButton2Enabled = it
+ },
+ enabled = toggleButtonsEnabled,
+ colors = ToggleButtonDefaults.toggleButtonColors(
+ checkedBackgroundColor = Color.Yellow,
+ checkedContentColor = Color.Black
+ ),
+ modifier = Modifier.size(ButtonDefaults.SmallButtonSize),
+ ) {
+ DemoIcon(R.drawable.ic_airplanemode_active_24px)
+ }
+ }
+ Spacer(modifier = Modifier.size(4.dp))
+ Row(
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Grouped",
+ style = MaterialTheme.typography.body2,
+ color = Color.White
+ )
+ Spacer(modifier = Modifier.size(4.dp))
+ ToggleButton(
+ checked = !groupButtonState,
+ onCheckedChange = {
+ groupButtonState = !it
+ },
+ enabled = toggleButtonsEnabled,
+ modifier = Modifier.size(ButtonDefaults.SmallButtonSize),
+ ) {
+ DemoIcon(R.drawable.ic_check_24px)
+ }
+ Spacer(modifier = Modifier.size(4.dp))
+ ToggleButton(
+ checked = groupButtonState,
+ onCheckedChange = {
+ groupButtonState = it
+ },
+ enabled = toggleButtonsEnabled,
+ modifier = Modifier.size(ButtonDefaults.SmallButtonSize),
+ ) {
+ DemoIcon(R.drawable.ic_clear_24px)
+ }
+ }
+ Spacer(modifier = Modifier.size(4.dp))
+ Row(
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "Buttons Enabled",
+ style = MaterialTheme.typography.caption2,
+ color = Color.White
+ )
+ Spacer(modifier = Modifier.size(4.dp))
+ ToggleButton(
+ checked = toggleButtonsEnabled,
+ onCheckedChange = {
+ toggleButtonsEnabled = it
+ },
+ modifier = Modifier.size(ButtonDefaults.SmallButtonSize),
+ ) {
+ DemoIcon(R.drawable.ic_check_24px)
+ }
+ }
+ }
+}
diff --git a/wear/compose/integration-tests/demos/src/main/res/drawable/backgroundimage1.png b/wear/compose/integration-tests/demos/src/main/res/drawable/backgroundimage1.png
new file mode 100644
index 0000000..fbb9332
--- /dev/null
+++ b/wear/compose/integration-tests/demos/src/main/res/drawable/backgroundimage1.png
Binary files differ
diff --git a/wear/compose/integration-tests/demos/src/main/res/drawable/ic_accessibility_24px.xml b/wear/compose/integration-tests/demos/src/main/res/drawable/ic_accessibility_24px.xml
new file mode 100644
index 0000000..a282b8fe
--- /dev/null
+++ b/wear/compose/integration-tests/demos/src/main/res/drawable/ic_accessibility_24px.xml
@@ -0,0 +1,26 @@
+<!--
+ Copyright 2021 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ 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.
+ -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:pathData="M14,4C14,5.1 13.1,6 12,6C10.9,6 10,5.1 10,4C10,2.9 10.9,2 12,2C13.1,2 14,2.9 14,4ZM12,7C14.83,7 17.89,6.7 20.5,6L21,8C19.14,8.5 17,8.83 15,9V22H13V16H11V22H9V9C7,8.83 4.86,8.5 3,8L3.5,6C6.11,6.7 9.17,7 12,7Z"
+ android:fillColor="#ffffff"
+ android:fillType="evenOdd"/>
+</vector>
diff --git a/wear/compose/integration-tests/demos/src/main/res/drawable/ic_airplanemode_active_24px.xml b/wear/compose/integration-tests/demos/src/main/res/drawable/ic_airplanemode_active_24px.xml
new file mode 100644
index 0000000..2cbab48
--- /dev/null
+++ b/wear/compose/integration-tests/demos/src/main/res/drawable/ic_airplanemode_active_24px.xml
@@ -0,0 +1,25 @@
+<!--
+ Copyright 2021 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ 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.
+ -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="19dp"
+ android:height="20dp"
+ android:viewportWidth="19"
+ android:viewportHeight="20">
+ <path
+ android:pathData="M19,14V12L11,7V1.5C11,0.67 10.33,0 9.5,0C8.67,0 8,0.67 8,1.5V7L0,12V14L8,11.5V17L6,18.5V20L9.5,19L13,20V18.5L11,17V11.5L19,14Z"
+ android:fillColor="#ffffff"/>
+</vector>
diff --git a/wear/compose/integration-tests/demos/src/main/res/drawable/ic_check_24px.xml b/wear/compose/integration-tests/demos/src/main/res/drawable/ic_check_24px.xml
new file mode 100644
index 0000000..2160e73
--- /dev/null
+++ b/wear/compose/integration-tests/demos/src/main/res/drawable/ic_check_24px.xml
@@ -0,0 +1,25 @@
+<!--
+ Copyright 2021 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ 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.
+ -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:pathData="M9,16.1701L4.83,12.0001L3.41,13.4101L9,19.0001L21,7.0001L19.59,5.5901L9,16.1701Z"
+ android:fillColor="#30A0F2"/>
+</vector>
diff --git a/wear/compose/integration-tests/demos/src/main/res/drawable/ic_clear_24px.xml b/wear/compose/integration-tests/demos/src/main/res/drawable/ic_clear_24px.xml
new file mode 100644
index 0000000..e96bc94
--- /dev/null
+++ b/wear/compose/integration-tests/demos/src/main/res/drawable/ic_clear_24px.xml
@@ -0,0 +1,25 @@
+<!--
+ Copyright 2021 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ 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.
+ -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:pathData="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"
+ android:fillColor="#E24444"/>
+</vector>
diff --git a/wear/compose/integration-tests/demos/src/main/res/drawable/ic_volume_off_24px.xml b/wear/compose/integration-tests/demos/src/main/res/drawable/ic_volume_off_24px.xml
new file mode 100644
index 0000000..449bbce
--- /dev/null
+++ b/wear/compose/integration-tests/demos/src/main/res/drawable/ic_volume_off_24px.xml
@@ -0,0 +1,26 @@
+<!--
+ Copyright 2021 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ 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.
+ -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:pathData="M2.81,2.8101L1.39,4.2201L6.17,9.0001H3V15.0001H7L12,20.0001V14.8301L15.32,18.1501C14.9,18.3801 14.47,18.5701 14,18.7101V20.7701C15,20.5401 15.93,20.1301 16.77,19.6001L19.78,22.6101L21.19,21.2001L2.81,2.8101ZM10,15.1701L7.83,13.0001H5V11.0001H8.17L10,12.8301V15.1701ZM9.41,6.5901L12,4.0001V9.1701L9.41,6.5901ZM16.5,12.0001C16.5,10.2301 15.48,8.7101 14,7.9701V11.1701L16.26,13.4301C16.41,12.9801 16.5,12.5001 16.5,12.0001ZM18.16,15.3301C18.69,14.3401 19,13.2001 19,12.0001C19,8.8301 16.89,6.1501 14,5.2901V3.2301C18.01,4.1401 21,7.7201 21,12.0001C21,13.7601 20.49,15.4001 19.62,16.7901L18.16,15.3301Z"
+ android:fillColor="#9AA0A6"
+ android:fillType="evenOdd"/>
+</vector>
diff --git a/wear/compose/integration-tests/demos/src/main/res/drawable/ic_volume_up_24px.xml b/wear/compose/integration-tests/demos/src/main/res/drawable/ic_volume_up_24px.xml
new file mode 100644
index 0000000..2903eaf
--- /dev/null
+++ b/wear/compose/integration-tests/demos/src/main/res/drawable/ic_volume_up_24px.xml
@@ -0,0 +1,26 @@
+<!--
+ Copyright 2021 The Android Open Source Project
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ 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.
+ -->
+
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="24dp"
+ android:height="24dp"
+ android:viewportWidth="24"
+ android:viewportHeight="24">
+ <path
+ android:pathData="M14,5.29V3.23C18.01,4.14 21,7.72 21,12C21,16.28 18.01,19.86 14,20.77V18.71C16.89,17.85 19,15.17 19,12C19,8.83 16.89,6.15 14,5.29ZM3,15V9H7L12,4V20L7,15H3ZM10,15.17V8.83L7.83,11H5V13H7.83L10,15.17ZM16.5,12C16.5,10.23 15.48,8.71 14,7.97V16.02C15.48,15.29 16.5,13.77 16.5,12Z"
+ android:fillColor="#202124"
+ android:fillType="evenOdd"/>
+</vector>
diff --git a/wear/compose/material/api/current.txt b/wear/compose/material/api/current.txt
index 33f5370..5f663c0 100644
--- a/wear/compose/material/api/current.txt
+++ b/wear/compose/material/api/current.txt
@@ -33,14 +33,16 @@
method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.painter.Painter cardBackgroundPainter(optional long startBackgroundColor, optional long endBackgroundColor, optional androidx.compose.ui.unit.LayoutDirection gradientDirection);
method public float getAppImageSize();
method public androidx.compose.foundation.layout.PaddingValues getContentPadding();
+ method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.painter.Painter imageBackgroundPainter(androidx.compose.ui.graphics.painter.Painter backgroundImagePainter, optional androidx.compose.ui.graphics.Brush backgroundImageScrimBrush);
property public final float AppImageSize;
property public final androidx.compose.foundation.layout.PaddingValues ContentPadding;
field public static final androidx.wear.compose.material.CardDefaults INSTANCE;
}
public final class CardKt {
- method @androidx.compose.runtime.Composable public static void AppCard(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, kotlin.jvm.functions.Function0<kotlin.Unit> appName, kotlin.jvm.functions.Function0<kotlin.Unit> time, kotlin.jvm.functions.Function0<kotlin.Unit> title, kotlin.jvm.functions.Function0<kotlin.Unit> body, optional kotlin.jvm.functions.Function0<kotlin.Unit>? appImage, optional androidx.compose.ui.graphics.painter.Painter backgroundPainter, optional long appColor, optional long timeColor, optional long titleColor, optional long bodyColor);
+ method @androidx.compose.runtime.Composable public static void AppCard(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, kotlin.jvm.functions.Function0<kotlin.Unit> appName, kotlin.jvm.functions.Function0<kotlin.Unit> time, kotlin.jvm.functions.Function0<kotlin.Unit> title, kotlin.jvm.functions.Function0<kotlin.Unit> body, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? appImage, optional androidx.compose.ui.graphics.painter.Painter backgroundPainter, optional long appColor, optional long timeColor, optional long titleColor, optional long bodyColor);
method @androidx.compose.runtime.Composable public static void Card(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.painter.Painter backgroundPainter, optional long contentColor, optional boolean enabled, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.ui.semantics.Role? role, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+ method @androidx.compose.runtime.Composable public static void TitleCard(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, kotlin.jvm.functions.Function0<kotlin.Unit> title, kotlin.jvm.functions.Function0<kotlin.Unit> body, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? time, optional androidx.compose.ui.graphics.painter.Painter backgroundPainter, optional long titleColor, optional long timeColor, optional long bodyColor);
}
@androidx.compose.runtime.Stable public interface ChipColors {
@@ -201,17 +203,26 @@
}
public final class ToggleChipDefaults {
+ method @androidx.compose.runtime.Composable public void CheckboxIcon(boolean checked);
+ method @androidx.compose.runtime.Composable public void RadioIcon(boolean checked);
+ method @androidx.compose.runtime.Composable public void SwitchIcon(boolean checked);
+ method public androidx.compose.ui.graphics.vector.ImageVector getCheckboxOn();
method public androidx.compose.foundation.layout.PaddingValues getContentPadding();
method public float getIconSize();
+ method public androidx.compose.ui.graphics.vector.ImageVector getRadioOff();
+ method public androidx.compose.ui.graphics.vector.ImageVector getRadioOn();
method @androidx.compose.runtime.Composable public androidx.wear.compose.material.ToggleChipColors toggleChipColors(optional long checkedStartBackgroundColor, optional long checkedEndBackgroundColor, optional long checkedContentColor, optional long checkedSecondaryContentColor, optional long checkedToggleIconTintColor, optional long uncheckedStartBackgroundColor, optional long uncheckedEndBackgroundColor, optional long uncheckedContentColor, optional long uncheckedSecondaryContentColor, optional long uncheckedToggleIconTintColor, optional long splitBackgroundOverlayColor, optional androidx.compose.ui.unit.LayoutDirection gradientDirection);
+ property public final androidx.compose.ui.graphics.vector.ImageVector CheckboxOn;
property public final androidx.compose.foundation.layout.PaddingValues ContentPadding;
property public final float IconSize;
+ property public final androidx.compose.ui.graphics.vector.ImageVector RadioOff;
+ property public final androidx.compose.ui.graphics.vector.ImageVector RadioOn;
field public static final androidx.wear.compose.material.ToggleChipDefaults INSTANCE;
}
public final class ToggleChipKt {
- method @androidx.compose.runtime.Composable public static void SplitToggleChip(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, kotlin.jvm.functions.Function0<kotlin.Unit> label, kotlin.jvm.functions.Function0<kotlin.Unit> toggleIcon, kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? secondaryLabel, optional androidx.wear.compose.material.ToggleChipColors colors, optional boolean enabled, optional androidx.compose.foundation.interaction.MutableInteractionSource checkedInteractionSource, optional androidx.compose.foundation.interaction.MutableInteractionSource clickInteractionSource, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.graphics.Shape shape);
- method @androidx.compose.runtime.Composable public static void ToggleChip(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, kotlin.jvm.functions.Function0<kotlin.Unit> label, kotlin.jvm.functions.Function0<kotlin.Unit> toggleIcon, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? appIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? secondaryLabel, optional androidx.wear.compose.material.ToggleChipColors colors, optional boolean enabled, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.graphics.Shape shape);
+ method @androidx.compose.runtime.Composable public static void SplitToggleChip(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, kotlin.jvm.functions.Function0<kotlin.Unit> label, kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit> toggleIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? secondaryLabel, optional androidx.wear.compose.material.ToggleChipColors colors, optional boolean enabled, optional androidx.compose.foundation.interaction.MutableInteractionSource checkedInteractionSource, optional androidx.compose.foundation.interaction.MutableInteractionSource clickInteractionSource, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.graphics.Shape shape);
+ method @androidx.compose.runtime.Composable public static void ToggleChip(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, kotlin.jvm.functions.Function0<kotlin.Unit> label, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit> toggleIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? appIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? secondaryLabel, optional androidx.wear.compose.material.ToggleChipColors colors, optional boolean enabled, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.graphics.Shape shape);
}
@androidx.compose.runtime.Immutable public final class Typography {
diff --git a/wear/compose/material/api/public_plus_experimental_current.txt b/wear/compose/material/api/public_plus_experimental_current.txt
index 33f5370..5f663c0 100644
--- a/wear/compose/material/api/public_plus_experimental_current.txt
+++ b/wear/compose/material/api/public_plus_experimental_current.txt
@@ -33,14 +33,16 @@
method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.painter.Painter cardBackgroundPainter(optional long startBackgroundColor, optional long endBackgroundColor, optional androidx.compose.ui.unit.LayoutDirection gradientDirection);
method public float getAppImageSize();
method public androidx.compose.foundation.layout.PaddingValues getContentPadding();
+ method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.painter.Painter imageBackgroundPainter(androidx.compose.ui.graphics.painter.Painter backgroundImagePainter, optional androidx.compose.ui.graphics.Brush backgroundImageScrimBrush);
property public final float AppImageSize;
property public final androidx.compose.foundation.layout.PaddingValues ContentPadding;
field public static final androidx.wear.compose.material.CardDefaults INSTANCE;
}
public final class CardKt {
- method @androidx.compose.runtime.Composable public static void AppCard(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, kotlin.jvm.functions.Function0<kotlin.Unit> appName, kotlin.jvm.functions.Function0<kotlin.Unit> time, kotlin.jvm.functions.Function0<kotlin.Unit> title, kotlin.jvm.functions.Function0<kotlin.Unit> body, optional kotlin.jvm.functions.Function0<kotlin.Unit>? appImage, optional androidx.compose.ui.graphics.painter.Painter backgroundPainter, optional long appColor, optional long timeColor, optional long titleColor, optional long bodyColor);
+ method @androidx.compose.runtime.Composable public static void AppCard(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, kotlin.jvm.functions.Function0<kotlin.Unit> appName, kotlin.jvm.functions.Function0<kotlin.Unit> time, kotlin.jvm.functions.Function0<kotlin.Unit> title, kotlin.jvm.functions.Function0<kotlin.Unit> body, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? appImage, optional androidx.compose.ui.graphics.painter.Painter backgroundPainter, optional long appColor, optional long timeColor, optional long titleColor, optional long bodyColor);
method @androidx.compose.runtime.Composable public static void Card(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.painter.Painter backgroundPainter, optional long contentColor, optional boolean enabled, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.ui.semantics.Role? role, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+ method @androidx.compose.runtime.Composable public static void TitleCard(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, kotlin.jvm.functions.Function0<kotlin.Unit> title, kotlin.jvm.functions.Function0<kotlin.Unit> body, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? time, optional androidx.compose.ui.graphics.painter.Painter backgroundPainter, optional long titleColor, optional long timeColor, optional long bodyColor);
}
@androidx.compose.runtime.Stable public interface ChipColors {
@@ -201,17 +203,26 @@
}
public final class ToggleChipDefaults {
+ method @androidx.compose.runtime.Composable public void CheckboxIcon(boolean checked);
+ method @androidx.compose.runtime.Composable public void RadioIcon(boolean checked);
+ method @androidx.compose.runtime.Composable public void SwitchIcon(boolean checked);
+ method public androidx.compose.ui.graphics.vector.ImageVector getCheckboxOn();
method public androidx.compose.foundation.layout.PaddingValues getContentPadding();
method public float getIconSize();
+ method public androidx.compose.ui.graphics.vector.ImageVector getRadioOff();
+ method public androidx.compose.ui.graphics.vector.ImageVector getRadioOn();
method @androidx.compose.runtime.Composable public androidx.wear.compose.material.ToggleChipColors toggleChipColors(optional long checkedStartBackgroundColor, optional long checkedEndBackgroundColor, optional long checkedContentColor, optional long checkedSecondaryContentColor, optional long checkedToggleIconTintColor, optional long uncheckedStartBackgroundColor, optional long uncheckedEndBackgroundColor, optional long uncheckedContentColor, optional long uncheckedSecondaryContentColor, optional long uncheckedToggleIconTintColor, optional long splitBackgroundOverlayColor, optional androidx.compose.ui.unit.LayoutDirection gradientDirection);
+ property public final androidx.compose.ui.graphics.vector.ImageVector CheckboxOn;
property public final androidx.compose.foundation.layout.PaddingValues ContentPadding;
property public final float IconSize;
+ property public final androidx.compose.ui.graphics.vector.ImageVector RadioOff;
+ property public final androidx.compose.ui.graphics.vector.ImageVector RadioOn;
field public static final androidx.wear.compose.material.ToggleChipDefaults INSTANCE;
}
public final class ToggleChipKt {
- method @androidx.compose.runtime.Composable public static void SplitToggleChip(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, kotlin.jvm.functions.Function0<kotlin.Unit> label, kotlin.jvm.functions.Function0<kotlin.Unit> toggleIcon, kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? secondaryLabel, optional androidx.wear.compose.material.ToggleChipColors colors, optional boolean enabled, optional androidx.compose.foundation.interaction.MutableInteractionSource checkedInteractionSource, optional androidx.compose.foundation.interaction.MutableInteractionSource clickInteractionSource, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.graphics.Shape shape);
- method @androidx.compose.runtime.Composable public static void ToggleChip(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, kotlin.jvm.functions.Function0<kotlin.Unit> label, kotlin.jvm.functions.Function0<kotlin.Unit> toggleIcon, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? appIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? secondaryLabel, optional androidx.wear.compose.material.ToggleChipColors colors, optional boolean enabled, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.graphics.Shape shape);
+ method @androidx.compose.runtime.Composable public static void SplitToggleChip(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, kotlin.jvm.functions.Function0<kotlin.Unit> label, kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit> toggleIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? secondaryLabel, optional androidx.wear.compose.material.ToggleChipColors colors, optional boolean enabled, optional androidx.compose.foundation.interaction.MutableInteractionSource checkedInteractionSource, optional androidx.compose.foundation.interaction.MutableInteractionSource clickInteractionSource, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.graphics.Shape shape);
+ method @androidx.compose.runtime.Composable public static void ToggleChip(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, kotlin.jvm.functions.Function0<kotlin.Unit> label, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit> toggleIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? appIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? secondaryLabel, optional androidx.wear.compose.material.ToggleChipColors colors, optional boolean enabled, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.graphics.Shape shape);
}
@androidx.compose.runtime.Immutable public final class Typography {
diff --git a/wear/compose/material/api/restricted_current.txt b/wear/compose/material/api/restricted_current.txt
index 33f5370..5f663c0 100644
--- a/wear/compose/material/api/restricted_current.txt
+++ b/wear/compose/material/api/restricted_current.txt
@@ -33,14 +33,16 @@
method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.painter.Painter cardBackgroundPainter(optional long startBackgroundColor, optional long endBackgroundColor, optional androidx.compose.ui.unit.LayoutDirection gradientDirection);
method public float getAppImageSize();
method public androidx.compose.foundation.layout.PaddingValues getContentPadding();
+ method @androidx.compose.runtime.Composable public androidx.compose.ui.graphics.painter.Painter imageBackgroundPainter(androidx.compose.ui.graphics.painter.Painter backgroundImagePainter, optional androidx.compose.ui.graphics.Brush backgroundImageScrimBrush);
property public final float AppImageSize;
property public final androidx.compose.foundation.layout.PaddingValues ContentPadding;
field public static final androidx.wear.compose.material.CardDefaults INSTANCE;
}
public final class CardKt {
- method @androidx.compose.runtime.Composable public static void AppCard(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, kotlin.jvm.functions.Function0<kotlin.Unit> appName, kotlin.jvm.functions.Function0<kotlin.Unit> time, kotlin.jvm.functions.Function0<kotlin.Unit> title, kotlin.jvm.functions.Function0<kotlin.Unit> body, optional kotlin.jvm.functions.Function0<kotlin.Unit>? appImage, optional androidx.compose.ui.graphics.painter.Painter backgroundPainter, optional long appColor, optional long timeColor, optional long titleColor, optional long bodyColor);
+ method @androidx.compose.runtime.Composable public static void AppCard(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, kotlin.jvm.functions.Function0<kotlin.Unit> appName, kotlin.jvm.functions.Function0<kotlin.Unit> time, kotlin.jvm.functions.Function0<kotlin.Unit> title, kotlin.jvm.functions.Function0<kotlin.Unit> body, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? appImage, optional androidx.compose.ui.graphics.painter.Painter backgroundPainter, optional long appColor, optional long timeColor, optional long titleColor, optional long bodyColor);
method @androidx.compose.runtime.Composable public static void Card(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.graphics.painter.Painter backgroundPainter, optional long contentColor, optional boolean enabled, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.ui.semantics.Role? role, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+ method @androidx.compose.runtime.Composable public static void TitleCard(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, kotlin.jvm.functions.Function0<kotlin.Unit> title, kotlin.jvm.functions.Function0<kotlin.Unit> body, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? time, optional androidx.compose.ui.graphics.painter.Painter backgroundPainter, optional long titleColor, optional long timeColor, optional long bodyColor);
}
@androidx.compose.runtime.Stable public interface ChipColors {
@@ -201,17 +203,26 @@
}
public final class ToggleChipDefaults {
+ method @androidx.compose.runtime.Composable public void CheckboxIcon(boolean checked);
+ method @androidx.compose.runtime.Composable public void RadioIcon(boolean checked);
+ method @androidx.compose.runtime.Composable public void SwitchIcon(boolean checked);
+ method public androidx.compose.ui.graphics.vector.ImageVector getCheckboxOn();
method public androidx.compose.foundation.layout.PaddingValues getContentPadding();
method public float getIconSize();
+ method public androidx.compose.ui.graphics.vector.ImageVector getRadioOff();
+ method public androidx.compose.ui.graphics.vector.ImageVector getRadioOn();
method @androidx.compose.runtime.Composable public androidx.wear.compose.material.ToggleChipColors toggleChipColors(optional long checkedStartBackgroundColor, optional long checkedEndBackgroundColor, optional long checkedContentColor, optional long checkedSecondaryContentColor, optional long checkedToggleIconTintColor, optional long uncheckedStartBackgroundColor, optional long uncheckedEndBackgroundColor, optional long uncheckedContentColor, optional long uncheckedSecondaryContentColor, optional long uncheckedToggleIconTintColor, optional long splitBackgroundOverlayColor, optional androidx.compose.ui.unit.LayoutDirection gradientDirection);
+ property public final androidx.compose.ui.graphics.vector.ImageVector CheckboxOn;
property public final androidx.compose.foundation.layout.PaddingValues ContentPadding;
property public final float IconSize;
+ property public final androidx.compose.ui.graphics.vector.ImageVector RadioOff;
+ property public final androidx.compose.ui.graphics.vector.ImageVector RadioOn;
field public static final androidx.wear.compose.material.ToggleChipDefaults INSTANCE;
}
public final class ToggleChipKt {
- method @androidx.compose.runtime.Composable public static void SplitToggleChip(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, kotlin.jvm.functions.Function0<kotlin.Unit> label, kotlin.jvm.functions.Function0<kotlin.Unit> toggleIcon, kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? secondaryLabel, optional androidx.wear.compose.material.ToggleChipColors colors, optional boolean enabled, optional androidx.compose.foundation.interaction.MutableInteractionSource checkedInteractionSource, optional androidx.compose.foundation.interaction.MutableInteractionSource clickInteractionSource, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.graphics.Shape shape);
- method @androidx.compose.runtime.Composable public static void ToggleChip(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, kotlin.jvm.functions.Function0<kotlin.Unit> label, kotlin.jvm.functions.Function0<kotlin.Unit> toggleIcon, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit>? appIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? secondaryLabel, optional androidx.wear.compose.material.ToggleChipColors colors, optional boolean enabled, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.graphics.Shape shape);
+ method @androidx.compose.runtime.Composable public static void SplitToggleChip(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, kotlin.jvm.functions.Function0<kotlin.Unit> label, kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit> toggleIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? secondaryLabel, optional androidx.wear.compose.material.ToggleChipColors colors, optional boolean enabled, optional androidx.compose.foundation.interaction.MutableInteractionSource checkedInteractionSource, optional androidx.compose.foundation.interaction.MutableInteractionSource clickInteractionSource, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.graphics.Shape shape);
+ method @androidx.compose.runtime.Composable public static void ToggleChip(boolean checked, kotlin.jvm.functions.Function1<? super java.lang.Boolean,kotlin.Unit> onCheckedChange, kotlin.jvm.functions.Function0<kotlin.Unit> label, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit> toggleIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? appIcon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? secondaryLabel, optional androidx.wear.compose.material.ToggleChipColors colors, optional boolean enabled, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.ui.graphics.Shape shape);
}
@androidx.compose.runtime.Immutable public final class Typography {
diff --git a/wear/compose/material/src/androidAndroidTest/kotlin/androidx/wear/compose/material/CardTest.kt b/wear/compose/material/src/androidAndroidTest/kotlin/androidx/wear/compose/material/CardTest.kt
index ef3a6ee1..674c6da 100644
--- a/wear/compose/material/src/androidAndroidTest/kotlin/androidx/wear/compose/material/CardTest.kt
+++ b/wear/compose/material/src/androidAndroidTest/kotlin/androidx/wear/compose/material/CardTest.kt
@@ -226,14 +226,82 @@
) { MaterialTheme.colors.onSurfaceVariant2 }
@Test
- public fun app_chip_gives_default_colors(): Unit =
- verifyAppCardColors(
- { MaterialTheme.colors.primary },
- { MaterialTheme.colors.primary },
- { MaterialTheme.colors.onSurfaceVariant },
- { MaterialTheme.colors.onSurface },
- { MaterialTheme.colors.onSurfaceVariant2 },
- )
+ public fun app_card_gives_default_colors() {
+ var expectedAppImageColor = Color.Transparent
+ var expectedAppColor = Color.Transparent
+ var expectedTimeColor = Color.Transparent
+ var expectedTitleColor = Color.Transparent
+ var expectedBodyColor = Color.Transparent
+ var actualBodyColor = Color.Transparent
+ var actualTitleColor = Color.Transparent
+ var actualTimeColor = Color.Transparent
+ var actualAppColor = Color.Transparent
+ var actualAppImageColor = Color.Transparent
+ val testBackground = Color.White
+
+ rule.setContentWithTheme {
+ expectedAppImageColor = MaterialTheme.colors.primary
+ expectedAppColor = MaterialTheme.colors.primary
+ expectedTimeColor = MaterialTheme.colors.onSurfaceVariant
+ expectedTitleColor = MaterialTheme.colors.onSurface
+ expectedBodyColor = MaterialTheme.colors.onSurfaceVariant2
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(testBackground)
+ ) {
+ AppCard(
+ onClick = {},
+ appName = { actualAppColor = LocalContentColor.current },
+ appImage = { actualAppImageColor = LocalContentColor.current },
+ time = { actualTimeColor = LocalContentColor.current },
+ body = { actualBodyColor = LocalContentColor.current },
+ title = { actualTitleColor = LocalContentColor.current },
+ modifier = Modifier.testTag(TEST_TAG)
+ )
+ }
+ }
+
+ assertEquals(expectedAppImageColor, actualAppImageColor)
+ assertEquals(expectedAppColor, actualAppColor)
+ assertEquals(expectedTimeColor, actualTimeColor)
+ assertEquals(expectedTitleColor, actualTitleColor)
+ assertEquals(expectedBodyColor, actualBodyColor)
+ }
+
+ @Test
+ public fun title_card_gives_default_colors() {
+ var expectedTimeColor = Color.Transparent
+ var expectedTitleColor = Color.Transparent
+ var expectedBodyColor = Color.Transparent
+ var actualBodyColor = Color.Transparent
+ var actualTitleColor = Color.Transparent
+ var actualTimeColor = Color.Transparent
+ val testBackground = Color.White
+
+ rule.setContentWithTheme {
+ expectedTimeColor = MaterialTheme.colors.onSurfaceVariant
+ expectedTitleColor = MaterialTheme.colors.onSurface
+ expectedBodyColor = MaterialTheme.colors.onSurfaceVariant2
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(testBackground)
+ ) {
+ TitleCard(
+ onClick = {},
+ time = { actualTimeColor = LocalContentColor.current },
+ body = { actualBodyColor = LocalContentColor.current },
+ title = { actualTitleColor = LocalContentColor.current },
+ modifier = Modifier.testTag(TEST_TAG)
+ )
+ }
+ }
+
+ assertEquals(expectedTimeColor, actualTimeColor)
+ assertEquals(expectedTitleColor, actualTitleColor)
+ assertEquals(expectedBodyColor, actualBodyColor)
+ }
private fun verifyColors(
status: CardStatus,
@@ -261,55 +329,6 @@
assertEquals(expectedContent, actualContent)
}
-
- private fun verifyAppCardColors(
- appImageColor: @Composable () -> Color,
- appColor: @Composable () -> Color,
- timeColor: @Composable () -> Color,
- titleColor: @Composable () -> Color,
- bodyColor: @Composable () -> Color,
- ) {
- var expectedAppImageColor = Color.Transparent
- var expectedAppColor = Color.Transparent
- var expectedTimeColor = Color.Transparent
- var expectedTitleColor = Color.Transparent
- var expectedBodyColor = Color.Transparent
- var actualBodyColor = Color.Transparent
- var actualTitleColor = Color.Transparent
- var actualTimeColor = Color.Transparent
- var actualAppColor = Color.Transparent
- var actualAppImageColor = Color.Transparent
- val testBackground = Color.White
-
- rule.setContentWithTheme {
- expectedAppImageColor = appImageColor()
- expectedAppColor = appColor()
- expectedTimeColor = timeColor()
- expectedTitleColor = titleColor()
- expectedBodyColor = bodyColor()
- Box(
- modifier = Modifier
- .fillMaxSize()
- .background(testBackground)
- ) {
- AppCard(
- onClick = {},
- appName = { actualAppColor = LocalContentColor.current },
- appImage = { actualAppImageColor = LocalContentColor.current },
- time = { actualTimeColor = LocalContentColor.current },
- body = { actualBodyColor = LocalContentColor.current },
- title = { actualTitleColor = LocalContentColor.current },
- modifier = Modifier.testTag(TEST_TAG)
- )
- }
- }
-
- assertEquals(expectedAppImageColor, actualAppImageColor)
- assertEquals(expectedAppColor, actualAppColor)
- assertEquals(expectedTimeColor, actualTimeColor)
- assertEquals(expectedTitleColor, actualTitleColor)
- assertEquals(expectedBodyColor, actualBodyColor)
- }
}
public class CardFontTest {
@@ -373,6 +392,39 @@
assertEquals(expectedTitleTextStyle, actualTitleTextStyle)
assertEquals(expectedBodyTextStyle, actualBodyTextStyle)
}
+
+ @Test
+ public fun title_card_gives_correct_text_style_base() {
+ var actualTimeTextStyle = TextStyle.Default
+ var actualTitleTextStyle = TextStyle.Default
+ var actualBodyTextStyle = TextStyle.Default
+ var expectedTimeTextStyle = TextStyle.Default
+ var expectedTitleTextStyle = TextStyle.Default
+ var expectedBodyTextStyle = TextStyle.Default
+
+ rule.setContentWithTheme {
+ expectedTimeTextStyle = MaterialTheme.typography.caption1
+ expectedTitleTextStyle = MaterialTheme.typography.button
+ expectedBodyTextStyle = MaterialTheme.typography.body1
+
+ TitleCard(
+ onClick = {},
+ time = {
+ actualTimeTextStyle = LocalTextStyle.current
+ },
+ title = {
+ actualTitleTextStyle = LocalTextStyle.current
+ },
+ body = {
+ actualBodyTextStyle = LocalTextStyle.current
+ },
+ modifier = Modifier.testTag(TEST_TAG)
+ )
+ }
+ assertEquals(expectedTimeTextStyle, actualTimeTextStyle)
+ assertEquals(expectedTitleTextStyle, actualTitleTextStyle)
+ assertEquals(expectedBodyTextStyle, actualBodyTextStyle)
+ }
}
private fun ComposeContentTestRule.verifyHeight(expected: Dp, content: @Composable () -> Unit) {
diff --git a/wear/compose/material/src/commonMain/kotlin/androidx/wear/compose/material/Card.kt b/wear/compose/material/src/commonMain/kotlin/androidx/wear/compose/material/Card.kt
index 566c192..d1d93f47 100644
--- a/wear/compose/material/src/commonMain/kotlin/androidx/wear/compose/material/Card.kt
+++ b/wear/compose/material/src/commonMain/kotlin/androidx/wear/compose/material/Card.kt
@@ -40,6 +40,7 @@
import androidx.compose.ui.draw.paint
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.LinearGradientShader
import androidx.compose.ui.graphics.Shader
@@ -48,6 +49,7 @@
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Dp
@@ -110,6 +112,7 @@
.matchParentSize()
.paint(
painter = backgroundPainter,
+ contentScale = ContentScale.FillBounds
)
val contentBoxModifier = Modifier
@@ -140,7 +143,10 @@
/**
* Opinionated Wear Material [Card] that offers a specific 5 slot layout to show information about
- * an application, e.g. a notification.
+ * an application, e.g. a notification. AppCards are designed to show interactive elements from
+ * multiple applications. They will typically be used by the system UI, e.g. for showing a list of
+ * notifications from different applications. However it could also be adapted by individual
+ * application developers to show information about different parts of their application.
*
* The first row of the layout has three slots, 1) a small optional application [Image] or [Icon] of
* size [CardDefaults.AppImageSize]x[CardDefaults.AppImageSize] dp, 2) an application name
@@ -154,13 +160,13 @@
* The rest of the [Card] contains the body content which can be either [Text] or an [Image].
*
* @param onClick Will be called when the user clicks the card
- * @param modifier Modifier to be applied to the card
* @param appName A slot for displaying the application name, expected to be a single line of text
* of [Typography.title3]
* @param time A slot for displaying the time relevant to the contents of the card, expected to be a
- * short piece of right aligned text.
+ * short piece of end aligned text.
* @param body A slot for displaying the details of the [Card], expected to be either [Text]
* (single or multiple-line) or an [Image]
+ * @param modifier Modifier to be applied to the card
* @param appImage A slot for a small ([CardDefaults.AppImageSize]x[CardDefaults.AppImageSize] )
* [Image] or [Icon] associated with the application.
* @param backgroundPainter A painter used to paint the background of the card. A card will
@@ -175,11 +181,11 @@
@Composable
public fun AppCard(
onClick: () -> Unit,
- modifier: Modifier = Modifier,
appName: @Composable () -> Unit,
time: @Composable () -> Unit,
title: @Composable () -> Unit,
body: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
appImage: @Composable (() -> Unit)? = null,
backgroundPainter: Painter = CardDefaults.cardBackgroundPainter(),
appColor: Color = MaterialTheme.colors.primary,
@@ -233,6 +239,88 @@
}
/**
+ * Opinionated Wear Material [Card] that offers a specific 3 slot layout to show interactive
+ * information about an application, e.g. a message. TitleCards are designed for use within an
+ * application.
+ *
+ * The first row of the layout has two slots. 1. a start aligned title (emphasised with the
+ * [titleColor] and expected to be start aligned text). The title text is expected to be a maximum
+ * of 2 lines of text. 2. An optional time that the application activity has occurred shown at the
+ * end of the row, expected to be an end aligned [Text] composable showing a time relevant to the
+ * contents of the [Card].
+ *
+ * The rest of the [Card] contains the body content which is expected to be [Text] or a contained
+ * [Image].
+ *
+ * Overall the [title] and [body] text should be no more than 5 rows of text combined.
+ *
+ * @param onClick Will be called when the user clicks the card
+ * @param title A slot for displaying the title of the card, expected to be one or two lines of text
+ * of [Typography.button]
+ * @param body A slot for displaying the details of the [Card], expected to be either [Text]
+ * (single or multiple-line) or an [Image]. If [Text] then it is expected to be a maximum of 4 lines
+ * of text of [Typography.body1]
+ * @param modifier Modifier to be applied to the card
+ * @param time An optional slot for displaying the time relevant to the contents of the card,
+ * expected to be a short piece of end aligned text.
+ * @param backgroundPainter A painter used to paint the background of the card. A title card can
+ * have either a gradient background or an image background, use
+ * [CardDefaults.cardBackgroundPainter()] or [CardDefaults.imageBackgroundPainter()] to obtain an
+ * appropriate painter
+ * @param titleColor The default color to use for title() slot unless explicitly set.
+ * @param timeColor The default color to use for time() slot unless explicitly set.
+ * @param bodyColor The default color to use for body() slot unless explicitly set.
+ */
+@Composable
+public fun TitleCard(
+ onClick: () -> Unit,
+ title: @Composable () -> Unit,
+ body: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ time: @Composable (() -> Unit)? = null,
+ backgroundPainter: Painter = CardDefaults.cardBackgroundPainter(),
+ titleColor: Color = MaterialTheme.colors.onSurface,
+ timeColor: Color = MaterialTheme.colors.onSurfaceVariant,
+ bodyColor: Color = MaterialTheme.colors.onSurfaceVariant2,
+) {
+ Card(
+ onClick = onClick,
+ modifier = modifier,
+ backgroundPainter = backgroundPainter,
+ enabled = true,
+ ) {
+ Column {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ CompositionLocalProvider(
+ LocalContentColor provides titleColor,
+ LocalTextStyle provides MaterialTheme.typography.button,
+ content = title
+ )
+ if (time != null) {
+ Spacer(modifier = Modifier.width(4.dp))
+ Box(modifier = Modifier.weight(1.0f), contentAlignment = Alignment.CenterEnd) {
+ CompositionLocalProvider(
+ LocalContentColor provides timeColor,
+ LocalTextStyle provides MaterialTheme.typography.caption1,
+ content = time
+ )
+ }
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ CompositionLocalProvider(
+ LocalContentColor provides bodyColor,
+ LocalTextStyle provides MaterialTheme.typography.body1,
+ content = body
+ )
+ }
+ }
+}
+
+/**
* Contains the default values used by [Card]
*/
public object CardDefaults {
@@ -274,6 +362,35 @@
return BrushPainter(FortyFiveDegreeLinearGradient(backgroundColors))
}
+ /**
+ * Creates a [Painter] for the background of a [Card] that displays an Image with a scrim over
+ * the image to make sure that any content above the background will be legible.
+ *
+ * An Image background is a means to reinforce the meaning of information in a Card, e.g. To
+ * help to contextualize the information in a TitleCard
+ *
+ * Cards should have a content color that contrasts with the background image and scrim
+ *
+ * @param backgroundImagePainter The [Painter] to use to draw the background of the [Card]
+ * @param backgroundImageScrimBrush The [Brush] to use to paint a scrim over the background
+ * image to ensure that any text drawn over the image is legible
+ */
+ @Composable
+ public fun imageBackgroundPainter(
+ backgroundImagePainter: Painter,
+ backgroundImageScrimBrush: Brush = Brush.linearGradient(
+ colors = listOf(
+ MaterialTheme.colors.surface.copy(alpha = 1.0f),
+ MaterialTheme.colors.surface.copy(alpha = 0f)
+ )
+ )
+ ): Painter {
+ return ImageWithScrimPainter(
+ imagePainter = backgroundImagePainter,
+ brush = backgroundImageScrimBrush
+ )
+ }
+
private val CardHorizontalPadding = 12.dp
private val CardVerticalPadding = 12.dp
diff --git a/wear/compose/material/src/commonMain/kotlin/androidx/wear/compose/material/ToggleChip.kt b/wear/compose/material/src/commonMain/kotlin/androidx/wear/compose/material/ToggleChip.kt
index 5f5fdb1..626dd79 100644
--- a/wear/compose/material/src/commonMain/kotlin/androidx/wear/compose/material/ToggleChip.kt
+++ b/wear/compose/material/src/commonMain/kotlin/androidx/wear/compose/material/ToggleChip.kt
@@ -36,6 +36,8 @@
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.selection.toggleable
+import androidx.compose.material.icons.materialIcon
+import androidx.compose.material.icons.materialPath
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -51,8 +53,10 @@
import androidx.compose.ui.draw.paint
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathFillType
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.Dp
@@ -80,11 +84,12 @@
* @param onCheckedChange Callback to be invoked when this buttons checked/selected status is
* @param label A slot for providing the chip's main label. The contents are expected to be text
* which is "start" aligned.
- * @param toggleIcon A slot for providing the chip's toggle icon(s). The
- * contents are expected to be a horizontally and vertically centre aligned icon of size
- * [ToggleChipDefaults.IconSize].
- * changed.
* @param modifier Modifier to be applied to the chip
+ * @param toggleIcon A slot for providing the chip's toggle icon(s). The contents are expected to be
+ * a horizontally and vertically centre aligned icon of size [ToggleChipDefaults.IconSize]. Three
+ * types of toggle icon are supported and can be obtained from
+ * [ToggleChipDefaults.SwitchIcon], [ToggleChipDefaults.RadioIcon] and
+ * [ToggleChipDefaults.CheckboxIcon]
* @param appIcon An optional slot for providing an icon to indicate the purpose of the chip. The
* contents are expected to be a horizontally and vertically centre aligned icon of size
* [ToggleChipDefaults.IconSize].
@@ -110,8 +115,8 @@
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
label: @Composable () -> Unit,
- toggleIcon: @Composable () -> Unit,
modifier: Modifier = Modifier,
+ toggleIcon: @Composable () -> Unit = { ToggleChipDefaults.CheckboxIcon(checked = checked) },
appIcon: @Composable (() -> Unit)? = null,
secondaryLabel: @Composable (() -> Unit)? = null,
colors: ToggleChipColors = ToggleChipDefaults.toggleChipColors(),
@@ -244,12 +249,14 @@
* changed.
* @param label A slot for providing the chip's main label. The contents are expected to be text
* which is "start" aligned.
- * @param toggleIcon A slot for providing the chip's toggle icon(s). The
- * contents are expected to be a horizontally and vertically centre aligned icon of size
- * [ToggleChipDefaults.IconSize].
* @param onClick Click listener called when the user clicks the main body of the chip, the area
* behind the labels.
* @param modifier Modifier to be applied to the chip
+ * @param toggleIcon A slot for providing the chip's toggle icon(s). The contents are expected to be
+ * a horizontally and vertically centre aligned icon of size [ToggleChipDefaults.IconSize]. Three
+ * types of toggle icon are supported and can be obtained from
+ * [ToggleChipDefaults.SwitchIcon], [ToggleChipDefaults.RadioIcon] and
+ * [ToggleChipDefaults.CheckboxIcon]
* @param secondaryLabel A slot for providing the chip's secondary label. The contents are expected
* to be "start" or "center" aligned. label and secondaryLabel contents should be consistently
* aligned.
@@ -276,9 +283,9 @@
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
label: @Composable () -> Unit,
- toggleIcon: @Composable () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
+ toggleIcon: @Composable () -> Unit = { ToggleChipDefaults.CheckboxIcon(checked = checked) },
secondaryLabel: @Composable (() -> Unit)? = null,
colors: ToggleChipColors = ToggleChipDefaults.toggleChipColors(),
enabled: Boolean = true,
@@ -496,14 +503,14 @@
}
/**
- * Contains the default values used by [Chip]
+ * Contains the default values used by [ToggleChip]s
*/
public object ToggleChipDefaults {
/**
* Creates a [ToggleChipColors] for use in a ToggleChip or SplitToggleChip.
* [ToggleChip]s are expected to have a linear gradient background when
- * checked/selected, similar to a [ChipDefaults.gradientBackgroundChipColors()] and a solid
+ * checked/selected, similar to a [ChipDefaults.gradientBackgroundChipColors] and a solid
* neutral background when not checked/selected (similar to a
* [ChipDefaults.secondaryChipColors])
*
@@ -652,13 +659,63 @@
)
/**
- * The default height applied for the [Chip].
- * Note that you can override it by applying Modifier.heightIn directly on [Chip].
+ * Creates switch style toggle [Icon]s for use in the toggleIcon slot of a [ToggleChip].
+ * Depending on [checked] will return either an 'on' (checked) or 'off' (unchecked) switch icon.
+ *
+ * @param checked whether the [ToggleChip] or [SplitToggleChip] is currently 'on' (checked/true)
+ * or 'off' (unchecked/false)
+ */
+ @Composable
+ public fun SwitchIcon(checked: Boolean) {
+ Icon(
+ imageVector = if (checked) SwitchOn else SwitchOff,
+ contentDescription = "Switch selector",
+ modifier = Modifier.size(24.dp)
+ )
+ }
+
+ /**
+ * Creates a radio button style toggle [Icon]s for use in the toggleIcon slot of a [ToggleChip].
+ * Depending on [checked] will return either an 'on' (checked) or 'off' (unchecked) radio button
+ * icon.
+ *
+ * @param checked whether the [ToggleChip] or [SplitToggleChip] is currently 'on' (checked/true)
+ * or 'off' (unchecked/false)
+ */
+ @Composable
+ public fun RadioIcon(checked: Boolean) {
+ Icon(
+ imageVector = if (checked) RadioOn else RadioOff,
+ contentDescription = "Radio selector",
+ modifier = Modifier.size(24.dp)
+ )
+ }
+
+ /**
+ * Creates a checkbox style toggle [Icon]s for use in the toggleIcon slot of a [ToggleChip].
+ * Depending on [checked] will return either an 'on' (ticked/checked) or 'off'
+ * (unticked/unchecked) checkbox icon.
+ *
+ * @param checked whether the [ToggleChip] or [SplitToggleChip] is currently 'on' (checked/true)
+ * or 'off' (unchecked/false)
+ */
+ @Composable
+ public fun CheckboxIcon(checked: Boolean) {
+ Icon(
+ imageVector = if (checked) CheckboxOn else CheckboxOff,
+ contentDescription = "Checkbox selector",
+ modifier = Modifier.size(24.dp)
+ )
+ }
+
+ /**
+ * The default height applied for the [ToggleChip].
+ * Note that you can override it by applying Modifier.heightIn directly on [ToggleChip].
*/
internal val Height = 52.dp
/**
- * The default size of the icon when used inside a [Chip].
+ * The default size of app or toggle icons when used inside a [ToggleChip].
*/
public val IconSize: Dp = 24.dp
@@ -673,6 +730,197 @@
* inside a [ToggleChip].
*/
internal val ToggleIconSpacing = 4.dp
+
+ private val SwitchOn: ImageVector
+ get() {
+ if (_switchOn != null) {
+ return _switchOn!!
+ }
+ _switchOn = materialIcon(name = "SwitchOn") {
+ materialPath(fillAlpha = 0.38f, strokeAlpha = 0.38f) {
+ moveTo(5.0f, 7.0f)
+ lineTo(19.0f, 7.0f)
+ arcTo(5.0f, 5.0f, 0.0f, false, true, 24.0f, 12.0f)
+ lineTo(24.0f, 12.0f)
+ arcTo(5.0f, 5.0f, 0.0f, false, true, 19.0f, 17.0f)
+ lineTo(5.0f, 17.0f)
+ arcTo(5.0f, 5.0f, 0.0f, false, true, 0.0f, 12.0f)
+ lineTo(0.0f, 12.0f)
+ arcTo(5.0f, 5.0f, 0.0f, false, true, 5.0f, 7.0f)
+ close()
+ }
+ materialPath(pathFillType = PathFillType.EvenOdd) {
+ moveTo(17.0f, 19.0f)
+ curveTo(20.866f, 19.0f, 24.0f, 15.866f, 24.0f, 12.0f)
+ curveTo(24.0f, 8.134f, 20.866f, 5.0f, 17.0f, 5.0f)
+ curveTo(13.134f, 5.0f, 10.0f, 8.134f, 10.0f, 12.0f)
+ curveTo(10.0f, 15.866f, 13.134f, 19.0f, 17.0f, 19.0f)
+ close()
+ }
+ }
+ return _switchOn!!
+ }
+
+ private var _switchOn: ImageVector? = null
+
+ private val SwitchOff: ImageVector
+ get() {
+ if (_switchOff != null) {
+ return _switchOff!!
+ }
+ _switchOff = materialIcon(name = "SwitchOff") {
+ materialPath(fillAlpha = 0.38f, strokeAlpha = 0.38f) {
+ moveTo(5.0f, 7.0f)
+ lineTo(19.0f, 7.0f)
+ arcTo(5.0f, 5.0f, 0.0f, false, true, 24.0f, 12.0f)
+ lineTo(24.0f, 12.0f)
+ arcTo(5.0f, 5.0f, 0.0f, false, true, 19.0f, 17.0f)
+ lineTo(5.0f, 17.0f)
+ arcTo(5.0f, 5.0f, 0.0f, false, true, 0.0f, 12.0f)
+ lineTo(0.0f, 12.0f)
+ arcTo(5.0f, 5.0f, 0.0f, false, true, 5.0f, 7.0f)
+ close()
+ }
+ materialPath(pathFillType = PathFillType.EvenOdd) {
+ moveTo(7.0f, 19.0f)
+ curveTo(10.866f, 19.0f, 14.0f, 15.866f, 14.0f, 12.0f)
+ curveTo(14.0f, 8.134f, 10.866f, 5.0f, 7.0f, 5.0f)
+ curveTo(3.134f, 5.0f, 0.0f, 8.134f, 0.0f, 12.0f)
+ curveTo(0.0f, 15.866f, 3.134f, 19.0f, 7.0f, 19.0f)
+ close()
+ }
+ }
+ return _switchOff!!
+ }
+
+ private var _switchOff: ImageVector? = null
+
+ public val RadioOn: ImageVector
+ get() {
+ if (_radioOn != null) {
+ return _radioOn!!
+ }
+ _radioOn = materialIcon(name = "RadioOn") {
+ materialPath {
+ moveTo(12.0f, 2.0f)
+ curveTo(6.48f, 2.0f, 2.0f, 6.48f, 2.0f, 12.0f)
+ curveTo(2.0f, 17.52f, 6.48f, 22.0f, 12.0f, 22.0f)
+ curveTo(17.52f, 22.0f, 22.0f, 17.52f, 22.0f, 12.0f)
+ curveTo(22.0f, 6.48f, 17.52f, 2.0f, 12.0f, 2.0f)
+ close()
+ moveTo(12.0f, 20.0f)
+ curveTo(7.58f, 20.0f, 4.0f, 16.42f, 4.0f, 12.0f)
+ curveTo(4.0f, 7.58f, 7.58f, 4.0f, 12.0f, 4.0f)
+ curveTo(16.42f, 4.0f, 20.0f, 7.58f, 20.0f, 12.0f)
+ curveTo(20.0f, 16.42f, 16.42f, 20.0f, 12.0f, 20.0f)
+ close()
+ }
+ materialPath {
+ moveTo(12.0f, 12.0f)
+ moveToRelative(-5.0f, 0.0f)
+ arcToRelative(5.0f, 5.0f, 0.0f, true, true, 10.0f, 0.0f)
+ arcToRelative(5.0f, 5.0f, 0.0f, true, true, -10.0f, 0.0f)
+ }
+ }
+ return _radioOn!!
+ }
+
+ private var _radioOn: ImageVector? = null
+
+ public val RadioOff: ImageVector
+ get() {
+ if (_radioOff != null) {
+ return _radioOff!!
+ }
+ _radioOff = materialIcon(name = "RadioOff") {
+ materialPath {
+ moveTo(12.0f, 2.0f)
+ curveTo(6.48f, 2.0f, 2.0f, 6.48f, 2.0f, 12.0f)
+ curveTo(2.0f, 17.52f, 6.48f, 22.0f, 12.0f, 22.0f)
+ curveTo(17.52f, 22.0f, 22.0f, 17.52f, 22.0f, 12.0f)
+ curveTo(22.0f, 6.48f, 17.52f, 2.0f, 12.0f, 2.0f)
+ close()
+ moveTo(12.0f, 20.0f)
+ curveTo(7.58f, 20.0f, 4.0f, 16.42f, 4.0f, 12.0f)
+ curveTo(4.0f, 7.58f, 7.58f, 4.0f, 12.0f, 4.0f)
+ curveTo(16.42f, 4.0f, 20.0f, 7.58f, 20.0f, 12.0f)
+ curveTo(20.0f, 16.42f, 16.42f, 20.0f, 12.0f, 20.0f)
+ close()
+ }
+ }
+ return _radioOff!!
+ }
+
+ private var _radioOff: ImageVector? = null
+
+ public val CheckboxOn: ImageVector
+ get() {
+ if (_checkboxOn != null) {
+ return _checkboxOn!!
+ }
+ _checkboxOn = materialIcon(name = "CheckboxOn") {
+ materialPath {
+ moveTo(19.0f, 3.0f)
+ horizontalLineTo(5.0f)
+ curveTo(3.9f, 3.0f, 3.0f, 3.9f, 3.0f, 5.0f)
+ verticalLineTo(19.0f)
+ curveTo(3.0f, 20.1f, 3.9f, 21.0f, 5.0f, 21.0f)
+ horizontalLineTo(19.0f)
+ curveTo(20.1f, 21.0f, 21.0f, 20.1f, 21.0f, 19.0f)
+ verticalLineTo(5.0f)
+ curveTo(21.0f, 3.9f, 20.1f, 3.0f, 19.0f, 3.0f)
+ close()
+ moveTo(19.0f, 19.0f)
+ horizontalLineTo(5.0f)
+ verticalLineTo(5.0f)
+ horizontalLineTo(19.0f)
+ verticalLineTo(19.0f)
+ close()
+ moveTo(18.0f, 9.0f)
+ lineTo(16.6f, 7.6f)
+ lineTo(13.3f, 10.9f)
+ lineTo(10.0f, 14.2f)
+ lineTo(7.4f, 11.6f)
+ lineTo(6.0f, 13.0f)
+ lineTo(10.0f, 17.0f)
+ lineTo(18.0f, 9.0f)
+ close()
+ }
+ }
+ return _checkboxOn!!
+ }
+
+ private var _checkboxOn: ImageVector? = null
+
+ private val CheckboxOff: ImageVector
+ get() {
+ if (_checkboxOff != null) {
+ return _checkboxOff!!
+ }
+ _checkboxOff = materialIcon(name = "CheckboxOff") {
+ materialPath {
+ moveTo(19.0f, 5.0f)
+ verticalLineTo(19.0f)
+ horizontalLineTo(5.0f)
+ verticalLineTo(5.0f)
+ horizontalLineTo(19.0f)
+ close()
+ moveTo(19.0f, 3.0f)
+ horizontalLineTo(5.0f)
+ curveTo(3.9f, 3.0f, 3.0f, 3.9f, 3.0f, 5.0f)
+ verticalLineTo(19.0f)
+ curveTo(3.0f, 20.1f, 3.9f, 21.0f, 5.0f, 21.0f)
+ horizontalLineTo(19.0f)
+ curveTo(20.1f, 21.0f, 21.0f, 20.1f, 21.0f, 19.0f)
+ verticalLineTo(5.0f)
+ curveTo(21.0f, 3.9f, 20.1f, 3.0f, 19.0f, 3.0f)
+ close()
+ }
+ }
+ return _checkboxOff!!
+ }
+
+ private var _checkboxOff: ImageVector? = null
}
/**
diff --git a/wear/wear-complications-data-source-ktx/api/current.txt b/wear/wear-complications-data-source-ktx/api/current.txt
new file mode 100644
index 0000000..874628e
--- /dev/null
+++ b/wear/wear-complications-data-source-ktx/api/current.txt
@@ -0,0 +1,11 @@
+// Signature format: 4.0
+package androidx.wear.complications.datasource {
+
+ public abstract class SuspendingComplicationDataSourceService extends androidx.wear.complications.datasource.ComplicationDataSourceService {
+ ctor public SuspendingComplicationDataSourceService();
+ method public final void onComplicationRequest(androidx.wear.complications.datasource.ComplicationRequest request, androidx.wear.complications.datasource.ComplicationDataSourceService.ComplicationRequestListener listener);
+ method @UiThread public abstract suspend Object? onComplicationRequest(androidx.wear.complications.datasource.ComplicationRequest request, kotlin.coroutines.Continuation<? super androidx.wear.complications.data.ComplicationData> p);
+ }
+
+}
+
diff --git a/wear/wear-complications-data-source-ktx/api/public_plus_experimental_current.txt b/wear/wear-complications-data-source-ktx/api/public_plus_experimental_current.txt
new file mode 100644
index 0000000..874628e
--- /dev/null
+++ b/wear/wear-complications-data-source-ktx/api/public_plus_experimental_current.txt
@@ -0,0 +1,11 @@
+// Signature format: 4.0
+package androidx.wear.complications.datasource {
+
+ public abstract class SuspendingComplicationDataSourceService extends androidx.wear.complications.datasource.ComplicationDataSourceService {
+ ctor public SuspendingComplicationDataSourceService();
+ method public final void onComplicationRequest(androidx.wear.complications.datasource.ComplicationRequest request, androidx.wear.complications.datasource.ComplicationDataSourceService.ComplicationRequestListener listener);
+ method @UiThread public abstract suspend Object? onComplicationRequest(androidx.wear.complications.datasource.ComplicationRequest request, kotlin.coroutines.Continuation<? super androidx.wear.complications.data.ComplicationData> p);
+ }
+
+}
+
diff --git a/wear/wear-complications-provider/api/res-current.txt b/wear/wear-complications-data-source-ktx/api/res-current.txt
similarity index 100%
copy from wear/wear-complications-provider/api/res-current.txt
copy to wear/wear-complications-data-source-ktx/api/res-current.txt
diff --git a/wear/wear-complications-data-source-ktx/api/restricted_current.txt b/wear/wear-complications-data-source-ktx/api/restricted_current.txt
new file mode 100644
index 0000000..874628e
--- /dev/null
+++ b/wear/wear-complications-data-source-ktx/api/restricted_current.txt
@@ -0,0 +1,11 @@
+// Signature format: 4.0
+package androidx.wear.complications.datasource {
+
+ public abstract class SuspendingComplicationDataSourceService extends androidx.wear.complications.datasource.ComplicationDataSourceService {
+ ctor public SuspendingComplicationDataSourceService();
+ method public final void onComplicationRequest(androidx.wear.complications.datasource.ComplicationRequest request, androidx.wear.complications.datasource.ComplicationDataSourceService.ComplicationRequestListener listener);
+ method @UiThread public abstract suspend Object? onComplicationRequest(androidx.wear.complications.datasource.ComplicationRequest request, kotlin.coroutines.Continuation<? super androidx.wear.complications.data.ComplicationData> p);
+ }
+
+}
+
diff --git a/wear/wear-complications-provider/build.gradle b/wear/wear-complications-data-source-ktx/build.gradle
similarity index 72%
copy from wear/wear-complications-provider/build.gradle
copy to wear/wear-complications-data-source-ktx/build.gradle
index d56365c..43db873 100644
--- a/wear/wear-complications-provider/build.gradle
+++ b/wear/wear-complications-data-source-ktx/build.gradle
@@ -1,5 +1,5 @@
/*
- * Copyright 2020 The Android Open Source Project
+ * Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -13,11 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
import androidx.build.LibraryGroups
import androidx.build.LibraryVersions
import androidx.build.Publish
-import androidx.build.RunApiTasks
plugins {
id("AndroidXPlugin")
@@ -26,24 +24,18 @@
}
dependencies {
- api("androidx.annotation:annotation:1.1.0")
- api(project(":wear:wear-complications-data"))
-
implementation("androidx.core:core:1.1.0")
- implementation("androidx.preference:preference:1.1.0")
+ api(project(":wear:wear-complications-data-source"))
+ api(libs.kotlinStdlib)
testImplementation(libs.testCore)
testImplementation(libs.testRunner)
testImplementation(libs.testRules)
testImplementation(libs.robolectric)
- testImplementation(libs.mockitoCore)
testImplementation(libs.truth)
testImplementation("junit:junit:4.13")
}
android {
- buildFeatures {
- aidl = true
- }
defaultConfig {
minSdkVersion 25
}
@@ -53,10 +45,11 @@
}
androidx {
- name = "Android Wear Complications"
+ name = "Android Wear Complications Data Source Ktx"
publish = Publish.SNAPSHOT_AND_RELEASE
mavenGroup = LibraryGroups.WEAR
- mavenVersion = LibraryVersions.WEAR_COMPLICATIONS_PROVIDER
- inceptionYear = "2020"
- description = "Android Wear Complications"
+ mavenVersion = LibraryVersions.WEAR_COMPLICATIONS_DATA_SOURCE_KTX
+ inceptionYear = "2021"
+ description = "Kotlin suspend wrapper for Android Wear Complications Data Source"
}
+
diff --git a/wear/wear-complications-data-source-ktx/src/main/AndroidManifest.xml b/wear/wear-complications-data-source-ktx/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..5e7a162
--- /dev/null
+++ b/wear/wear-complications-data-source-ktx/src/main/AndroidManifest.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+ package="androidx.wear.complications.datasource.ktx">
+ <application>
+ <uses-library android:name="com.google.android.wearable" android:required="false" />
+ </application>
+</manifest>
diff --git a/wear/wear-complications-data-source-ktx/src/main/java/androidx/wear/complications/datasource/SuspendingComplicationDataSourceService.kt b/wear/wear-complications-data-source-ktx/src/main/java/androidx/wear/complications/datasource/SuspendingComplicationDataSourceService.kt
new file mode 100644
index 0000000..c608815
--- /dev/null
+++ b/wear/wear-complications-data-source-ktx/src/main/java/androidx/wear/complications/datasource/SuspendingComplicationDataSourceService.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.complications.datasource
+
+import androidx.annotation.UiThread
+import androidx.wear.complications.data.ComplicationData
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+
+/**
+ * Kotlin coroutine wrapper for [ComplicationDataSourceService].
+ */
+public abstract class SuspendingComplicationDataSourceService : ComplicationDataSourceService() {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
+
+ final override fun onComplicationRequest(
+ request: ComplicationRequest,
+ listener: ComplicationRequestListener
+ ) {
+ scope.launch {
+ listener.onComplicationData(onComplicationRequest(request))
+ }
+ }
+
+ /**
+ * Computes the [ComplicationData] for the given [request].
+ *
+ * The [ComplicationData] returned from this method will be passed to the
+ * [ComplicationDataSourceService.ComplicationRequestListener] provided to
+ * [onComplicationRequest].
+ * Return `null` to indicate that the previous complication data shouldn't be overwritten.
+ *
+ * @see ComplicationDataSourceService.onComplicationRequest
+ * @see ComplicationDataSourceService.ComplicationRequestListener.onComplicationData
+ */
+ @UiThread
+ abstract suspend fun onComplicationRequest(request: ComplicationRequest): ComplicationData?
+
+ override fun onDestroy() {
+ super.onDestroy()
+ scope.cancel()
+ }
+}
diff --git a/wear/wear-complications-data-source-ktx/src/test/java/androidx/wear/complications/datasource/SuspendingComplicationDataSourceServiceTest.kt b/wear/wear-complications-data-source-ktx/src/test/java/androidx/wear/complications/datasource/SuspendingComplicationDataSourceServiceTest.kt
new file mode 100644
index 0000000..cdd7d9d
--- /dev/null
+++ b/wear/wear-complications-data-source-ktx/src/test/java/androidx/wear/complications/datasource/SuspendingComplicationDataSourceServiceTest.kt
@@ -0,0 +1,98 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * 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.complications.datasource
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider
+import androidx.wear.complications.data.ComplicationData
+import androidx.wear.complications.data.ComplicationText
+import androidx.wear.complications.data.ComplicationType
+import androidx.wear.complications.data.PlainComplicationText
+import androidx.wear.complications.data.ShortTextComplicationData
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.model.FrameworkMethod
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.internal.DoNotInstrument
+import org.robolectric.internal.bytecode.InstrumentationConfiguration
+
+class TestService : SuspendingComplicationDataSourceService() {
+ override suspend fun onComplicationRequest(request: ComplicationRequest) =
+ ShortTextComplicationData.Builder(
+ PlainComplicationText.Builder("Complication").build(),
+ ComplicationText.EMPTY
+ ).build()
+
+ override fun getPreviewData(type: ComplicationType) =
+ ShortTextComplicationData.Builder(
+ PlainComplicationText.Builder("Preview").build(),
+ ComplicationText.EMPTY
+ ).build()
+}
+
+/** Needed to prevent Robolectric from instrumenting various classes. */
+class ComplicationsTestRunner(clazz: Class<*>?) : RobolectricTestRunner(clazz) {
+ override fun createClassLoaderConfig(method: FrameworkMethod): InstrumentationConfiguration {
+ return InstrumentationConfiguration.Builder(super.createClassLoaderConfig(method))
+ .doNotInstrumentPackage("android.support.wearable.complications")
+ .doNotInstrumentPackage("android.support.wearable.watchface")
+ .doNotInstrumentPackage("androidx.wear.complications")
+ .doNotInstrumentPackage("androidx.wear.complications.datasource")
+ .doNotInstrumentPackage("androidx.wear.watchface")
+ .build()
+ }
+}
+
+/** Tests for {@link ComplicationDataSourceService}. */
+@RunWith(ComplicationsTestRunner::class)
+@DoNotInstrument
+public class SuspendingComplicationDataSourceServiceTest {
+ @Test
+ public fun onComplicationRequest() {
+ val testService = TestService()
+ lateinit var result: ComplicationData
+
+ testService.onComplicationRequest(
+ ComplicationRequest(123, ComplicationType.SMALL_IMAGE),
+ object : ComplicationDataSourceService.ComplicationRequestListener {
+ override fun onComplicationData(complicationData: ComplicationData?) {
+ result = complicationData!!
+ }
+ }
+ )
+
+ assertThat(
+ (result as ShortTextComplicationData).text.getTextAt(
+ ApplicationProvider.getApplicationContext<Context>().resources,
+ 0
+ )
+ ).isEqualTo("Complication")
+ }
+
+ @Test
+ public fun getPreviewData() {
+ val testService = TestService()
+
+ assertThat(
+ testService.getPreviewData(ComplicationType.SMALL_IMAGE).text.getTextAt(
+ ApplicationProvider.getApplicationContext<Context>().resources,
+ 0
+ )
+ ).isEqualTo("Preview")
+ }
+}
\ No newline at end of file
diff --git a/wear/wear-complications-provider/samples/build.gradle b/wear/wear-complications-data-source-samples/build.gradle
similarity index 93%
rename from wear/wear-complications-provider/samples/build.gradle
rename to wear/wear-complications-data-source-samples/build.gradle
index 4a8fea2..a930d97 100644
--- a/wear/wear-complications-provider/samples/build.gradle
+++ b/wear/wear-complications-data-source-samples/build.gradle
@@ -22,7 +22,7 @@
dependencies {
implementation("androidx.core:core:1.1.0")
- api(project(":wear:wear-complications-provider"))
+ api(project(":wear:wear-complications-data-source"))
api(libs.guavaAndroid)
api(libs.kotlinStdlib)
}
diff --git a/wear/wear-complications-provider/samples/src/main/AndroidManifest.xml b/wear/wear-complications-data-source-samples/src/main/AndroidManifest.xml
similarity index 100%
rename from wear/wear-complications-provider/samples/src/main/AndroidManifest.xml
rename to wear/wear-complications-data-source-samples/src/main/AndroidManifest.xml
diff --git a/wear/wear-complications-provider/samples/src/main/java/androidx/wear/complications/datasource/samples/AsynchronousDataSourceService.kt b/wear/wear-complications-data-source-samples/src/main/java/androidx/wear/complications/datasource/samples/AsynchronousDataSourceService.kt
similarity index 100%
rename from wear/wear-complications-provider/samples/src/main/java/androidx/wear/complications/datasource/samples/AsynchronousDataSourceService.kt
rename to wear/wear-complications-data-source-samples/src/main/java/androidx/wear/complications/datasource/samples/AsynchronousDataSourceService.kt
diff --git a/wear/wear-complications-provider/samples/src/main/java/androidx/wear/complications/datasource/samples/BackgroundDataSourceService.kt b/wear/wear-complications-data-source-samples/src/main/java/androidx/wear/complications/datasource/samples/BackgroundDataSourceService.kt
similarity index 100%
rename from wear/wear-complications-provider/samples/src/main/java/androidx/wear/complications/datasource/samples/BackgroundDataSourceService.kt
rename to wear/wear-complications-data-source-samples/src/main/java/androidx/wear/complications/datasource/samples/BackgroundDataSourceService.kt
diff --git a/wear/wear-complications-provider/samples/src/main/java/androidx/wear/complications/datasource/samples/PlainComplicationText.kt b/wear/wear-complications-data-source-samples/src/main/java/androidx/wear/complications/datasource/samples/PlainComplicationText.kt
similarity index 100%
rename from wear/wear-complications-provider/samples/src/main/java/androidx/wear/complications/datasource/samples/PlainComplicationText.kt
rename to wear/wear-complications-data-source-samples/src/main/java/androidx/wear/complications/datasource/samples/PlainComplicationText.kt
diff --git a/wear/wear-complications-provider/samples/src/main/java/androidx/wear/complications/datasource/samples/SynchronousDataSourceService.kt b/wear/wear-complications-data-source-samples/src/main/java/androidx/wear/complications/datasource/samples/SynchronousDataSourceService.kt
similarity index 100%
rename from wear/wear-complications-provider/samples/src/main/java/androidx/wear/complications/datasource/samples/SynchronousDataSourceService.kt
rename to wear/wear-complications-data-source-samples/src/main/java/androidx/wear/complications/datasource/samples/SynchronousDataSourceService.kt
diff --git a/wear/wear-complications-provider/samples/src/main/res/drawable/circle.xml b/wear/wear-complications-data-source-samples/src/main/res/drawable/circle.xml
similarity index 100%
rename from wear/wear-complications-provider/samples/src/main/res/drawable/circle.xml
rename to wear/wear-complications-data-source-samples/src/main/res/drawable/circle.xml
diff --git a/wear/wear-complications-provider/samples/src/main/res/values/strings.xml b/wear/wear-complications-data-source-samples/src/main/res/values/strings.xml
similarity index 100%
rename from wear/wear-complications-provider/samples/src/main/res/values/strings.xml
rename to wear/wear-complications-data-source-samples/src/main/res/values/strings.xml
diff --git a/wear/wear-complications-provider/api/current.txt b/wear/wear-complications-data-source/api/current.txt
similarity index 100%
rename from wear/wear-complications-provider/api/current.txt
rename to wear/wear-complications-data-source/api/current.txt
diff --git a/wear/wear-complications-provider/api/public_plus_experimental_current.txt b/wear/wear-complications-data-source/api/public_plus_experimental_current.txt
similarity index 100%
rename from wear/wear-complications-provider/api/public_plus_experimental_current.txt
rename to wear/wear-complications-data-source/api/public_plus_experimental_current.txt
diff --git a/wear/wear-complications-provider/api/res-current.txt b/wear/wear-complications-data-source/api/res-current.txt
similarity index 100%
rename from wear/wear-complications-provider/api/res-current.txt
rename to wear/wear-complications-data-source/api/res-current.txt
diff --git a/wear/wear-complications-provider/api/restricted_current.txt b/wear/wear-complications-data-source/api/restricted_current.txt
similarity index 100%
rename from wear/wear-complications-provider/api/restricted_current.txt
rename to wear/wear-complications-data-source/api/restricted_current.txt
diff --git a/wear/wear-complications-provider/build.gradle b/wear/wear-complications-data-source/build.gradle
similarity index 90%
rename from wear/wear-complications-provider/build.gradle
rename to wear/wear-complications-data-source/build.gradle
index d56365c..b8a5d7f 100644
--- a/wear/wear-complications-provider/build.gradle
+++ b/wear/wear-complications-data-source/build.gradle
@@ -53,10 +53,10 @@
}
androidx {
- name = "Android Wear Complications"
+ name = "Android Wear Complications Data Source"
publish = Publish.SNAPSHOT_AND_RELEASE
mavenGroup = LibraryGroups.WEAR
- mavenVersion = LibraryVersions.WEAR_COMPLICATIONS_PROVIDER
+ mavenVersion = LibraryVersions.WEAR_COMPLICATIONS_DATA_SOURCE
inceptionYear = "2020"
- description = "Android Wear Complications"
+ description = "Android Wear Complications Data Source"
}
diff --git a/wear/wear-complications-provider/src/main/AndroidManifest.xml b/wear/wear-complications-data-source/src/main/AndroidManifest.xml
similarity index 100%
rename from wear/wear-complications-provider/src/main/AndroidManifest.xml
rename to wear/wear-complications-data-source/src/main/AndroidManifest.xml
diff --git a/wear/wear-complications-provider/src/main/java/androidx/wear/complications/datasource/ComplicationDataSourceService.kt b/wear/wear-complications-data-source/src/main/java/androidx/wear/complications/datasource/ComplicationDataSourceService.kt
similarity index 100%
rename from wear/wear-complications-provider/src/main/java/androidx/wear/complications/datasource/ComplicationDataSourceService.kt
rename to wear/wear-complications-data-source/src/main/java/androidx/wear/complications/datasource/ComplicationDataSourceService.kt
diff --git a/wear/wear-complications-provider/src/main/java/androidx/wear/complications/datasource/ComplicationDataSourceUpdateRequester.kt b/wear/wear-complications-data-source/src/main/java/androidx/wear/complications/datasource/ComplicationDataSourceUpdateRequester.kt
similarity index 100%
rename from wear/wear-complications-provider/src/main/java/androidx/wear/complications/datasource/ComplicationDataSourceUpdateRequester.kt
rename to wear/wear-complications-data-source/src/main/java/androidx/wear/complications/datasource/ComplicationDataSourceUpdateRequester.kt
diff --git a/wear/wear-complications-provider/src/test/java/androidx/wear/complications/ComplicationDataSourceServiceTest.java b/wear/wear-complications-data-source/src/test/java/androidx/wear/complications/ComplicationDataSourceServiceTest.java
similarity index 100%
rename from wear/wear-complications-provider/src/test/java/androidx/wear/complications/ComplicationDataSourceServiceTest.java
rename to wear/wear-complications-data-source/src/test/java/androidx/wear/complications/ComplicationDataSourceServiceTest.java
diff --git a/wear/wear-complications-provider/src/test/java/androidx/wear/complications/ComplicationsTestRunner.java b/wear/wear-complications-data-source/src/test/java/androidx/wear/complications/ComplicationsTestRunner.java
similarity index 100%
rename from wear/wear-complications-provider/src/test/java/androidx/wear/complications/ComplicationsTestRunner.java
rename to wear/wear-complications-data-source/src/test/java/androidx/wear/complications/ComplicationsTestRunner.java
diff --git a/wear/wear-complications-provider/src/test/resources/robolectric.properties b/wear/wear-complications-data-source/src/test/resources/robolectric.properties
similarity index 100%
rename from wear/wear-complications-provider/src/test/resources/robolectric.properties
rename to wear/wear-complications-data-source/src/test/resources/robolectric.properties
diff --git a/wear/wear-ongoing/build.gradle b/wear/wear-ongoing/build.gradle
index cc047e2..3ca05b6 100644
--- a/wear/wear-ongoing/build.gradle
+++ b/wear/wear-ongoing/build.gradle
@@ -10,7 +10,7 @@
dependencies {
api("androidx.annotation:annotation:1.1.0")
- api("androidx.core:core:1.5.0-alpha04")
+ api("androidx.core:core:1.6.0")
api("androidx.versionedparcelable:versionedparcelable:1.1.1")
testImplementation(libs.kotlinStdlib)
@@ -18,7 +18,7 @@
testImplementation(libs.testRunner)
testImplementation(libs.robolectric)
- implementation "androidx.core:core-ktx:1.5.0-alpha04"
+ implementation "androidx.core:core-ktx:1.6.0"
annotationProcessor(project(":versionedparcelable:versionedparcelable-compiler"))
}
diff --git a/wear/wear-phone-interactions/api/current.txt b/wear/wear-phone-interactions/api/current.txt
index 310f3a1..e50ac2f 100644
--- a/wear/wear-phone-interactions/api/current.txt
+++ b/wear/wear-phone-interactions/api/current.txt
@@ -64,7 +64,7 @@
method @UiThread public void close();
method public static androidx.wear.phone.interactions.authentication.RemoteAuthClient create(android.content.Context context);
method protected void finalize();
- method @UiThread public void sendAuthorizationRequest(androidx.wear.phone.interactions.authentication.OAuthRequest request, androidx.wear.phone.interactions.authentication.RemoteAuthClient.Callback clientCallback);
+ method @UiThread public void sendAuthorizationRequest(androidx.wear.phone.interactions.authentication.OAuthRequest request, java.util.concurrent.Executor executor, androidx.wear.phone.interactions.authentication.RemoteAuthClient.Callback clientCallback);
field public static final androidx.wear.phone.interactions.authentication.RemoteAuthClient.Companion Companion;
field public static final int ERROR_PHONE_UNAVAILABLE = 1; // 0x1
field public static final int ERROR_UNSUPPORTED = 0; // 0x0
@@ -73,7 +73,7 @@
public abstract static class RemoteAuthClient.Callback {
ctor public RemoteAuthClient.Callback();
- method @UiThread public abstract void onAuthorizationError(int errorCode);
+ method @UiThread public abstract void onAuthorizationError(androidx.wear.phone.interactions.authentication.OAuthRequest request, int errorCode);
method @UiThread public abstract void onAuthorizationResponse(androidx.wear.phone.interactions.authentication.OAuthRequest request, androidx.wear.phone.interactions.authentication.OAuthResponse response);
}
diff --git a/wear/wear-phone-interactions/api/public_plus_experimental_current.txt b/wear/wear-phone-interactions/api/public_plus_experimental_current.txt
index 310f3a1..e50ac2f 100644
--- a/wear/wear-phone-interactions/api/public_plus_experimental_current.txt
+++ b/wear/wear-phone-interactions/api/public_plus_experimental_current.txt
@@ -64,7 +64,7 @@
method @UiThread public void close();
method public static androidx.wear.phone.interactions.authentication.RemoteAuthClient create(android.content.Context context);
method protected void finalize();
- method @UiThread public void sendAuthorizationRequest(androidx.wear.phone.interactions.authentication.OAuthRequest request, androidx.wear.phone.interactions.authentication.RemoteAuthClient.Callback clientCallback);
+ method @UiThread public void sendAuthorizationRequest(androidx.wear.phone.interactions.authentication.OAuthRequest request, java.util.concurrent.Executor executor, androidx.wear.phone.interactions.authentication.RemoteAuthClient.Callback clientCallback);
field public static final androidx.wear.phone.interactions.authentication.RemoteAuthClient.Companion Companion;
field public static final int ERROR_PHONE_UNAVAILABLE = 1; // 0x1
field public static final int ERROR_UNSUPPORTED = 0; // 0x0
@@ -73,7 +73,7 @@
public abstract static class RemoteAuthClient.Callback {
ctor public RemoteAuthClient.Callback();
- method @UiThread public abstract void onAuthorizationError(int errorCode);
+ method @UiThread public abstract void onAuthorizationError(androidx.wear.phone.interactions.authentication.OAuthRequest request, int errorCode);
method @UiThread public abstract void onAuthorizationResponse(androidx.wear.phone.interactions.authentication.OAuthRequest request, androidx.wear.phone.interactions.authentication.OAuthResponse response);
}
diff --git a/wear/wear-phone-interactions/api/restricted_current.txt b/wear/wear-phone-interactions/api/restricted_current.txt
index 310f3a1..e50ac2f 100644
--- a/wear/wear-phone-interactions/api/restricted_current.txt
+++ b/wear/wear-phone-interactions/api/restricted_current.txt
@@ -64,7 +64,7 @@
method @UiThread public void close();
method public static androidx.wear.phone.interactions.authentication.RemoteAuthClient create(android.content.Context context);
method protected void finalize();
- method @UiThread public void sendAuthorizationRequest(androidx.wear.phone.interactions.authentication.OAuthRequest request, androidx.wear.phone.interactions.authentication.RemoteAuthClient.Callback clientCallback);
+ method @UiThread public void sendAuthorizationRequest(androidx.wear.phone.interactions.authentication.OAuthRequest request, java.util.concurrent.Executor executor, androidx.wear.phone.interactions.authentication.RemoteAuthClient.Callback clientCallback);
field public static final androidx.wear.phone.interactions.authentication.RemoteAuthClient.Companion Companion;
field public static final int ERROR_PHONE_UNAVAILABLE = 1; // 0x1
field public static final int ERROR_UNSUPPORTED = 0; // 0x0
@@ -73,7 +73,7 @@
public abstract static class RemoteAuthClient.Callback {
ctor public RemoteAuthClient.Callback();
- method @UiThread public abstract void onAuthorizationError(int errorCode);
+ method @UiThread public abstract void onAuthorizationError(androidx.wear.phone.interactions.authentication.OAuthRequest request, int errorCode);
method @UiThread public abstract void onAuthorizationResponse(androidx.wear.phone.interactions.authentication.OAuthRequest request, androidx.wear.phone.interactions.authentication.OAuthResponse response);
}
diff --git a/wear/wear-phone-interactions/src/main/java/androidx/wear/phone/interactions/authentication/RemoteAuthClient.kt b/wear/wear-phone-interactions/src/main/java/androidx/wear/phone/interactions/authentication/RemoteAuthClient.kt
index 6dd4162..842e9ac 100644
--- a/wear/wear-phone-interactions/src/main/java/androidx/wear/phone/interactions/authentication/RemoteAuthClient.kt
+++ b/wear/wear-phone-interactions/src/main/java/androidx/wear/phone/interactions/authentication/RemoteAuthClient.kt
@@ -65,6 +65,7 @@
* .setAuthProviderUrl(Uri.parse("https://...."))
* .setCodeChallenge(CodeChallenge(codeVerifier))
* .build(),
+ * Executors.newSingleThreadExecutor()
* new MyAuthCallback()
* );
* }
@@ -80,7 +81,7 @@
* ...
* }
*
- * override public fun onAuthorizationError(errorCode: int) {
+ * override public fun onAuthorizationError(request: OAuthRequest, errorCode: int) {
* // Compare against codes available in RemoteAuthClient.ErrorCode
* // You'll also want to display an error UI.
* ...
@@ -205,7 +206,7 @@
* see [sendAuthorizationRequest]
*/
@UiThread
- public abstract fun onAuthorizationError(@ErrorCode errorCode: Int)
+ public abstract fun onAuthorizationError(request: OAuthRequest, @ErrorCode errorCode: Int)
}
/**
@@ -215,13 +216,18 @@
* completes.
*
* @param request Request that will be sent to the phone. The auth response should redirect
- * to the Wear OS companion. See [WEAR_REDIRECT_URL_PREFIX]
+ * to the Wear OS companion. See [OAuthRequest.WEAR_REDIRECT_URL_PREFIX]
+ * @param executor The executor that callback will called on.
+ * @param clientCallback The callback that will be notified when request is completed.
*
* @Throws RuntimeException if the service has error to open the request
*/
@UiThread
- @SuppressLint("ExecutorRegistration")
- public fun sendAuthorizationRequest(request: OAuthRequest, clientCallback: Callback) {
+ public fun sendAuthorizationRequest(
+ request: OAuthRequest,
+ executor: Executor,
+ clientCallback: Callback
+ ) {
require(packageName == request.getPackageName()) {
"The request's package name is different from the auth client's package name."
}
@@ -229,18 +235,16 @@
if (connectionState == STATE_DISCONNECTED) {
connect()
}
- whenConnected(
- Runnable {
- val callback = RequestCallback(request, clientCallback)
- outstandingRequests.add(callback)
- try {
- service!!.openUrl(request.toBundle(), callback)
- } catch (e: Exception) {
- removePendingCallback(callback)
- throw RuntimeException(e)
- }
+ whenConnected {
+ val callback = RequestCallback(request, clientCallback, executor)
+ outstandingRequests.add(callback)
+ try {
+ service!!.openUrl(request.toBundle(), callback)
+ } catch (e: Exception) {
+ removePendingCallback(callback)
+ throw RuntimeException(e)
}
- )
+ }
}
/**
@@ -320,7 +324,8 @@
/** Receives results of async requests to the remote auth service. */
internal inner class RequestCallback internal constructor(
private val request: OAuthRequest,
- private val clientCallback: Callback
+ private val clientCallback: Callback,
+ private val executor: Executor
) : IAuthenticationRequestCallback.Stub() {
override fun getApiVersion(): Int = IAuthenticationRequestCallback.API_VERSION
@@ -345,9 +350,13 @@
Runnable {
removePendingCallback(this@RequestCallback)
if (error == NO_ERROR) {
- clientCallback.onAuthorizationResponse(request, response)
+ executor.execute {
+ clientCallback.onAuthorizationResponse(request, response)
+ }
} else {
- clientCallback.onAuthorizationError(response.getErrorCode())
+ executor.execute {
+ clientCallback.onAuthorizationError(request, response.getErrorCode())
+ }
}
}
)
diff --git a/wear/wear-phone-interactions/src/main/java/androidx/wear/phone/interactions/authentication/RemoteAuthService.kt b/wear/wear-phone-interactions/src/main/java/androidx/wear/phone/interactions/authentication/RemoteAuthService.kt
index dadd75b..cadc0c3 100644
--- a/wear/wear-phone-interactions/src/main/java/androidx/wear/phone/interactions/authentication/RemoteAuthService.kt
+++ b/wear/wear-phone-interactions/src/main/java/androidx/wear/phone/interactions/authentication/RemoteAuthService.kt
@@ -46,8 +46,8 @@
/**
* Handle the auth request by sending it to the phone.
* Typically, if the paired phone is not connected, send a response with error code of
- * ERROR_PHONE_UNAVAILABLE; otherwise listening for the response from the phone and send it
- * back to the 3p app.
+ * [RemoteAuthClient.ERROR_PHONE_UNAVAILABLE]; otherwise listening for the response from the
+ * phone and send it back to the 3p app.
*
* [RemoteAuthService.sendResponseToCallback] is provided for sending response back to the
* callback provided by the 3p app.
diff --git a/wear/wear-phone-interactions/src/test/java/androidx/wear/phone/interactions/authentication/RemoteAuthTest.kt b/wear/wear-phone-interactions/src/test/java/androidx/wear/phone/interactions/authentication/RemoteAuthTest.kt
index 8d8bf82..4b2ec78 100644
--- a/wear/wear-phone-interactions/src/test/java/androidx/wear/phone/interactions/authentication/RemoteAuthTest.kt
+++ b/wear/wear-phone-interactions/src/test/java/androidx/wear/phone/interactions/authentication/RemoteAuthTest.kt
@@ -69,6 +69,7 @@
private var fakeService: FakeClockworkHomeAuthService = FakeClockworkHomeAuthService()
private var clientUnderTest: RemoteAuthClient =
RemoteAuthClient(fakeServiceBinder, DIRECT_EXECUTOR, appPackageName)
+ private val executor: Executor = SyncExecutor()
@Test
public fun doesntConnectUntilARequestIsMade() {
@@ -86,6 +87,7 @@
.setAuthProviderUrl(Uri.parse(requestUri))
.setCodeChallenge(CodeChallenge(CodeVerifier()))
.build(),
+ executor,
mockCallback
)
// THEN a connection is made to Clockwork Home's Auth service
@@ -95,7 +97,7 @@
@Test
public fun sendAuthorizationRequestShouldCallBinderMethod() {
// WHEN an authorization request is sent
- clientUnderTest.sendAuthorizationRequest(requestA, mockCallback)
+ clientUnderTest.sendAuthorizationRequest(requestA, executor, mockCallback)
fakeServiceBinder.completeConnection()
// THEN a request is made to Clockwork Home
val request = fakeService.requests[0]
@@ -114,8 +116,8 @@
@Test
public fun twoQueuedAuthorizationRequestsBeforeConnectCompletes() {
// GIVEN two authorization requests were made before connecting to Clockwork Home completes
- clientUnderTest.sendAuthorizationRequest(requestA, mockCallback)
- clientUnderTest.sendAuthorizationRequest(requestB, mockCallback)
+ clientUnderTest.sendAuthorizationRequest(requestA, executor, mockCallback)
+ clientUnderTest.sendAuthorizationRequest(requestB, executor, mockCallback)
// WHEN the connection does complete
fakeServiceBinder.completeConnection()
// THEN two requests are made to Clockwork Home
@@ -145,7 +147,7 @@
@Throws(RemoteException::class)
public fun requestCompletionShouldCallBackToClient() {
// GIVEN an authorization request was sent
- clientUnderTest.sendAuthorizationRequest(requestA, mockCallback)
+ clientUnderTest.sendAuthorizationRequest(requestA, executor, mockCallback)
fakeServiceBinder.completeConnection()
val request = fakeService.requests[0]
// WHEN the request completes
@@ -157,10 +159,10 @@
@Throws(RemoteException::class)
public fun doesntDisconnectWhenRequestStillInProgress() {
// GIVEN 2 authorization requests were sent
- clientUnderTest.sendAuthorizationRequest(requestA, mockCallback)
+ clientUnderTest.sendAuthorizationRequest(requestA, executor, mockCallback)
// GIVEN the async binding to Clockwork Home completed after the 1st but before the 2nd
fakeServiceBinder.completeConnection()
- clientUnderTest.sendAuthorizationRequest(requestB, mockCallback)
+ clientUnderTest.sendAuthorizationRequest(requestB, executor, mockCallback)
// WHEN the first one completes
RemoteAuthService.sendResponseToCallback(
response,
@@ -175,10 +177,10 @@
@Throws(RemoteException::class)
public fun disconnectsWhenAllRequestsComplete() {
// GIVEN 2 authorization requests were sent
- clientUnderTest.sendAuthorizationRequest(requestA, mockCallback)
+ clientUnderTest.sendAuthorizationRequest(requestA, executor, mockCallback)
// GIVEN the async binding to Clockwork Home completed after the 1st but before the 2nd
fakeServiceBinder.completeConnection()
- clientUnderTest.sendAuthorizationRequest(requestB, mockCallback)
+ clientUnderTest.sendAuthorizationRequest(requestB, executor, mockCallback)
RemoteAuthService.sendResponseToCallback(
response,
fakeService.requests[0].second
@@ -271,3 +273,9 @@
}
}
}
+
+private class SyncExecutor : Executor {
+ override fun execute(command: Runnable?) {
+ command?.run()
+ }
+}
diff --git a/wear/wear-remote-interactions/api/current.txt b/wear/wear-remote-interactions/api/current.txt
index 7c87d3b..4f35fd7 100644
--- a/wear/wear-remote-interactions/api/current.txt
+++ b/wear/wear-remote-interactions/api/current.txt
@@ -1,18 +1,6 @@
// Signature format: 4.0
package androidx.wear.remote.interactions {
- @RequiresApi(android.os.Build.VERSION_CODES.N) public final class PlayStoreAvailability {
- method public static int getPlayStoreAvailabilityOnPhone(android.content.Context context);
- field public static final androidx.wear.remote.interactions.PlayStoreAvailability.Companion Companion;
- field public static final int PLAY_STORE_AVAILABLE = 1; // 0x1
- field public static final int PLAY_STORE_ERROR_UNKNOWN = 0; // 0x0
- field public static final int PLAY_STORE_UNAVAILABLE = 2; // 0x2
- }
-
- public static final class PlayStoreAvailability.Companion {
- method public int getPlayStoreAvailabilityOnPhone(android.content.Context context);
- }
-
public final class RemoteIntentHelper {
ctor public RemoteIntentHelper(android.content.Context context, optional java.util.concurrent.Executor executor);
method public static android.content.IntentFilter createActionRemoteIntentFilter();
diff --git a/wear/wear-remote-interactions/api/public_plus_experimental_current.txt b/wear/wear-remote-interactions/api/public_plus_experimental_current.txt
index 7c87d3b..4f35fd7 100644
--- a/wear/wear-remote-interactions/api/public_plus_experimental_current.txt
+++ b/wear/wear-remote-interactions/api/public_plus_experimental_current.txt
@@ -1,18 +1,6 @@
// Signature format: 4.0
package androidx.wear.remote.interactions {
- @RequiresApi(android.os.Build.VERSION_CODES.N) public final class PlayStoreAvailability {
- method public static int getPlayStoreAvailabilityOnPhone(android.content.Context context);
- field public static final androidx.wear.remote.interactions.PlayStoreAvailability.Companion Companion;
- field public static final int PLAY_STORE_AVAILABLE = 1; // 0x1
- field public static final int PLAY_STORE_ERROR_UNKNOWN = 0; // 0x0
- field public static final int PLAY_STORE_UNAVAILABLE = 2; // 0x2
- }
-
- public static final class PlayStoreAvailability.Companion {
- method public int getPlayStoreAvailabilityOnPhone(android.content.Context context);
- }
-
public final class RemoteIntentHelper {
ctor public RemoteIntentHelper(android.content.Context context, optional java.util.concurrent.Executor executor);
method public static android.content.IntentFilter createActionRemoteIntentFilter();
diff --git a/wear/wear-remote-interactions/api/restricted_current.txt b/wear/wear-remote-interactions/api/restricted_current.txt
index 7c87d3b..4f35fd7 100644
--- a/wear/wear-remote-interactions/api/restricted_current.txt
+++ b/wear/wear-remote-interactions/api/restricted_current.txt
@@ -1,18 +1,6 @@
// Signature format: 4.0
package androidx.wear.remote.interactions {
- @RequiresApi(android.os.Build.VERSION_CODES.N) public final class PlayStoreAvailability {
- method public static int getPlayStoreAvailabilityOnPhone(android.content.Context context);
- field public static final androidx.wear.remote.interactions.PlayStoreAvailability.Companion Companion;
- field public static final int PLAY_STORE_AVAILABLE = 1; // 0x1
- field public static final int PLAY_STORE_ERROR_UNKNOWN = 0; // 0x0
- field public static final int PLAY_STORE_UNAVAILABLE = 2; // 0x2
- }
-
- public static final class PlayStoreAvailability.Companion {
- method public int getPlayStoreAvailabilityOnPhone(android.content.Context context);
- }
-
public final class RemoteIntentHelper {
ctor public RemoteIntentHelper(android.content.Context context, optional java.util.concurrent.Executor executor);
method public static android.content.IntentFilter createActionRemoteIntentFilter();
diff --git a/wear/wear-remote-interactions/src/main/java/androidx/wear/remote/interactions/PlayStoreAvailability.kt b/wear/wear-remote-interactions/src/main/java/androidx/wear/remote/interactions/PlayStoreAvailability.kt
deleted file mode 100644
index 8b05980..0000000
--- a/wear/wear-remote-interactions/src/main/java/androidx/wear/remote/interactions/PlayStoreAvailability.kt
+++ /dev/null
@@ -1,109 +0,0 @@
-/*
- * Copyright 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * 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.remote.interactions
-
-import android.content.Context
-import android.net.Uri
-import android.os.Build
-import android.provider.Settings
-import androidx.annotation.IntDef
-import androidx.annotation.RequiresApi
-import androidx.wear.remote.interactions.RemoteInteractionsUtil.isCurrentDeviceAWatch
-import com.google.android.gms.common.ConnectionResult
-import com.google.android.gms.common.GoogleApiAvailabilityLight
-
-/**
- * Helper class for checking whether the phone paired to a given Wear OS device has the Play Store.
- */
-@RequiresApi(Build.VERSION_CODES.N)
-public class PlayStoreAvailability private constructor() {
- public companion object {
- /**
- * This value means that there was an error in checking for whether the Play Store is
- * available son the phone.
- */
- public const val PLAY_STORE_ERROR_UNKNOWN: Int = 0
-
- /** This value means that the Play Store is available on the phone. */
- public const val PLAY_STORE_AVAILABLE: Int = 1
-
- /** This value means that the Play Store is not available on the phone. */
- public const val PLAY_STORE_UNAVAILABLE: Int = 2
-
- private const val PLAY_STORE_AVAILABILITY_PATH = "play_store_availability"
- internal const val SETTINGS_AUTHORITY_URI = "com.google.android.wearable.settings"
- internal val PLAY_STORE_AVAILABILITY_URI = Uri.Builder()
- .scheme("content")
- .authority(SETTINGS_AUTHORITY_URI)
- .path(PLAY_STORE_AVAILABILITY_PATH)
- .build()
-
- // The name of the row which stores the play store availability setting in versions before
- // R.
- internal const val KEY_PLAY_STORE_AVAILABILITY = "play_store_availability"
-
- // The name of the settings value which stores the play store availability setting in
- // versions from R.
- private const val SETTINGS_PLAY_STORE_AVAILABILITY = "phone_play_store_availability"
-
- /**
- * Returns whether the Play Store is available on the Phone. If
- * [PLAY_STORE_ERROR_UNKNOWN] is returned, the caller should try again later. This
- * method should not be run on the main thread.
- *
- * @return One of three values: [PLAY_STORE_AVAILABLE],
- * [PLAY_STORE_UNAVAILABLE], or [PLAY_STORE_ERROR_UNKNOWN].
- */
- @JvmStatic
- @PlayStoreStatus
- public fun getPlayStoreAvailabilityOnPhone(context: Context): Int {
- if (!isCurrentDeviceAWatch(context)) {
- val isPlayServiceAvailable =
- GoogleApiAvailabilityLight.getInstance().isGooglePlayServicesAvailable(context)
- return if (isPlayServiceAvailable == ConnectionResult.SUCCESS) PLAY_STORE_AVAILABLE
- else PLAY_STORE_UNAVAILABLE
- }
-
- if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
- context.contentResolver.query(
- PLAY_STORE_AVAILABILITY_URI, null, null, null,
- null
- )?.use { cursor ->
- while (cursor.moveToNext()) {
- if (KEY_PLAY_STORE_AVAILABILITY == cursor.getString(0)) {
- return cursor.getInt(1)
- }
- }
- }
- } else {
- return Settings.Global.getInt(
- context.contentResolver, SETTINGS_PLAY_STORE_AVAILABILITY,
- PLAY_STORE_ERROR_UNKNOWN
- )
- }
- return PLAY_STORE_ERROR_UNKNOWN
- }
-
- /** @hide */
- @IntDef(
- PLAY_STORE_ERROR_UNKNOWN,
- PLAY_STORE_AVAILABLE,
- PLAY_STORE_UNAVAILABLE
- )
- @Retention(AnnotationRetention.SOURCE)
- public annotation class PlayStoreStatus
- }
-}
\ No newline at end of file
diff --git a/wear/wear-remote-interactions/src/main/java/androidx/wear/remote/interactions/RemoteIntentHelper.kt b/wear/wear-remote-interactions/src/main/java/androidx/wear/remote/interactions/RemoteIntentHelper.kt
index f9b5195..ae36af8 100644
--- a/wear/wear-remote-interactions/src/main/java/androidx/wear/remote/interactions/RemoteIntentHelper.kt
+++ b/wear/wear-remote-interactions/src/main/java/androidx/wear/remote/interactions/RemoteIntentHelper.kt
@@ -39,15 +39,19 @@
* The following example opens play store for the given app on another device:
*
* ```
- * RemoteIntentHelper.startRemoteActivity(
- * context, nodeId,
+ * val remoteIntentHelper = RemoteIntentHelper(context, executor)
+ *
+ * val result = remoteIntentHelper.startRemoteActivity(
* new Intent(Intent.ACTION_VIEW).setData(
* Uri.parse("http://play.google.com/store/apps/details?id=com.example.myapp")
* ),
- * null
+ * nodeId
* )
* ```
*
+ * [startRemoteActivity] returns a [ListenableFuture], which is completed after the intent has
+ * been sent or failed if there was an issue with sending the intent.
+ *
* @param context The [Context] of the application for sending the intent.
* @param executor [Executor] used for getting data to be passed in remote intent. If not
* specified, default will be `Executors.newSingleThreadExecutor()`.
diff --git a/wear/wear-remote-interactions/src/test/java/androidx/wear/remote/interactions/PlayStoreAvailabilityTest.kt b/wear/wear-remote-interactions/src/test/java/androidx/wear/remote/interactions/PlayStoreAvailabilityTest.kt
deleted file mode 100644
index 36e7580..0000000
--- a/wear/wear-remote-interactions/src/test/java/androidx/wear/remote/interactions/PlayStoreAvailabilityTest.kt
+++ /dev/null
@@ -1,122 +0,0 @@
-/*
- * Copyright 2021 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * 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.remote.interactions
-
-import android.content.ContentProvider
-import android.content.ContentResolver
-import android.content.Context
-import android.database.Cursor
-import android.database.MatrixCursor
-import androidx.test.core.app.ApplicationProvider
-import org.junit.Assert.assertEquals
-import org.junit.Before
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.mockito.ArgumentMatchers
-import org.mockito.Mock
-import org.mockito.Mockito
-import org.mockito.MockitoAnnotations
-import org.robolectric.Shadows
-import org.robolectric.annotation.Config
-import org.robolectric.annotation.internal.DoNotInstrument
-import org.robolectric.shadows.ShadowContentResolver
-import org.robolectric.shadows.ShadowPackageManager
-
-@RunWith(WearRemoteInteractionsTestRunner::class)
-@DoNotInstrument // Stop Robolectric instrumenting this class due to it being in package "android".
-class PlayStoreAvailabilityTest {
- @Mock
- private var mockContentProvider: ContentProvider? = null
- private var contentResolver: ContentResolver? = null
- private var shadowPackageManager: ShadowPackageManager? = null
-
- @Before
- fun setUp() {
- MockitoAnnotations.initMocks(this)
- ShadowContentResolver.registerProviderInternal(
- PlayStoreAvailability.SETTINGS_AUTHORITY_URI,
- mockContentProvider
- )
- val context: Context = ApplicationProvider.getApplicationContext()
- contentResolver = context.contentResolver
- shadowPackageManager = Shadows.shadowOf(context.packageManager)
- shadowPackageManager?.setSystemFeature(RemoteInteractionsUtil.SYSTEM_FEATURE_WATCH, true)
- }
-
- @Test
- @Config(sdk = [25, 26, 27, 28])
- fun getPlayStoreAvailabilityOnPhone_returnsAvailable() {
- createFakePlayStoreAvailabilityQuery(PlayStoreAvailability.PLAY_STORE_AVAILABLE)
- assertEquals(
- PlayStoreAvailability.getPlayStoreAvailabilityOnPhone(
- ApplicationProvider.getApplicationContext()
- ),
- PlayStoreAvailability.PLAY_STORE_AVAILABLE
- )
- }
-
- @Test
- @Config(sdk = [25, 26, 27, 28])
- fun getPlayStoreAvailabilityOnPhone_returnsUnavailable() {
- createFakePlayStoreAvailabilityQuery(PlayStoreAvailability.PLAY_STORE_UNAVAILABLE)
- assertEquals(
- PlayStoreAvailability.getPlayStoreAvailabilityOnPhone(
- ApplicationProvider.getApplicationContext()
- ),
- PlayStoreAvailability.PLAY_STORE_UNAVAILABLE
- )
- }
-
- @Test
- @Config(sdk = [25, 26, 27, 28])
- fun getPlayStoreAvailabilityOnPhone_returnsError() {
- assertEquals(
- PlayStoreAvailability.getPlayStoreAvailabilityOnPhone(
- ApplicationProvider.getApplicationContext()
- ),
- PlayStoreAvailability.PLAY_STORE_ERROR_UNKNOWN
- )
- }
-
- /*
- TODO(b/178086256): Since Roboelectric doesn't support API 30 for now, add tests for it when it
- is supported.
- */
-
- companion object {
- private fun createFakePlayStoreAvailabilityCursor(availability: Int): Cursor {
- val cursor = MatrixCursor(arrayOf("key", "value"))
- cursor.addRow(
- arrayOf<Any>(PlayStoreAvailability.KEY_PLAY_STORE_AVAILABILITY, availability)
- )
- return cursor
- }
- }
-
- private fun createFakePlayStoreAvailabilityQuery(availability: Int) {
- Mockito.`when`(
- mockContentProvider!!.query(
- ArgumentMatchers.eq(PlayStoreAvailability.PLAY_STORE_AVAILABILITY_URI),
- ArgumentMatchers.any(),
- ArgumentMatchers.any(),
- ArgumentMatchers.any(),
- ArgumentMatchers.any()
- )
- )
- .thenReturn(createFakePlayStoreAvailabilityCursor(availability))
- }
-}
diff --git a/wear/wear-watchface-client/api/current.txt b/wear/wear-watchface-client/api/current.txt
index ac0f719..8ff9354 100644
--- a/wear/wear-watchface-client/api/current.txt
+++ b/wear/wear-watchface-client/api/current.txt
@@ -45,6 +45,9 @@
property public final boolean hasLowBitAmbient;
}
+ public final class DeviceConfigKt {
+ }
+
public interface EditorListener {
method public void onEditorStateChanged(androidx.wear.watchface.client.EditorState editorState);
}
@@ -57,10 +60,12 @@
public final class EditorState {
method public java.util.Map<java.lang.Integer,androidx.wear.complications.data.ComplicationData> getPreviewComplicationsData();
+ method public android.graphics.Bitmap? getPreviewImage();
method public boolean getShouldCommitChanges();
method public androidx.wear.watchface.style.UserStyleData getUserStyle();
method public androidx.wear.watchface.client.WatchFaceId getWatchFaceId();
property public final java.util.Map<java.lang.Integer,androidx.wear.complications.data.ComplicationData> previewComplicationsData;
+ property public final android.graphics.Bitmap? previewImage;
property public final boolean shouldCommitChanges;
property public final androidx.wear.watchface.style.UserStyleData userStyle;
property public final androidx.wear.watchface.client.WatchFaceId watchFaceId;
diff --git a/wear/wear-watchface-client/api/public_plus_experimental_current.txt b/wear/wear-watchface-client/api/public_plus_experimental_current.txt
index 586d7a9..8ee8d1a 100644
--- a/wear/wear-watchface-client/api/public_plus_experimental_current.txt
+++ b/wear/wear-watchface-client/api/public_plus_experimental_current.txt
@@ -65,6 +65,9 @@
property public final boolean hasLowBitAmbient;
}
+ public final class DeviceConfigKt {
+ }
+
public interface EditorListener {
method public void onEditorStateChanged(androidx.wear.watchface.client.EditorState editorState);
}
@@ -77,10 +80,12 @@
public final class EditorState {
method public java.util.Map<java.lang.Integer,androidx.wear.complications.data.ComplicationData> getPreviewComplicationsData();
+ method public android.graphics.Bitmap? getPreviewImage();
method public boolean getShouldCommitChanges();
method public androidx.wear.watchface.style.UserStyleData getUserStyle();
method public androidx.wear.watchface.client.WatchFaceId getWatchFaceId();
property public final java.util.Map<java.lang.Integer,androidx.wear.complications.data.ComplicationData> previewComplicationsData;
+ property public final android.graphics.Bitmap? previewImage;
property public final boolean shouldCommitChanges;
property public final androidx.wear.watchface.style.UserStyleData userStyle;
property public final androidx.wear.watchface.client.WatchFaceId watchFaceId;
diff --git a/wear/wear-watchface-client/api/restricted_current.txt b/wear/wear-watchface-client/api/restricted_current.txt
index 5b6dd55..ecca18f 100644
--- a/wear/wear-watchface-client/api/restricted_current.txt
+++ b/wear/wear-watchface-client/api/restricted_current.txt
@@ -36,6 +36,7 @@
public final class DeviceConfig {
ctor public DeviceConfig(boolean hasLowBitAmbient, boolean hasBurnInProtection, long analogPreviewReferenceTimeMillis, long digitalPreviewReferenceTimeMillis);
+ method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP) public androidx.wear.watchface.data.DeviceConfig asWireDeviceConfig();
method public long getAnalogPreviewReferenceTimeMillis();
method public long getDigitalPreviewReferenceTimeMillis();
method public boolean getHasBurnInProtection();
@@ -46,6 +47,10 @@
property public final boolean hasLowBitAmbient;
}
+ public final class DeviceConfigKt {
+ method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP) public static androidx.wear.watchface.client.DeviceConfig asApiDeviceConfig(androidx.wear.watchface.data.DeviceConfig);
+ }
+
public interface EditorListener {
method public void onEditorStateChanged(androidx.wear.watchface.client.EditorState editorState);
}
@@ -58,10 +63,12 @@
public final class EditorState {
method public java.util.Map<java.lang.Integer,androidx.wear.complications.data.ComplicationData> getPreviewComplicationsData();
+ method public android.graphics.Bitmap? getPreviewImage();
method public boolean getShouldCommitChanges();
method public androidx.wear.watchface.style.UserStyleData getUserStyle();
method public androidx.wear.watchface.client.WatchFaceId getWatchFaceId();
property public final java.util.Map<java.lang.Integer,androidx.wear.complications.data.ComplicationData> previewComplicationsData;
+ property public final android.graphics.Bitmap? previewImage;
property public final boolean shouldCommitChanges;
property public final androidx.wear.watchface.style.UserStyleData userStyle;
property public final androidx.wear.watchface.client.WatchFaceId watchFaceId;
diff --git a/wear/wear-watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/EditorServiceClientTest.kt b/wear/wear-watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/EditorServiceClientTest.kt
index d61c17d..549cbff 100644
--- a/wear/wear-watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/EditorServiceClientTest.kt
+++ b/wear/wear-watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/EditorServiceClientTest.kt
@@ -55,7 +55,8 @@
)
),
emptyList(),
- true
+ true,
+ null
)
)
diff --git a/wear/wear-watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/WatchFaceControlClientTest.kt b/wear/wear-watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/WatchFaceControlClientTest.kt
index f89832a..f0782f2 100644
--- a/wear/wear-watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/WatchFaceControlClientTest.kt
+++ b/wear/wear-watchface-client/src/androidTest/java/androidx/wear/watchface/client/test/WatchFaceControlClientTest.kt
@@ -16,6 +16,7 @@
package androidx.wear.watchface.client.test
+import android.annotation.SuppressLint
import android.content.ComponentName
import android.content.Context
import android.content.Intent
@@ -200,6 +201,7 @@
return value!!
}
+ @SuppressLint("NewApi") // renderWatchFaceToBitmap
@Test
fun headlessScreenshot() {
val headlessInstance = service.createHeadlessWatchFaceClient(
@@ -229,6 +231,7 @@
headlessInstance.close()
}
+ @SuppressLint("NewApi") // renderWatchFaceToBitmap
@Test
fun yellowComplicationHighlights() {
val headlessInstance = service.createHeadlessWatchFaceClient(
@@ -262,6 +265,7 @@
headlessInstance.close()
}
+ @SuppressLint("NewApi") // renderWatchFaceToBitmap
@Test
fun highlightOnlyLayer() {
val headlessInstance = service.createHeadlessWatchFaceClient(
@@ -395,6 +399,7 @@
assertThat(headlessInstance.userStyleSchema.userStyleSettings.size).isEqualTo(4)
}
+ @SuppressLint("NewApi") // renderWatchFaceToBitmap
@Test
fun getOrCreateInteractiveWatchFaceClient() {
val deferredInteractiveInstance = handlerCoroutineScope.async {
@@ -430,6 +435,7 @@
}
}
+ @SuppressLint("NewApi") // renderWatchFaceToBitmap
@Test
fun getOrCreateInteractiveWatchFaceClient_initialStyle() {
val deferredInteractiveInstance = handlerCoroutineScope.async {
@@ -590,6 +596,62 @@
assertThat(awaitWithTimeout(deferredInteractiveInstance2).instanceId).isEqualTo("testId")
}
+ @SuppressLint("NewApi") // renderWatchFaceToBitmap
+ @Test
+ fun getOrCreateInteractiveWatchFaceClient_existingOpenInstance_styleChange() {
+ val deferredInteractiveInstance = handlerCoroutineScope.async {
+ service.getOrCreateInteractiveWatchFaceClient(
+ "testId",
+ deviceConfig,
+ systemState,
+ null,
+ complications
+ )
+ }
+
+ // Create the engine which triggers creation of InteractiveWatchFaceClient.
+ createEngine()
+
+ awaitWithTimeout(deferredInteractiveInstance)
+
+ val deferredInteractiveInstance2 = handlerCoroutineScope.async {
+ service.getOrCreateInteractiveWatchFaceClient(
+ "testId",
+ deviceConfig,
+ systemState,
+ UserStyleData(
+ mapOf(
+ "color_style_setting" to "blue_style".encodeToByteArray(),
+ "draw_hour_pips_style_setting" to BooleanOption(false).id.value,
+ "watch_hand_length_style_setting" to DoubleRangeOption(0.25).id.value
+ )
+ ),
+ complications
+ )
+ }
+
+ val interactiveInstance2 = awaitWithTimeout(deferredInteractiveInstance2)
+ assertThat(interactiveInstance2.instanceId).isEqualTo("testId")
+
+ val bitmap = interactiveInstance2.renderWatchFaceToBitmap(
+ RenderParameters(
+ DrawMode.INTERACTIVE,
+ WatchFaceLayer.ALL_WATCH_FACE_LAYERS,
+ null
+ ),
+ 1234567,
+ null,
+ complications
+ )
+
+ try {
+ // Note the hour hand pips and both complicationSlots should be visible in this image.
+ bitmap.assertAgainstGolden(screenshotRule, "existingOpenInstance_styleChange")
+ } finally {
+ interactiveInstance2.close()
+ }
+ }
+
@Test
fun getOrCreateInteractiveWatchFaceClient_existingClosedInstance() {
val deferredInteractiveInstance = handlerCoroutineScope.async {
@@ -749,6 +811,7 @@
.isEqualTo("After")
}
+ @SuppressLint("NewApi") // renderWatchFaceToBitmap
@Test
fun updateInstance() {
val deferredInteractiveInstance = handlerCoroutineScope.async {
diff --git a/wear/wear-watchface-client/src/main/java/androidx/wear/watchface/client/DeviceConfig.kt b/wear/wear-watchface-client/src/main/java/androidx/wear/watchface/client/DeviceConfig.kt
index de51b11..647d502 100644
--- a/wear/wear-watchface-client/src/main/java/androidx/wear/watchface/client/DeviceConfig.kt
+++ b/wear/wear-watchface-client/src/main/java/androidx/wear/watchface/client/DeviceConfig.kt
@@ -16,6 +16,10 @@
package androidx.wear.watchface.client
+import androidx.annotation.RestrictTo
+
+typealias WireDeviceConfig = androidx.wear.watchface.data.DeviceConfig
+
/**
* Describes the hardware configuration of the device the watch face is running on.
*
@@ -33,4 +37,22 @@
public val hasBurnInProtection: Boolean,
public val analogPreviewReferenceTimeMillis: Long,
public val digitalPreviewReferenceTimeMillis: Long
-)
+) {
+ /** @hide */
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ public fun asWireDeviceConfig(): WireDeviceConfig = WireDeviceConfig(
+ hasLowBitAmbient,
+ hasBurnInProtection,
+ analogPreviewReferenceTimeMillis,
+ digitalPreviewReferenceTimeMillis
+ )
+}
+
+/** @hide */
+@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+public fun WireDeviceConfig.asApiDeviceConfig(): DeviceConfig = DeviceConfig(
+ hasLowBitAmbient,
+ hasBurnInProtection,
+ analogPreviewReferenceTimeMillis,
+ digitalPreviewReferenceTimeMillis
+)
\ No newline at end of file
diff --git a/wear/wear-watchface-client/src/main/java/androidx/wear/watchface/client/EditorState.kt b/wear/wear-watchface-client/src/main/java/androidx/wear/watchface/client/EditorState.kt
index 7854673..4b0ffe4 100644
--- a/wear/wear-watchface-client/src/main/java/androidx/wear/watchface/client/EditorState.kt
+++ b/wear/wear-watchface-client/src/main/java/androidx/wear/watchface/client/EditorState.kt
@@ -16,7 +16,9 @@
package androidx.wear.watchface.client
+import android.graphics.Bitmap
import android.os.Build
+import android.support.wearable.watchface.SharedMemoryImage
import androidx.annotation.RequiresApi
import androidx.annotation.RestrictTo
import androidx.wear.complications.data.ComplicationData
@@ -63,6 +65,11 @@
* the session). If it's not committed then any changes (E.g. complication data source changes)
* should be abandoned. There's no need to resend the style to the watchface because the library
* will have restored the previous style.
+ * @param previewImage If `non-null` this [Bitmap] contains a preview image of the watch face
+ * rendered with the final style and complications and the
+ * [androidx.wear.watchface.editor.PreviewScreenshotParams] specified in the
+ * [androidx.wear.watchface.editor.EditorRequest]. If [shouldCommitChanges] is `false` then this
+ * will also be `null` (see implementation of [androidx.wear.watchface.editor.EditorSession.close]).
*/
public class EditorState internal constructor(
@RequiresApi(Build.VERSION_CODES.R)
@@ -70,13 +77,14 @@
public val userStyle: UserStyleData,
public val previewComplicationsData: Map<Int, ComplicationData>,
@get:JvmName("shouldCommitChanges")
- public val shouldCommitChanges: Boolean
+ public val shouldCommitChanges: Boolean,
+ public val previewImage: Bitmap?
) {
override fun toString(): String =
"{watchFaceId: ${watchFaceId.id}, userStyle: $userStyle" +
", previewComplicationsData: [" +
previewComplicationsData.map { "${it.key} -> ${it.value}" }.joinToString() +
- "], shouldCommitChanges: $shouldCommitChanges}"
+ "], shouldCommitChanges: $shouldCommitChanges, previewImage: ${previewImage != null}"
}
/** @hide */
@@ -89,6 +97,13 @@
{ it.id },
{ it.complicationData.toApiComplicationData() }
),
- commitChanges
+ commitChanges,
+ previewImageBundle?.let {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
+ SharedMemoryImage.ashmemReadImageBundle(it)
+ } else {
+ null
+ }
+ }
)
}
diff --git a/wear/wear-watchface-client/src/main/java/androidx/wear/watchface/client/WatchFaceControlClient.kt b/wear/wear-watchface-client/src/main/java/androidx/wear/watchface/client/WatchFaceControlClient.kt
index 6ac376e..b56d69a 100644
--- a/wear/wear-watchface-client/src/main/java/androidx/wear/watchface/client/WatchFaceControlClient.kt
+++ b/wear/wear-watchface-client/src/main/java/androidx/wear/watchface/client/WatchFaceControlClient.kt
@@ -253,12 +253,7 @@
return service.createHeadlessWatchFaceInstance(
HeadlessWatchFaceInstanceParams(
watchFaceName,
- androidx.wear.watchface.data.DeviceConfig(
- deviceConfig.hasLowBitAmbient,
- deviceConfig.hasBurnInProtection,
- deviceConfig.analogPreviewReferenceTimeMillis,
- deviceConfig.digitalPreviewReferenceTimeMillis
- ),
+ deviceConfig.asWireDeviceConfig(),
surfaceWidth,
surfaceHeight
)
diff --git a/wear/wear-watchface-complications-rendering/api/current.txt b/wear/wear-watchface-complications-rendering/api/current.txt
index 897ab96..f8923f6 100644
--- a/wear/wear-watchface-complications-rendering/api/current.txt
+++ b/wear/wear-watchface-complications-rendering/api/current.txt
@@ -6,13 +6,10 @@
method public void drawHighlight(android.graphics.Canvas canvas, android.graphics.Rect bounds, int boundsType, android.icu.util.Calendar calendar, @ColorInt int color);
method public androidx.wear.complications.data.ComplicationData? getData();
method public final androidx.wear.watchface.complications.rendering.ComplicationDrawable getDrawable();
- method public boolean isHighlighted();
method @CallSuper public void loadData(androidx.wear.complications.data.ComplicationData? complicationData, boolean loadDrawablesAsynchronous);
- method public void render(android.graphics.Canvas canvas, android.graphics.Rect bounds, android.icu.util.Calendar calendar, androidx.wear.watchface.RenderParameters renderParameters);
+ method public void render(android.graphics.Canvas canvas, android.graphics.Rect bounds, android.icu.util.Calendar calendar, androidx.wear.watchface.RenderParameters renderParameters, int slotId);
method public final void setDrawable(androidx.wear.watchface.complications.rendering.ComplicationDrawable value);
- method public void setHighlighted(boolean value);
property public final androidx.wear.watchface.complications.rendering.ComplicationDrawable drawable;
- property public boolean isHighlighted;
}
public final class ComplicationDrawable extends android.graphics.drawable.Drawable {
@@ -145,11 +142,11 @@
}
public final class GlesTextureComplication {
- ctor public GlesTextureComplication(androidx.wear.watchface.CanvasComplication canvasComplication, @Px int textureWidth, @Px int textureHeight, int textureType);
+ ctor public GlesTextureComplication(androidx.wear.watchface.ComplicationSlot complicationSlot, @Px int textureWidth, @Px int textureHeight, int textureType);
method public void bind();
- method public androidx.wear.watchface.CanvasComplication getCanvasComplication();
+ method public androidx.wear.watchface.ComplicationSlot getComplicationSlot();
method public void renderToTexture(android.icu.util.Calendar calendar, androidx.wear.watchface.RenderParameters renderParameters);
- property public final androidx.wear.watchface.CanvasComplication canvasComplication;
+ property public final androidx.wear.watchface.ComplicationSlot complicationSlot;
}
}
diff --git a/wear/wear-watchface-complications-rendering/api/public_plus_experimental_current.txt b/wear/wear-watchface-complications-rendering/api/public_plus_experimental_current.txt
index 897ab96..f8923f6 100644
--- a/wear/wear-watchface-complications-rendering/api/public_plus_experimental_current.txt
+++ b/wear/wear-watchface-complications-rendering/api/public_plus_experimental_current.txt
@@ -6,13 +6,10 @@
method public void drawHighlight(android.graphics.Canvas canvas, android.graphics.Rect bounds, int boundsType, android.icu.util.Calendar calendar, @ColorInt int color);
method public androidx.wear.complications.data.ComplicationData? getData();
method public final androidx.wear.watchface.complications.rendering.ComplicationDrawable getDrawable();
- method public boolean isHighlighted();
method @CallSuper public void loadData(androidx.wear.complications.data.ComplicationData? complicationData, boolean loadDrawablesAsynchronous);
- method public void render(android.graphics.Canvas canvas, android.graphics.Rect bounds, android.icu.util.Calendar calendar, androidx.wear.watchface.RenderParameters renderParameters);
+ method public void render(android.graphics.Canvas canvas, android.graphics.Rect bounds, android.icu.util.Calendar calendar, androidx.wear.watchface.RenderParameters renderParameters, int slotId);
method public final void setDrawable(androidx.wear.watchface.complications.rendering.ComplicationDrawable value);
- method public void setHighlighted(boolean value);
property public final androidx.wear.watchface.complications.rendering.ComplicationDrawable drawable;
- property public boolean isHighlighted;
}
public final class ComplicationDrawable extends android.graphics.drawable.Drawable {
@@ -145,11 +142,11 @@
}
public final class GlesTextureComplication {
- ctor public GlesTextureComplication(androidx.wear.watchface.CanvasComplication canvasComplication, @Px int textureWidth, @Px int textureHeight, int textureType);
+ ctor public GlesTextureComplication(androidx.wear.watchface.ComplicationSlot complicationSlot, @Px int textureWidth, @Px int textureHeight, int textureType);
method public void bind();
- method public androidx.wear.watchface.CanvasComplication getCanvasComplication();
+ method public androidx.wear.watchface.ComplicationSlot getComplicationSlot();
method public void renderToTexture(android.icu.util.Calendar calendar, androidx.wear.watchface.RenderParameters renderParameters);
- property public final androidx.wear.watchface.CanvasComplication canvasComplication;
+ property public final androidx.wear.watchface.ComplicationSlot complicationSlot;
}
}
diff --git a/wear/wear-watchface-complications-rendering/api/restricted_current.txt b/wear/wear-watchface-complications-rendering/api/restricted_current.txt
index a1772fa..8781006 100644
--- a/wear/wear-watchface-complications-rendering/api/restricted_current.txt
+++ b/wear/wear-watchface-complications-rendering/api/restricted_current.txt
@@ -6,13 +6,10 @@
method public void drawHighlight(android.graphics.Canvas canvas, android.graphics.Rect bounds, int boundsType, android.icu.util.Calendar calendar, @ColorInt int color);
method public androidx.wear.complications.data.ComplicationData? getData();
method public final androidx.wear.watchface.complications.rendering.ComplicationDrawable getDrawable();
- method public boolean isHighlighted();
method @CallSuper public void loadData(androidx.wear.complications.data.ComplicationData? complicationData, boolean loadDrawablesAsynchronous);
- method public void render(android.graphics.Canvas canvas, android.graphics.Rect bounds, android.icu.util.Calendar calendar, androidx.wear.watchface.RenderParameters renderParameters);
+ method public void render(android.graphics.Canvas canvas, android.graphics.Rect bounds, android.icu.util.Calendar calendar, androidx.wear.watchface.RenderParameters renderParameters, int slotId);
method public final void setDrawable(androidx.wear.watchface.complications.rendering.ComplicationDrawable value);
- method public void setHighlighted(boolean value);
property public final androidx.wear.watchface.complications.rendering.ComplicationDrawable drawable;
- property public boolean isHighlighted;
}
public final class ComplicationDrawable extends android.graphics.drawable.Drawable {
@@ -148,11 +145,11 @@
}
public final class GlesTextureComplication {
- ctor public GlesTextureComplication(androidx.wear.watchface.CanvasComplication canvasComplication, @Px int textureWidth, @Px int textureHeight, int textureType);
+ ctor public GlesTextureComplication(androidx.wear.watchface.ComplicationSlot complicationSlot, @Px int textureWidth, @Px int textureHeight, int textureType);
method public void bind();
- method public androidx.wear.watchface.CanvasComplication getCanvasComplication();
+ method public androidx.wear.watchface.ComplicationSlot getComplicationSlot();
method public void renderToTexture(android.icu.util.Calendar calendar, androidx.wear.watchface.RenderParameters renderParameters);
- property public final androidx.wear.watchface.CanvasComplication canvasComplication;
+ property public final androidx.wear.watchface.ComplicationSlot complicationSlot;
}
}
diff --git a/wear/wear-watchface-complications-rendering/src/main/java/androidx/wear/watchface/complications/rendering/CanvasComplicationDrawable.kt b/wear/wear-watchface-complications-rendering/src/main/java/androidx/wear/watchface/complications/rendering/CanvasComplicationDrawable.kt
index fc971bc..dc02aaf 100644
--- a/wear/wear-watchface-complications-rendering/src/main/java/androidx/wear/watchface/complications/rendering/CanvasComplicationDrawable.kt
+++ b/wear/wear-watchface-complications-rendering/src/main/java/androidx/wear/watchface/complications/rendering/CanvasComplicationDrawable.kt
@@ -28,6 +28,7 @@
import androidx.wear.complications.data.ComplicationData
import androidx.wear.utility.TraceEvent
import androidx.wear.watchface.CanvasComplication
+import androidx.wear.watchface.DrawMode
import androidx.wear.watchface.RenderParameters
import androidx.wear.watchface.WatchState
import androidx.wear.watchface.data.ComplicationSlotBoundsType
@@ -93,31 +94,25 @@
// update.
value.setComplicationData(field.complicationData, false)
field = value
- value.isInAmbientMode = watchState.isAmbient.value
value.isLowBitAmbient = watchState.hasLowBitAmbient
value.isBurnInProtectionOn = watchState.hasBurnInProtection
}
- init {
- // This observer needs to use the property drawable defined above, not the constructor
- // argument with the same name.
- watchState.isAmbient.addObserver {
- this.drawable.isInAmbientMode = it
- }
- }
-
override fun render(
canvas: Canvas,
bounds: Rect,
calendar: Calendar,
- renderParameters: RenderParameters
+ renderParameters: RenderParameters,
+ slotId: Int
) {
if (!renderParameters.watchFaceLayers.contains(WatchFaceLayer.COMPLICATIONS)) {
return
}
+ drawable.isInAmbientMode = renderParameters.drawMode == DrawMode.AMBIENT
drawable.bounds = bounds
drawable.currentTimeMillis = calendar.timeInMillis
+ drawable.isHighlighted = renderParameters.pressedComplicationSlotIds.contains(slotId)
drawable.draw(canvas)
}
@@ -137,12 +132,6 @@
}
}
- public override var isHighlighted: Boolean
- get() = drawable.isHighlighted
- set(value) {
- drawable.isHighlighted = value
- }
-
private var _data: ComplicationData? = null
/** Returns the [ComplicationData] to render with. */
@@ -170,4 +159,4 @@
loadDrawablesAsynchronous
)
}
-}
\ No newline at end of file
+}
diff --git a/wear/wear-watchface-complications-rendering/src/main/java/androidx/wear/watchface/complications/rendering/GlesTextureComplication.kt b/wear/wear-watchface-complications-rendering/src/main/java/androidx/wear/watchface/complications/rendering/GlesTextureComplication.kt
index 3c5287b..c7b9a45 100644
--- a/wear/wear-watchface-complications-rendering/src/main/java/androidx/wear/watchface/complications/rendering/GlesTextureComplication.kt
+++ b/wear/wear-watchface-complications-rendering/src/main/java/androidx/wear/watchface/complications/rendering/GlesTextureComplication.kt
@@ -24,20 +24,20 @@
import android.opengl.GLES20
import android.opengl.GLUtils
import androidx.annotation.Px
-import androidx.wear.watchface.CanvasComplication
+import androidx.wear.watchface.ComplicationSlot
import androidx.wear.watchface.RenderParameters
/**
- * Helper for rendering a [CanvasComplication] to a GLES20 texture. To use call [renderToTexture]
+ * Helper for rendering a [ComplicationSlot] to a GLES20 texture. To use call [renderToTexture]
* and then [bind] before drawing.
*
- * @param canvasComplication The [CanvasComplication] to render to texture.
+ * @param complicationSlot The [ComplicationSlot] to render to texture.
* @param textureWidth The width of the texture in pixels to create.
* @param textureHeight The height of the texture in pixels to create.
* @param textureType The texture type, e.g. [GLES20.GL_TEXTURE_2D].
*/
public class GlesTextureComplication(
- public val canvasComplication: CanvasComplication,
+ public val complicationSlot: ComplicationSlot,
@Px textureWidth: Int,
@Px textureHeight: Int,
private val textureType: Int
@@ -51,10 +51,16 @@
private val canvas = Canvas(bitmap)
private val bounds = Rect(0, 0, textureWidth, textureHeight)
- /** Renders [canvasComplication] to an OpenGL texture. */
+ /** Renders [complicationSlot] to an OpenGL texture. */
public fun renderToTexture(calendar: Calendar, renderParameters: RenderParameters) {
canvas.drawColor(Color.BLACK)
- canvasComplication.render(canvas, bounds, calendar, renderParameters)
+ complicationSlot.renderer.render(
+ canvas,
+ bounds,
+ calendar,
+ renderParameters,
+ complicationSlot.id
+ )
bind()
GLUtils.texImage2D(textureType, 0, bitmap, 0)
}
diff --git a/wear/wear-watchface-data/api/restricted_current.txt b/wear/wear-watchface-data/api/restricted_current.txt
index 65faa1a..0989e7d 100644
--- a/wear/wear-watchface-data/api/restricted_current.txt
+++ b/wear/wear-watchface-data/api/restricted_current.txt
@@ -301,7 +301,7 @@
}
@RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.versionedparcelable.VersionedParcelize public class RenderParametersWireFormat implements android.os.Parcelable androidx.versionedparcelable.VersionedParcelable {
- ctor public RenderParametersWireFormat(int, int, int, int, String?, @ColorInt int, @ColorInt int);
+ ctor public RenderParametersWireFormat(int, int, int, int, String?, @ColorInt int, @ColorInt int, int[]);
method public int describeContents();
method @ColorInt public int getBackgroundTint();
method public int getDrawMode();
@@ -309,6 +309,7 @@
method public int getElementType();
method public String? getElementUserStyleSettingId();
method @ColorInt public int getHighlightTint();
+ method public int[] getPressedComplicationSlotIds();
method public int getWatchFaceLayerSetBitfield();
method public void writeToParcel(android.os.Parcel, int);
field public static final android.os.Parcelable.Creator<androidx.wear.watchface.data.RenderParametersWireFormat!>! CREATOR;
@@ -332,10 +333,11 @@
package androidx.wear.watchface.editor.data {
@RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @androidx.versionedparcelable.VersionedParcelize(allowSerialization=true) public final class EditorStateWireFormat implements android.os.Parcelable androidx.versionedparcelable.VersionedParcelable {
- ctor public EditorStateWireFormat(String?, androidx.wear.watchface.style.data.UserStyleWireFormat, java.util.List<androidx.wear.watchface.data.IdAndComplicationDataWireFormat!>, boolean);
+ ctor public EditorStateWireFormat(String?, androidx.wear.watchface.style.data.UserStyleWireFormat, java.util.List<androidx.wear.watchface.data.IdAndComplicationDataWireFormat!>, boolean, android.os.Bundle?);
method public int describeContents();
method public boolean getCommitChanges();
method public java.util.List<androidx.wear.watchface.data.IdAndComplicationDataWireFormat!> getPreviewComplicationData();
+ method public android.os.Bundle? getPreviewImageBundle();
method public androidx.wear.watchface.style.data.UserStyleWireFormat getUserStyle();
method public String? getWatchFaceInstanceId();
method public void writeToParcel(android.os.Parcel, int);
diff --git a/wear/wear-watchface-data/src/main/java/androidx/wear/watchface/data/RenderParametersWireFormat.java b/wear/wear-watchface-data/src/main/java/androidx/wear/watchface/data/RenderParametersWireFormat.java
index 130ef96..59359aa 100644
--- a/wear/wear-watchface-data/src/main/java/androidx/wear/watchface/data/RenderParametersWireFormat.java
+++ b/wear/wear-watchface-data/src/main/java/androidx/wear/watchface/data/RenderParametersWireFormat.java
@@ -108,6 +108,11 @@
@ColorInt
int mBackgroundTint;
+ /** Optional set of ComplicationSlots to render as pressed. */
+ @ParcelField(8)
+ @NonNull
+ int[] mPressedComplicationSlotIds = new int[0];
+
RenderParametersWireFormat() {
}
@@ -118,7 +123,8 @@
int complicationSlotId,
@Nullable String elementUserStyleSettingId,
@ColorInt int highlightTint,
- @ColorInt int backgroundTint) {
+ @ColorInt int backgroundTint,
+ @NonNull int[] pressedComplicationSlotIds) {
mDrawMode = drawMode;
mWatchFaceLayerSetBitfield = watchFaceLayerSetBitfield;
mElementType = elementType;
@@ -126,6 +132,7 @@
mElementUserStyleSettingId = elementUserStyleSettingId;
mHighlightTint = highlightTint;
mBackgroundTint = backgroundTint;
+ mPressedComplicationSlotIds = pressedComplicationSlotIds;
if (elementType == ELEMENT_TYPE_USER_STYLE) {
if (elementUserStyleSettingId == null) {
throw new IllegalArgumentException(
@@ -172,6 +179,11 @@
return mBackgroundTint;
}
+ @NonNull
+ public int[] getPressedComplicationSlotIds() {
+ return mPressedComplicationSlotIds;
+ }
+
/** Serializes this IndicatorState to the specified {@link Parcel}. */
@Override
public void writeToParcel(@NonNull Parcel parcel, int flags) {
@@ -187,9 +199,8 @@
new Parcelable.Creator<RenderParametersWireFormat>() {
@Override
public RenderParametersWireFormat createFromParcel(Parcel source) {
- return RenderParametersWireFormatParcelizer.read(
- ParcelUtils.fromParcelable(source.readParcelable(
- getClass().getClassLoader())));
+ return ParcelUtils.fromParcelable(
+ source.readParcelable(getClass().getClassLoader()));
}
@Override
diff --git a/wear/wear-watchface-data/src/main/java/androidx/wear/watchface/editor/data/EditorStateWireFormat.java b/wear/wear-watchface-data/src/main/java/androidx/wear/watchface/editor/data/EditorStateWireFormat.java
index b53d7ce..5b8636d 100644
--- a/wear/wear-watchface-data/src/main/java/androidx/wear/watchface/editor/data/EditorStateWireFormat.java
+++ b/wear/wear-watchface-data/src/main/java/androidx/wear/watchface/editor/data/EditorStateWireFormat.java
@@ -17,6 +17,7 @@
package androidx.wear.watchface.editor.data;
import android.annotation.SuppressLint;
+import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
@@ -56,6 +57,10 @@
@ParcelField(4)
boolean mCommitChanges;
+ @ParcelField(5)
+ @Nullable
+ Bundle mPreviewImageBundle;
+
/** Used by VersionedParcelable. */
EditorStateWireFormat() {
}
@@ -64,11 +69,13 @@
@Nullable String watchFaceInstanceId,
@NonNull UserStyleWireFormat userStyle,
@NonNull List<IdAndComplicationDataWireFormat> previewComplicationData,
- boolean commitChanges) {
+ boolean commitChanges,
+ @Nullable Bundle previewImageBundle) {
mWatchFaceInstanceId = watchFaceInstanceId;
mUserStyle = userStyle;
mPreviewComplicationData = previewComplicationData;
mCommitChanges = commitChanges;
+ mPreviewImageBundle = previewImageBundle;
}
@Nullable
@@ -90,6 +97,11 @@
return mCommitChanges;
}
+ @Nullable
+ public Bundle getPreviewImageBundle() {
+ return mPreviewImageBundle;
+ }
+
/** Serializes this EditorState to the specified {@link Parcel}. */
@Override
public void writeToParcel(@NonNull Parcel parcel, int flags) {
diff --git a/wear/wear-watchface-editor/api/current.txt b/wear/wear-watchface-editor/api/current.txt
index 8b52965..24fa279 100644
--- a/wear/wear-watchface-editor/api/current.txt
+++ b/wear/wear-watchface-editor/api/current.txt
@@ -12,15 +12,19 @@
}
public final class EditorRequest {
- ctor @RequiresApi(android.os.Build.VERSION_CODES.R) public EditorRequest(android.content.ComponentName watchFaceComponentName, String editorPackageName, androidx.wear.watchface.style.UserStyleData? initialUserStyle, @RequiresApi androidx.wear.watchface.client.WatchFaceId watchFaceId);
+ ctor @RequiresApi(android.os.Build.VERSION_CODES.R) public EditorRequest(android.content.ComponentName watchFaceComponentName, String editorPackageName, androidx.wear.watchface.style.UserStyleData? initialUserStyle, @RequiresApi androidx.wear.watchface.client.WatchFaceId watchFaceId, androidx.wear.watchface.client.DeviceConfig? headlessDeviceConfig, androidx.wear.watchface.editor.PreviewScreenshotParams? previewScreenshotParams);
ctor public EditorRequest(android.content.ComponentName watchFaceComponentName, String editorPackageName, androidx.wear.watchface.style.UserStyleData? initialUserStyle);
method @kotlin.jvm.Throws(exceptionClasses=TimeoutCancellationException::class) public static androidx.wear.watchface.editor.EditorRequest createFromIntent(android.content.Intent intent) throws kotlinx.coroutines.TimeoutCancellationException;
method public String getEditorPackageName();
+ method public androidx.wear.watchface.client.DeviceConfig? getHeadlessDeviceConfig();
method public androidx.wear.watchface.style.UserStyleData? getInitialUserStyle();
+ method public androidx.wear.watchface.editor.PreviewScreenshotParams? getPreviewScreenshotParams();
method public android.content.ComponentName getWatchFaceComponentName();
method @RequiresApi(android.os.Build.VERSION_CODES.R) public androidx.wear.watchface.client.WatchFaceId getWatchFaceId();
property public final String editorPackageName;
+ property public final androidx.wear.watchface.client.DeviceConfig? headlessDeviceConfig;
property public final androidx.wear.watchface.style.UserStyleData? initialUserStyle;
+ property public final androidx.wear.watchface.editor.PreviewScreenshotParams? previewScreenshotParams;
property public final android.content.ComponentName watchFaceComponentName;
property @RequiresApi(android.os.Build.VERSION_CODES.R) public final androidx.wear.watchface.client.WatchFaceId watchFaceId;
field public static final androidx.wear.watchface.editor.EditorRequest.Companion Companion;
@@ -69,6 +73,14 @@
public final class EditorSessionKt {
}
+ public final class PreviewScreenshotParams {
+ ctor public PreviewScreenshotParams(androidx.wear.watchface.RenderParameters renderParameters, long calendarTimeMillis);
+ method public long getCalendarTimeMillis();
+ method public androidx.wear.watchface.RenderParameters getRenderParameters();
+ property public final long calendarTimeMillis;
+ property public final androidx.wear.watchface.RenderParameters renderParameters;
+ }
+
public class WatchFaceEditorContract extends androidx.activity.result.contract.ActivityResultContract<androidx.wear.watchface.editor.EditorRequest,kotlin.Unit> {
ctor public WatchFaceEditorContract();
method public android.content.Intent createIntent(android.content.Context context, androidx.wear.watchface.editor.EditorRequest input);
diff --git a/wear/wear-watchface-editor/api/public_plus_experimental_current.txt b/wear/wear-watchface-editor/api/public_plus_experimental_current.txt
index 8b52965..24fa279 100644
--- a/wear/wear-watchface-editor/api/public_plus_experimental_current.txt
+++ b/wear/wear-watchface-editor/api/public_plus_experimental_current.txt
@@ -12,15 +12,19 @@
}
public final class EditorRequest {
- ctor @RequiresApi(android.os.Build.VERSION_CODES.R) public EditorRequest(android.content.ComponentName watchFaceComponentName, String editorPackageName, androidx.wear.watchface.style.UserStyleData? initialUserStyle, @RequiresApi androidx.wear.watchface.client.WatchFaceId watchFaceId);
+ ctor @RequiresApi(android.os.Build.VERSION_CODES.R) public EditorRequest(android.content.ComponentName watchFaceComponentName, String editorPackageName, androidx.wear.watchface.style.UserStyleData? initialUserStyle, @RequiresApi androidx.wear.watchface.client.WatchFaceId watchFaceId, androidx.wear.watchface.client.DeviceConfig? headlessDeviceConfig, androidx.wear.watchface.editor.PreviewScreenshotParams? previewScreenshotParams);
ctor public EditorRequest(android.content.ComponentName watchFaceComponentName, String editorPackageName, androidx.wear.watchface.style.UserStyleData? initialUserStyle);
method @kotlin.jvm.Throws(exceptionClasses=TimeoutCancellationException::class) public static androidx.wear.watchface.editor.EditorRequest createFromIntent(android.content.Intent intent) throws kotlinx.coroutines.TimeoutCancellationException;
method public String getEditorPackageName();
+ method public androidx.wear.watchface.client.DeviceConfig? getHeadlessDeviceConfig();
method public androidx.wear.watchface.style.UserStyleData? getInitialUserStyle();
+ method public androidx.wear.watchface.editor.PreviewScreenshotParams? getPreviewScreenshotParams();
method public android.content.ComponentName getWatchFaceComponentName();
method @RequiresApi(android.os.Build.VERSION_CODES.R) public androidx.wear.watchface.client.WatchFaceId getWatchFaceId();
property public final String editorPackageName;
+ property public final androidx.wear.watchface.client.DeviceConfig? headlessDeviceConfig;
property public final androidx.wear.watchface.style.UserStyleData? initialUserStyle;
+ property public final androidx.wear.watchface.editor.PreviewScreenshotParams? previewScreenshotParams;
property public final android.content.ComponentName watchFaceComponentName;
property @RequiresApi(android.os.Build.VERSION_CODES.R) public final androidx.wear.watchface.client.WatchFaceId watchFaceId;
field public static final androidx.wear.watchface.editor.EditorRequest.Companion Companion;
@@ -69,6 +73,14 @@
public final class EditorSessionKt {
}
+ public final class PreviewScreenshotParams {
+ ctor public PreviewScreenshotParams(androidx.wear.watchface.RenderParameters renderParameters, long calendarTimeMillis);
+ method public long getCalendarTimeMillis();
+ method public androidx.wear.watchface.RenderParameters getRenderParameters();
+ property public final long calendarTimeMillis;
+ property public final androidx.wear.watchface.RenderParameters renderParameters;
+ }
+
public class WatchFaceEditorContract extends androidx.activity.result.contract.ActivityResultContract<androidx.wear.watchface.editor.EditorRequest,kotlin.Unit> {
ctor public WatchFaceEditorContract();
method public android.content.Intent createIntent(android.content.Context context, androidx.wear.watchface.editor.EditorRequest input);
diff --git a/wear/wear-watchface-editor/api/restricted_current.txt b/wear/wear-watchface-editor/api/restricted_current.txt
index 6000c1e..3942e6a 100644
--- a/wear/wear-watchface-editor/api/restricted_current.txt
+++ b/wear/wear-watchface-editor/api/restricted_current.txt
@@ -33,15 +33,19 @@
}
public final class EditorRequest {
- ctor @RequiresApi(android.os.Build.VERSION_CODES.R) public EditorRequest(android.content.ComponentName watchFaceComponentName, String editorPackageName, androidx.wear.watchface.style.UserStyleData? initialUserStyle, @RequiresApi androidx.wear.watchface.client.WatchFaceId watchFaceId);
+ ctor @RequiresApi(android.os.Build.VERSION_CODES.R) public EditorRequest(android.content.ComponentName watchFaceComponentName, String editorPackageName, androidx.wear.watchface.style.UserStyleData? initialUserStyle, @RequiresApi androidx.wear.watchface.client.WatchFaceId watchFaceId, androidx.wear.watchface.client.DeviceConfig? headlessDeviceConfig, androidx.wear.watchface.editor.PreviewScreenshotParams? previewScreenshotParams);
ctor public EditorRequest(android.content.ComponentName watchFaceComponentName, String editorPackageName, androidx.wear.watchface.style.UserStyleData? initialUserStyle);
method @kotlin.jvm.Throws(exceptionClasses=TimeoutCancellationException::class) public static androidx.wear.watchface.editor.EditorRequest createFromIntent(android.content.Intent intent) throws kotlinx.coroutines.TimeoutCancellationException;
method public String getEditorPackageName();
+ method public androidx.wear.watchface.client.DeviceConfig? getHeadlessDeviceConfig();
method public androidx.wear.watchface.style.UserStyleData? getInitialUserStyle();
+ method public androidx.wear.watchface.editor.PreviewScreenshotParams? getPreviewScreenshotParams();
method public android.content.ComponentName getWatchFaceComponentName();
method @RequiresApi(android.os.Build.VERSION_CODES.R) public androidx.wear.watchface.client.WatchFaceId getWatchFaceId();
property public final String editorPackageName;
+ property public final androidx.wear.watchface.client.DeviceConfig? headlessDeviceConfig;
property public final androidx.wear.watchface.style.UserStyleData? initialUserStyle;
+ property public final androidx.wear.watchface.editor.PreviewScreenshotParams? previewScreenshotParams;
property public final android.content.ComponentName watchFaceComponentName;
property @RequiresApi(android.os.Build.VERSION_CODES.R) public final androidx.wear.watchface.client.WatchFaceId watchFaceId;
field public static final androidx.wear.watchface.editor.EditorRequest.Companion Companion;
@@ -90,6 +94,14 @@
public final class EditorSessionKt {
}
+ public final class PreviewScreenshotParams {
+ ctor public PreviewScreenshotParams(androidx.wear.watchface.RenderParameters renderParameters, long calendarTimeMillis);
+ method public long getCalendarTimeMillis();
+ method public androidx.wear.watchface.RenderParameters getRenderParameters();
+ property public final long calendarTimeMillis;
+ property public final androidx.wear.watchface.RenderParameters renderParameters;
+ }
+
public class WatchFaceEditorContract extends androidx.activity.result.contract.ActivityResultContract<androidx.wear.watchface.editor.EditorRequest,kotlin.Unit> {
ctor public WatchFaceEditorContract();
method public android.content.Intent createIntent(android.content.Context context, androidx.wear.watchface.editor.EditorRequest input);
diff --git a/wear/wear-watchface-editor/src/androidTest/java/androidx/wear/watchface/editor/EditingSessionTest.kt b/wear/wear-watchface-editor/src/androidTest/java/androidx/wear/watchface/editor/EditingSessionTest.kt
index 562c94a..529d9ef 100644
--- a/wear/wear-watchface-editor/src/androidTest/java/androidx/wear/watchface/editor/EditingSessionTest.kt
+++ b/wear/wear-watchface-editor/src/androidTest/java/androidx/wear/watchface/editor/EditingSessionTest.kt
@@ -16,16 +16,19 @@
package androidx.wear.watchface.editor
+import android.annotation.SuppressLint
import android.app.Activity
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Canvas
+import android.graphics.Color
import android.graphics.Rect
import android.graphics.RectF
import android.graphics.drawable.Icon
import android.icu.util.Calendar
+import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.HandlerThread
@@ -66,7 +69,10 @@
import androidx.wear.watchface.WatchFace
import androidx.wear.watchface.WatchFaceHostApi
import androidx.wear.watchface.WatchFaceImpl
+import androidx.wear.watchface.WatchFaceService
import androidx.wear.watchface.WatchFaceType
+import androidx.wear.watchface.WatchState
+import androidx.wear.watchface.client.DeviceConfig
import androidx.wear.watchface.client.WatchFaceId
import androidx.wear.watchface.client.asApiEditorState
import androidx.wear.watchface.complications.rendering.CanvasComplicationDrawable
@@ -76,6 +82,7 @@
import androidx.wear.watchface.editor.data.EditorStateWireFormat
import androidx.wear.watchface.style.CurrentUserStyleRepository
import androidx.wear.watchface.style.UserStyle
+import androidx.wear.watchface.style.UserStyleData
import androidx.wear.watchface.style.UserStyleSchema
import androidx.wear.watchface.style.UserStyleSetting
import androidx.wear.watchface.style.UserStyleSetting.ListUserStyleSetting.ListOption
@@ -93,6 +100,8 @@
import kotlinx.coroutines.withContext
import org.junit.After
import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
@@ -125,11 +134,141 @@
private typealias WireComplicationProviderInfo =
android.support.wearable.complications.ComplicationProviderInfo
+private val redStyleOption = ListOption(Option.Id("red_style"), "Red", icon = null)
+private val greenStyleOption = ListOption(Option.Id("green_style"), "Green", icon = null)
+private val blueStyleOption = ListOption(Option.Id("bluestyle"), "Blue", icon = null)
+private val colorStyleList = listOf(redStyleOption, greenStyleOption, blueStyleOption)
+private val colorStyleSetting = UserStyleSetting.ListUserStyleSetting(
+ UserStyleSetting.Id("color_style_setting"),
+ "Colors",
+ "Watchface colorization", /* icon = */
+ null,
+ colorStyleList,
+ listOf(WatchFaceLayer.BASE)
+)
+
+private val classicStyleOption = ListOption(Option.Id("classic_style"), "Classic", icon = null)
+private val modernStyleOption = ListOption(Option.Id("modern_style"), "Modern", icon = null)
+private val gothicStyleOption = ListOption(Option.Id("gothic_style"), "Gothic", icon = null)
+private val watchHandStyleList =
+ listOf(classicStyleOption, modernStyleOption, gothicStyleOption)
+private val watchHandStyleSetting = UserStyleSetting.ListUserStyleSetting(
+ UserStyleSetting.Id("hand_style_setting"),
+ "Hand Style",
+ "Hand visual look", /* icon = */
+ null,
+ watchHandStyleList,
+ listOf(WatchFaceLayer.COMPLICATIONS_OVERLAY)
+)
+
+private val mockInvalidateCallback =
+ Mockito.mock(CanvasComplication.InvalidateCallback::class.java)
+private val placeholderWatchState = MutableWatchState().asWatchState()
+private val mockLeftCanvasComplication = CanvasComplicationDrawable(
+ ComplicationDrawable(),
+ placeholderWatchState,
+ mockInvalidateCallback
+)
+private val leftComplication =
+ ComplicationSlot.createRoundRectComplicationSlotBuilder(
+ LEFT_COMPLICATION_ID,
+ { _, _ -> mockLeftCanvasComplication },
+ listOf(
+ ComplicationType.RANGED_VALUE,
+ ComplicationType.LONG_TEXT,
+ ComplicationType.SHORT_TEXT,
+ ComplicationType.MONOCHROMATIC_IMAGE,
+ ComplicationType.SMALL_IMAGE
+ ),
+ DefaultComplicationDataSourcePolicy(SystemDataSources.DATA_SOURCE_SUNRISE_SUNSET),
+ ComplicationSlotBounds(RectF(0.2f, 0.4f, 0.4f, 0.6f))
+ ).setDefaultDataSourceType(ComplicationType.SHORT_TEXT)
+ .build()
+
+private val mockRightCanvasComplication = CanvasComplicationDrawable(
+ ComplicationDrawable(),
+ placeholderWatchState,
+ mockInvalidateCallback
+)
+private val rightComplication =
+ ComplicationSlot.createRoundRectComplicationSlotBuilder(
+ RIGHT_COMPLICATION_ID,
+ { _, _ -> mockRightCanvasComplication },
+ listOf(
+ ComplicationType.RANGED_VALUE,
+ ComplicationType.LONG_TEXT,
+ ComplicationType.SHORT_TEXT,
+ ComplicationType.MONOCHROMATIC_IMAGE,
+ ComplicationType.SMALL_IMAGE
+ ),
+ DefaultComplicationDataSourcePolicy(SystemDataSources.DATA_SOURCE_DAY_OF_WEEK),
+ ComplicationSlotBounds(RectF(0.6f, 0.4f, 0.8f, 0.6f))
+ ).setDefaultDataSourceType(ComplicationType.SHORT_TEXT)
+ .setConfigExtras(
+ Bundle().apply {
+ putString(PROVIDER_CHOOSER_EXTRA_KEY, PROVIDER_CHOOSER_EXTRA_VALUE)
+ }
+ )
+ .build()
+
+private val mockBackgroundCanvasComplication =
+ CanvasComplicationDrawable(
+ ComplicationDrawable(),
+ placeholderWatchState,
+ mockInvalidateCallback
+ )
+private val backgroundComplication =
+ ComplicationSlot.createBackgroundComplicationSlotBuilder(
+ BACKGROUND_COMPLICATION_ID,
+ { _, _ -> mockBackgroundCanvasComplication },
+ emptyList(),
+ DefaultComplicationDataSourcePolicy()
+ ).setEnabled(false).build()
+
+/** A trivial [WatchFaceService] used for testing headless editor instances. */
+public class TestHeadlessWatchFaceService : WatchFaceService() {
+ override fun createUserStyleSchema() =
+ UserStyleSchema(listOf(colorStyleSetting, watchHandStyleSetting))
+
+ override fun createComplicationSlotsManager(
+ currentUserStyleRepository: CurrentUserStyleRepository
+ ) = ComplicationSlotsManager(emptyList(), currentUserStyleRepository)
+
+ override suspend fun createWatchFace(
+ surfaceHolder: SurfaceHolder,
+ watchState: WatchState,
+ complicationSlotsManager: ComplicationSlotsManager,
+ currentUserStyleRepository: CurrentUserStyleRepository
+ ) = WatchFace(
+ WatchFaceType.ANALOG,
+ object : Renderer.CanvasRenderer(
+ surfaceHolder,
+ currentUserStyleRepository,
+ watchState,
+ CanvasType.SOFTWARE,
+ 100
+ ) {
+ override fun render(canvas: Canvas, bounds: Rect, calendar: Calendar) {
+ when (currentUserStyleRepository.userStyle[colorStyleSetting]!!) {
+ redStyleOption -> canvas.drawColor(Color.RED)
+ greenStyleOption -> canvas.drawColor(Color.GREEN)
+ blueStyleOption -> canvas.drawColor(Color.BLUE)
+ }
+ }
+
+ override fun renderHighlightLayer(canvas: Canvas, bounds: Rect, calendar: Calendar) {
+ // NOP
+ }
+ }
+ )
+}
+
/** Trivial "editor" which exposes the EditorSession for testing. */
public open class OnWatchFaceEditingTestActivity : ComponentActivity() {
public lateinit var editorSession: EditorSession
public lateinit var onCreateException: Exception
public val creationLatch: CountDownLatch = CountDownLatch(1)
+ public val deferredDone = CompletableDeferred<Unit>()
public val listenableEditorSession: ListenableEditorSession by lazy {
ListenableEditorSession(editorSession)
@@ -155,6 +294,7 @@
} catch (e: Exception) {
onCreateException = e
} finally {
+ deferredDone.complete(Unit)
creationLatch.countDown()
}
}
@@ -274,111 +414,15 @@
@RunWith(AndroidJUnit4::class)
@MediumTest
public class EditorSessionTest {
- private val testComponentName = ComponentName("test.package", "test.class")
+ private val headlessWatchFaceComponentName = ComponentName(
+ "test.package",
+ TestHeadlessWatchFaceService::class.qualifiedName!!
+ )
private val testEditorPackageName = "test.package"
private val testInstanceId = WatchFaceId("TEST_INSTANCE_ID")
private lateinit var editorDelegate: WatchFace.EditorDelegate
private val screenBounds = Rect(0, 0, 400, 400)
- private val redStyleOption = ListOption(Option.Id("red_style"), "Red", icon = null)
-
- private val greenStyleOption = ListOption(Option.Id("green_style"), "Green", icon = null)
-
- private val blueStyleOption = ListOption(Option.Id("bluestyle"), "Blue", icon = null)
-
- private val colorStyleList = listOf(redStyleOption, greenStyleOption, blueStyleOption)
-
- private val colorStyleSetting = UserStyleSetting.ListUserStyleSetting(
- UserStyleSetting.Id("color_style_setting"),
- "Colors",
- "Watchface colorization", /* icon = */
- null,
- colorStyleList,
- listOf(WatchFaceLayer.BASE)
- )
-
- private val classicStyleOption = ListOption(Option.Id("classic_style"), "Classic", icon = null)
-
- private val modernStyleOption = ListOption(Option.Id("modern_style"), "Modern", icon = null)
-
- private val gothicStyleOption = ListOption(Option.Id("gothic_style"), "Gothic", icon = null)
-
- private val watchHandStyleList =
- listOf(classicStyleOption, modernStyleOption, gothicStyleOption)
-
- private val watchHandStyleSetting = UserStyleSetting.ListUserStyleSetting(
- UserStyleSetting.Id("hand_style_setting"),
- "Hand Style",
- "Hand visual look", /* icon = */
- null,
- watchHandStyleList,
- listOf(WatchFaceLayer.COMPLICATIONS_OVERLAY)
- )
-
- private val mockInvalidateCallback =
- Mockito.mock(CanvasComplication.InvalidateCallback::class.java)
- private val placeholderWatchState = MutableWatchState().asWatchState()
- private val mockLeftCanvasComplication = CanvasComplicationDrawable(
- ComplicationDrawable(),
- placeholderWatchState,
- mockInvalidateCallback
- )
- private val leftComplication =
- ComplicationSlot.createRoundRectComplicationSlotBuilder(
- LEFT_COMPLICATION_ID,
- { _, _ -> mockLeftCanvasComplication },
- listOf(
- ComplicationType.RANGED_VALUE,
- ComplicationType.LONG_TEXT,
- ComplicationType.SHORT_TEXT,
- ComplicationType.MONOCHROMATIC_IMAGE,
- ComplicationType.SMALL_IMAGE
- ),
- DefaultComplicationDataSourcePolicy(SystemDataSources.DATA_SOURCE_SUNRISE_SUNSET),
- ComplicationSlotBounds(RectF(0.2f, 0.4f, 0.4f, 0.6f))
- ).setDefaultDataSourceType(ComplicationType.SHORT_TEXT)
- .build()
-
- private val mockRightCanvasComplication = CanvasComplicationDrawable(
- ComplicationDrawable(),
- placeholderWatchState,
- mockInvalidateCallback
- )
- private val rightComplication =
- ComplicationSlot.createRoundRectComplicationSlotBuilder(
- RIGHT_COMPLICATION_ID,
- { _, _ -> mockRightCanvasComplication },
- listOf(
- ComplicationType.RANGED_VALUE,
- ComplicationType.LONG_TEXT,
- ComplicationType.SHORT_TEXT,
- ComplicationType.MONOCHROMATIC_IMAGE,
- ComplicationType.SMALL_IMAGE
- ),
- DefaultComplicationDataSourcePolicy(SystemDataSources.DATA_SOURCE_DAY_OF_WEEK),
- ComplicationSlotBounds(RectF(0.6f, 0.4f, 0.8f, 0.6f))
- ).setDefaultDataSourceType(ComplicationType.SHORT_TEXT)
- .setConfigExtras(
- Bundle().apply {
- putString(PROVIDER_CHOOSER_EXTRA_KEY, PROVIDER_CHOOSER_EXTRA_VALUE)
- }
- )
- .build()
-
- private val mockBackgroundCanvasComplication =
- CanvasComplicationDrawable(
- ComplicationDrawable(),
- placeholderWatchState,
- mockInvalidateCallback
- )
- private val backgroundComplication =
- ComplicationSlot.createBackgroundComplicationSlotBuilder(
- BACKGROUND_COMPLICATION_ID,
- { _, _ -> mockBackgroundCanvasComplication },
- emptyList(),
- DefaultComplicationDataSourcePolicy()
- ).setEnabled(false).build()
-
private val fakeBitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
private val onDestroyLatch = CountDownLatch(1)
private val dataSourceIcon =
@@ -404,6 +448,7 @@
fun stateChangeObserved() = this::editorState.isInitialized
}
+ @SuppressLint("NewApi")
private fun createOnWatchFaceEditingTestActivity(
userStyleSettings: List<UserStyleSetting>,
complicationSlots: List<ComplicationSlot>,
@@ -412,7 +457,11 @@
complicationDataSourceInfoRetrieverProvider: ComplicationDataSourceInfoRetrieverProvider =
TestComplicationDataSourceInfoRetrieverProvider(),
shouldTimeout: Boolean = false,
- preRFlow: Boolean = false
+ preRFlow: Boolean = false,
+ headlessDeviceConfig: DeviceConfig? = null,
+ initialUserStyle: UserStyleData? = null,
+ watchComponentName: ComponentName = ComponentName("test.package", "test.class"),
+ previewScreenshotParams: PreviewScreenshotParams? = null
): ActivityScenario<OnWatchFaceEditingTestActivity> {
val userStyleRepository = CurrentUserStyleRepository(UserStyleSchema(userStyleSettings))
val complicationSlotsManager =
@@ -452,7 +501,7 @@
}
}
if (!shouldTimeout) {
- WatchFace.registerEditorDelegate(testComponentName, editorDelegate)
+ WatchFace.registerEditorDelegate(watchComponentName, editorDelegate)
}
OnWatchFaceEditingTestActivity.complicationDataSourceInfoRetrieverProvider =
@@ -461,7 +510,7 @@
if (preRFlow) {
return ActivityScenario.launch(
Intent().apply {
- putExtra(Constants.EXTRA_WATCH_FACE_COMPONENT, testComponentName)
+ putExtra(Constants.EXTRA_WATCH_FACE_COMPONENT, watchComponentName)
component = ComponentName(
ApplicationProvider.getApplicationContext<Context>(),
OnWatchFaceEditingTestActivity::class.java
@@ -473,7 +522,14 @@
return ActivityScenario.launch(
WatchFaceEditorContract().createIntent(
ApplicationProvider.getApplicationContext<Context>(),
- EditorRequest(testComponentName, testEditorPackageName, null, watchFaceId)
+ EditorRequest(
+ watchComponentName,
+ testEditorPackageName,
+ initialUserStyle,
+ watchFaceId,
+ headlessDeviceConfig,
+ previewScreenshotParams
+ )
).apply {
component = ComponentName(
ApplicationProvider.getApplicationContext<Context>(),
@@ -495,6 +551,7 @@
ComplicationHelperActivity.useTestComplicationDataSourceChooserActivity = false
ComplicationHelperActivity.skipPermissionCheck = false
WatchFace.clearAllEditorDelegates()
+ EditorService.globalEditorService.clearCloseCallbacks()
}
@Test
@@ -510,11 +567,31 @@
public fun watchFaceComponentName() {
val scenario = createOnWatchFaceEditingTestActivity(emptyList(), emptyList())
scenario.onActivity {
- assertThat(it.editorSession.watchFaceComponentName).isEqualTo(testComponentName)
+ assertThat(it.editorSession.watchFaceComponentName)
+ .isEqualTo(ComponentName("test.package", "test.class"))
}
}
@Test
+ public fun watchFaceComponentName_headless() {
+ val scenario = createOnWatchFaceEditingTestActivity(
+ emptyList(),
+ emptyList(),
+ headlessDeviceConfig = DeviceConfig(false, false, 0, 0),
+ watchComponentName = headlessWatchFaceComponentName
+ )
+ lateinit var activity: OnWatchFaceEditingTestActivity
+ scenario.onActivity {
+ activity = it
+ }
+ runBlocking {
+ activity.deferredDone.await()
+ }
+ assertThat(activity.editorSession.watchFaceComponentName)
+ .isEqualTo(headlessWatchFaceComponentName)
+ }
+
+ @Test
public fun instanceId() {
val scenario = createOnWatchFaceEditingTestActivity(emptyList(), emptyList())
scenario.onActivity {
@@ -523,6 +600,24 @@
}
@Test
+ public fun instanceId_headless() {
+ val scenario = createOnWatchFaceEditingTestActivity(
+ emptyList(),
+ emptyList(),
+ headlessDeviceConfig = DeviceConfig(false, false, 0, 0),
+ watchComponentName = headlessWatchFaceComponentName
+ )
+ lateinit var activity: OnWatchFaceEditingTestActivity
+ scenario.onActivity {
+ activity = it
+ }
+ runBlocking {
+ activity.deferredDone.await()
+ }
+ assertThat(activity.editorSession.watchFaceId.id).isEqualTo(testInstanceId.id)
+ }
+
+ @Test
public fun backgroundComplicationId_noBackgroundComplication() {
val scenario = createOnWatchFaceEditingTestActivity(emptyList(), emptyList())
scenario.onActivity {
@@ -1203,6 +1298,27 @@
}
@Test
+ public fun initialUserStyle() {
+ val scenario = createOnWatchFaceEditingTestActivity(
+ listOf(colorStyleSetting, watchHandStyleSetting),
+ listOf(leftComplication, rightComplication),
+ initialUserStyle = UserStyleData(
+ mapOf(
+ colorStyleSetting.id.value to greenStyleOption.id.value,
+ watchHandStyleSetting.id.value to modernStyleOption.id.value,
+ )
+ )
+ )
+
+ scenario.onActivity { activity ->
+ assertThat(activity.editorSession.userStyle[colorStyleSetting])
+ .isEqualTo(greenStyleOption)
+ assertThat(activity.editorSession.userStyle[watchHandStyleSetting])
+ .isEqualTo(modernStyleOption)
+ }
+ }
+
+ @Test
public fun userStyleAndComplicationPreviewDataInEditorObserver() {
val scenario = createOnWatchFaceEditingTestActivity(
listOf(colorStyleSetting, watchHandStyleSetting),
@@ -1212,9 +1328,6 @@
val editorObserver = TestEditorObserver()
val observerId = EditorService.globalEditorService.registerObserver(editorObserver)
- val oldWFColorStyleSetting = editorDelegate.userStyle[colorStyleSetting]!!.id.value
- val oldWFWatchHandStyleSetting = editorDelegate.userStyle[watchHandStyleSetting]!!.id.value
-
scenario.onActivity { activity ->
runBlocking {
// Select [blueStyleOption] and [gothicStyleOption].
@@ -1240,12 +1353,11 @@
assertThat(result.watchFaceId.id).isEqualTo(testInstanceId.id)
assertTrue(result.shouldCommitChanges)
- // The style change shouldn't be applied to the watchface as it gets reverted to the old
- // one when editor closes.
+ // The style change should also be applied to the watchface.
assertThat(editorDelegate.userStyle[colorStyleSetting]!!.id.value)
- .isEqualTo(oldWFColorStyleSetting)
+ .isEqualTo(blueStyleOption.id.value)
assertThat(editorDelegate.userStyle[watchHandStyleSetting]!!.id.value)
- .isEqualTo(oldWFWatchHandStyleSetting)
+ .isEqualTo(gothicStyleOption.id.value)
assertThat(result.previewComplicationsData.size).isEqualTo(2)
val leftComplicationData = result.previewComplicationsData[LEFT_COMPLICATION_ID] as
@@ -1319,10 +1431,106 @@
}
@Test
- public fun dotNotCommit() {
+ public fun commit_headless() {
val scenario = createOnWatchFaceEditingTestActivity(
listOf(colorStyleSetting, watchHandStyleSetting),
- emptyList()
+ emptyList(),
+ headlessDeviceConfig = DeviceConfig(false, false, 0, 0),
+ watchComponentName = headlessWatchFaceComponentName
+ )
+ val editorObserver = TestEditorObserver()
+ val observerId = EditorService.globalEditorService.registerObserver(editorObserver)
+ scenario.onActivity { activity ->
+ runBlocking {
+ activity.deferredDone.await()
+ }
+ assertThat(editorDelegate.userStyle[colorStyleSetting]!!.id.value)
+ .isEqualTo(redStyleOption.id.value)
+ assertThat(editorDelegate.userStyle[watchHandStyleSetting]!!.id.value)
+ .isEqualTo(classicStyleOption.id.value)
+
+ // Select [blueStyleOption] and [gothicStyleOption].
+ val styleMap = activity.editorSession.userStyle.selectedOptions.toMutableMap()
+ for (userStyleSetting in activity.editorSession.userStyleSchema.userStyleSettings) {
+ styleMap[userStyleSetting] = userStyleSetting.options.last()
+ }
+ activity.editorSession.userStyle = UserStyle(styleMap)
+
+ // The editorDelegate should be unaffected because a separate headless instance is
+ // used.
+ assertThat(editorDelegate.userStyle[colorStyleSetting]!!.id.value)
+ .isEqualTo(redStyleOption.id.value)
+ assertThat(editorDelegate.userStyle[watchHandStyleSetting]!!.id.value)
+ .isEqualTo(classicStyleOption.id.value)
+
+ activity.editorSession.close()
+ activity.finish()
+ }
+
+ val result = editorObserver.awaitEditorStateChange(
+ TIMEOUT_MILLIS,
+ TimeUnit.MILLISECONDS
+ ).asApiEditorState()
+ assertThat(result.userStyle.userStyleMap[colorStyleSetting.id.value])
+ .isEqualTo(blueStyleOption.id.value)
+ assertThat(result.userStyle.userStyleMap[watchHandStyleSetting.id.value])
+ .isEqualTo(gothicStyleOption.id.value)
+ assertTrue(result.shouldCommitChanges)
+ assertNull(result.previewImage)
+
+ EditorService.globalEditorService.unregisterObserver(observerId)
+ }
+
+ @SuppressLint("NewApi")
+ @Test
+ public fun commitWithPreviewImage() {
+ val scenario = createOnWatchFaceEditingTestActivity(
+ listOf(colorStyleSetting, watchHandStyleSetting),
+ emptyList(),
+ headlessDeviceConfig = DeviceConfig(false, false, 0, 0),
+ watchComponentName = headlessWatchFaceComponentName,
+ previewScreenshotParams =
+ PreviewScreenshotParams(RenderParameters.DEFAULT_INTERACTIVE, 0)
+ )
+ val editorObserver = TestEditorObserver()
+ val observerId = EditorService.globalEditorService.registerObserver(editorObserver)
+ scenario.onActivity { activity ->
+ runBlocking {
+ activity.deferredDone.await()
+ }
+ // Select [blueStyleOption] and [gothicStyleOption].
+ val styleMap = activity.editorSession.userStyle.selectedOptions.toMutableMap()
+ for (userStyleSetting in activity.editorSession.userStyleSchema.userStyleSettings) {
+ styleMap[userStyleSetting] = userStyleSetting.options.last()
+ }
+ activity.editorSession.userStyle = UserStyle(styleMap)
+ activity.editorSession.close()
+ activity.finish()
+ }
+
+ val result = editorObserver.awaitEditorStateChange(
+ TIMEOUT_MILLIS,
+ TimeUnit.MILLISECONDS
+ ).asApiEditorState()
+
+ // previewImage is only supported from API 27 onwards.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
+ assertNotNull(result.previewImage)
+ assertThat(result.previewImage!!.getPixel(0, 0)).isEqualTo(Color.BLUE)
+ } else {
+ assertNull(result.previewImage)
+ }
+
+ EditorService.globalEditorService.unregisterObserver(observerId)
+ }
+
+ @Test
+ public fun doNotCommit() {
+ val scenario = createOnWatchFaceEditingTestActivity(
+ listOf(colorStyleSetting, watchHandStyleSetting),
+ emptyList(),
+ previewScreenshotParams =
+ PreviewScreenshotParams(RenderParameters.DEFAULT_INTERACTIVE, 0)
)
val editorObserver = TestEditorObserver()
val observerId = EditorService.globalEditorService.registerObserver(editorObserver)
@@ -1356,6 +1564,7 @@
assertThat(result.userStyle.userStyleMap[watchHandStyleSetting.id.value])
.isEqualTo(gothicStyleOption.id.value)
assertFalse(result.shouldCommitChanges)
+ assertNull(result.previewImage)
// The original style should be applied to the watch face however because
// commitChangesOnClose is false.
@@ -1416,10 +1625,18 @@
@Test
public fun watchFaceEditorContract_createIntent() {
+ val testComponentName = ComponentName("test.package", "test.class")
runBlocking {
val intent = WatchFaceEditorContract().createIntent(
ApplicationProvider.getApplicationContext<Context>(),
- EditorRequest(testComponentName, testEditorPackageName, null, testInstanceId)
+ EditorRequest(
+ testComponentName,
+ testEditorPackageName,
+ null,
+ testInstanceId,
+ null,
+ null
+ )
)
assertThat(intent.getPackage()).isEqualTo(testEditorPackageName)
@@ -1467,8 +1684,10 @@
EditorService.globalEditorService.unregisterObserver(observerId)
}
+ @SuppressLint("NewApi")
@Test
public fun closeEditorSessionBeforeInitCompleted() {
+ val testComponentName = ComponentName("test.package", "test.class")
val session: ActivityScenario<OnWatchFaceEditingTestActivity> = ActivityScenario.launch(
WatchFaceEditorContract().createIntent(
ApplicationProvider.getApplicationContext<Context>(),
@@ -1476,7 +1695,9 @@
testComponentName,
testEditorPackageName,
null,
- WatchFaceId("instanceId")
+ WatchFaceId("instanceId"),
+ null,
+ null
)
).apply {
component = ComponentName(
@@ -1643,11 +1864,19 @@
@Test
public fun testComponentNameMismatch() {
+ val testComponentName = ComponentName("test.package", "test.class")
val watchFaceId = WatchFaceId("ID-1")
val scenario: ActivityScenario<OnWatchFaceEditingTestActivity> = ActivityScenario.launch(
WatchFaceEditorContract().createIntent(
ApplicationProvider.getApplicationContext<Context>(),
- EditorRequest(testComponentName, testEditorPackageName, null, watchFaceId)
+ EditorRequest(
+ testComponentName,
+ testEditorPackageName,
+ null,
+ watchFaceId,
+ null,
+ null
+ )
).apply {
component = ComponentName(
ApplicationProvider.getApplicationContext<Context>(),
diff --git a/wear/wear-watchface-editor/src/androidTest/java/androidx/wear/watchface/editor/EditorSessionGuavaTest.kt b/wear/wear-watchface-editor/src/androidTest/java/androidx/wear/watchface/editor/EditorSessionGuavaTest.kt
index 29c8aa5..05f7c99 100644
--- a/wear/wear-watchface-editor/src/androidTest/java/androidx/wear/watchface/editor/EditorSessionGuavaTest.kt
+++ b/wear/wear-watchface-editor/src/androidTest/java/androidx/wear/watchface/editor/EditorSessionGuavaTest.kt
@@ -147,7 +147,14 @@
return ActivityScenario.launch(
WatchFaceEditorContract().createIntent(
ApplicationProvider.getApplicationContext<Context>(),
- EditorRequest(testComponentName, testEditorPackageName, null, watchFaceId)
+ EditorRequest(
+ testComponentName,
+ testEditorPackageName,
+ null,
+ watchFaceId,
+ null,
+ null
+ )
).apply {
component = ComponentName(
ApplicationProvider.getApplicationContext<Context>(),
diff --git a/wear/wear-watchface-editor/src/main/java/androidx/wear/watchface/editor/EditorSession.kt b/wear/wear-watchface-editor/src/main/java/androidx/wear/watchface/editor/EditorSession.kt
index 4110b11..0903379 100644
--- a/wear/wear-watchface-editor/src/main/java/androidx/wear/watchface/editor/EditorSession.kt
+++ b/wear/wear-watchface-editor/src/main/java/androidx/wear/watchface/editor/EditorSession.kt
@@ -26,6 +26,7 @@
import android.os.Handler
import android.os.Looper
import android.support.wearable.watchface.Constants
+import android.support.wearable.watchface.SharedMemoryImage
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContract
import androidx.annotation.Px
@@ -55,6 +56,7 @@
import androidx.wear.watchface.client.EditorState
import androidx.wear.watchface.client.HeadlessWatchFaceClient
import androidx.wear.watchface.client.WatchFaceId
+import androidx.wear.watchface.control.data.HeadlessWatchFaceInstanceParams
import androidx.wear.watchface.data.ComplicationSlotBoundsType
import androidx.wear.watchface.data.IdAndComplicationDataWireFormat
import androidx.wear.watchface.editor.data.EditorStateWireFormat
@@ -67,6 +69,7 @@
import kotlinx.coroutines.android.asCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
@@ -74,13 +77,17 @@
android.support.wearable.complications.ComplicationProviderInfo
/**
- * Interface for manipulating watch face state during an editing session for a watch face editing
- * session. The editor should adjust [userStyle] and call [openComplicationDataSourceChooser] to
- * configure the watch face and call [close] when done. This reports the updated [EditorState] to
- * the [EditorListener]s registered via [EditorServiceClient.addListener]. Style changes applied
- * during the editor session are temporary and will be reverted when the editor session completes.
- * In the event that the editor sessions results in a new watch face configuration that will be
- * subsequently reapplied when the new configuration is provided by the system.
+ * Interface for manipulating watch face state during a watch face editing session. The editor
+ * should adjust [userStyle] and call [openComplicationDataSourceChooser] to configure the watch
+ * face and call [close] when done. This reports the updated [EditorState] to the [EditorListener]s
+ * registered via [EditorServiceClient.addListener].
+ *
+ * For EditorSessions backed by a headless instance (see [createHeadlessEditingSession] and
+ * [EditorRequest.headlessDeviceConfig]), style changes are not applied to the interactive
+ * instance and it's up to the system to apply them. For EditorSessions backed by an
+ * interactive instance style changes are applied immediately. Its possible the system might fail to
+ * persist the style changes (e.g. to data base write failure or a crash) and if this happens its
+ * the responsibiltiy of the system to revert the style change.
*/
public abstract class EditorSession : AutoCloseable {
/** The [ComponentName] of the watch face being edited. */
@@ -253,7 +260,8 @@
editorRequest.initialUserStyle,
complicationDataSourceInfoRetrieverProvider,
coroutineScope,
- isRFlow
+ isRFlow,
+ editorRequest.previewScreenshotParams
)
// But full initialization has to be deferred because
// [WatchFace.getOrCreateEditorDelegate] is async.
@@ -261,9 +269,24 @@
withContext(coroutineScope.coroutineContext) {
withTimeout(EDITING_SESSION_TIMEOUT_MILLIS) {
session.setEditorDelegate(
- WatchFace.getOrCreateEditorDelegate(
- editorRequest.watchFaceComponentName
- ).await()
+ // Either create a delegate for a new headless client or await an
+ // interactive one.
+ if (editorRequest.headlessDeviceConfig != null) {
+ WatchFace.createHeadlessSessionDelegate(
+ editorRequest.watchFaceComponentName,
+ HeadlessWatchFaceInstanceParams(
+ editorRequest.watchFaceComponentName,
+ editorRequest.headlessDeviceConfig.asWireDeviceConfig(),
+ activity.resources.displayMetrics.widthPixels,
+ activity.resources.displayMetrics.heightPixels
+ ),
+ activity
+ )
+ } else {
+ WatchFace.getOrCreateEditorDelegate(
+ editorRequest.watchFaceComponentName
+ ).await()
+ }
)
// Resolve only after init has been completed.
session
@@ -300,7 +323,8 @@
},
CoroutineScope(
Handler(Looper.getMainLooper()).asCoroutineDispatcher().immediate
- )
+ ),
+ it.previewScreenshotParams
)
}
}
@@ -342,7 +366,8 @@
private val activity: ComponentActivity,
private val complicationDataSourceInfoRetrieverProvider:
ComplicationDataSourceInfoRetrieverProvider,
- public val coroutineScope: CoroutineScope
+ public val coroutineScope: CoroutineScope,
+ private val previewScreenshotParams: PreviewScreenshotParams?
) : EditorSession() {
protected var closed: Boolean = false
protected var forceClosed: Boolean = false
@@ -350,7 +375,11 @@
private val editorSessionTraceEvent = AsyncTraceEvent("EditorSession")
private val closeCallback = object : EditorService.CloseCallback() {
override fun onClose() {
- forceClose()
+ // onClose could be called on any thread but forceClose needs to be called from the UI
+ // thread.
+ coroutineScope.launch {
+ forceClose()
+ }
}
}
@@ -582,6 +611,20 @@
coroutineScope.launchWithTracing("BaseEditorSession.close") {
try {
withTimeout(CLOSE_BROADCAST_TIMEOUT_MILLIS) {
+ val previewImage =
+ if (commitChangesOnClose && previewScreenshotParams != null &&
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1
+ ) {
+ SharedMemoryImage.ashmemWriteImageBundle(
+ renderWatchFaceToBitmap(
+ previewScreenshotParams.renderParameters,
+ previewScreenshotParams.calendarTimeMillis,
+ getComplicationsPreviewData()
+ )
+ )
+ } else {
+ null
+ }
EditorService.globalEditorService.broadcastEditorState(
EditorStateWireFormat(
watchFaceId.id,
@@ -592,7 +635,8 @@
it.value.asWireComplicationData()
)
},
- commitChangesOnClose
+ commitChangesOnClose,
+ previewImage
)
)
}
@@ -613,10 +657,10 @@
closed = true
forceClosed = true
releaseResources()
- activity.finish()
EditorService.globalEditorService.removeCloseCallback(closeCallback)
editorSessionTraceEvent.close()
coroutineScope.cancel()
+ activity.finish()
}
protected fun requireNotClosed() {
@@ -636,8 +680,14 @@
private val initialEditorUserStyle: UserStyleData?,
complicationDataSourceInfoRetrieverProvider: ComplicationDataSourceInfoRetrieverProvider,
coroutineScope: CoroutineScope,
- private val isRFlow: Boolean
-) : BaseEditorSession(activity, complicationDataSourceInfoRetrieverProvider, coroutineScope) {
+ private val isRFlow: Boolean,
+ previewScreenshotParams: PreviewScreenshotParams?
+) : BaseEditorSession(
+ activity,
+ complicationDataSourceInfoRetrieverProvider,
+ coroutineScope,
+ previewScreenshotParams
+) {
private lateinit var editorDelegate: WatchFace.EditorDelegate
override val userStyleSchema by lazy {
@@ -702,18 +752,18 @@
}
override fun releaseResources() {
+ // If commitChangesOnClose is true, the userStyle is not restored which for non-headless
+ // watch faces meaning the style is applied immediately. It's possible for the System to
+ // fail to persist this change and we rely on the system reverting the style change in this
+ // eventuality.
+ if (!commitChangesOnClose && this::previousWatchFaceUserStyle.isInitialized) {
+ userStyle = previousWatchFaceUserStyle
+ }
+
+ // Note this has to be done after resetting userStyle to ensure tests are not racy.
if (this::editorDelegate.isInitialized) {
editorDelegate.onDestroy()
}
- // In android R flow we always revert any changes to the user style that was set during the
- // editing session. The system will update the user style and communicate it to the active
- // watch face if needed. This guarantees that the system is always the source of truth
- // for the current style.
- // Pre android R the watch face is the source of truth and we only revert if
- // commitChangesOnClose is false.
- if ((isRFlow || !commitChangesOnClose) && this::previousWatchFaceUserStyle.isInitialized) {
- userStyle = previousWatchFaceUserStyle
- }
if (this::backgroundCoroutineScope.isInitialized) {
backgroundCoroutineScope.cancel()
@@ -748,8 +798,14 @@
override val watchFaceId: WatchFaceId,
initialUserStyle: UserStyleData,
complicationDataSourceInfoRetrieverProvider: ComplicationDataSourceInfoRetrieverProvider,
- coroutineScope: CoroutineScope
-) : BaseEditorSession(activity, complicationDataSourceInfoRetrieverProvider, coroutineScope) {
+ coroutineScope: CoroutineScope,
+ previewScreenshotParams: PreviewScreenshotParams?
+) : BaseEditorSession(
+ activity,
+ complicationDataSourceInfoRetrieverProvider,
+ coroutineScope,
+ previewScreenshotParams
+) {
override val userStyleSchema = headlessWatchFaceClient.userStyleSchema
override var userStyle = UserStyle(initialUserStyle, userStyleSchema)
diff --git a/wear/wear-watchface-editor/src/main/java/androidx/wear/watchface/editor/WatchFaceEditorContract.kt b/wear/wear-watchface-editor/src/main/java/androidx/wear/watchface/editor/WatchFaceEditorContract.kt
index 8a139a6..6c9679c 100644
--- a/wear/wear-watchface-editor/src/main/java/androidx/wear/watchface/editor/WatchFaceEditorContract.kt
+++ b/wear/wear-watchface-editor/src/main/java/androidx/wear/watchface/editor/WatchFaceEditorContract.kt
@@ -24,20 +24,40 @@
import android.os.Build
import androidx.activity.result.contract.ActivityResultContract
import androidx.annotation.RequiresApi
+import androidx.wear.watchface.RenderParameters
+import androidx.wear.watchface.client.DeviceConfig
import androidx.wear.watchface.client.EditorServiceClient
import androidx.wear.watchface.client.EditorState
import androidx.wear.watchface.client.WatchFaceControlClient
import androidx.wear.watchface.client.WatchFaceId
+import androidx.wear.watchface.client.asApiDeviceConfig
+import androidx.wear.watchface.data.RenderParametersWireFormat
import androidx.wear.watchface.style.UserStyle
import androidx.wear.watchface.style.UserStyleData
import kotlinx.coroutines.TimeoutCancellationException
-import kotlin.jvm.Throws
internal const val INSTANCE_ID_KEY: String = "INSTANCE_ID_KEY"
internal const val COMPONENT_NAME_KEY: String = "COMPONENT_NAME_KEY"
+internal const val HEADLESS_DEVICE_CONFIG_KEY: String = "HEADLESS_DEVICE_CONFIG_KEY"
+internal const val RENDER_PARAMETERS_KEY: String = "RENDER_PARAMETERS_KEY"
+internal const val RENDER_TIME_MILLIS_KEY: String = "RENDER_TIME_MILLIS_KEY"
internal const val USER_STYLE_KEY: String = "USER_STYLE_KEY"
internal const val USER_STYLE_VALUES: String = "USER_STYLE_VALUES"
+typealias WireDeviceConfig = androidx.wear.watchface.data.DeviceConfig
+
+/**
+ * Parameters for an optional final screenshot taken by [EditorSession] upon exit and reported via
+ * [EditorState].
+ *
+ * @param renderParameters The [RenderParameters] to use when rendering the screen shot
+ * @param calendarTimeMillis The UTC time in milliseconds since the epoch to render with.
+ */
+public class PreviewScreenshotParams(
+ public val renderParameters: RenderParameters,
+ public val calendarTimeMillis: Long
+)
+
/**
* The request sent by [WatchFaceEditorContract.createIntent].
*
@@ -48,6 +68,12 @@
* @param watchFaceId Unique ID for the instance of the watch face being edited, only defined for
* Android R and beyond, it's `null` on Android P and earlier. Note each distinct [ComponentName]
* can have multiple instances.
+ * @param headlessDeviceConfig If `non-null` then this is the [DeviceConfig] to use when creating
+ * a headless instance to back the [EditorSession]. If `null` then the current interactive instance
+ * will be used. If there isn't one then the [EditorSession] won't launch until it's been created.
+ * @param previewScreenshotParams If `non-null` then [EditorSession] upon
+ * closing will render a screenshot with [PreviewScreenshotParams] using the existing interactive
+ * or headless instance which will be sent in [EditorState] to any registered clients.
*/
public class EditorRequest @RequiresApi(Build.VERSION_CODES.R) constructor(
public val watchFaceComponentName: ComponentName,
@@ -56,7 +82,9 @@
@get:RequiresApi(Build.VERSION_CODES.R)
@RequiresApi(Build.VERSION_CODES.R)
- public val watchFaceId: WatchFaceId
+ public val watchFaceId: WatchFaceId,
+ public val headlessDeviceConfig: DeviceConfig?,
+ public val previewScreenshotParams: PreviewScreenshotParams?
) {
/**
* Constructs an [EditorRequest] without a [WatchFaceId]. This is for use pre-android R.
@@ -76,7 +104,9 @@
watchFaceComponentName,
editorPackageName,
initialUserStyle,
- WatchFaceId("")
+ WatchFaceId(""),
+ null,
+ null
)
public companion object {
@@ -88,32 +118,33 @@
@SuppressLint("NewApi")
@JvmStatic
@Throws(TimeoutCancellationException::class)
- public fun createFromIntent(intent: Intent): EditorRequest {
- val componentName =
- intent.getParcelableExtra<ComponentName>(COMPONENT_NAME_KEY)!!
- val editorPackageName = intent.getPackage() ?: ""
- val instanceId = WatchFaceId(intent.getStringExtra(INSTANCE_ID_KEY) ?: "")
- val userStyleKey = intent.getStringArrayExtra(USER_STYLE_KEY)
- return componentName.let {
- if (userStyleKey != null) {
- EditorRequest(
- componentName,
- editorPackageName,
- UserStyleData(
- HashMap<String, ByteArray>().apply {
- for (i in userStyleKey.indices) {
- val userStyleValue =
- intent.getByteArrayExtra(USER_STYLE_VALUES + i)!!
- put(userStyleKey[i], userStyleValue)
- }
- }
- ),
- instanceId
- )
- }
- EditorRequest(componentName, editorPackageName, null, instanceId)
+ public fun createFromIntent(intent: Intent): EditorRequest = EditorRequest(
+ watchFaceComponentName = intent.getParcelableExtra<ComponentName>(COMPONENT_NAME_KEY)!!,
+ editorPackageName = intent.getPackage() ?: "",
+ initialUserStyle = intent.getStringArrayExtra(USER_STYLE_KEY)?.let {
+ UserStyleData(
+ HashMap<String, ByteArray>().apply {
+ for (i in it.indices) {
+ val userStyleValue =
+ intent.getByteArrayExtra(USER_STYLE_VALUES + i)!!
+ put(it[i], userStyleValue)
+ }
+ }
+ )
+ },
+ watchFaceId = WatchFaceId(intent.getStringExtra(INSTANCE_ID_KEY) ?: ""),
+ headlessDeviceConfig = intent.getParcelableExtra<WireDeviceConfig>(
+ HEADLESS_DEVICE_CONFIG_KEY
+ )?.asApiDeviceConfig(),
+ previewScreenshotParams = intent.getParcelableExtra<RenderParametersWireFormat>(
+ RENDER_PARAMETERS_KEY
+ )?.let {
+ PreviewScreenshotParams(
+ RenderParameters(it),
+ intent.getLongExtra(RENDER_TIME_MILLIS_KEY, 0)
+ )
}
- }
+ )
}
}
@@ -145,6 +176,11 @@
putExtra(USER_STYLE_VALUES + index, value)
}
}
+ putExtra(HEADLESS_DEVICE_CONFIG_KEY, input.headlessDeviceConfig?.asWireDeviceConfig())
+ input.previewScreenshotParams?.let {
+ putExtra(RENDER_PARAMETERS_KEY, it.renderParameters.toWireFormat())
+ putExtra(RENDER_TIME_MILLIS_KEY, it.calendarTimeMillis)
+ }
}
}
diff --git a/wear/wear-watchface/api/current.txt b/wear/wear-watchface/api/current.txt
index 2e16536..20299ac 100644
--- a/wear/wear-watchface/api/current.txt
+++ b/wear/wear-watchface/api/current.txt
@@ -9,12 +9,9 @@
public interface CanvasComplication {
method public void drawHighlight(android.graphics.Canvas canvas, android.graphics.Rect bounds, int boundsType, android.icu.util.Calendar calendar, @ColorInt int color);
method public androidx.wear.complications.data.ComplicationData? getData();
- method public boolean isHighlighted();
method public void loadData(androidx.wear.complications.data.ComplicationData? complicationData, boolean loadDrawablesAsynchronous);
method @WorkerThread public default void onRendererCreated(androidx.wear.watchface.Renderer renderer);
- method @UiThread public void render(android.graphics.Canvas canvas, android.graphics.Rect bounds, android.icu.util.Calendar calendar, androidx.wear.watchface.RenderParameters renderParameters);
- method public void setIsHighlighted(boolean isHighlighted);
- property public abstract boolean isHighlighted;
+ method @UiThread public void render(android.graphics.Canvas canvas, android.graphics.Rect bounds, android.icu.util.Calendar calendar, androidx.wear.watchface.RenderParameters renderParameters, int slotId);
}
public static interface CanvasComplication.InvalidateCallback {
@@ -89,8 +86,10 @@
method public androidx.wear.watchface.ComplicationSlot? getBackgroundComplicationSlot();
method public androidx.wear.watchface.ComplicationSlot? getComplicationSlotAt(@Px int x, @Px int y);
method public java.util.Map<java.lang.Integer,androidx.wear.watchface.ComplicationSlot> getComplicationSlots();
+ method public java.util.Set<java.lang.Integer> getPressedSlotIds();
method @UiThread public void removeTapListener(androidx.wear.watchface.ComplicationSlotsManager.TapCallback tapCallback);
property public final java.util.Map<java.lang.Integer,androidx.wear.watchface.ComplicationSlot> complicationSlots;
+ property public final java.util.Set<java.lang.Integer> pressedSlotIds;
}
public static interface ComplicationSlotsManager.TapCallback {
@@ -147,13 +146,16 @@
}
public final class RenderParameters {
+ ctor public RenderParameters(androidx.wear.watchface.DrawMode drawMode, java.util.Set<? extends androidx.wear.watchface.style.WatchFaceLayer> watchFaceLayers, optional androidx.wear.watchface.RenderParameters.HighlightLayer? highlightLayer, optional java.util.Set<java.lang.Integer> pressedComplicationSlotIds);
ctor public RenderParameters(androidx.wear.watchface.DrawMode drawMode, java.util.Set<? extends androidx.wear.watchface.style.WatchFaceLayer> watchFaceLayers, optional androidx.wear.watchface.RenderParameters.HighlightLayer? highlightLayer);
ctor public RenderParameters(androidx.wear.watchface.DrawMode drawMode, java.util.Set<? extends androidx.wear.watchface.style.WatchFaceLayer> watchFaceLayers);
method public androidx.wear.watchface.DrawMode getDrawMode();
method public androidx.wear.watchface.RenderParameters.HighlightLayer? getHighlightLayer();
+ method public java.util.Set<java.lang.Integer> getPressedComplicationSlotIds();
method public java.util.Set<androidx.wear.watchface.style.WatchFaceLayer> getWatchFaceLayers();
property public final androidx.wear.watchface.DrawMode drawMode;
property public final androidx.wear.watchface.RenderParameters.HighlightLayer? highlightLayer;
+ property public final java.util.Set<java.lang.Integer> pressedComplicationSlotIds;
property public final java.util.Set<androidx.wear.watchface.style.WatchFaceLayer> watchFaceLayers;
field public static final androidx.wear.watchface.RenderParameters.Companion Companion;
field public static final androidx.wear.watchface.RenderParameters DEFAULT_INTERACTIVE;
diff --git a/wear/wear-watchface/api/public_plus_experimental_current.txt b/wear/wear-watchface/api/public_plus_experimental_current.txt
index 2e16536..20299ac 100644
--- a/wear/wear-watchface/api/public_plus_experimental_current.txt
+++ b/wear/wear-watchface/api/public_plus_experimental_current.txt
@@ -9,12 +9,9 @@
public interface CanvasComplication {
method public void drawHighlight(android.graphics.Canvas canvas, android.graphics.Rect bounds, int boundsType, android.icu.util.Calendar calendar, @ColorInt int color);
method public androidx.wear.complications.data.ComplicationData? getData();
- method public boolean isHighlighted();
method public void loadData(androidx.wear.complications.data.ComplicationData? complicationData, boolean loadDrawablesAsynchronous);
method @WorkerThread public default void onRendererCreated(androidx.wear.watchface.Renderer renderer);
- method @UiThread public void render(android.graphics.Canvas canvas, android.graphics.Rect bounds, android.icu.util.Calendar calendar, androidx.wear.watchface.RenderParameters renderParameters);
- method public void setIsHighlighted(boolean isHighlighted);
- property public abstract boolean isHighlighted;
+ method @UiThread public void render(android.graphics.Canvas canvas, android.graphics.Rect bounds, android.icu.util.Calendar calendar, androidx.wear.watchface.RenderParameters renderParameters, int slotId);
}
public static interface CanvasComplication.InvalidateCallback {
@@ -89,8 +86,10 @@
method public androidx.wear.watchface.ComplicationSlot? getBackgroundComplicationSlot();
method public androidx.wear.watchface.ComplicationSlot? getComplicationSlotAt(@Px int x, @Px int y);
method public java.util.Map<java.lang.Integer,androidx.wear.watchface.ComplicationSlot> getComplicationSlots();
+ method public java.util.Set<java.lang.Integer> getPressedSlotIds();
method @UiThread public void removeTapListener(androidx.wear.watchface.ComplicationSlotsManager.TapCallback tapCallback);
property public final java.util.Map<java.lang.Integer,androidx.wear.watchface.ComplicationSlot> complicationSlots;
+ property public final java.util.Set<java.lang.Integer> pressedSlotIds;
}
public static interface ComplicationSlotsManager.TapCallback {
@@ -147,13 +146,16 @@
}
public final class RenderParameters {
+ ctor public RenderParameters(androidx.wear.watchface.DrawMode drawMode, java.util.Set<? extends androidx.wear.watchface.style.WatchFaceLayer> watchFaceLayers, optional androidx.wear.watchface.RenderParameters.HighlightLayer? highlightLayer, optional java.util.Set<java.lang.Integer> pressedComplicationSlotIds);
ctor public RenderParameters(androidx.wear.watchface.DrawMode drawMode, java.util.Set<? extends androidx.wear.watchface.style.WatchFaceLayer> watchFaceLayers, optional androidx.wear.watchface.RenderParameters.HighlightLayer? highlightLayer);
ctor public RenderParameters(androidx.wear.watchface.DrawMode drawMode, java.util.Set<? extends androidx.wear.watchface.style.WatchFaceLayer> watchFaceLayers);
method public androidx.wear.watchface.DrawMode getDrawMode();
method public androidx.wear.watchface.RenderParameters.HighlightLayer? getHighlightLayer();
+ method public java.util.Set<java.lang.Integer> getPressedComplicationSlotIds();
method public java.util.Set<androidx.wear.watchface.style.WatchFaceLayer> getWatchFaceLayers();
property public final androidx.wear.watchface.DrawMode drawMode;
property public final androidx.wear.watchface.RenderParameters.HighlightLayer? highlightLayer;
+ property public final java.util.Set<java.lang.Integer> pressedComplicationSlotIds;
property public final java.util.Set<androidx.wear.watchface.style.WatchFaceLayer> watchFaceLayers;
field public static final androidx.wear.watchface.RenderParameters.Companion Companion;
field public static final androidx.wear.watchface.RenderParameters DEFAULT_INTERACTIVE;
diff --git a/wear/wear-watchface/api/restricted_current.txt b/wear/wear-watchface/api/restricted_current.txt
index 9638d56..ca81607 100644
--- a/wear/wear-watchface/api/restricted_current.txt
+++ b/wear/wear-watchface/api/restricted_current.txt
@@ -35,12 +35,9 @@
public interface CanvasComplication {
method public void drawHighlight(android.graphics.Canvas canvas, android.graphics.Rect bounds, @androidx.wear.watchface.data.ComplicationSlotBoundsType int boundsType, android.icu.util.Calendar calendar, @ColorInt int color);
method public androidx.wear.complications.data.ComplicationData? getData();
- method public boolean isHighlighted();
method public void loadData(androidx.wear.complications.data.ComplicationData? complicationData, boolean loadDrawablesAsynchronous);
method @WorkerThread public default void onRendererCreated(androidx.wear.watchface.Renderer renderer);
- method @UiThread public void render(android.graphics.Canvas canvas, android.graphics.Rect bounds, android.icu.util.Calendar calendar, androidx.wear.watchface.RenderParameters renderParameters);
- method public void setIsHighlighted(boolean isHighlighted);
- property public abstract boolean isHighlighted;
+ method @UiThread public void render(android.graphics.Canvas canvas, android.graphics.Rect bounds, android.icu.util.Calendar calendar, androidx.wear.watchface.RenderParameters renderParameters, int slotId);
}
public static interface CanvasComplication.InvalidateCallback {
@@ -138,8 +135,10 @@
method public androidx.wear.watchface.ComplicationSlot? getBackgroundComplicationSlot();
method public androidx.wear.watchface.ComplicationSlot? getComplicationSlotAt(@Px int x, @Px int y);
method public java.util.Map<java.lang.Integer,androidx.wear.watchface.ComplicationSlot> getComplicationSlots();
+ method public java.util.Set<java.lang.Integer> getPressedSlotIds();
method @UiThread public void removeTapListener(androidx.wear.watchface.ComplicationSlotsManager.TapCallback tapCallback);
property public final java.util.Map<java.lang.Integer,androidx.wear.watchface.ComplicationSlot> complicationSlots;
+ property public final java.util.Set<java.lang.Integer> pressedSlotIds;
field @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP) @VisibleForTesting public androidx.wear.watchface.WatchState watchState;
}
@@ -228,15 +227,18 @@
}
public final class RenderParameters {
+ ctor public RenderParameters(androidx.wear.watchface.DrawMode drawMode, java.util.Set<? extends androidx.wear.watchface.style.WatchFaceLayer> watchFaceLayers, optional androidx.wear.watchface.RenderParameters.HighlightLayer? highlightLayer, optional java.util.Set<java.lang.Integer> pressedComplicationSlotIds);
ctor public RenderParameters(androidx.wear.watchface.DrawMode drawMode, java.util.Set<? extends androidx.wear.watchface.style.WatchFaceLayer> watchFaceLayers, optional androidx.wear.watchface.RenderParameters.HighlightLayer? highlightLayer);
ctor public RenderParameters(androidx.wear.watchface.DrawMode drawMode, java.util.Set<? extends androidx.wear.watchface.style.WatchFaceLayer> watchFaceLayers);
ctor @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public RenderParameters(androidx.wear.watchface.data.RenderParametersWireFormat wireFormat);
method public androidx.wear.watchface.DrawMode getDrawMode();
method public androidx.wear.watchface.RenderParameters.HighlightLayer? getHighlightLayer();
+ method public java.util.Set<java.lang.Integer> getPressedComplicationSlotIds();
method public java.util.Set<androidx.wear.watchface.style.WatchFaceLayer> getWatchFaceLayers();
method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) public androidx.wear.watchface.data.RenderParametersWireFormat toWireFormat();
property public final androidx.wear.watchface.DrawMode drawMode;
property public final androidx.wear.watchface.RenderParameters.HighlightLayer? highlightLayer;
+ property public final java.util.Set<java.lang.Integer> pressedComplicationSlotIds;
property public final java.util.Set<androidx.wear.watchface.style.WatchFaceLayer> watchFaceLayers;
field public static final androidx.wear.watchface.RenderParameters.Companion Companion;
field public static final androidx.wear.watchface.RenderParameters DEFAULT_INTERACTIVE;
@@ -344,6 +346,7 @@
public final class WatchFace {
ctor public WatchFace(int watchFaceType, androidx.wear.watchface.Renderer renderer);
method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP) @UiThread @VisibleForTesting public static void clearAllEditorDelegates();
+ method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP) @UiThread public static suspend Object? createHeadlessSessionDelegate(android.content.ComponentName componentName, androidx.wear.watchface.control.data.HeadlessWatchFaceInstanceParams params, android.content.Context context, kotlin.coroutines.Continuation<? super androidx.wear.watchface.WatchFace.EditorDelegate> p);
method public androidx.wear.watchface.WatchFace.LegacyWatchFaceOverlayStyle getLegacyWatchFaceStyle();
method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP) @UiThread public static kotlinx.coroutines.CompletableDeferred<androidx.wear.watchface.WatchFace.EditorDelegate> getOrCreateEditorDelegate(android.content.ComponentName componentName);
method public Long? getOverridePreviewReferenceTimeMillis();
@@ -365,6 +368,7 @@
public static final class WatchFace.Companion {
method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP) @UiThread @VisibleForTesting public void clearAllEditorDelegates();
+ method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP) @UiThread public suspend Object? createHeadlessSessionDelegate(android.content.ComponentName componentName, androidx.wear.watchface.control.data.HeadlessWatchFaceInstanceParams params, android.content.Context context, kotlin.coroutines.Continuation<? super androidx.wear.watchface.WatchFace.EditorDelegate> p);
method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP) @UiThread public kotlinx.coroutines.CompletableDeferred<androidx.wear.watchface.WatchFace.EditorDelegate> getOrCreateEditorDelegate(android.content.ComponentName componentName);
method public boolean isLegacyWatchFaceOverlayStyleSupported();
method @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP) @UiThread public void registerEditorDelegate(android.content.ComponentName componentName, androidx.wear.watchface.WatchFace.EditorDelegate editorDelegate);
@@ -525,6 +529,7 @@
method public void addCloseCallback(androidx.wear.watchface.editor.EditorService.CloseCallback closeCallback);
method public android.os.IBinder! asBinder();
method public void broadcastEditorState(androidx.wear.watchface.editor.data.EditorStateWireFormat editorState);
+ method public void clearCloseCallbacks();
method public void closeEditor();
method public int getApiVersion();
method public boolean onTransact(int, android.os.Parcel!, android.os.Parcel!, int) throws android.os.RemoteException;
diff --git a/wear/wear-watchface/samples/app/build.gradle b/wear/wear-watchface/samples/app/build.gradle
index 6ac4a42..056b9a9 100644
--- a/wear/wear-watchface/samples/app/build.gradle
+++ b/wear/wear-watchface/samples/app/build.gradle
@@ -42,11 +42,19 @@
}
buildTypes {
- release {
- minifyEnabled true
- shrinkResources true
- proguardFiles getDefaultProguardFile('proguard-android.txt')
- }
+ release {
+ minifyEnabled true
+ shrinkResources true
+ proguardFiles getDefaultProguardFile('proguard-android.txt')
+ }
+
+ /*
+ Release and debug targets will have different package names. Package name for debug version
+ will have suffix .debug, so this should be appended to package name when i.e. uninstalling.
+ */
+ debug {
+ applicationIdSuffix ".debug"
+ }
}
compileOptions {
diff --git a/wear/wear-watchface/samples/src/main/AndroidManifest.xml b/wear/wear-watchface/samples/src/main/AndroidManifest.xml
index df3d30c..fe35e6d 100644
--- a/wear/wear-watchface/samples/src/main/AndroidManifest.xml
+++ b/wear/wear-watchface/samples/src/main/AndroidManifest.xml
@@ -62,6 +62,10 @@
<meta-data
android:name="android.service.wallpaper"
android:resource="@xml/watch_face" />
+ <meta-data
+ android:name=
+ "com.google.android.wearable.watchface.companionBuiltinConfigurationEnabled"
+ android:value="true" />
</service>
<service
@@ -88,6 +92,10 @@
<meta-data
android:name="android.service.wallpaper"
android:resource="@xml/watch_face" />
+ <meta-data
+ android:name=
+ "com.google.android.wearable.watchface.companionBuiltinConfigurationEnabled"
+ android:value="true" />
</service>
<service
@@ -114,6 +122,10 @@
<meta-data
android:name="android.service.wallpaper"
android:resource="@xml/watch_face" />
+ <meta-data
+ android:name=
+ "com.google.android.wearable.watchface.companionBuiltinConfigurationEnabled"
+ android:value="true" />
</service>
<service
diff --git a/wear/wear-watchface/samples/src/main/java/androidx/wear/watchface/samples/ExampleOpenGLWatchFaceService.kt b/wear/wear-watchface/samples/src/main/java/androidx/wear/watchface/samples/ExampleOpenGLWatchFaceService.kt
index 64d50b6..76a4aa2 100644
--- a/wear/wear-watchface/samples/src/main/java/androidx/wear/watchface/samples/ExampleOpenGLWatchFaceService.kt
+++ b/wear/wear-watchface/samples/src/main/java/androidx/wear/watchface/samples/ExampleOpenGLWatchFaceService.kt
@@ -337,7 +337,7 @@
) // up vector
complicationTexture = GlesTextureComplication(
- complicationSlot.renderer,
+ complicationSlot,
128,
128,
GLES20.GL_TEXTURE_2D
diff --git a/wear/wear-watchface/src/androidTest/java/androidx/wear/watchface/test/WatchFaceControlServiceTest.kt b/wear/wear-watchface/src/androidTest/java/androidx/wear/watchface/test/WatchFaceControlServiceTest.kt
index c718ba4..bcc5c10 100644
--- a/wear/wear-watchface/src/androidTest/java/androidx/wear/watchface/test/WatchFaceControlServiceTest.kt
+++ b/wear/wear-watchface/src/androidTest/java/androidx/wear/watchface/test/WatchFaceControlServiceTest.kt
@@ -244,7 +244,7 @@
ComplicationRenderParams(
EXAMPLE_CANVAS_WATCHFACE_LEFT_COMPLICATION_ID,
RenderParameters(
- DrawMode.AMBIENT,
+ DrawMode.INTERACTIVE,
WatchFaceLayer.ALL_WATCH_FACE_LAYERS,
null,
).toWireFormat(),
diff --git a/wear/wear-watchface/src/androidTest/java/androidx/wear/watchface/test/WatchFaceServiceImageTest.kt b/wear/wear-watchface/src/androidTest/java/androidx/wear/watchface/test/WatchFaceServiceImageTest.kt
index 87d91b8..314edbe 100644
--- a/wear/wear-watchface/src/androidTest/java/androidx/wear/watchface/test/WatchFaceServiceImageTest.kt
+++ b/wear/wear-watchface/src/androidTest/java/androidx/wear/watchface/test/WatchFaceServiceImageTest.kt
@@ -16,6 +16,7 @@
package androidx.wear.watchface.test
+import android.annotation.SuppressLint
import android.app.Activity
import android.app.PendingIntent
import android.content.Context
@@ -533,6 +534,7 @@
bitmap.assertAgainstGolden(screenshotRule, "ambient_screenshot2")
}
+ @SuppressLint("NewApi")
@Test
public fun testCommandTakeScreenShot() {
val latch = CountDownLatch(1)
@@ -562,10 +564,11 @@
assertThat(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)).isTrue()
bitmap!!.assertAgainstGolden(
screenshotRule,
- "ambient_screenshot"
+ "testCommandTakeScreenShot"
)
}
+ @SuppressLint("NewApi")
@Test
public fun testCommandTakeOpenGLScreenShot() {
val latch = CountDownLatch(1)
@@ -619,6 +622,7 @@
bitmap.assertAgainstGolden(screenshotRule, "green_screenshot")
}
+ @SuppressLint("NewApi")
@Test
public fun testHighlightAllComplicationsInScreenshot() {
val latch = CountDownLatch(1)
@@ -657,6 +661,43 @@
)
}
+ @SuppressLint("NewApi")
+ @Test
+ public fun testRenderLeftComplicationPressed() {
+ val latch = CountDownLatch(1)
+
+ handler.post(this::initCanvasWatchFace)
+ assertThat(initLatch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)).isTrue()
+ sendComplications()
+
+ var bitmap: Bitmap? = null
+ handler.post {
+ bitmap = SharedMemoryImage.ashmemReadImageBundle(
+ interactiveWatchFaceInstance.renderWatchFaceToBitmap(
+ WatchFaceRenderParams(
+ RenderParameters(
+ DrawMode.INTERACTIVE,
+ WatchFaceLayer.ALL_WATCH_FACE_LAYERS,
+ null,
+ setOf(EXAMPLE_CANVAS_WATCHFACE_LEFT_COMPLICATION_ID)
+ ).toWireFormat(),
+ 123456789,
+ null,
+ null
+ )
+ )
+ )
+ latch.countDown()
+ }
+
+ assertThat(latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)).isTrue()
+ bitmap!!.assertAgainstGolden(
+ screenshotRule,
+ "left_complication_pressed"
+ )
+ }
+
+ @SuppressLint("NewApi")
@Test
public fun testHighlightRightComplicationInScreenshot() {
val latch = CountDownLatch(1)
@@ -697,6 +738,7 @@
)
}
+ @SuppressLint("NewApi")
@Test
public fun testScreenshotWithPreviewComplicationData() {
val latch = CountDownLatch(1)
@@ -812,6 +854,7 @@
}
}
+ @SuppressLint("NewApi")
@Test
public fun complicationTapLaunchesActivity() {
handler.post(this::initCanvasWatchFace)
diff --git a/wear/wear-watchface/src/main/AndroidManifest.xml b/wear/wear-watchface/src/main/AndroidManifest.xml
index 3f1c779..af4bd44 100644
--- a/wear/wear-watchface/src/main/AndroidManifest.xml
+++ b/wear/wear-watchface/src/main/AndroidManifest.xml
@@ -46,7 +46,8 @@
-->
<activity
android:name="androidx.wear.watchface.ComplicationHelperActivity"
- android:exported="false" />
+ android:exported="false"
+ android:theme="@android:style/Theme.Translucent.NoTitleBar"/>
</application>
</manifest>
diff --git a/wear/wear-watchface/src/main/java/androidx/wear/watchface/ComplicationHelperActivity.java b/wear/wear-watchface/src/main/java/androidx/wear/watchface/ComplicationHelperActivity.java
index abd9e9b..ae93020 100644
--- a/wear/wear-watchface/src/main/java/androidx/wear/watchface/ComplicationHelperActivity.java
+++ b/wear/wear-watchface/src/main/java/androidx/wear/watchface/ComplicationHelperActivity.java
@@ -125,8 +125,6 @@
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
- setTheme(android.R.style.Theme_Translucent_NoTitleBar);
-
super.onCreate(savedInstanceState);
Intent intent = getIntent();
diff --git a/wear/wear-watchface/src/main/java/androidx/wear/watchface/ComplicationSlot.kt b/wear/wear-watchface/src/main/java/androidx/wear/watchface/ComplicationSlot.kt
index f72ed54..da6551a 100644
--- a/wear/wear-watchface/src/main/java/androidx/wear/watchface/ComplicationSlot.kt
+++ b/wear/wear-watchface/src/main/java/androidx/wear/watchface/ComplicationSlot.kt
@@ -69,13 +69,15 @@
* @param bounds A [Rect] describing the bounds of the complication
* @param calendar The current [Calendar]
* @param renderParameters The current [RenderParameters]
+ * @param slotId The Id of the [ComplicationSlot] being rendered
*/
@UiThread
public fun render(
canvas: Canvas,
bounds: Rect,
calendar: Calendar,
- renderParameters: RenderParameters
+ renderParameters: RenderParameters,
+ slotId: Int
)
/**
@@ -97,15 +99,6 @@
@ColorInt color: Int
)
- /**
- * Whether the complication should be drawn highlighted. This is to provide visual feedback when
- * the user taps on a complication.
- */
- @Suppress("INAPPLICABLE_JVM_NAME") // https://stackoverflow.com/questions/47504279
- @get:JvmName("isHighlighted")
- @set:JvmName("setIsHighlighted")
- public var isHighlighted: Boolean
-
/** Returns the [ComplicationData] to render with. */
public fun getData(): ComplicationData?
@@ -612,7 +605,7 @@
renderParameters: RenderParameters
) {
val bounds = computeBounds(Rect(0, 0, canvas.width, canvas.height))
- renderer.render(canvas, bounds, calendar, renderParameters)
+ renderer.render(canvas, bounds, calendar, renderParameters, id)
}
/**
@@ -661,16 +654,6 @@
}
}
- /**
- * Sets whether the complication should be drawn highlighted or not. This is to provide visual
- * feedback when the user taps on a complication.
- *
- * @param highlight Whether or not the complication should be drawn highlighted.
- */
- internal fun setIsHighlighted(highlight: Boolean) {
- renderer.isHighlighted = highlight
- }
-
internal fun init(invalidateListener: InvalidateListener) {
this.invalidateListener = invalidateListener
}
@@ -699,7 +682,6 @@
writer.increaseIndent()
writer.println("fixedComplicationDataSource=$fixedComplicationDataSource")
writer.println("enabled=$enabled")
- writer.println("renderer.isHighlighted=${renderer.isHighlighted}")
writer.println("boundsType=$boundsType")
writer.println("configExtras=$configExtras")
writer.println("supportedTypes=${supportedTypes.joinToString { it.toString() }}")
diff --git a/wear/wear-watchface/src/main/java/androidx/wear/watchface/ComplicationSlotsManager.kt b/wear/wear-watchface/src/main/java/androidx/wear/watchface/ComplicationSlotsManager.kt
index e056e16..9246c09 100644
--- a/wear/wear-watchface/src/main/java/androidx/wear/watchface/ComplicationSlotsManager.kt
+++ b/wear/wear-watchface/src/main/java/androidx/wear/watchface/ComplicationSlotsManager.kt
@@ -91,6 +91,9 @@
public val complicationSlots: Map<Int, ComplicationSlot> =
complicationSlotCollection.associateBy(ComplicationSlot::id)
+ /** The set of slot ids that are rendered as pressed. */
+ public val pressedSlotIds: Set<Int> = HashSet()
+
private class InitialComplicationConfig(
val complicationSlotBounds: ComplicationSlotBounds,
val enabled: Boolean,
@@ -283,17 +286,17 @@
*/
@UiThread
public fun displayPressedAnimation(complicationSlotId: Int) {
- val complication = requireNotNull(complicationSlots[complicationSlotId]) {
+ requireNotNull(complicationSlots[complicationSlotId]) {
"No complication found with ID $complicationSlotId"
}
- complication.setIsHighlighted(true)
+ (pressedSlotIds as HashSet<Int>).add(complicationSlotId)
val weakRef = WeakReference(this)
watchFaceHostApi.getUiThreadHandler().postDelayed(
{
// The watch face might go away before this can run.
if (weakRef.get() != null) {
- complication.setIsHighlighted(false)
+ pressedSlotIds.remove(complicationSlotId)
}
},
WatchFaceImpl.CANCEL_COMPLICATION_HIGHLIGHTED_DELAY_MS
@@ -379,6 +382,7 @@
@UiThread
internal fun dump(writer: IndentingPrintWriter) {
writer.println("ComplicationSlotsManager:")
+ writer.println("renderer.pressedSlotIds=${pressedSlotIds.joinToString()}")
writer.increaseIndent()
for ((_, complication) in complicationSlots) {
complication.dump(writer)
diff --git a/wear/wear-watchface/src/main/java/androidx/wear/watchface/RenderParameters.kt b/wear/wear-watchface/src/main/java/androidx/wear/watchface/RenderParameters.kt
index dd06ae0..8b66e7f 100644
--- a/wear/wear-watchface/src/main/java/androidx/wear/watchface/RenderParameters.kt
+++ b/wear/wear-watchface/src/main/java/androidx/wear/watchface/RenderParameters.kt
@@ -81,11 +81,13 @@
* @param highlightLayer Optional [HighlightLayer] used by editors to visually highlight an
* aspect of the watch face. Rendered last on top of [watchFaceLayers]. If highlighting isn't needed
* this will be `null`.
+ * @param pressedComplicationSlotIds A set of [ComplicationSlot.id]s to render as pressed.
*/
public class RenderParameters @JvmOverloads constructor(
public val drawMode: DrawMode,
public val watchFaceLayers: Set<WatchFaceLayer>,
- public val highlightLayer: HighlightLayer? = null
+ public val highlightLayer: HighlightLayer? = null,
+ public val pressedComplicationSlotIds: Set<Int> = emptySet()
) {
init {
require(watchFaceLayers.isNotEmpty() || highlightLayer != null) {
@@ -240,7 +242,8 @@
}
else -> null
- }
+ },
+ wireFormat.pressedComplicationSlotIds.toSet()
)
/** @hide */
@@ -254,7 +257,8 @@
0,
null,
highlightLayer!!.highlightTint,
- highlightLayer.backgroundTint
+ highlightLayer.backgroundTint,
+ pressedComplicationSlotIds.toIntArray()
)
is HighlightedElement.ComplicationSlot -> RenderParametersWireFormat(
@@ -264,7 +268,8 @@
thingHighlighted.id,
null,
highlightLayer!!.highlightTint,
- highlightLayer.backgroundTint
+ highlightLayer.backgroundTint,
+ pressedComplicationSlotIds.toIntArray()
)
is HighlightedElement.UserStyle -> RenderParametersWireFormat(
@@ -274,7 +279,8 @@
0,
thingHighlighted.id.value,
highlightLayer!!.highlightTint,
- highlightLayer.backgroundTint
+ highlightLayer.backgroundTint,
+ pressedComplicationSlotIds.toIntArray()
)
else -> RenderParametersWireFormat(
@@ -284,7 +290,8 @@
0,
null,
Color.BLACK,
- Color.BLACK
+ Color.BLACK,
+ pressedComplicationSlotIds.toIntArray()
)
}
@@ -342,6 +349,7 @@
if (drawMode != other.drawMode) return false
if (watchFaceLayers != other.watchFaceLayers) return false
if (highlightLayer != other.highlightLayer) return false
+ if (pressedComplicationSlotIds != other.pressedComplicationSlotIds) return false
return true
}
@@ -350,6 +358,7 @@
var result = drawMode.hashCode()
result = 31 * result + watchFaceLayers.hashCode()
result = 31 * result + (highlightLayer?.hashCode() ?: 0)
+ result = 31 * result + pressedComplicationSlotIds.hashCode()
return result
}
}
diff --git a/wear/wear-watchface/src/main/java/androidx/wear/watchface/WatchFace.kt b/wear/wear-watchface/src/main/java/androidx/wear/watchface/WatchFace.kt
index d15d3f8..c19817e 100644
--- a/wear/wear-watchface/src/main/java/androidx/wear/watchface/WatchFace.kt
+++ b/wear/wear-watchface/src/main/java/androidx/wear/watchface/WatchFace.kt
@@ -19,6 +19,7 @@
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.content.ComponentName
+import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Bitmap
@@ -49,6 +50,7 @@
import androidx.wear.utility.TraceEvent
import androidx.wear.watchface.ObservableWatchData.MutableObservableWatchData
import androidx.wear.watchface.control.data.ComplicationRenderParams
+import androidx.wear.watchface.control.data.HeadlessWatchFaceInstanceParams
import androidx.wear.watchface.control.data.WatchFaceRenderParams
import androidx.wear.watchface.data.ComplicationStateWireFormat
import androidx.wear.watchface.data.IdAndComplicationStateWireFormat
@@ -170,6 +172,40 @@
pendingEditorDelegateCB = CompletableDeferred()
return pendingEditorDelegateCB!!
}
+
+ /**
+ * For use by on watch face editors.
+ * @hide
+ */
+ @SuppressLint("NewApi")
+ @JvmStatic
+ @UiThread
+ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+ public suspend fun createHeadlessSessionDelegate(
+ componentName: ComponentName,
+ params: HeadlessWatchFaceInstanceParams,
+ context: Context
+ ): EditorDelegate {
+ // Attempt to construct the class for the specified watchFaceName, failing if it either
+ // doesn't exist or isn't a [WatchFaceService].
+ val watchFaceServiceClass =
+ Class.forName(componentName.className) ?: throw IllegalArgumentException(
+ "Can't create ${componentName.className}"
+ )
+ if (!WatchFaceService::class.java.isAssignableFrom(WatchFaceService::class.java)) {
+ throw IllegalArgumentException(
+ "${componentName.className} is not a WatchFaceService"
+ )
+ } else {
+ val watchFaceService =
+ watchFaceServiceClass.getConstructor().newInstance() as WatchFaceService
+ watchFaceService.setContext(context)
+ val engine = watchFaceService.createHeadlessEngine() as
+ WatchFaceService.EngineWrapper
+ engine.createHeadlessInstance(params)
+ return engine.deferredWatchFaceImpl.await().WFEditorDelegate()
+ }
+ }
}
/**
@@ -399,29 +435,40 @@
// available programmatically. The value below is the default but it could be overridden
// by OEMs.
internal const val INITIAL_LOW_BATTERY_THRESHOLD = 15.0f
-
- internal val defaultRenderParametersForDrawMode: HashMap<DrawMode, RenderParameters> =
- hashMapOf(
- DrawMode.AMBIENT to
- RenderParameters(
- DrawMode.AMBIENT, WatchFaceLayer.ALL_WATCH_FACE_LAYERS, null
- ),
- DrawMode.INTERACTIVE to
- RenderParameters(
- DrawMode.INTERACTIVE, WatchFaceLayer.ALL_WATCH_FACE_LAYERS, null
- ),
- DrawMode.LOW_BATTERY_INTERACTIVE to
- RenderParameters(
- DrawMode.LOW_BATTERY_INTERACTIVE, WatchFaceLayer.ALL_WATCH_FACE_LAYERS,
- null
- ),
- DrawMode.MUTE to
- RenderParameters(
- DrawMode.MUTE, WatchFaceLayer.ALL_WATCH_FACE_LAYERS, null
- ),
- )
}
+ private val defaultRenderParametersForDrawMode: HashMap<DrawMode, RenderParameters> =
+ hashMapOf(
+ DrawMode.AMBIENT to
+ RenderParameters(
+ DrawMode.AMBIENT,
+ WatchFaceLayer.ALL_WATCH_FACE_LAYERS,
+ null,
+ complicationSlotsManager.pressedSlotIds
+ ),
+ DrawMode.INTERACTIVE to
+ RenderParameters(
+ DrawMode.INTERACTIVE,
+ WatchFaceLayer.ALL_WATCH_FACE_LAYERS,
+ null,
+ complicationSlotsManager.pressedSlotIds
+ ),
+ DrawMode.LOW_BATTERY_INTERACTIVE to
+ RenderParameters(
+ DrawMode.LOW_BATTERY_INTERACTIVE,
+ WatchFaceLayer.ALL_WATCH_FACE_LAYERS,
+ null,
+ complicationSlotsManager.pressedSlotIds
+ ),
+ DrawMode.MUTE to
+ RenderParameters(
+ DrawMode.MUTE,
+ WatchFaceLayer.ALL_WATCH_FACE_LAYERS,
+ null,
+ complicationSlotsManager.pressedSlotIds
+ ),
+ )
+
private val systemTimeProvider = watchface.systemTimeProvider
private val legacyWatchFaceStyle = watchface.legacyWatchFaceStyle
internal val renderer = watchface.renderer
@@ -983,7 +1030,8 @@
Canvas(complicationBitmap),
Rect(0, 0, bounds.width(), bounds.height()),
calendar,
- RenderParameters(params.renderParametersWireFormat)
+ RenderParameters(params.renderParametersWireFormat),
+ params.complicationSlotId
)
// Restore previous ComplicationData & style if required.
diff --git a/wear/wear-watchface/src/main/java/androidx/wear/watchface/WatchFaceHostApi.kt b/wear/wear-watchface/src/main/java/androidx/wear/watchface/WatchFaceHostApi.kt
index b338f99..f25d5ad 100644
--- a/wear/wear-watchface/src/main/java/androidx/wear/watchface/WatchFaceHostApi.kt
+++ b/wear/wear-watchface/src/main/java/androidx/wear/watchface/WatchFaceHostApi.kt
@@ -44,19 +44,13 @@
public fun getInitialUserStyle(): UserStyleWireFormat?
/**
- * Sets ContentDescriptionLabels for text-to-speech screen readers to make your
+ * Creates/updates ContentDescriptionLabels for text-to-speech screen readers to make your
* [ComplicationSlot]s, buttons, and any other text on your watchface accessible.
*
- * Each label is a region of the screen in absolute coordinates, along with
- * time-dependent text. The regions must not overlap.
- *
- * You must set all labels at the same time; previous labels will be cleared. An empty
- * array clears all labels.
- *
- * In addition to labeling your [ComplicationSlot]s, please include a label that will read the
- * current time. You can use [android.support.wearable.watchface.accessibility
- * .AccessibilityUtils.makeTimeAsComplicationText] to generate the proper
- * [android.support.wearable.complications.ComplicationText].
+ * Each label is a region of the screen in absolute pixel coordinates, along with
+ * time-dependent text, the labels are generated from data in [ComplicationSlotsManager],
+ * [Renderer.additionalContentDescriptionLabels], [Renderer.screenBounds] and
+ * [Renderer.getMainClockElementBounds].
*
* This is a fairly expensive operation so use it sparingly (e.g. do not call it in
* `onDraw()`).
diff --git a/wear/wear-watchface/src/main/java/androidx/wear/watchface/WatchFaceService.kt b/wear/wear-watchface/src/main/java/androidx/wear/watchface/WatchFaceService.kt
index 1a70832..a13a43b 100644
--- a/wear/wear-watchface/src/main/java/androidx/wear/watchface/WatchFaceService.kt
+++ b/wear/wear-watchface/src/main/java/androidx/wear/watchface/WatchFaceService.kt
@@ -219,6 +219,10 @@
* <meta-data
* android:name="android.service.wallpaper"
* android:resource="@xml/watch_face" />
+ * <meta-data
+ * android:name=
+ * "com.google.android.wearable.watchface.companionBuiltinConfigurationEnabled"
+ * android:value="true" />
* </service>
* ```
*
@@ -718,6 +722,12 @@
isHeadless = headless
}
+ // It's possible for two getOrCreateInteractiveWatchFaceClient calls to come in back to
+ // back for the same instance. If the second one specifies a UserStyle we need to apply it
+ // but if the instance isn't fully initialized we need to defer application to avoid
+ // blocking in getOrCreateInteractiveWatchFaceClient until the watch face is ready.
+ internal var pendingUserStyle: UserStyleWireFormat? = null
+
/**
* Whether or not we allow watchfaces to animate. In some tests or for headless
* rendering (for remote config) we don't want this.
@@ -921,7 +931,11 @@
internal suspend fun setUserStyle(
userStyle: UserStyleWireFormat
): Unit = TraceEvent("EngineWrapper.setUserStyle").use {
- val watchFaceImpl = deferredWatchFaceImpl.await()
+ setUserStyleImpl(deferredWatchFaceImpl.await(), userStyle)
+ }
+
+ @UiThread
+ private fun setUserStyleImpl(watchFaceImpl: WatchFaceImpl, userStyle: UserStyleWireFormat) {
watchFaceImpl.onSetStyleInternal(
UserStyle(UserStyleData(userStyle), watchFaceImpl.currentUserStyleRepository.schema)
)
@@ -1479,6 +1493,15 @@
// deferredWatchFaceImpl) occurs before initStyleAndComplications has
// executed. NB usually we won't have to wait at all.
initStyleAndComplicationsDone.await()
+
+ // Its possible a second getOrCreateInteractiveWatchFaceClient call came in before
+ // the watch face for the first one had finished initalizing, in that case we want
+ // to apply the updated style. NB pendingUserStyle is accessed on the UiThread so
+ // there shouldn't be any problems with race conditions.
+ pendingUserStyle?.let {
+ setUserStyleImpl(watchFaceImpl, it)
+ pendingUserStyle = null
+ }
deferredWatchFaceImpl.complete(watchFaceImpl)
asyncWatchFaceConstructionPending = false
watchFaceImpl.initComplete = true
diff --git a/wear/wear-watchface/src/main/java/androidx/wear/watchface/control/InteractiveInstanceManager.kt b/wear/wear-watchface/src/main/java/androidx/wear/watchface/control/InteractiveInstanceManager.kt
index ea9da42..1087df1 100644
--- a/wear/wear-watchface/src/main/java/androidx/wear/watchface/control/InteractiveInstanceManager.kt
+++ b/wear/wear-watchface/src/main/java/androidx/wear/watchface/control/InteractiveInstanceManager.kt
@@ -22,6 +22,8 @@
import androidx.wear.utility.TraceEvent
import androidx.wear.watchface.IndentingPrintWriter
import androidx.wear.watchface.control.data.WallpaperInteractiveWatchFaceInstanceParams
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
/** Keeps track of [InteractiveWatchFaceImpl]s. */
internal class InteractiveInstanceManager {
@@ -113,6 +115,23 @@
synchronized(pendingWallpaperInteractiveWatchFaceInstanceLock) {
val instance = instances[value.params.instanceId]
return if (instance != null) {
+ // The system on reboot will use this to connect to an existing watch face, we
+ // need to ensure there isn't a skew between the style the watch face actually
+ // has and what the system thinks we should have. Note runBlocking is safe here
+ // because we never await.
+ val engine = instance.impl.engine
+ runBlocking {
+ withContext(engine.uiThreadCoroutineScope.coroutineContext) {
+ if (engine.deferredWatchFaceImpl.isCompleted) {
+ // setUserStyle awaits deferredWatchFaceImpl but it's completed.
+ engine.setUserStyle(value.params.userStyle)
+ } else {
+ // Defer the UI update until deferredWatchFaceImpl is about to
+ // complete.
+ engine.pendingUserStyle = value.params.userStyle
+ }
+ }
+ }
instance.impl
} else {
TraceEvent("Set pendingWallpaperInteractiveWatchFaceInstance").use {
diff --git a/wear/wear-watchface/src/main/java/androidx/wear/watchface/editor/EditorService.kt b/wear/wear-watchface/src/main/java/androidx/wear/watchface/editor/EditorService.kt
index b86ceef..239e95b 100644
--- a/wear/wear-watchface/src/main/java/androidx/wear/watchface/editor/EditorService.kt
+++ b/wear/wear-watchface/src/main/java/androidx/wear/watchface/editor/EditorService.kt
@@ -97,6 +97,15 @@
}
/**
+ * Removes all [closeEditorCallbacks].
+ */
+ public fun clearCloseCallbacks() {
+ synchronized(lock) {
+ closeEditorCallbacks.clear()
+ }
+ }
+
+ /**
* Calls [IEditorObserver.onEditorStateChange] with [editorState] for each [IEditorObserver].
*/
public fun broadcastEditorState(editorState: EditorStateWireFormat) {
diff --git a/wear/wear-watchface/src/test/java/androidx/wear/watchface/RenderParametersTest.kt b/wear/wear-watchface/src/test/java/androidx/wear/watchface/RenderParametersTest.kt
index 18a2cec..bc881a2 100644
--- a/wear/wear-watchface/src/test/java/androidx/wear/watchface/RenderParametersTest.kt
+++ b/wear/wear-watchface/src/test/java/androidx/wear/watchface/RenderParametersTest.kt
@@ -252,4 +252,4 @@
assertThat(renderParameters3a).isNotEqualTo(renderParameters3c)
assertThat(renderParameters3a).isNotEqualTo(renderParameters4a)
}
-}
\ No newline at end of file
+}
diff --git a/wear/wear-watchface/src/test/java/androidx/wear/watchface/WatchFaceServiceTest.kt b/wear/wear-watchface/src/test/java/androidx/wear/watchface/WatchFaceServiceTest.kt
index ba939fa..1f72f17 100644
--- a/wear/wear-watchface/src/test/java/androidx/wear/watchface/WatchFaceServiceTest.kt
+++ b/wear/wear-watchface/src/test/java/androidx/wear/watchface/WatchFaceServiceTest.kt
@@ -720,27 +720,27 @@
UserStyleSchema(emptyList())
)
- assertThat(complicationDrawableLeft.isHighlighted).isFalse()
- assertThat(complicationDrawableRight.isHighlighted).isFalse()
+ assertThat(complicationSlotsManager.pressedSlotIds).doesNotContain(LEFT_COMPLICATION_ID)
+ assertThat(complicationSlotsManager.pressedSlotIds).doesNotContain(RIGHT_COMPLICATION_ID)
// Tap left complication.
tapAt(30, 50)
- assertThat(complicationDrawableLeft.isHighlighted).isTrue()
+ assertThat(complicationSlotsManager.pressedSlotIds).contains(LEFT_COMPLICATION_ID)
assertThat(testWatchFaceService.tappedComplicationSlotIds)
.isEqualTo(listOf(LEFT_COMPLICATION_ID))
runPostedTasksFor(WatchFaceImpl.CANCEL_COMPLICATION_HIGHLIGHTED_DELAY_MS)
- assertThat(complicationDrawableLeft.isHighlighted).isFalse()
+ assertThat(complicationSlotsManager.pressedSlotIds).doesNotContain(LEFT_COMPLICATION_ID)
// Tap right complication.
testWatchFaceService.reset()
tapAt(70, 50)
- assertThat(complicationDrawableRight.isHighlighted).isTrue()
+ assertThat(complicationSlotsManager.pressedSlotIds).contains(RIGHT_COMPLICATION_ID)
assertThat(testWatchFaceService.tappedComplicationSlotIds)
.isEqualTo(listOf(RIGHT_COMPLICATION_ID))
runPostedTasksFor(WatchFaceImpl.CANCEL_COMPLICATION_HIGHLIGHTED_DELAY_MS)
- assertThat(complicationDrawableLeft.isHighlighted).isFalse()
+ assertThat(complicationSlotsManager.pressedSlotIds).doesNotContain(LEFT_COMPLICATION_ID)
// Tap on blank space.
testWatchFaceService.reset()
@@ -748,7 +748,7 @@
assertThat(testWatchFaceService.tappedComplicationSlotIds).isEmpty()
runPostedTasksFor(WatchFaceImpl.CANCEL_COMPLICATION_HIGHLIGHTED_DELAY_MS)
- assertThat(complicationDrawableLeft.isHighlighted).isFalse()
+ assertThat(complicationSlotsManager.pressedSlotIds).doesNotContain(LEFT_COMPLICATION_ID)
assertThat(testWatchFaceService.tappedComplicationSlotIds).isEmpty()
}
@@ -760,21 +760,21 @@
UserStyleSchema(emptyList())
)
- assertThat(complicationDrawableLeft.isHighlighted).isFalse()
- assertThat(complicationDrawableRight.isHighlighted).isFalse()
+ assertThat(complicationSlotsManager.pressedSlotIds).doesNotContain(LEFT_COMPLICATION_ID)
+ assertThat(complicationSlotsManager.pressedSlotIds).doesNotContain(RIGHT_COMPLICATION_ID)
// Rapidly tap left then right complication.
tapAt(30, 50)
tapAt(70, 50)
// Both complicationSlots get temporarily highlighted.
- assertThat(complicationDrawableLeft.isHighlighted).isTrue()
- assertThat(complicationDrawableRight.isHighlighted).isTrue()
+ assertThat(complicationSlotsManager.pressedSlotIds).contains(LEFT_COMPLICATION_ID)
+ assertThat(complicationSlotsManager.pressedSlotIds).contains(RIGHT_COMPLICATION_ID)
// And the highlight goes away after a delay.
runPostedTasksFor(WatchFaceImpl.CANCEL_COMPLICATION_HIGHLIGHTED_DELAY_MS)
- assertThat(complicationDrawableLeft.isHighlighted).isFalse()
- assertThat(complicationDrawableRight.isHighlighted).isFalse()
+ assertThat(complicationSlotsManager.pressedSlotIds).doesNotContain(LEFT_COMPLICATION_ID)
+ assertThat(complicationSlotsManager.pressedSlotIds).doesNotContain(RIGHT_COMPLICATION_ID)
// Taps are registered on both complicationSlots.
assertThat(testWatchFaceService.tappedComplicationSlotIds)
@@ -804,7 +804,7 @@
tapListener = tapListener
)
- assertThat(complicationDrawableEdge.isHighlighted).isFalse()
+ assertThat(complicationSlotsManager.pressedSlotIds).doesNotContain(EDGE_COMPLICATION_ID)
`when`(
edgeComplicationHitTester.hitTest(
@@ -817,12 +817,12 @@
// Tap the edge complication.
tapAt(0, 50)
- assertThat(complicationDrawableEdge.isHighlighted).isTrue()
+ assertThat(complicationSlotsManager.pressedSlotIds).contains(EDGE_COMPLICATION_ID)
assertThat(testWatchFaceService.tappedComplicationSlotIds)
.isEqualTo(listOf(EDGE_COMPLICATION_ID))
runPostedTasksFor(WatchFaceImpl.CANCEL_COMPLICATION_HIGHLIGHTED_DELAY_MS)
- assertThat(complicationDrawableEdge.isHighlighted).isFalse()
+ assertThat(complicationSlotsManager.pressedSlotIds).doesNotContain(EDGE_COMPLICATION_ID)
}
@Test
diff --git a/wear/wear/src/androidTest/java/androidx/wear/widget/ConfirmationOverlayTest.java b/wear/wear/src/androidTest/java/androidx/wear/widget/ConfirmationOverlayTest.java
index 1cd7644..2063f3f 100644
--- a/wear/wear/src/androidTest/java/androidx/wear/widget/ConfirmationOverlayTest.java
+++ b/wear/wear/src/androidTest/java/androidx/wear/widget/ConfirmationOverlayTest.java
@@ -29,7 +29,6 @@
import androidx.test.annotation.UiThreadTest;
import androidx.test.ext.junit.runners.AndroidJUnit4;
-import androidx.test.filters.FlakyTest;
import androidx.test.filters.LargeTest;
import androidx.test.rule.ActivityTestRule;
import androidx.wear.R;
@@ -75,7 +74,6 @@
}
@Test
- @FlakyTest(bugId = 190194611)
public void testDefaults_onActivity() throws Throwable {
final CountDownLatch latch = new CountDownLatch(1);
final ConfirmationOverlay overlay = new ConfirmationOverlay();
@@ -118,7 +116,6 @@
}
@Test
- @FlakyTest(bugId = 190194611)
public void testSuccess_onActivity() throws Throwable {
final CountDownLatch latch = new CountDownLatch(1);
final ConfirmationOverlay overlay = new ConfirmationOverlay()
@@ -150,7 +147,6 @@
}
@Test
- @FlakyTest(bugId = 190194611)
public void testFailure_onActivity() throws Throwable {
final CountDownLatch latch = new CountDownLatch(1);
final ConfirmationOverlay overlay = new ConfirmationOverlay()
@@ -182,7 +178,6 @@
}
@Test
- @FlakyTest(bugId = 190194611)
public void testOpenOnPhone_onActivity() throws Throwable {
final CountDownLatch latch = new CountDownLatch(1);
final ConfirmationOverlay overlay = new ConfirmationOverlay()
@@ -255,7 +250,6 @@
}
@Test
- @FlakyTest(bugId = 190194611)
public void testOverlayHiddenAfterSpecifiedDuration() throws Throwable {
final CountDownLatch latch = new CountDownLatch(1);
final int overlayDurationMillis = 2000;
diff --git a/webkit/webkit/src/main/java/androidx/webkit/WebViewCompat.java b/webkit/webkit/src/main/java/androidx/webkit/WebViewCompat.java
index 68a57ab..01a3a1a 100644
--- a/webkit/webkit/src/main/java/androidx/webkit/WebViewCompat.java
+++ b/webkit/webkit/src/main/java/androidx/webkit/WebViewCompat.java
@@ -507,7 +507,6 @@
* HOSTNAME_PATTERN [ ":" PORT ] ]}, each part is explained in the below table:
*
* <table>
- * <col width="25%">
* <tr><th>Rule</th><th>Description</th><th>Example</th></tr>
*
* <tr>
diff --git a/window/window/proguard-rules.pro b/window/window/proguard-rules.pro
index 957f9c6..63c9b44 100644
--- a/window/window/proguard-rules.pro
+++ b/window/window/proguard-rules.pro
@@ -21,7 +21,7 @@
# Keep the whole library for now since there is a crash with a missing method.
# TODO(b/165268619) Make a narrow rule
--keep class androidx.window.window.** { *; }
+-keep class androidx.window.** { *; }
# We also neep to keep sidecar.** for the same reason.
-keep class androidx.window.sidecar.** { *; }
diff --git a/work/workmanager-lint/build.gradle b/work/workmanager-lint/build.gradle
index b06eb51..a8faacc 100644
--- a/work/workmanager-lint/build.gradle
+++ b/work/workmanager-lint/build.gradle
@@ -23,7 +23,7 @@
}
dependencies {
- compileOnly(libs.androidLintApi)
+ compileOnly(libs.androidLintMinApi)
compileOnly(libs.kotlinStdlib)
testImplementation(libs.kotlinStdlib)
diff --git a/work/workmanager-lint/src/main/java/androidx/work/lint/WorkManagerIssueRegistry.kt b/work/workmanager-lint/src/main/java/androidx/work/lint/WorkManagerIssueRegistry.kt
index 4d04fb6..b2f4b69 100644
--- a/work/workmanager-lint/src/main/java/androidx/work/lint/WorkManagerIssueRegistry.kt
+++ b/work/workmanager-lint/src/main/java/androidx/work/lint/WorkManagerIssueRegistry.kt
@@ -19,7 +19,6 @@
package androidx.work.lint
import com.android.tools.lint.client.api.IssueRegistry
-import com.android.tools.lint.client.api.Vendor
import com.android.tools.lint.detector.api.CURRENT_API
import com.android.tools.lint.detector.api.Issue
@@ -37,8 +36,4 @@
SpecifyJobSchedulerIdRangeIssueDetector.ISSUE,
WorkerHasPublicModifierDetector.ISSUE
)
- override val vendor = Vendor(
- vendorName = "Android Open Source Project",
- feedbackUrl = "https://issuetracker.google.com/issues/new?component=409906"
- )
}
diff --git a/work/workmanager-lint/src/test/java/androidx/work/lint/ApiLintVersionsTest.kt b/work/workmanager-lint/src/test/java/androidx/work/lint/ApiLintVersionsTest.kt
index 372dbc7..3dc48cd 100644
--- a/work/workmanager-lint/src/test/java/androidx/work/lint/ApiLintVersionsTest.kt
+++ b/work/workmanager-lint/src/test/java/androidx/work/lint/ApiLintVersionsTest.kt
@@ -36,6 +36,6 @@
// We hardcode version registry.api to the version that is used to run tests.
assertEquals("registry.api matches version used to run tests", CURRENT_API, registry.api)
// Intentionally fails in IDE, because we use different API version in Studio and CLI.
- assertEquals("registry.minApi is set to minimum level of 10", 10, registry.minApi)
+ assertEquals("registry.minApi is set to minimum level of 8", 8, registry.minApi)
}
}
diff --git a/work/workmanager/src/androidTest/java/androidx/work/impl/utils/ForceStopRunnableTest.java b/work/workmanager/src/androidTest/java/androidx/work/impl/utils/ForceStopRunnableTest.java
index 37207a0..16a795f 100644
--- a/work/workmanager/src/androidTest/java/androidx/work/impl/utils/ForceStopRunnableTest.java
+++ b/work/workmanager/src/androidTest/java/androidx/work/impl/utils/ForceStopRunnableTest.java
@@ -24,6 +24,7 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
@@ -194,4 +195,12 @@
verify(runnable, times(MAX_ATTEMPTS)).forceStopRunnable();
verify(handler, times(1)).handleException(any(Throwable.class));
}
+
+ @Test
+ public void test_completeOnMultiProcessChecks() {
+ ForceStopRunnable runnable = spy(mRunnable);
+ doReturn(false).when(runnable).multiProcessChecks();
+ runnable.run();
+ verify(mWorkManager).onForceStopRunnableCompleted();
+ }
}
diff --git a/work/workmanager/src/main/java/androidx/work/impl/utils/ForceStopRunnable.java b/work/workmanager/src/main/java/androidx/work/impl/utils/ForceStopRunnable.java
index c1cc106..4a31178 100644
--- a/work/workmanager/src/main/java/androidx/work/impl/utils/ForceStopRunnable.java
+++ b/work/workmanager/src/main/java/androidx/work/impl/utils/ForceStopRunnable.java
@@ -89,54 +89,59 @@
@Override
public void run() {
- if (!multiProcessChecks()) {
- return;
- }
-
- while (true) {
- // Migrate the database to the no-backup directory if necessary.
- WorkDatabasePathHelper.migrateDatabase(mContext);
- // Clean invalid jobs attributed to WorkManager, and Workers that might have been
- // interrupted because the application crashed (RUNNING state).
- Logger.get().debug(TAG, "Performing cleanup operations.");
- try {
- forceStopRunnable();
- break;
- } catch (SQLiteCantOpenDatabaseException
- | SQLiteDatabaseCorruptException
- | SQLiteDatabaseLockedException
- | SQLiteTableLockedException
- | SQLiteConstraintException
- | SQLiteAccessPermException exception) {
- mRetryCount++;
- if (mRetryCount >= MAX_ATTEMPTS) {
- // ForceStopRunnable is usually the first thing that accesses a database
- // (or an app's internal data directory). This means that weird
- // PackageManager bugs are attributed to ForceStopRunnable, which is
- // unfortunate. This gives the developer a better error
- // message.
- String message = "The file system on the device is in a bad state. "
- + "WorkManager cannot access the app's internal data store.";
- Logger.get().error(TAG, message, exception);
- IllegalStateException throwable = new IllegalStateException(message, exception);
- InitializationExceptionHandler exceptionHandler =
- mWorkManager.getConfiguration().getExceptionHandler();
- if (exceptionHandler != null) {
- Logger.get().debug(TAG,
- "Routing exception to the specified exception handler",
- throwable);
- exceptionHandler.handleException(throwable);
- break;
+ try {
+ if (!multiProcessChecks()) {
+ return;
+ }
+ while (true) {
+ // Migrate the database to the no-backup directory if necessary.
+ WorkDatabasePathHelper.migrateDatabase(mContext);
+ // Clean invalid jobs attributed to WorkManager, and Workers that might have been
+ // interrupted because the application crashed (RUNNING state).
+ Logger.get().debug(TAG, "Performing cleanup operations.");
+ try {
+ forceStopRunnable();
+ break;
+ } catch (SQLiteCantOpenDatabaseException
+ | SQLiteDatabaseCorruptException
+ | SQLiteDatabaseLockedException
+ | SQLiteTableLockedException
+ | SQLiteConstraintException
+ | SQLiteAccessPermException exception) {
+ mRetryCount++;
+ if (mRetryCount >= MAX_ATTEMPTS) {
+ // ForceStopRunnable is usually the first thing that accesses a database
+ // (or an app's internal data directory). This means that weird
+ // PackageManager bugs are attributed to ForceStopRunnable, which is
+ // unfortunate. This gives the developer a better error
+ // message.
+ String message = "The file system on the device is in a bad state. "
+ + "WorkManager cannot access the app's internal data store.";
+ Logger.get().error(TAG, message, exception);
+ IllegalStateException throwable = new IllegalStateException(message,
+ exception);
+ InitializationExceptionHandler exceptionHandler =
+ mWorkManager.getConfiguration().getExceptionHandler();
+ if (exceptionHandler != null) {
+ Logger.get().debug(TAG,
+ "Routing exception to the specified exception handler",
+ throwable);
+ exceptionHandler.handleException(throwable);
+ break;
+ } else {
+ throw throwable;
+ }
} else {
- throw throwable;
+ long duration = mRetryCount * BACKOFF_DURATION_MS;
+ Logger.get()
+ .debug(TAG, String.format("Retrying after %s", duration),
+ exception);
+ sleep(mRetryCount * BACKOFF_DURATION_MS);
}
- } else {
- long duration = mRetryCount * BACKOFF_DURATION_MS;
- Logger.get()
- .debug(TAG, String.format("Retrying after %s", duration), exception);
- sleep(mRetryCount * BACKOFF_DURATION_MS);
}
}
+ } finally {
+ mWorkManager.onForceStopRunnableCompleted();
}
}
@@ -187,7 +192,6 @@
mWorkManager.getWorkDatabase(),
mWorkManager.getSchedulers());
}
- mWorkManager.onForceStopRunnableCompleted();
}
/**