diff options
author | Bill Yi <byi@google.com> | 2018-11-28 18:34:27 -0800 |
---|---|---|
committer | Bill Yi <byi@google.com> | 2018-11-28 18:34:27 -0800 |
commit | e847c546dd93bb9a6a5bd320e6108f2ff52171cd (patch) | |
tree | 1fa604cc39cd608146aa60c143ae29d2c1279994 | |
parent | ae78fb8a99ef9b253681ce60ac5f3be0b9c77c0f (diff) | |
parent | e74a0e388018f7d217bd2a6efc73bede96dcd64a (diff) | |
download | support-e847c546dd93bb9a6a5bd320e6108f2ff52171cd.tar.gz |
Merge pi-qpr1-release PQ1A.181105.017.A1 to pi-platform-releasepie-platform-releasepie-cuttlefish-testing
Change-Id: I192eb70ee1f81a4e177a530061ec849044c27d4a
70 files changed, 3316 insertions, 475 deletions
diff --git a/buildSrc/src/main/kotlin/androidx/build/DiffAndDocs.kt b/buildSrc/src/main/kotlin/androidx/build/DiffAndDocs.kt index 40ed9c4d5d6..e74ee0c5c82 100644 --- a/buildSrc/src/main/kotlin/androidx/build/DiffAndDocs.kt +++ b/buildSrc/src/main/kotlin/androidx/build/DiffAndDocs.kt @@ -27,6 +27,7 @@ import androidx.build.doclava.CHECK_API_CONFIG_DEVELOP import androidx.build.doclava.CHECK_API_CONFIG_RELEASE import androidx.build.doclava.CHECK_API_CONFIG_PATCH import androidx.build.doclava.ChecksConfig +import androidx.build.docs.ConcatenateFilesTask import androidx.build.docs.GenerateDocsTask import androidx.build.jdiff.JDiffTask import com.android.build.gradle.AppExtension @@ -46,6 +47,8 @@ import org.gradle.api.tasks.bundling.Zip import org.gradle.api.tasks.compile.JavaCompile import org.gradle.api.tasks.javadoc.Javadoc import java.io.File +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter import kotlin.collections.Collection import kotlin.collections.List import kotlin.collections.MutableMap @@ -69,7 +72,13 @@ object DiffAndDocs { private lateinit var rules: List<PublishDocsRules> private val docsTasks: MutableMap<String, GenerateDocsTask> = mutableMapOf() + private lateinit var aggregateOldApiTxtsTask: ConcatenateFilesTask + private lateinit var aggregateNewApiTxtsTask: ConcatenateFilesTask + private lateinit var generateDiffsTask: JDiffTask + /** + * Initialization that should happen only once (and on the root project) + */ @JvmStatic fun configureDiffAndDocs( root: Project, @@ -82,6 +91,10 @@ object DiffAndDocs { anchorTask = root.tasks.create("anchorDocsTask") val doclavaConfiguration = root.configurations.getByName("doclava") val generateSdkApiTask = createGenerateSdkApiTask(root, doclavaConfiguration) + val now = LocalDateTime.now() + // The diff output assumes that each library is of the same version, but our libraries may each be of different versions + // So, we display the date as the new version + val newVersion = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) rules.forEach { val task = createGenerateDocsTask( project = root, generateSdkApiTask = generateSdkApiTask, @@ -95,7 +108,41 @@ object DiffAndDocs { root.tasks.create("generateDocs").dependsOn(docsTasks[TIP_OF_TREE.name]) + val docletClasspath = doclavaConfiguration.resolve() + + aggregateOldApiTxtsTask = root.tasks.create("aggregateOldApiTxts", ConcatenateFilesTask::class.java) + aggregateOldApiTxtsTask.Output = File(root.docsDir(), "previous.txt") + + val oldApisTask = root.tasks.createWithConfig("oldApisXml", ApiXmlConversionTask::class.java) { + classpath = root.files(docletClasspath) + dependsOn(doclavaConfiguration) + + inputApiFile = aggregateOldApiTxtsTask.Output + dependsOn(aggregateOldApiTxtsTask) + + outputApiXmlFile = File(root.docsDir(), "previous.xml") + } + + aggregateNewApiTxtsTask = root.tasks.create("aggregateNewApiTxts", ConcatenateFilesTask::class.java) + aggregateNewApiTxtsTask.Output = File(root.docsDir(), "$newVersion") + + val newApisTask = root.tasks.createWithConfig("newApisXml", ApiXmlConversionTask::class.java) { + classpath = root.files(docletClasspath) + + inputApiFile = aggregateNewApiTxtsTask.Output + dependsOn(aggregateNewApiTxtsTask) + + outputApiXmlFile = File(root.docsDir(), "$newVersion.xml") + } + + val jdiffConfiguration = root.configurations.getByName("jdiff") + generateDiffsTask = createGenerateDiffsTask(root, + oldApisTask, + newApisTask, + jdiffConfiguration) + setupDocsProject() + return anchorTask } @@ -185,7 +232,7 @@ object DiffAndDocs { } /** - * Registers a Java project for global docs generation, local API file generation, and + * Registers a Java project to be included in docs generation, local API file generation, and * local API diff generation tasks. */ fun registerJavaProject(project: Project, extension: SupportLibraryExtension) { @@ -205,16 +252,16 @@ object DiffAndDocs { "ignoring API tasks.") return } - val tasks = initializeApiChecksForProject(project) + val tasks = initializeApiChecksForProject(project, aggregateOldApiTxtsTask, aggregateNewApiTxtsTask) registerJavaProjectForDocsTask(tasks.generateApi, compileJava) - registerJavaProjectForDocsTask(tasks.generateDiffs, compileJava) + registerJavaProjectForDocsTask(generateDiffsTask, compileJava) setupDocsTasks(project, tasks) anchorTask.dependsOn(tasks.checkApiTask) } /** - * Registers an Android project for global docs generation, local API file generation, and - * local API diff generation tasks. + * Registers an Android project to be included in global docs generation, local API file + * generation, and local API diff generation tasks. */ fun registerAndroidProject( project: Project, @@ -248,9 +295,9 @@ object DiffAndDocs { "an api folder, ignoring API tasks.") return@all } - val tasks = initializeApiChecksForProject(project) + val tasks = initializeApiChecksForProject(project, aggregateOldApiTxtsTask, aggregateNewApiTxtsTask) registerAndroidProjectForDocsTask(tasks.generateApi, variant) - registerAndroidProjectForDocsTask(tasks.generateDiffs, variant) + registerAndroidProjectForDocsTask(generateDiffsTask, variant) setupDocsTasks(project, tasks) anchorTask.dependsOn(tasks.checkApiTask) } @@ -259,7 +306,7 @@ object DiffAndDocs { private fun setupDocsTasks(project: Project, tasks: Tasks) { docsTasks.values.forEach { docs -> - tasks.generateDiffs.dependsOn(docs) + generateDiffsTask.dependsOn(docs) // Track API change history. docs.addSinceFilesFrom(project.projectDir) // Associate current API surface with the Maven artifact. @@ -281,21 +328,20 @@ private fun stripExtension(fileName: String) = fileName.substringBeforeLast('.') private fun getLastReleasedApiFile(rootFolder: File, refVersion: Version?): File? { val apiDir = File(rootFolder, "api") - val lastFile = getLastReleasedApiFileFromDir(apiDir, refVersion) - if (lastFile != null) { - return lastFile - } - - return null + return getLastReleasedApiFileFromDir(apiDir, refVersion) } +/** + * Returns the api file with highest version among those having version less than refVersion + */ private fun getLastReleasedApiFileFromDir(apiDir: File, refVersion: Version?): File? { var lastFile: File? = null var lastVersion: Version? = null apiDir.listFiles().forEach { file -> - Version.parseOrNull(file)?.let { version -> - if ((lastFile == null || lastVersion!! < version) && - (refVersion == null || version < refVersion)) { + val parsed = Version.parseOrNull(file) + parsed?.let { version -> + if ((lastFile == null || lastVersion!! < version) + && (refVersion == null || version < refVersion)) { lastFile = file lastVersion = version } @@ -327,7 +373,8 @@ private fun getApiFile(rootDir: File, refVersion: Version, forceRelease: Boolean return File(apiDir, "current.txt") } -// Generates API files + +// Creates a new task on the project for generating API files private fun createGenerateApiTask(project: Project, docletpathParam: Collection<File>) = project.tasks.createWithConfig("generateApi", DoclavaTask::class.java) { setDocletpath(docletpathParam) @@ -345,6 +392,7 @@ private fun createGenerateApiTask(project: Project, docletpathParam: Collection< exclude("**/R.java") } +// Creates a new task on the project for verifying the API private fun createCheckApiTask( project: Project, taskName: String, @@ -437,57 +485,37 @@ private fun createUpdateApiTask(project: Project, checkApiRelease: CheckApiTask) } /** - * Converts the <code>fromApi</code>.txt file (or the most recently released - * X.Y.Z.txt if not explicitly defined using -PfromAPi=<file>) to XML format - * for use by JDiff. + * Returns the filepath of the previous API txt file (for computing diffs against) */ -private fun createOldApiXml(project: Project, doclavaConfig: Configuration) = - project.tasks.createWithConfig("oldApiXml", ApiXmlConversionTask::class.java) { - val toApi = project.processProperty("toApi")?.let { - Version.parseOrNull(it) - } - val fromApi = project.processProperty("fromApi") - classpath = project.files(doclavaConfig.resolve()) - val rootFolder = project.projectDir - inputApiFile = if (fromApi != null) { - // Use an explicit API file. - File(rootFolder, "api/$fromApi.txt") - } else { - // Use the most recently released API file bounded by toApi. - getLastReleasedApiFile(rootFolder, toApi) - } +private fun getOldApiTxt(project: Project): File? { + val toApi = project.processProperty("toApi")?.let { + Version.parseOrNull(it) + } + val fromApi = project.processProperty("fromApi") + val rootFolder = project.projectDir + if (fromApi != null) { + // Use an explicit API file. + return File(rootFolder, "api/$fromApi.txt") + } else { + // Use the most recently released API file bounded by toApi. + return getLastReleasedApiFile(rootFolder, toApi) + } +} - outputApiXmlFile = File(project.docsDir(), - "release/${stripExtension(inputApiFile?.name ?: "creation")}.xml") - dependsOn(doclavaConfig) - } +data class FileProvider(val file: File, val task: Task?) -/** - * Converts the <code>toApi</code>.txt file (or current.txt if not explicitly - * defined using -PtoApi=<file>) to XML format for use by JDiff. - */ -private fun createNewApiXmlTask( - project: Project, - generateApi: DoclavaTask, - doclavaConfig: Configuration -) = - project.tasks.createWithConfig("newApiXml", ApiXmlConversionTask::class.java) { - classpath = project.files(doclavaConfig.resolve()) - val toApi = project.processProperty("toApi") - - if (toApi != null) { - // Use an explicit API file. - inputApiFile = File(project.projectDir, "api/$toApi.txt") - } else { - // Use the current API file (e.g. current.txt). - inputApiFile = generateApi.apiFile!! - dependsOn(generateApi, doclavaConfig) - } +private fun getNewApiTxt(project: Project, generateApi: DoclavaTask): FileProvider { + val toApi = project.processProperty("toApi") + if (toApi != null) { + // Use an explicit API file. + return FileProvider(File(project.projectDir, "api/$toApi.txt"), null) + } else { + // Use the current API file (e.g. current.txt). + return FileProvider(generateApi.apiFile!!, generateApi) + } - outputApiXmlFile = File(project.docsDir(), - "release/${stripExtension(inputApiFile?.name ?: "creation")}.xml") - } +} /** * Generates API diffs. @@ -532,7 +560,7 @@ private fun createGenerateDiffsTask( newApiXmlFile = newApiTask.outputApiXmlFile val newApi = newApiXmlFile.name.substringBeforeLast('.') - val docsDir = project.rootProject.docsDir() + val docsDir = File(project.rootProject.docsDir(), "public") newJavadocPrefix = "../../../../../reference/" destinationDir = File(docsDir, "online/sdk/support_api_diff/${project.name}/$newApi") @@ -543,6 +571,9 @@ private fun createGenerateDiffsTask( exclude("**/BuildConfig.java", "**/R.java") dependsOn(oldApiTask, newApiTask, jdiffConfig) + doLast { + project.logger.lifecycle("generated diffs into $destinationDir") + } } // Generates a distribution artifact for online docs. @@ -642,11 +673,13 @@ private fun createGenerateDocsTask( private data class Tasks( val generateApi: DoclavaTask, - val generateDiffs: JDiffTask, val checkApiTask: CheckApiTask ) -private fun initializeApiChecksForProject(project: Project): Tasks { +/** + * Sets up api tasks for the given project + */ +private fun initializeApiChecksForProject(project: Project, aggregateOldApiTxtsTask: ConcatenateFilesTask, aggregateNewApiTxtsTask:ConcatenateFilesTask): Tasks { if (!project.hasProperty("docsDir")) { project.extensions.add("docsDir", File(project.rootProject.docsDir(), project.name)) } @@ -658,7 +691,7 @@ private fun initializeApiChecksForProject(project: Project): Tasks { val generateApi = createGenerateApiTask(project, docletClasspath) generateApi.dependsOn(doclavaConfiguration) - // Make sure the API surface has not broken since the last release. + // for verifying that the API surface has not broken since the last release val lastReleasedApiFile = getLastReleasedApiFile(workingDir, version) val whitelistFile = lastReleasedApiFile?.let { apiFile -> @@ -696,15 +729,20 @@ private fun initializeApiChecksForProject(project: Project): Tasks { val updateApiTask = createUpdateApiTask(project, checkApiRelease) updateApiTask.dependsOn(checkApiRelease) - val newApiTask = createNewApiXmlTask(project, generateApi, doclavaConfiguration) - val oldApiTask = createOldApiXml(project, doclavaConfiguration) - - val jdiffConfiguration = project.rootProject.configurations.getByName("jdiff") - val generateDiffTask = createGenerateDiffsTask(project, - oldApiTask, - newApiTask, - jdiffConfiguration) - return Tasks(generateApi, generateDiffTask, checkApi) + + + val oldApiTxt = getOldApiTxt(project) + if (oldApiTxt != null) { + aggregateOldApiTxtsTask.addInput(project.name, oldApiTxt) + } + val newApiTxtProvider = getNewApiTxt(project, generateApi) + aggregateNewApiTxtsTask.inputs.file(newApiTxtProvider.file) + aggregateNewApiTxtsTask.addInput(project.name, newApiTxtProvider.file) + if (newApiTxtProvider.task != null) { + aggregateNewApiTxtsTask.dependsOn(newApiTxtProvider.task) + } + + return Tasks(generateApi, checkApi) } fun hasApiTasks(project: Project, extension: SupportLibraryExtension): Boolean { diff --git a/buildSrc/src/main/kotlin/androidx/build/LibraryVersions.kt b/buildSrc/src/main/kotlin/androidx/build/LibraryVersions.kt index 12160e1c94f..7d81cd8cd68 100644 --- a/buildSrc/src/main/kotlin/androidx/build/LibraryVersions.kt +++ b/buildSrc/src/main/kotlin/androidx/build/LibraryVersions.kt @@ -87,7 +87,7 @@ object LibraryVersions { /** * Version code for WorkManager */ - val WORKMANAGER = Version("1.0.0-alpha02") + val WORKMANAGER = Version("1.0.0-alpha04") /** * Version code for Jetifier diff --git a/buildSrc/src/main/kotlin/androidx/build/checkapi/ApiXmlConversionTask.kt b/buildSrc/src/main/kotlin/androidx/build/checkapi/ApiXmlConversionTask.kt index f66706ca732..b191d56ec2f 100644 --- a/buildSrc/src/main/kotlin/androidx/build/checkapi/ApiXmlConversionTask.kt +++ b/buildSrc/src/main/kotlin/androidx/build/checkapi/ApiXmlConversionTask.kt @@ -23,7 +23,7 @@ import org.gradle.api.tasks.OutputFile import java.io.File /** - * Task that converts the given API file to XML format. + * Task that converts the given API txt file to XML format. */ open class ApiXmlConversionTask : JavaExec() { @Optional diff --git a/buildSrc/src/main/kotlin/androidx/build/docs/ConcatenateFilesTask.kt b/buildSrc/src/main/kotlin/androidx/build/docs/ConcatenateFilesTask.kt new file mode 100644 index 00000000000..d97c7019df8 --- /dev/null +++ b/buildSrc/src/main/kotlin/androidx/build/docs/ConcatenateFilesTask.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.build.docs + +import org.gradle.api.Action +import org.gradle.api.DefaultTask +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.OutputFile +import java.io.File +import java.util.SortedMap + +open class ConcatenateFilesTask : DefaultTask() { + private var keyedInputs: MutableMap<String, File> = mutableMapOf() + + @get:OutputFile + lateinit var Output: File + + // Adds the given input file + // The order that files are concatenated in is based on sorting the corresponding keys + fun addInput(key: String, inputFile: File) { + if (this.keyedInputs.containsKey(key)) { + throw IllegalArgumentException("Key $key already exists") + } + this.inputs.file(inputFile) + this.keyedInputs[key] = inputFile + } + + @TaskAction + fun aggregate() { + val destFile = this.Output + + // sort the input files to make sure this task always concatenates them in the same order + val sortedInputs = this.keyedInputs.toSortedMap() + + val inputFiles = sortedInputs.values + if (inputFiles.contains(destFile)) { + throw IllegalArgumentException("Output file $destFile is also an input file") + } + + val text = inputFiles.joinToString(separator = "") { file -> file.readText() } + this.project.logger.info("Joining ${inputFiles.count()} files, and storing the result in ${destFile.path}") + destFile.writeText(text) + } +} diff --git a/buildSrc/src/main/kotlin/androidx/build/jdiff/JDiffTask.kt b/buildSrc/src/main/kotlin/androidx/build/jdiff/JDiffTask.kt index 10466ea5182..54e9ec6c033 100644 --- a/buildSrc/src/main/kotlin/androidx/build/jdiff/JDiffTask.kt +++ b/buildSrc/src/main/kotlin/androidx/build/jdiff/JDiffTask.kt @@ -33,7 +33,7 @@ import java.util.ArrayList open class JDiffTask : Javadoc() { /** - * Sets the doclet path which has the `com.google.doclava.Doclava` class. + * Sets the doclet path, which will be used to locate the `com.google.doclava.Doclava` class. * * * This option will override any doclet path set in this instance's @@ -82,7 +82,7 @@ open class JDiffTask : Javadoc() { } /** - * "Configures" this JDiffTask with parameters that might not be at their final values + * Configures this JDiffTask with parameters that might not be at their final values * until this task is run. */ private fun configureJDiffTask() { diff --git a/car/build.gradle b/car/build.gradle index 2c65ab4bdac..1eacc0e6573 100644 --- a/car/build.gradle +++ b/car/build.gradle @@ -13,6 +13,7 @@ dependencies { api(project(":legacy-support-v4")) api(project(":recyclerview")) api(project(":gridlayout")) + api(project(":preference")) api(SUPPORT_DESIGN, libs.exclude_for_material) androidTestImplementation(TEST_RUNNER_TMP, libs.exclude_for_espresso) diff --git a/car/res/layout/preference_category_material_car.xml b/car/res/layout/preference_category_material_car.xml new file mode 100644 index 00000000000..2012c69f50d --- /dev/null +++ b/car/res/layout/preference_category_material_car.xml @@ -0,0 +1,53 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright 2018 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:minHeight="?android:attr/listPreferredItemHeightSmall" + android:gravity="center_vertical" + android:paddingStart="?android:attr/listPreferredItemPaddingStart" + android:paddingEnd="?android:attr/listPreferredItemPaddingEnd" + android:paddingVertical="@dimen/car_preference_list_margin" + android:background="@drawable/car_card_ripple_background" + android:focusable="true" + android:orientation="horizontal"> + + <androidx.preference.internal.PreferenceImageView + android:id="@android:id/icon" + android:layout_width="@dimen/car_drawer_list_item_icon_size" + android:layout_height="@dimen/car_drawer_list_item_icon_size" /> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical"> + <TextView + android:id="@android:id/title" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:textAlignment="viewStart" + android:textAppearance="@style/TextAppearance.Car.Subheader"/> + <TextView + android:id="@android:id/summary" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textColor="?android:attr/textColorSecondary"/> + </LinearLayout> + +</LinearLayout> diff --git a/car/res/layout/preference_dropdown_material_car.xml b/car/res/layout/preference_dropdown_material_car.xml new file mode 100644 index 00000000000..932274a775e --- /dev/null +++ b/car/res/layout/preference_dropdown_material_car.xml @@ -0,0 +1,63 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright 2018 The Android Open Source Project + ~ + ~ Licensed under the Apache License, Version 2.0 (the "License"); + ~ you may not use this file except in compliance with the License. + ~ You may obtain a copy of the License at + ~ + ~ http://www.apache.org/licenses/LICENSE-2.0 + ~ + ~ Unless required by applicable law or agreed to in writing, software + ~ distributed under the License is distributed on an "AS IS" BASIS, + ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + ~ See the License for the specific language governing permissions and + ~ limitations under the License. + --> + +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:minHeight="?android:attr/listPreferredItemHeightSmall" + android:gravity="center_vertical" + android:paddingStart="?android:attr/listPreferredItemPaddingStart" + android:paddingEnd="?android:attr/listPreferredItemPaddingEnd" + android:paddingVertical="@dimen/car_preference_list_margin" + android:background="@drawable/car_card_ripple_background" + android:focusable="true" + android:orientation="horizontal"> + + <Spinner + android:id="@+id/spinner" + android:layout_width="0dp" + android:layout_weight="0" + android:layout_height="wrap_content" + android:visibility="invisible" /> + + <androidx.preference.internal.PreferenceImageView + android:id="@android:id/icon" + android:layout_width="@dimen/car_drawer_list_item_icon_size" + android:layout_height="@dimen/car_drawer_list_item_icon_size" /> + + <LinearLayout + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="vertical" + android:paddingTop="@dimen/car_preference_list_margin" + android:paddingBottom="@dimen/car_preference_list_margin"> + + <TextView + android:id="@android:id/title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textAppearance="@style/TextAppearance.Car.Body1" /> + + <TextView + android:id="@android:id/summary" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textAppearance="@style/TextAppearance.Car.Body2" /> + </LinearLayout> + +</LinearLayout> diff --git a/car/res/layout/preference_material_car.xml b/car/res/layout/preference_material_car.xml new file mode 100644 index 00000000000..c3049a2b03f --- /dev/null +++ b/car/res/layout/preference_material_car.xml @@ -0,0 +1,67 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright 2018 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. + --> + +<RelativeLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:minHeight="?android:attr/listPreferredItemHeightSmall" + android:paddingStart="?android:attr/listPreferredItemPaddingStart" + android:paddingEnd="?android:attr/listPreferredItemPaddingEnd" + android:paddingVertical="@dimen/car_preference_list_margin" + android:background="@drawable/car_card_ripple_background" + android:focusable="true"> + + <androidx.preference.internal.PreferenceImageView + android:id="@android:id/icon" + android:layout_width="@dimen/car_drawer_list_item_icon_size" + android:layout_height="@dimen/car_drawer_list_item_icon_size" + android:layout_alignParentStart="true" + android:layout_centerVertical="true"/> + + <LinearLayout + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="vertical" + android:layout_toEndOf="@android:id/icon" + android:layout_centerVertical="true"> + + <TextView + android:id="@android:id/title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:singleLine="true" + android:ellipsize="end" + android:textAppearance="@style/TextAppearance.Car.Body1" /> + + <TextView + android:id="@android:id/summary" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textAppearance="@style/TextAppearance.Car.Body2" /> + + </LinearLayout> + + <!-- Preference should place its actual preference widget here. --> + <FrameLayout + android:id="@android:id/widget_frame" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_centerVertical="true" + android:layout_alignParentEnd="true" /> + +</RelativeLayout> diff --git a/car/res/layout/preference_material_car_child.xml b/car/res/layout/preference_material_car_child.xml new file mode 100644 index 00000000000..eec2d0b501f --- /dev/null +++ b/car/res/layout/preference_material_car_child.xml @@ -0,0 +1,76 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright 2018 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" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:minHeight="?android:attr/listPreferredItemHeightSmall" + android:paddingStart="?android:attr/listPreferredItemPaddingStart" + android:paddingEnd="?android:attr/listPreferredItemPaddingEnd" + android:paddingVertical="@dimen/car_preference_list_margin" + android:background="@drawable/car_card_ripple_background" + android:focusable="true"> + + <!-- Wrap in a FrameLayout so the full list item has a touch ripple --> + <RelativeLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginStart="?android:attr/listPreferredItemPaddingStart"> + + <androidx.preference.internal.PreferenceImageView + android:id="@android:id/icon" + android:layout_width="@dimen/car_drawer_list_item_small_icon_size" + android:layout_height="@dimen/car_drawer_list_item_small_icon_size" + android:layout_centerVertical="true" + android:layout_alignParentStart="true"/> + + <LinearLayout + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:orientation="vertical" + android:layout_toEndOf="@android:id/icon" + android:layout_centerVertical="true"> + + <TextView + android:id="@android:id/title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:singleLine="true" + android:ellipsize="end" + android:textAppearance="@style/TextAppearance.Car.Label1" /> + + <TextView + android:id="@android:id/summary" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textAppearance="@style/TextAppearance.Car.Body3" /> + + </LinearLayout> + + <!-- Preference should place its actual preference widget here. --> + <FrameLayout + android:id="@android:id/widget_frame" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_centerVertical="true" + android:layout_alignParentEnd="true" /> + + </RelativeLayout> + +</FrameLayout> + diff --git a/car/res/layout/preference_widget_seekbar_material_car.xml b/car/res/layout/preference_widget_seekbar_material_car.xml new file mode 100644 index 00000000000..00640450036 --- /dev/null +++ b/car/res/layout/preference_widget_seekbar_material_car.xml @@ -0,0 +1,88 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + ~ Copyright 2018 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. + --> +<!-- Layout used by SeekBarPreference for the seekbar widget style. --> +<LinearLayout + xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:gravity="center_vertical" + android:minHeight="?android:attr/listPreferredItemHeight" + android:paddingEnd="?android:attr/listPreferredItemPaddingEnd" + android:paddingStart="?android:attr/listPreferredItemPaddingStart" + android:paddingVertical="@dimen/car_preference_list_margin" + android:orientation="horizontal"> + + <androidx.preference.internal.PreferenceImageView + android:id="@android:id/icon" + android:layout_width="@dimen/car_drawer_list_item_icon_size" + android:layout_height="@dimen/car_drawer_list_item_icon_size" /> + + <RelativeLayout + android:layout_width="wrap_content" + android:layout_height="wrap_content"> + + <TextView + android:id="@android:id/title" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:singleLine="true" + android:ellipsize="end" + android:textAppearance="@style/TextAppearance.Car.Body1"/> + + <TextView + android:id="@android:id/summary" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_below="@android:id/title" + android:layout_alignStart="@android:id/title" + android:textAppearance="@style/TextAppearance.Car.Body2"/> + + <!-- Using UnPressableLinearLayout as a workaround to disable the pressed state propagation + to the children of this container layout. Otherwise, the animated pressed state will also + play for the thumb in the AbsSeekBar in addition to the preference's ripple background. + The background of the SeekBar is also set to null to disable the ripple background --> + <androidx.preference.UnPressableLinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_below="@android:id/summary" + android:layout_alignStart="@android:id/title" + android:clipChildren="false" + android:clipToPadding="false"> + <SeekBar + android:id="@+id/seekbar" + android:layout_width="0dp" + android:layout_weight="1" + android:layout_height="wrap_content" + android:paddingStart="@dimen/preference_seekbar_padding_start" + android:paddingEnd="@dimen/preference_seekbar_padding_end" + android:focusable="false" + android:clickable="false" + android:background="@null" /> + + <TextView + android:id="@+id/seekbar_value" + android:layout_width="@dimen/preference_seekbar_value_width" + android:layout_height="match_parent" + android:gravity="right|center_vertical" + android:textAppearance="@style/TextAppearance.Car.Body2" + android:fadingEdge="horizontal" + android:scrollbars="none"/> + </androidx.preference.UnPressableLinearLayout> + + </RelativeLayout> + +</LinearLayout> diff --git a/car/res/values/dimens.xml b/car/res/values/dimens.xml index fd8d54d11cc..94c8edc39da 100644 --- a/car/res/values/dimens.xml +++ b/car/res/values/dimens.xml @@ -215,4 +215,8 @@ limitations under the License. <dimen name="speed_bump_lock_out_message_height">96dp</dimen> <dimen name="speed_bump_lock_out_drawable_margin_bottom">8dp</dimen> + <!-- Preferences --> + <!-- list margin won't be necessary after PagedListView is brought in --> + <dimen name="car_preference_list_margin">8dp</dimen> + </resources> diff --git a/car/res/values/styles.xml b/car/res/values/styles.xml index 67ad199a30a..5db209e1ca4 100644 --- a/car/res/values/styles.xml +++ b/car/res/values/styles.xml @@ -415,4 +415,107 @@ limitations under the License. <item name="android:background">@drawable/car_action_button_background</item> <item name="android:tint">@color/car_tint</item> </style> + + <!-- ================= --> + <!-- Preference Themes --> + <!-- ================= --> + <eat-comment /> + + <style name="CarPreference"> + <item name="android:layout">@layout/preference_material_car</item> + <item name="allowDividerAbove">true</item> + <item name="allowDividerBelow">true</item> + <item name="singleLineTitle">true</item> + <item name="iconSpaceReserved">false</item> + </style> + + <style name="CarPreference.Information"> + <item name="android:layout">@layout/preference_material_car</item> + <item name="android:enabled">false</item> + <item name="android:shouldDisableView">false</item> + </style> + + <style name="CarPreference.Category"> + <item name="android:layout">@layout/preference_category_material_car</item> + <item name="allowDividerAbove">true</item> + <item name="allowDividerBelow">true</item> + <item name="iconSpaceReserved">false</item> + </style> + + <style name="CarPreference.CheckBoxPreference"> + <item name="android:layout">@layout/preference_material_car</item> + <item name="allowDividerAbove">true</item> + <item name="allowDividerBelow">true</item> + <item name="iconSpaceReserved">false</item> + <item name="android:widgetLayout">@layout/preference_widget_checkbox</item> + </style> + + <style name="CarPreference.SwitchPreferenceCompat"> + <item name="android:layout">@layout/preference_material_car</item> + <item name="allowDividerAbove">true</item> + <item name="allowDividerBelow">true</item> + <item name="iconSpaceReserved">false</item> + <item name="android:widgetLayout">@layout/preference_widget_switch_compat</item> + </style> + + <style name="CarPreference.SwitchPreference"> + <item name="android:layout">@layout/preference_material_car</item> + <item name="allowDividerAbove">true</item> + <item name="allowDividerBelow">true</item> + <item name="singleLineTitle">true</item> + <item name="iconSpaceReserved">false</item> + <item name="android:widgetLayout">@layout/preference_widget_switch</item> + </style> + + <style name="CarPreference.SeekBarPreference"> + <item name="android:layout">@layout/preference_widget_seekbar_material_car</item> + <item name="adjustable">true</item> + <item name="showSeekBarValue">true</item> + <item name="allowDividerAbove">true</item> + <item name="allowDividerBelow">true</item> + <item name="iconSpaceReserved">false</item> + <item name="android:widgetLayout">@layout/preference_widget_seekbar</item> + </style> + + <style name="CarPreference.PreferenceScreen"> + <item name="android:layout">@layout/preference_material_car</item> + <item name="allowDividerAbove">true</item> + <item name="allowDividerBelow">true</item> + <item name="iconSpaceReserved">false</item> + </style> + + <style name="CarPreference.DialogPreference"> + <item name="android:layout">@layout/preference_material_car</item> + <item name="allowDividerAbove">true</item> + <item name="allowDividerBelow">true</item> + <item name="iconSpaceReserved">false</item> + <item name="android:positiveButtonText">@android:string/ok</item> + <item name="android:negativeButtonText">@android:string/cancel</item> + </style> + + <style name="CarPreference.DialogPreference.EditTextPreference"> + <item name="android:layout">@layout/preference_material_car</item> + <item name="allowDividerAbove">true</item> + <item name="allowDividerBelow">true</item> + <item name="singleLineTitle">true</item> + <item name="iconSpaceReserved">false</item> + <item name="android:dialogLayout">@layout/preference_dialog_edittext</item> + </style> + + <style name="CarPreference.DropDown"> + <item name="android:layout">@layout/preference_dropdown_material_car</item> + <item name="allowDividerAbove">true</item> + <item name="allowDividerBelow">true</item> + <item name="iconSpaceReserved">false</item> + </style> + + <style name="CarPreferenceFragment"> + <item name="android:divider">@drawable/car_list_divider</item> + <item name="allowDividerAfterLastItem">false</item> + </style> + + <style name="CarPreferenceFragmentList"> + <item name="android:paddingLeft">0dp</item> + <item name="android:paddingRight">0dp</item> + </style> </resources> diff --git a/car/res/values/themes.xml b/car/res/values/themes.xml index 7f862d75cab..30303969d1a 100644 --- a/car/res/values/themes.xml +++ b/car/res/values/themes.xml @@ -187,4 +187,29 @@ limitations under the License. <item name="listItemTitleTextAppearance">@style/TextAppearance.Car.Body1.Light</item> <item name="listItemBodyTextAppearance">@style/TextAppearance.Car.Body2.Light</item> </style> + + + <!-- ================ --> + <!-- Preference Theme --> + <!-- ================ --> + <eat-comment /> + + <!-- Car theme for support library PreferenceFragments --> + <style name="PreferenceThemeOverlayCar"> + <item name="preferenceScreenStyle">@style/CarPreference.PreferenceScreen</item> + <item name="preferenceFragmentCompatStyle">@style/CarPreferenceFragment</item> + <item name="preferenceFragmentStyle">@style/CarPreferenceFragment</item> + <item name="preferenceCategoryStyle">@style/CarPreference.Category</item> + <item name="preferenceStyle">@style/CarPreference</item> + <item name="preferenceInformationStyle">@style/CarPreference.Information</item> + <item name="checkBoxPreferenceStyle">@style/CarPreference.CheckBoxPreference</item> + <item name="switchPreferenceCompatStyle">@style/CarPreference.SwitchPreferenceCompat</item> + <item name="switchPreferenceStyle">@style/CarPreference.SwitchPreference</item> + <item name="seekBarPreferenceStyle">@style/CarPreference.SeekBarPreference</item> + <item name="dialogPreferenceStyle">@style/CarPreference.DialogPreference</item> + <item name="editTextPreferenceStyle">@style/CarPreference.DialogPreference.EditTextPreference</item> + <item name="dropdownPreferenceStyle">@style/CarPreference.DropDown</item> + <item name="preferenceFragmentListStyle">@style/CarPreferenceFragmentList</item> + <item name="android:preferenceLayoutChild">@layout/preference_material_car_child</item> + </style> </resources> diff --git a/car/src/androidTest/java/androidx/car/navigation/utils/BundlableTest.java b/car/src/androidTest/java/androidx/car/navigation/utils/BundlableTest.java new file mode 100644 index 00000000000..7f149d0b210 --- /dev/null +++ b/car/src/androidTest/java/androidx/car/navigation/utils/BundlableTest.java @@ -0,0 +1,131 @@ +/* + * Copyright 2018 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.navigation.utils; + +import static org.junit.Assert.assertEquals; + +import android.support.test.filters.SmallTest; +import android.support.test.runner.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Arrays; + +/** + * Unit tests for {@link Bundlable}. + */ +@RunWith(AndroidJUnit4.class) +@SmallTest +public class BundlableTest { + /** + * Serialization test value. + */ + private static final TestBundlable TEST_VALUE = new TestBundlable() + .setInt(123) + .setString("TEST") + .setEnumValue(TestBundlable.TestEnum.VALUE1) + .setListValue(Arrays.asList( + new TestBundlable() + .setString("TEST2") + .setEnumValue(TestBundlable.TestEnum.VALUE2), + new TestBundlable() + .setString("TEST3") + )) + .setBundlableValue( + new TestBundlable() + .setString("TEST4") + ); + + /** + * Equivalent to {@link #TEST_VALUE} after a schema change (see + * {@link TestBundlableNewVersion}). In this new schema, {@link TestBundlable} has its + * {@link TestBundlable#mStringValue} field deprecated, and a new + * {@link TestBundlableNewVersion#mNewValue} non-null field was added. + */ + private static final TestBundlableNewVersion TEST_VALUE_NEW_VERSION = + new TestBundlableNewVersion() + .setInt(123) + .setEnumValue(TestBundlableNewVersion.TestEnum.VALUE1) + .setListValue(Arrays.asList( + new TestBundlableNewVersion() + .setEnumValue(TestBundlableNewVersion.TestEnum.VALUE2), + new TestBundlableNewVersion() + )) + .setBundlableValue( + new TestBundlableNewVersion() + ); + + /** + * Expected value when interpreting {@link #TEST_VALUE_NEW_VERSION} using the same schema as + * {@link TestBundlable}. Given that {@link TestBundlableNewVersion#mNewValue} doesn't exist + * the old schema, and {@link TestBundlable#mStringValue} doesn't exist in the new schema, + * both values are dropped during serialization/deserialization. + */ + private static final TestBundlable TEST_VALUE_NEW_VERSION_OLD_SCHEMA = new TestBundlable() + .setInt(123) + .setEnumValue(TestBundlable.TestEnum.VALUE1) + .setListValue(Arrays.asList( + new TestBundlable() + .setEnumValue(TestBundlable.TestEnum.VALUE2), + new TestBundlable() + )) + .setBundlableValue( + new TestBundlable() + ); + + private BundleMarshaller mBundleMarshaller = new BundleMarshaller(); + + /** + * Asserts that serializing and deserializing a {@link Bundlable} produces the same content. + * This includes testing instances with null values in them. + */ + @Test + public void testSerializationDeserializationMaintainsContent() { + TestBundlable output = new TestBundlable(); + + TEST_VALUE.toBundle(mBundleMarshaller); + output.fromBundle(mBundleMarshaller); + assertEquals(TEST_VALUE, output); + } + + /** + * Asserts that serialization and deserialization works in a forward compatible way, as long as + * they follow the versioning rules listed in {@link Bundlable}. + */ + @Test + public void testForwardCompatibleChangesMaintainsCommonContent() { + TestBundlableNewVersion output = new TestBundlableNewVersion(); + + TEST_VALUE.toBundle(mBundleMarshaller); + output.fromBundle(mBundleMarshaller); + assertEquals(TEST_VALUE_NEW_VERSION, output); + } + + /** + * Asserts that serialization and deserialization works in a backwards compatible way, as long + * as they follow the versioning rules listed in {@link Bundlable}. + */ + @Test + public void testBackwardCompatibleChangesMaintainsCommonContent() { + TestBundlable output = new TestBundlable(); + + TEST_VALUE_NEW_VERSION.toBundle(mBundleMarshaller); + output.fromBundle(mBundleMarshaller); + assertEquals(TEST_VALUE_NEW_VERSION_OLD_SCHEMA, output); + } +} diff --git a/car/src/androidTest/java/androidx/car/navigation/utils/BundleMarshallerTest.java b/car/src/androidTest/java/androidx/car/navigation/utils/BundleMarshallerTest.java new file mode 100644 index 00000000000..cf5ac15c2db --- /dev/null +++ b/car/src/androidTest/java/androidx/car/navigation/utils/BundleMarshallerTest.java @@ -0,0 +1,219 @@ +/* + * Copyright 2018 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.navigation.utils; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import android.os.Bundle; +import android.support.test.filters.SmallTest; +import android.support.test.runner.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Unit tests for {@link BundleMarshaller}. + */ +@RunWith(AndroidJUnit4.class) +@SmallTest +public class BundleMarshallerTest { + private final BundleMarshaller mBundleMarshaller = new BundleMarshaller(); + + /** + * A random test value with a mix of primitives, null and non-null data. + */ + private static final TestBundlable TEST_VALUE = new TestBundlable() + .setInt(1) + .setString("TEST") + .setBundlableValue(new TestBundlable() + .setEnumValue(TestBundlable.TestEnum.VALUE1)); + + /** + * Tests that null values are serialized as expected. + */ + @Test + public void serialization_nullCase() { + new TestBundlable().toBundle(mBundleMarshaller); + + Bundle data = mBundleMarshaller.getBundle(); + assertTrue(data.containsKey("intValue")); + assertEquals(0, data.getInt("intValue")); + assertNull(data.getString("stringValue")); + assertNull(data.getString("enumValue")); + assertTrue(data.getBoolean("bundlableValue._isNull")); + assertEquals(-1, data.getInt("bundlableListValue._size")); + } + + /** + * Tests that nested {@link Bundlable}s are serialized as expected. + */ + @Test + public void serialization_nestedBundlable() { + String stringValue = "TEST"; + int intValue = 1; + + new TestBundlable() + .setBundlableValue(new TestBundlable() + .setInt(intValue) + .setString(stringValue)) + .toBundle(mBundleMarshaller); + + Bundle data = mBundleMarshaller.getBundle(); + assertTrue(data.containsKey("bundlableValue._isNull")); + assertFalse(data.getBoolean("bundlableValue._isNull")); + assertEquals(intValue, data.getInt("bundlableValue.intValue")); + assertEquals(stringValue, data.getString("bundlableValue.stringValue")); + } + + /** + * Tests the correct serialization of a list of size 0. + */ + @Test + public void listSerialization_listOfSize0() { + TestBundlable value = new TestBundlable().setListValue(new ArrayList<>()); + value.toBundle(mBundleMarshaller); + Bundle data = mBundleMarshaller.getBundle(); + assertEquals(0, data.getInt("bundlableListValue._size")); + } + + /** + * Tests the correct serialization of a list of size n. + */ + @Test + public void listSerialization_listOfSizeN() { + TestBundlable value = new TestBundlable().setListValue(Arrays.asList( + new TestBundlable().setInt(1), + new TestBundlable().setInt(2), + new TestBundlable().setInt(3))); + Bundle data = mBundleMarshaller.getBundle(); + value.toBundle(mBundleMarshaller); + assertEquals(3, data.getInt("bundlableListValue._size")); + assertEquals(1, data.getInt("bundlableListValue.0.intValue")); + assertEquals(2, data.getInt("bundlableListValue.1.intValue")); + assertEquals(3, data.getInt("bundlableListValue.2.intValue")); + } + + @Test + public void listSerialization_removingElementsInPlace() { + // Serialize and deserialize a list of a certain size + List<TestBundlable> mutableList = new ArrayList<>(Arrays.asList( + new TestBundlable().setInt(1), + new TestBundlable().setInt(2), + new TestBundlable().setInt(3))); + TestBundlable out = new TestBundlable().setListValue(mutableList); + TestBundlable in = new TestBundlable(); + out.toBundle(mBundleMarshaller); + in.fromBundle(mBundleMarshaller); + assertEquals(out, in); + + // Remove some elements and check that they are correctly removed during deserialization + mutableList.remove(0); + out.toBundle(mBundleMarshaller); + in.fromBundle(mBundleMarshaller); + assertEquals(out, in); + } + + /** + * Tests that {@link BundleMarshaller#getDelta()} returns the same value as + * {@link BundleMarshaller#getBundle()} during the initial serialization. + */ + @Test + public void deltaSerialization_equalsFullDataIfNotReset() { + TEST_VALUE.toBundle(mBundleMarshaller); + Bundle data = mBundleMarshaller.getBundle(); + Bundle delta = mBundleMarshaller.getDelta(); + assertBundlesEqual(data, delta); + } + + /** + * Tests that {@link BundleMarshaller#getDelta()} is empty if no data is modified between + * serializations. + */ + @Test + public void deltaSerialization_emptyIfNoDataIsModified() { + TEST_VALUE.toBundle(mBundleMarshaller); + mBundleMarshaller.resetDelta(); + TEST_VALUE.toBundle(mBundleMarshaller); + Bundle delta = mBundleMarshaller.getDelta(); + assertEquals(0, delta.size()); + } + + /** + * Tests that {@link BundleMarshaller#getDelta()} returns only the data that has been modified + * between two serializations. + */ + @Test + public void deltaSerialization_onlyContainsModifiedData() { + // Serialize some base data + TestBundlable testValue = new TestBundlable() + .setInt(1) + .setString("TEST") + .setBundlableValue(new TestBundlable() + .setEnumValue(TestBundlable.TestEnum.VALUE1)); + testValue.toBundle(mBundleMarshaller); + + // Reset change tracking and re-serialize after making some changes. + mBundleMarshaller.resetDelta(); + testValue.setInt(2).setString(null); + testValue.mBundableValue.setEnumValue(TestBundlable.TestEnum.VALUE2); + testValue.toBundle(mBundleMarshaller); + + Bundle expectedDelta = new Bundle(); + expectedDelta.putInt("intValue", testValue.mIntValue); + expectedDelta.putString("stringValue", testValue.mStringValue); + expectedDelta.putString("bundlableValue.enumValue", + testValue.mBundableValue.mEnumValue.name()); + + Bundle delta = mBundleMarshaller.getDelta(); + assertBundlesEqual(expectedDelta, delta); + } + + /** + * Asserts that the provided {@link Bundle}s are equal. It throws {@link AssertionError} + * otherwise. + */ + private void assertBundlesEqual(Bundle expected, Bundle actual) { + if (expected == null && actual == null) { + return; + } + if (expected == null || actual == null) { + fail(String.format("Expected %s value but found %s", + expected != null ? "non-null" : "null", + actual != null ? "non-null" : "null")); + } + if (!expected.keySet().equals(actual.keySet())) { + fail(String.format("Expected keys: %s, but found keys: %s", + expected.keySet().stream().sorted().collect(Collectors.joining(",")), + actual.keySet().stream().sorted().collect(Collectors.joining(",")))); + } + for (String key : expected.keySet()) { + assertEquals(String.format("Expected '%s' at key '%s' but found '%s", + expected.get(key), key, actual.get(key)), + expected.get(key), + actual.get(key)); + } + } +} diff --git a/car/src/androidTest/java/androidx/car/navigation/utils/TestBundlable.java b/car/src/androidTest/java/androidx/car/navigation/utils/TestBundlable.java new file mode 100644 index 00000000000..1ca19d31129 --- /dev/null +++ b/car/src/androidTest/java/androidx/car/navigation/utils/TestBundlable.java @@ -0,0 +1,107 @@ +/* + * Copyright 2018 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.navigation.utils; + +import java.util.List; +import java.util.Objects; + +/** + * Reference implementation of a {@link Bundlable} + */ +class TestBundlable implements Bundlable { + private static final String INT_VALUE_KEY = "intValue"; + private static final String STRING_VALUE_KEY = "stringValue"; + private static final String ENUM_VALUE_KEY = "enumValue"; + private static final String BUNDLABLE_VALUE_KEY = "bundlableValue"; + private static final String BUNDLABLE_LIST_VALUE_KEY = "bundlableListValue"; + + enum TestEnum { + VALUE1, + VALUE2, + VALUE3, + } + + int mIntValue; + String mStringValue; + TestEnum mEnumValue; + TestBundlable mBundableValue; + List<TestBundlable> mListValue; + + @Override + public void toBundle(BundleMarshaller out) { + out.putInt(INT_VALUE_KEY, mIntValue); + out.putString(STRING_VALUE_KEY, mStringValue); + out.putEnum(ENUM_VALUE_KEY, mEnumValue); + out.putBundlable(BUNDLABLE_VALUE_KEY, mBundableValue); + out.putBundlableList(BUNDLABLE_LIST_VALUE_KEY, mListValue); + } + + @Override + public void fromBundle(BundleMarshaller in) { + mIntValue = in.getInt(INT_VALUE_KEY); + mStringValue = in.getString(STRING_VALUE_KEY); + mEnumValue = in.getEnum(ENUM_VALUE_KEY, TestEnum.class); + mBundableValue = in.getBundlable(BUNDLABLE_VALUE_KEY, mBundableValue, TestBundlable::new); + mListValue = in.getBundlableList(BUNDLABLE_LIST_VALUE_KEY, mListValue, TestBundlable::new); + } + + public TestBundlable setInt(int value) { + mIntValue = value; + return this; + } + + public TestBundlable setString(String value) { + mStringValue = value; + return this; + } + + public TestBundlable setEnumValue(TestEnum value) { + mEnumValue = value; + return this; + } + + public TestBundlable setBundlableValue(TestBundlable value) { + mBundableValue = value; + return this; + } + + public TestBundlable setListValue(List<TestBundlable> value) { + mListValue = value; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + TestBundlable that = (TestBundlable) o; + return mIntValue == that.mIntValue + && Objects.equals(mStringValue, that.mStringValue) + && mEnumValue == that.mEnumValue + && Objects.equals(mBundableValue, that.mBundableValue) + && Objects.equals(mListValue, that.mListValue); + } + + @Override + public int hashCode() { + return Objects.hash(mIntValue, mStringValue, mEnumValue, mBundableValue, mListValue); + } +} diff --git a/car/src/androidTest/java/androidx/car/navigation/utils/TestBundlableNewVersion.java b/car/src/androidTest/java/androidx/car/navigation/utils/TestBundlableNewVersion.java new file mode 100644 index 00000000000..58ee6f61ad3 --- /dev/null +++ b/car/src/androidTest/java/androidx/car/navigation/utils/TestBundlableNewVersion.java @@ -0,0 +1,132 @@ +/* + * Copyright 2018 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.navigation.utils; + +import androidx.annotation.NonNull; +import androidx.core.util.Preconditions; + +import java.util.List; +import java.util.Objects; + +/** + * Example of a version change. This is a copy of {@link TestBundlable} but with one field + * deprecated ({@link TestBundlable#mStringValue}) and one field added + * ({@link TestBundlableNewVersion#mNewValue}) + */ +class TestBundlableNewVersion implements Bundlable { + private static final String INT_VALUE_KEY = "intValue"; + // In this version of TestBundlable, mStringValue field has been deprecated, and its key should + // not be re-used. + // private static final String STRING_VALUE_KEY = "stringValue"; + private static final String ENUM_VALUE_KEY = "enumValue"; + private static final String BUNDLABLE_VALUE_KEY = "bundlableValue"; + private static final String BUNDLABLE_LIST_VALUE_KEY = "bundlableListValue"; + private static final String NEW_VALUE_KEY = "newValue"; + + enum TestEnum { + VALUE1, + VALUE2, + VALUE3, + } + + public static final String DEFAULT_NEW_VALUE = "TEST_V2"; + + int mIntValue; + TestEnum mEnumValue; + // In this version of TestBundlable, we simulate mStringValue being deprecated. + // String mStringValue; + TestBundlableNewVersion mBundableValue; + List<TestBundlableNewVersion> mListValue; + // In this version of TestBundlable, we simulate the creation of a new mandatory field. + @NonNull + String mNewValue; + + TestBundlableNewVersion() { + mNewValue = DEFAULT_NEW_VALUE; + } + + @Override + public void toBundle(@NonNull BundleMarshaller out) { + out.putInt(INT_VALUE_KEY, mIntValue); + // dest.putString(STRING_VALUE_KEY, mStringValue) is now deprecated. + out.putEnum(ENUM_VALUE_KEY, mEnumValue); + out.putBundlable(BUNDLABLE_VALUE_KEY, mBundableValue); + out.putBundlableList(BUNDLABLE_LIST_VALUE_KEY, mListValue); + // new required value + out.putString(NEW_VALUE_KEY, mNewValue); + } + + @Override + public void fromBundle(@NonNull BundleMarshaller in) { + mIntValue = in.getInt(INT_VALUE_KEY); + // mStringValue = in.getString(STRING_VALUE_KEY) is now deprecated. + mEnumValue = in.getEnum(ENUM_VALUE_KEY, TestEnum.class); + mBundableValue = in.getBundlable(BUNDLABLE_VALUE_KEY, mBundableValue, + TestBundlableNewVersion::new); + mListValue = in.getBundlableList(BUNDLABLE_LIST_VALUE_KEY, mListValue, + TestBundlableNewVersion::new); + // new required value with a mandatory default + mNewValue = in.getStringNonNull(NEW_VALUE_KEY, DEFAULT_NEW_VALUE); + } + + public TestBundlableNewVersion setInt(int value) { + mIntValue = value; + return this; + } + + public TestBundlableNewVersion setEnumValue(TestEnum value) { + mEnumValue = value; + return this; + } + + public TestBundlableNewVersion setBundlableValue(TestBundlableNewVersion value) { + mBundableValue = value; + return this; + } + + public TestBundlableNewVersion setListValue(List<TestBundlableNewVersion> value) { + mListValue = value; + return this; + } + + public TestBundlableNewVersion setNewValue(@NonNull String value) { + Preconditions.checkNotNull(value); + mNewValue = value; + return this; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + TestBundlableNewVersion that = (TestBundlableNewVersion) o; + return mIntValue == that.mIntValue + && mEnumValue == that.mEnumValue + && Objects.equals(mBundableValue, that.mBundableValue) + && Objects.equals(mListValue, that.mListValue) + && Objects.equals(mNewValue, that.mNewValue); + } + + @Override + public int hashCode() { + return Objects.hash(mIntValue, mEnumValue, mBundableValue, mListValue, mNewValue); + } +} diff --git a/car/src/main/java/androidx/car/navigation/utils/Bundlable.java b/car/src/main/java/androidx/car/navigation/utils/Bundlable.java new file mode 100644 index 00000000000..65e8bf20cc7 --- /dev/null +++ b/car/src/main/java/androidx/car/navigation/utils/Bundlable.java @@ -0,0 +1,99 @@ +/* + * Copyright 2018 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.navigation.utils; + +import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP; + +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.RestrictTo; + +/** + * Interface for classes whose instances can be written to and restored from a {@link Bundle}. + * Classes implementing the {@link Bundlable} interface must also provide a public default + * constructor, or a constructor that accepts {@link BundleMarshaller} as its only parameter. + * <p> + * This serialization protocol is designed to: + * <ul> + * <li>provide backward/forward compatibility between producers and consumers (following the + * Protocol Buffers pattern). + * <li>minimize the number of objects being allocated during both serialization and deserialization. + * </ul> + * <p> + * Implementations of this interface should comply to the following rules: + * <ul> + * <li>Fields should be serialized and deserialized using {@link BundleMarshaller} "put" and + * "get" methods. + * <li>Marshalling keys must be lower camel case alphanumerical identifiers (i.e.: "distanceUnit"). + * (symbols such as "." and "_" are reserved by the system). + * <li>Field types should not be modified between versions of {@link Bundlable} objects to provide + * backward and forward compatibility. Only deprecations and additions are allowed. + * <li>When a field is deprecated, its marshalling key shouldn't be reused by any new field. + * <li>Enums are marshalled using {@link Enum#name()}. Because of this, enum values should not be + * renamed. Because enum values could be added or deprecated, clients must be prepared to accept + * null or a default value in case the server sends a value it doesn't know. + * <li>Fields annotated with {@link androidx.annotation.NonNull} should not be deprecated + * (as clients might not be prepared for their absence). Implementations of this interface should + * enforce this constraint, i.e. by initializing these fields at class instantiation, and using + * {@link androidx.core.util.Preconditions#checkNotNull(Object)} to prevent null values. If a new + * {@link androidx.annotation.NonNull} field is added on an existing {@link Bundlable}, the + * deserialization must provide a default value for it (as existing services won't provide values + * for it until they are updated). + * </ul> + * The following is an example of the suggested implementation: + * <pre> + * public class MyClass implements Bundlable { + * private static final String FOO_VALUE_KEY = "fooValue"; + * private static final String BAR_VALUE_KEY = "barValue"; + * + * public enum MyEnum { + * VALUE_1, + * VALUE_2 + * } + * + * public String mFooValue; + * public MyEnum mBarValue; + * + * @Override + * public void toBundle(@NonNull BundleMarshaller out) { + * out.putString(FOO_VALUE_KEY, mFooValue); + * out.putEnum(BAR_VALUE_KEY, mBarValue); + * } + * + * @Override + * public void fromBundle(@NonNull BundleMarshaller in) { + * mFooValue = in.getString(FOO_VALUE_KEY); + * mBarValue = in.getEnum(BAR_VALUE_KEY, MyEnum.class); + * } + * } + * </pre> + * + * @hide + */ +@RestrictTo(LIBRARY_GROUP) +public interface Bundlable { + /** + * Serializes this object into a {@link BundleMarshaller} by writing all its fields to it. + */ + void toBundle(@NonNull BundleMarshaller out); + + /** + * Deserializes this object from a {@link BundleMarshaller} by reading all its fields from it. + */ + void fromBundle(@NonNull BundleMarshaller in); +} diff --git a/car/src/main/java/androidx/car/navigation/utils/BundleMarshaller.java b/car/src/main/java/androidx/car/navigation/utils/BundleMarshaller.java new file mode 100644 index 00000000000..1e6178a39d2 --- /dev/null +++ b/car/src/main/java/androidx/car/navigation/utils/BundleMarshaller.java @@ -0,0 +1,514 @@ +/* + * Copyright 2018 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.navigation.utils; + +import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP; + +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.RestrictTo; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; + +/** + * Class responsible for serializing and deserializing data into a {@link Bundle}. It also + * provides a way to detect what items in the {@link Bundle} have been modified during + * marshalling. + * <p> + * A single {@link BundleMarshaller} can be re-used to serialize or deserialize data multiple times. + * Similarity, deserialization can be done in-place, updating existing {@link Bundlable}s. This + * reduces the number of instances being allocated. + * <p> + * When serializing, use {@link #resetBundle()} before marshalling and {@link #getBundle()} to + * obtain an snap-shot of the serialized content. Or use {@link #resetDelta()} and + * {@link #getDelta()} to obtain a {@link Bundle} representing the patch between the last and + * the new serialized data. + * <p> + * When deserializing, use {@link #setBundle(Bundle)} to deserialize a {@link Bundle} containing an + * snap-shot, or {@link #applyDelta(Bundle)} to process a patch from the last deserialized data. + * <p> + * Keys used in the "get" and "put" methods must be lower camel case alphanumerical identifiers + * (e.g.: "distanceUnit"). Symbols like "." and "_" are reserved by the system. + * <p> + * When deserializing {@link List} objects, this class assumes that they implement random access + * (e.g. {@link ArrayList}), or they are relatively small (see more details at + * {@link #trimList(List, int)}) + * + * @hide + */ +@RestrictTo(LIBRARY_GROUP) +public class BundleMarshaller { + /** + * Separator used to concatenate identifiers when marshalling non-primitive types (e.g. lists + * or {@link Bundlable}s). + */ + private static final String KEY_SEPARATOR = "."; + /** + * Identifier used to record if a given non-primitive field is null or not. This allows + * serializing null objects without the need of using reflection or static methods. + */ + private static final String IS_NULL_KEY = "_isNull"; + /** + * Identifier used to record the length of a collection. This allows serializing changes to the + * length of a collection without having to remove elements or having to iterate over every + * possible collection key. + */ + private static final String SIZE_KEY = "_size"; + /** + * Special value for {@link #SIZE_KEY} to serialize a null collection. + */ + private static final int NULL_SIZE = -1; + + private Bundle mBundle = new Bundle(); + private String mKeyPrefix = ""; + private final Bundle mBundleDelta = new Bundle(); + + /** + * Returns data serialized since the last time this instance was constructed, or + * {@link #resetBundle()} was called. + */ + public Bundle getBundle() { + return mBundle; + } + + /** + * Resets this {@link BundleMarshaller} causing {@link #getBundle()} to return an empty + * {@link Bundle} until the next marshalling is executed. This can be used occasionally to + * remove unused keys in the {@link Bundle}. + */ + public void resetBundle() { + mBundle.clear(); + } + + /** + * Replaces the {@link Bundle} to serialize into or deserialize from. + */ + public void setBundle(Bundle bundle) { + mBundle = bundle; + } + + /** + * Gets a {@link Bundle} containing only the entries of {@link #getBundle()} that were modified + * since this instance was constructed, or {@link #resetDelta()} was called. + */ + public Bundle getDelta() { + return mBundleDelta; + } + + /** + * Merges the provided {@link Bundle} on top of the one stored in this {@link BundleMarshaller}. + * + * @param delta a {@link Bundle} containing entries to be updated on one stored in this + * {@link BundleMarshaller} instance. Such {@link Bundle} can be produced by + * using the {@link #resetDelta()} and {@link #getDelta()} methods during data + * serialization. + */ + public void applyDelta(Bundle delta) { + mBundle.putAll(delta); + } + + /** + * Resets tracking of modified entries, causing {@link #getDelta()} to return an empty + * {@link Bundle} until the next marshalling is executed. This can be used between + * serializations make {@link #getDelta()} return only the differences. + */ + public void resetDelta() { + mBundleDelta.clear(); + } + + /** + * Inserts an int value, replacing any existing value for the given key. + * + * @param key lower camel case alphanumerical identifier + * @param value an int + */ + public void putInt(@NonNull String key, int value) { + String mangledKey = getMangledKey(key); + if (!mBundle.containsKey(mangledKey) || mBundle.getInt(mangledKey) != value) { + mBundleDelta.putInt(mangledKey, value); + mBundle.putInt(mangledKey, value); + } + } + + /** + * Returns the value associated with the given key, or 0 if no mapping of the desired type + * exists for the given key. + * + * @param key lower camel case alphanumerical identifier + * @return an int + */ + public int getInt(@NonNull String key) { + return mBundle.getInt(getMangledKey(key)); + } + + /** + * Inserts a float value, replacing any existing value for the given key. + * + * @param key lower camel case alphanumerical identifier + * @param value a float + */ + public void putFloat(@NonNull String key, float value) { + String mangledKey = getMangledKey(key); + if (!mBundle.containsKey(mangledKey) + || Float.compare(mBundle.getFloat(mangledKey), value) != 0) { + mBundleDelta.putFloat(mangledKey, value); + mBundle.putFloat(mangledKey, value); + } + } + + /** + * Returns the value associated with the given key, or 0.0f if no mapping of the desired type + * exists for the given key. + * + * @param key lower camel case alphanumerical identifier + * @return a float + */ + public float getFloat(@NonNull String key) { + return mBundle.getFloat(getMangledKey(key)); + } + + /** + * Inserts a double value, replacing any existing value for the given key. + * + * @param key lower camel case alphanumerical identifier + * @param value a double + */ + public void putDouble(@NonNull String key, double value) { + String mangledKey = getMangledKey(key); + if (!mBundle.containsKey(mangledKey) + || Double.compare(mBundle.getDouble(mangledKey), value) != 0) { + mBundleDelta.putDouble(mangledKey, value); + mBundle.putDouble(mangledKey, value); + } + } + + /** + * Returns the value associated with the given key, or 0.0 if no mapping of the desired type + * exists for the given key. + * + * @param key lower camel case alphanumerical identifier + * @return a double + */ + public double getDouble(@NonNull String key) { + return mBundle.getDouble(getMangledKey(key)); + } + + /** + * Inserts a boolean value, replacing any existing value for the given key. + * + * @param key lower camel case alphanumerical identifier + * @param value a boolean + */ + public void putBoolean(@NonNull String key, boolean value) { + String mangledKey = getMangledKey(key); + if (!mBundle.containsKey(mangledKey) || mBundle.getBoolean(mangledKey) != value) { + mBundleDelta.putBoolean(mangledKey, value); + mBundle.putBoolean(mangledKey, value); + } + } + + /** + * Returns the value associated with the given key, or false if no mapping of the desired type + * exists for the given key. + * + * @param key lower camel case alphanumerical identifier + * @return a boolean + */ + public boolean getBoolean(@NonNull String key) { + return mBundle.getBoolean(getMangledKey(key)); + } + + /** + * Inserts a string value, replacing any existing value for the given key. + * + * @param key lower camel case alphanumerical identifier + * @param value a string, or null + */ + public void putString(@NonNull String key, @Nullable String value) { + String mangledKey = getMangledKey(key); + if (!mBundle.containsKey(mangledKey) + || !Objects.equals(mBundle.getString(mangledKey), value)) { + mBundleDelta.putString(mangledKey, value); + mBundle.putString(mangledKey, value); + } + } + + /** + * Returns the value associated with the given key, or null if no mapping of the desired type + * exists for the given key. + * + * @param key lower camel case alphanumerical identifier + * @return a string, or null + */ + @Nullable + public String getString(@NonNull String key) { + return mBundle.getString(getMangledKey(key)); + } + + /** + * Returns the value associated with the given key, or the provided default value if no mapping + * of the desired type exists for the given key. + * + * @param key lower camel case alphanumerical identifier + * @param defaultValue value to return if key does not exist or if a null value is associated + * with the given key. + * @return a string + */ + @NonNull + public String getStringNonNull(@NonNull String key, @NonNull String defaultValue) { + return mBundle.getString(getMangledKey(key), defaultValue); + } + + /** + * Inserts an enum value, replacing any existing value for the given key. The provided enum + * will be serialized as a string using {@link Enum#name()}. + * + * @param key lower camel case alphanumerical identifier + * @param value an enum, or null + */ + public <T extends Enum<T>> void putEnum(@NonNull String key, @Nullable T value) { + putString(key, value != null ? value.name() : null); + } + + /** + * Returns the value associated with the given key, or null if no mapping of the desired type + * exists for the given key. + * + * @param key lower camel case alphanumerical identifier + * @param clazz {@link Enum} class to be used to deserialize the value. + * @param <T> {@link Enum} type to be returned. + * @return an enum, or null + */ + @Nullable + public <T extends Enum<T>> T getEnum(@NonNull String key, @NonNull Class<T> clazz) { + String name = getString(key); + try { + return name != null ? Enum.valueOf(clazz, name) : null; + } catch (IllegalArgumentException ex) { + return null; + } + } + + /** + * Returns the value associated with the given key, or the provided default value if no mapping + * of the desired type exists for the given key. + * + * @param key lower camel case alphanumerical identifier + * @param clazz {@link Enum} class to be used to deserialize the value. + * @param defaultValue value to return if key does not exist or if a null value is associated + * with the given key. + * @param <T> {@link Enum} type to be returned. + * @return an enum + */ + @NonNull + public <T extends Enum<T>> T getEnumNonNull(@NonNull String key, @NonNull Class<T> clazz, + @NonNull T defaultValue) { + T result = getEnum(key, clazz); + return result != null ? result : defaultValue; + } + + /** + * Inserts a {@link Bundlable} value, replacing any existing value for the given key. + * + * @param key lower camel case alphanumerical identifier + * @param value a {@link Bundlable}, or null + */ + public <T extends Bundlable> void putBundlable(@NonNull String key, @Nullable T value) { + withKeyPrefix(key, () -> { + putBoolean(IS_NULL_KEY, value == null); + if (value != null) { + value.toBundle(this); + } + }); + } + + /** + * Returns the value associated with the given key, or null if no mapping of the desired type + * exists for the given key. If a non-null "current" instance is provided, then the + * deserialization would be done in place. Otherwise, a new instance will be created using the + * provided factory. + * + * @param key lower camel case alphanumerical identifier + * @param current current value (if available) to perform in-place deserialization, or null + * @param factory a {@link Supplier} capable of providing an instance of a {@link Bundlable} of + * type T. The suggested implementation is to pass a reference to the default + * constructor of that class. + * @param <T> {@link Bundlable} type to be returned. + * @return an instance of type T, or null + */ + @Nullable + public <T extends Bundlable> T getBundlable(@NonNull String key, @Nullable T current, + @NonNull Supplier<T> factory) { + return withKeyPrefix(key, () -> { + if (getBoolean(IS_NULL_KEY)) { + return null; + } + T result = current != null ? current : factory.get(); + result.fromBundle(this); + return result; + }); + } + + /** + * Returns the value associated with the given key, or a default value if no mapping of the + * desired type exists for the given key. If a non-null value is available, then such value + * will be deserialized in-place on the given "current" instance. Otherwise, a default value + * will be generated using the provided factory. + * + * @param key lower camel case alphanumerical identifier + * @param current current value to perform in-place deserialization + * @param factory a {@link Supplier} capable of providing an instance of a {@link Bundlable} of + * type T. The suggested implementation is to pass a reference to the default + * constructor of that class. + * @param <T> {@link Bundlable} type to be returned. + * @return an instance of type T + */ + @NonNull + public <T extends Bundlable> T getBundlableNonNull(@NonNull String key, @NonNull T current, + @NonNull Supplier<T> factory) { + T result = getBundlable(key, current, factory); + return result != null ? result : factory.get(); + } + + /** + * Inserts a {@link List} of {@link Bundlable} values, replacing any existing value for the + * given key. + * + * @param key lower camel case alphanumerical identifier + * @param values a {@link List} of {@link Bundlable} values, or null + */ + public <T extends Bundlable> void putBundlableList(@NonNull String key, + @Nullable List<T> values) { + withKeyPrefix(key, () -> { + putInt(SIZE_KEY, values != null ? values.size() : NULL_SIZE); + if (values != null) { + int pos = 0; + // Using for-each as the provided list might not implement random access (e.g. it + // might be a linked list). + for (T value : values) { + putBundlable(String.valueOf(pos), value); + pos++; + } + } + }); + } + + /** + * Returns the value associated with the given key, or null if no mapping of the desired type + * exists for the given key. If a non-null "current" list is provided, then the deserialization + * would be done in place. Otherwise, a new list will be created and items will be instantiated + * using the provided factory. + * + * @param key lower camel case alphanumerical identifier + * @param current current value (if available) to perform in-place deserialization, or null + * @param factory a {@link Supplier} capable of providing an instance of a {@link Bundlable} of + * type T. The suggested implementation is to pass a reference to the default + * constructor of that class. + * @param <T> {@link Bundlable} type to be returned. + * @return a list of instances of type T, or null. The resulting list might contain null + * elements. + */ + @Nullable + public <T extends Bundlable> List<T> getBundlableList(@NonNull String key, + @Nullable List<T> current, @NonNull Supplier<T> factory) { + return withKeyPrefix(key, () -> { + int listSize = getInt(SIZE_KEY); + if (listSize == NULL_SIZE) { + return null; + } + List<T> result = current != null ? current : new ArrayList<>(listSize); + if (result.size() > listSize) { + result.subList(listSize, result.size()).clear(); + } + for (int pos = 0; pos < listSize; pos++) { + String subKey = String.valueOf(pos); + if (pos < result.size()) { + result.set(pos, getBundlable(subKey, result.get(pos), factory)); + } else { + result.add(getBundlable(String.valueOf(pos), + null /* force the creation of a new instance */, + factory)); + } + } + return result; + }); + } + + /** + * Returns the value associated with the given key, or an empty list if no mapping of the + * desired type exists for the given key. If a non-null "current" list is provided, then the + * deserialization would be done in place. Otherwise, a new list will be created and items will + * be instantiated using the provided factory. + * + * @param key lower camel case alphanumerical identifier + * @param current current value (if available) to perform in-place deserialization, or null + * @param factory a {@link Supplier} capable of providing an instance of a {@link Bundlable} of + * type T. The suggested implementation is to pass a reference to the default + * constructor of that class. + * @param <T> {@link Bundlable} type to be returned. + * @return a list of instances of type T, or an empty list. The resulting list might contain + * null elements. + */ + @NonNull + public <T extends Bundlable> List<T> getBundlableListNonNull(@NonNull String key, + @NonNull List<T> current, @NonNull Supplier<T> factory) { + List<T> result = getBundlableList(key, current, factory); + return result != null ? result : new ArrayList<>(); + } + + /** + * Executes the given {@link Runnable} in a context where {@link #getMangledKey(String)} + * includes the given key as part of the prefix. Calls to this method can be nested (the + * provided {@link Runnable} can call to this method if needed). This method should be used when + * serializing or deserializing nested objects. + * <p> + * For example: calling to {@link #withKeyPrefix(String, Runnable)} with "foo" as key and + * a {@link Runnable} that calls {@link #getMangledKey(String)} with "bar" as key, will + * cause such {@link #getMangledKey(String)} call to return "foo.bar". + */ + private void withKeyPrefix(@NonNull String key, @NonNull Runnable runnable) { + String originalKeyPrefix = mKeyPrefix; + mKeyPrefix = mKeyPrefix + key + KEY_SEPARATOR; + runnable.run(); + mKeyPrefix = originalKeyPrefix; + } + + /** + * Similar to {@link #withKeyPrefix(String, Runnable)} but allows returning a value. + */ + private <X> X withKeyPrefix(@NonNull String key, @NonNull Supplier<X> supplier) { + String originalKeyPrefix = mKeyPrefix; + mKeyPrefix = mKeyPrefix + key + KEY_SEPARATOR; + X res = supplier.get(); + mKeyPrefix = originalKeyPrefix; + return res; + } + + /** + * Returns a composed key based on the given one and the current serialization/deserialization + * key prefix (initially empty). This prefix can be temporarily changed with + * {@link #withKeyPrefix(String, Runnable)} or {@link #withKeyPrefix(String, Supplier)}. + */ + private String getMangledKey(@NonNull String key) { + return mKeyPrefix + key; + } +} diff --git a/jetifier/jetifier/source-transformer/rewriteMake.py b/jetifier/jetifier/source-transformer/rewriteMake.py index b33d4c1d469..a8ee1d702b4 100755 --- a/jetifier/jetifier/source-transformer/rewriteMake.py +++ b/jetifier/jetifier/source-transformer/rewriteMake.py @@ -88,6 +88,7 @@ android-arch-room-common,androidx.room_room-common android-arch-room-migration,androidx.room_room-migration android-arch-room-runtime,androidx.room_room-runtime android-arch-room-testing,androidx.room_room-testing +android-support-design,com.google.android.material_material $(ANDROID_SUPPORT_DESIGN_TARGETS),com.google.android.material_material""" reader = csv.reader(target_map.split('\n'), delimiter=',') diff --git a/leanback/src/main/res/values-bn/strings.xml b/leanback/src/main/res/values-bn/strings.xml index c7c7bedec43..a7ae625463e 100644 --- a/leanback/src/main/res/values-bn/strings.xml +++ b/leanback/src/main/res/values-bn/strings.xml @@ -18,7 +18,7 @@ limitations under the License. <resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <string name="lb_navigation_menu_contentDescription" msgid="8126335323963415494">"নেভিগেশন মেনু"</string> - <string name="orb_search_action" msgid="7534843523462177008">"খোঁজার কার্যকলাপ"</string> + <string name="orb_search_action" msgid="7534843523462177008">"খোঁজার অ্যাক্টিভিটি"</string> <string name="lb_search_bar_hint" msgid="4819380969103509861">"সার্চ"</string> <string name="lb_search_bar_hint_speech" msgid="2795474673510974502">"বলার মাধ্যমে খুঁজুন"</string> <string name="lb_search_bar_hint_with_title" msgid="7453744869467668159">"<xliff:g id="SEARCH_CONTEXT">%1$s</xliff:g> খুঁজুন"</string> @@ -33,7 +33,7 @@ limitations under the License. <string name="lb_playback_controls_rewind_multiplier" msgid="8651612807713092781">"%1$dX স্পিডে পিছিয়ে যান"</string> <string name="lb_playback_controls_skip_next" msgid="4877009494447817003">"সরাসরি পরেরটিতে চলে যান"</string> <string name="lb_playback_controls_skip_previous" msgid="3147124289285911980">"সরাসরি আগেরটিতে চলে যান"</string> - <string name="lb_playback_controls_more_actions" msgid="2827883329510404797">"আরও কার্যকলাপ"</string> + <string name="lb_playback_controls_more_actions" msgid="2827883329510404797">"আরও অ্যাক্টিভিটি"</string> <string name="lb_playback_controls_thumb_up" msgid="8332816524260995892">"উপরের দিকে করা বুড়ো আঙ্গুলের চিহ্নকে বাদ দিন"</string> <string name="lb_playback_controls_thumb_up_outline" msgid="1038344559734334272">"উপরের দিকে করা বুড়ো আঙ্গুলের চিহ্নকে বেছে নিন"</string> <string name="lb_playback_controls_thumb_down" msgid="5075744418630733006">"নিচের দিকে করা বুড়ো আঙ্গুলের চিহ্নকে বাদ দিন"</string> diff --git a/samples/SupportPreferenceDemos/build.gradle b/samples/SupportPreferenceDemos/build.gradle index d37bfa20cca..6aa83437417 100644 --- a/samples/SupportPreferenceDemos/build.gradle +++ b/samples/SupportPreferenceDemos/build.gradle @@ -8,4 +8,5 @@ dependencies { implementation(project(":preference")) implementation(project(":leanback")) implementation(project(":leanback-preference")) + implementation(project(":car")) } diff --git a/samples/SupportPreferenceDemos/src/main/AndroidManifest.xml b/samples/SupportPreferenceDemos/src/main/AndroidManifest.xml index 0e9aa9b784a..dc7f3dc1e67 100644 --- a/samples/SupportPreferenceDemos/src/main/AndroidManifest.xml +++ b/samples/SupportPreferenceDemos/src/main/AndroidManifest.xml @@ -20,7 +20,7 @@ package="com.example.android.supportpreference"> <uses-sdk - tools:overrideLibrary="androidx.leanback.preference, androidx.leanback" /> + tools:overrideLibrary="androidx.leanback.preference, androidx.leanback, androidx.car" /> <uses-feature android:name="android.software.Leanback" android:required="false" /> @@ -68,5 +68,14 @@ </intent-filter> </activity> + <activity android:name=".FragmentSupportPreferencesCar" + android:label="@string/fragment_support_preferences_car_demo" + android:theme="@style/SupportPreferenceCar"> + <intent-filter> + <action android:name="android.intent.action.MAIN"/> + <category android:name="com.example.android.supportpreference.SAMPLE_CODE"/> + </intent-filter> + </activity> + </application> -</manifest> +</manifest>
\ No newline at end of file diff --git a/samples/SupportPreferenceDemos/src/main/java/com/example/android/supportpreference/FragmentSupportPreferencesCar.java b/samples/SupportPreferenceDemos/src/main/java/com/example/android/supportpreference/FragmentSupportPreferencesCar.java new file mode 100644 index 00000000000..83cb38459d5 --- /dev/null +++ b/samples/SupportPreferenceDemos/src/main/java/com/example/android/supportpreference/FragmentSupportPreferencesCar.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2018 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 com.example.android.supportpreference; + +import android.os.Bundle; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.fragment.app.Fragment; +import androidx.preference.PreferenceFragmentCompat; +import androidx.preference.PreferenceScreen; + +/** + * Demonstration of PreferenceFragment, showing a single fragment in an + * activity for the Car. + */ +public class FragmentSupportPreferencesCar extends AppCompatActivity + implements PreferenceFragmentCompat.OnPreferenceStartScreenCallback { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Display the fragment as the main content. + if (savedInstanceState == null) { + getSupportFragmentManager().beginTransaction().replace(android.R.id.content, + new PrefsFragment()).commitNow(); + } + } + + @Override + public boolean onPreferenceStartScreen(PreferenceFragmentCompat caller, PreferenceScreen pref) { + Fragment fragment = new PrefsFragment(); + Bundle args = new Bundle(); + args.putString(PreferenceFragmentCompat.ARG_PREFERENCE_ROOT, pref.getKey()); + fragment.setArguments(args); + getSupportFragmentManager().beginTransaction() + .replace(android.R.id.content, fragment) + .commitNow(); + return true; + } + + /** + * Create a PrefsFragment from the xml file of preferences + */ + public static class PrefsFragment extends PreferenceFragmentCompat { + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + // Load the preferences from an XML resource + setPreferencesFromResource(R.xml.preferences, rootKey); + } + } +} + diff --git a/samples/SupportPreferenceDemos/src/main/res/values/strings.xml b/samples/SupportPreferenceDemos/src/main/res/values/strings.xml index e6effdf3e7e..a3a81b19169 100644 --- a/samples/SupportPreferenceDemos/src/main/res/values/strings.xml +++ b/samples/SupportPreferenceDemos/src/main/res/values/strings.xml @@ -19,6 +19,7 @@ <string name="fragment_support_preferences_demo">Support PreferenceFragment</string> <string name="fragment_support_preferences_compat_demo">Support PreferenceFragmentCompat</string> <string name="fragment_support_preferences_leanback_demo">Support LeanbackPreferenceFragment</string> + <string name="fragment_support_preferences_car_demo">Support Car PreferenceFragment</string> <string name="root_title">Demo Preferences</string> diff --git a/samples/SupportPreferenceDemos/src/main/res/values/styles.xml b/samples/SupportPreferenceDemos/src/main/res/values/styles.xml index 5194c9ff266..6b2837817c7 100644 --- a/samples/SupportPreferenceDemos/src/main/res/values/styles.xml +++ b/samples/SupportPreferenceDemos/src/main/res/values/styles.xml @@ -31,4 +31,9 @@ <item name="preferenceTheme">@style/PreferenceThemeOverlay.v14.Leanback</item> </style> + <style name="SupportPreferenceCar" parent="Theme.Car.NoActionBar"> + <item name="preferenceTheme">@style/PreferenceThemeOverlayCar</item> + <item name="android:windowBackground">@color/car_card</item> + </style> + </resources> diff --git a/samples/SupportPreferenceDemos/src/main/res/xml/preferences.xml b/samples/SupportPreferenceDemos/src/main/res/xml/preferences.xml index 4d1dca7105d..2f492023f00 100644 --- a/samples/SupportPreferenceDemos/src/main/res/xml/preferences.xml +++ b/samples/SupportPreferenceDemos/src/main/res/xml/preferences.xml @@ -57,6 +57,13 @@ android:entries="@array/entries_list_preference" android:entryValues="@array/entryvalues_list_preference" /> + <SeekBarPreference + android:key="seekbar_preference" + android:title="Seekbar preference" + android:summary="This is a seekbar preference" + android:max="10" + android:defaultValue="5"/> + </PreferenceCategory> <PreferenceCategory diff --git a/work/integration-tests/testapp/build.gradle b/work/integration-tests/testapp/build.gradle index 7c51a4b1f84..1b84be5b5c8 100644 --- a/work/integration-tests/testapp/build.gradle +++ b/work/integration-tests/testapp/build.gradle @@ -37,8 +37,8 @@ dependencies { implementation project(':work:work-runtime') implementation project(':work:work-firebase') implementation "android.arch.lifecycle:extensions:1.1.0" - implementation "android.arch.persistence.room:runtime:1.1.0" - annotationProcessor "android.arch.persistence.room:compiler:1.1.0" + implementation "android.arch.persistence.room:runtime:1.1.1-rc1" + annotationProcessor "android.arch.persistence.room:compiler:1.1.1-rc1" implementation MULTIDEX implementation "com.android.support:recyclerview-v7:26.1.0" implementation "com.android.support:appcompat-v7:26.1.0" diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/ToastWorker.java b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/ToastWorker.java index 37cd7fc8291..9f117ec8411 100644 --- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/ToastWorker.java +++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/ToastWorker.java @@ -45,12 +45,16 @@ public class ToastWorker extends Worker { @Override public @NonNull Result doWork() { Data input = getInputData(); - final String message = input.getString(ARG_MESSAGE, "completed!"); + String message = input.getString(ARG_MESSAGE); + if (message == null) { + message = "completed!"; + } + final String displayMessage = message; new Handler(Looper.getMainLooper()).post(new Runnable() { @Override public void run() { - Log.d("ToastWorker", message); - Toast.makeText(getApplicationContext(), message, Toast.LENGTH_SHORT).show(); + Log.d("ToastWorker", displayMessage); + Toast.makeText(getApplicationContext(), displayMessage, Toast.LENGTH_SHORT).show(); } }); return Result.SUCCESS; diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/imageprocessing/ImageProcessingWorker.java b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/imageprocessing/ImageProcessingWorker.java index 0fa1c5b1dbb..e28d94215c6 100644 --- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/imageprocessing/ImageProcessingWorker.java +++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/imageprocessing/ImageProcessingWorker.java @@ -47,7 +47,7 @@ public class ImageProcessingWorker extends Worker { public @NonNull Result doWork() { Log.d(TAG, "Started"); - String uriString = getInputData().getString(URI_KEY, null); + String uriString = getInputData().getString(URI_KEY); if (TextUtils.isEmpty(uriString)) { Log.e(TAG, "Invalid URI!"); return Result.FAILURE; diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/imageprocessing/ImageSetupWorker.java b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/imageprocessing/ImageSetupWorker.java index 304e7fee852..1d3520ff50d 100644 --- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/imageprocessing/ImageSetupWorker.java +++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/imageprocessing/ImageSetupWorker.java @@ -37,7 +37,7 @@ public class ImageSetupWorker extends Worker { public @NonNull Result doWork() { Log.d(TAG, "Started"); - String uriString = getInputData().getString(URI_KEY, null); + String uriString = getInputData().getString(URI_KEY); if (TextUtils.isEmpty(uriString)) { Log.e(TAG, "Invalid URI!"); return Result.FAILURE; diff --git a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/sherlockholmes/TextMappingWorker.java b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/sherlockholmes/TextMappingWorker.java index fcc754061e7..2fca4432b48 100644 --- a/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/sherlockholmes/TextMappingWorker.java +++ b/work/integration-tests/testapp/src/main/java/androidx/work/integration/testapp/sherlockholmes/TextMappingWorker.java @@ -57,7 +57,7 @@ public class TextMappingWorker extends Worker { @Override public @NonNull Result doWork() { Data input = getInputData(); - String inputFileName = input.getString(INPUT_FILE, null); + String inputFileName = input.getString(INPUT_FILE); String outputFileName = "out_" + inputFileName; AssetManager assetManager = getApplicationContext().getAssets(); diff --git a/work/workmanager-ktx/src/androidTest/java/androidx/work/DataTest.kt b/work/workmanager-ktx/src/androidTest/java/androidx/work/DataTest.kt index 20b19f6f3bf..0cd1eda741c 100644 --- a/work/workmanager-ktx/src/androidTest/java/androidx/work/DataTest.kt +++ b/work/workmanager-ktx/src/androidTest/java/androidx/work/DataTest.kt @@ -32,10 +32,10 @@ class DataTest { val data = map.toWorkData() assertEquals(data.getInt("one", 0), 1) assertEquals(data.getLong("two", 0L), 2L) - assertEquals(data.getString("three", null), "Three") + assertEquals(data.getString("three"), "Three") val longArray = data.getLongArray("four") assertNotNull(longArray) - assertEquals(longArray.size, 2) + assertEquals(longArray!!.size, 2) assertEquals(longArray[0], 1L) assertEquals(longArray[1], 2L) } diff --git a/work/workmanager/build.gradle b/work/workmanager/build.gradle index fedc05213bc..172c5ffcf3c 100644 --- a/work/workmanager/build.gradle +++ b/work/workmanager/build.gradle @@ -43,10 +43,10 @@ android { dependencies { api "android.arch.lifecycle:extensions:1.1.0" - implementation "android.arch.persistence.room:runtime:1.1.0" - annotationProcessor "android.arch.persistence.room:compiler:1.1.0" + implementation "android.arch.persistence.room:runtime:1.1.1-rc1" + annotationProcessor "android.arch.persistence.room:compiler:1.1.1-rc1" androidTestImplementation "android.arch.core:core-testing:1.1.0" - androidTestImplementation "android.arch.persistence.room:testing:1.1.0" + androidTestImplementation "android.arch.persistence.room:testing:1.1.1-rc1" androidTestImplementation(TEST_RUNNER) androidTestImplementation(ESPRESSO_CORE) androidTestImplementation(MOCKITO_CORE, libs.exclude_bytebuddy) // DexMaker has its own MockMaker diff --git a/work/workmanager/src/androidTest/java/androidx/work/WorkDatabaseMigrationTest.java b/work/workmanager/src/androidTest/java/androidx/work/WorkDatabaseMigrationTest.java index c1311173b1c..0cba3cf0c6c 100644 --- a/work/workmanager/src/androidTest/java/androidx/work/WorkDatabaseMigrationTest.java +++ b/work/workmanager/src/androidTest/java/androidx/work/WorkDatabaseMigrationTest.java @@ -18,6 +18,12 @@ package androidx.work; import static android.database.sqlite.SQLiteDatabase.CONFLICT_FAIL; +import static androidx.work.impl.WorkDatabaseMigrations.MIGRATION_3_4; +import static androidx.work.impl.WorkDatabaseMigrations.VERSION_1; +import static androidx.work.impl.WorkDatabaseMigrations.VERSION_2; +import static androidx.work.impl.WorkDatabaseMigrations.VERSION_3; +import static androidx.work.impl.WorkDatabaseMigrations.VERSION_4; + import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; @@ -25,16 +31,21 @@ import android.arch.persistence.db.SupportSQLiteDatabase; import android.arch.persistence.db.framework.FrameworkSQLiteOpenHelperFactory; import android.arch.persistence.room.testing.MigrationTestHelper; import android.content.ContentValues; +import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteException; +import android.os.Build; +import android.support.annotation.NonNull; import android.support.test.InstrumentationRegistry; import android.support.test.filters.MediumTest; import android.support.test.runner.AndroidJUnit4; import androidx.work.impl.WorkDatabase; import androidx.work.impl.WorkDatabaseMigrations; +import androidx.work.impl.WorkManagerImpl; import androidx.work.impl.model.WorkSpec; import androidx.work.impl.model.WorkTypeConverters; +import androidx.work.impl.utils.Preferences; import androidx.work.worker.TestWorker; import org.junit.Before; @@ -51,8 +62,6 @@ public class WorkDatabaseMigrationTest { private static final String TEST_DATABASE = "workdatabase-test"; private static final boolean VALIDATE_DROPPED_TABLES = true; - private static final int OLD_VERSION = 1; - private static final int NEW_VERSION = 2; private static final String COLUMN_WORKSPEC_ID = "work_spec_id"; private static final String COLUMN_SYSTEM_ID = "system_id"; private static final String COLUMN_ALARM_ID = "alarm_id"; @@ -69,6 +78,7 @@ public class WorkDatabaseMigrationTest { private static final String TABLE_WORKTAG = "WorkTag"; private static final String TABLE_WORKNAME = "WorkName"; + private Context mContext; private File mDatabasePath; @Rule @@ -80,6 +90,7 @@ public class WorkDatabaseMigrationTest { @Before public void setUp() { // Delete the database if it exists. + mContext = InstrumentationRegistry.getTargetContext(); mDatabasePath = InstrumentationRegistry.getContext().getDatabasePath(TEST_DATABASE); if (mDatabasePath.exists()) { mDatabasePath.delete(); @@ -90,34 +101,23 @@ public class WorkDatabaseMigrationTest { @MediumTest public void testMigrationVersion1To2() throws IOException { SupportSQLiteDatabase database = - mMigrationTestHelper.createDatabase(TEST_DATABASE, OLD_VERSION); - - String workSpecId0 = UUID.randomUUID().toString(); - ContentValues contentValues = new ContentValues(); - contentValues.put("id", workSpecId0); - contentValues.put("state", WorkTypeConverters.StateIds.ENQUEUED); - contentValues.put("worker_class_name", TestWorker.class.getName()); - contentValues.put("input_merger_class_name", OverwritingInputMerger.class.getName()); - contentValues.put("input", Data.toByteArray(Data.EMPTY)); - contentValues.put("output", Data.toByteArray(Data.EMPTY)); - contentValues.put("initial_delay", 0L); - contentValues.put("interval_duration", 0L); - contentValues.put("flex_duration", 0L); - contentValues.put("required_network_type", false); - contentValues.put("requires_charging", false); - contentValues.put("requires_device_idle", false); - contentValues.put("requires_battery_not_low", false); - contentValues.put("requires_storage_not_low", false); - contentValues.put("content_uri_triggers", - WorkTypeConverters.contentUriTriggersToByteArray(new ContentUriTriggers())); - contentValues.put("run_attempt_count", 0); - contentValues.put("backoff_policy", - WorkTypeConverters.backoffPolicyToInt(BackoffPolicy.EXPONENTIAL)); - contentValues.put("backoff_delay_duration", WorkRequest.DEFAULT_BACKOFF_DELAY_MILLIS); - contentValues.put("period_start_time", 0L); - contentValues.put("minimum_retention_duration", 0L); - contentValues.put("schedule_requested_at", WorkSpec.SCHEDULE_NOT_REQUESTED_YET); - database.insert("workspec", CONFLICT_FAIL, contentValues); + mMigrationTestHelper.createDatabase(TEST_DATABASE, VERSION_1); + + String[] prepopulatedWorkSpecIds = new String[]{ + UUID.randomUUID().toString(), + UUID.randomUUID().toString() + }; + for (String workSpecId : prepopulatedWorkSpecIds) { + ContentValues contentValues = contentValues(workSpecId); + database.insert("workspec", CONFLICT_FAIL, contentValues); + + if (workSpecId.equals(prepopulatedWorkSpecIds[0])) { + ContentValues tagValues = new ContentValues(); + tagValues.put("tag", TestWorker.class.getName()); + tagValues.put("work_spec_id", workSpecId); + database.insert("worktag", CONFLICT_FAIL, tagValues); + } + } String workSpecId1 = UUID.randomUUID().toString(); String workSpecId2 = UUID.randomUUID().toString(); @@ -130,16 +130,28 @@ public class WorkDatabaseMigrationTest { database = mMigrationTestHelper.runMigrationsAndValidate( TEST_DATABASE, - NEW_VERSION, + VERSION_2, VALIDATE_DROPPED_TABLES, WorkDatabaseMigrations.MIGRATION_1_2); Cursor tagCursor = database.query("SELECT * FROM worktag"); - assertThat(tagCursor.getCount(), is(1)); - tagCursor.moveToFirst(); - assertThat(tagCursor.getString(tagCursor.getColumnIndex("tag")), - is(TestWorker.class.getName())); - assertThat(tagCursor.getString(tagCursor.getColumnIndex("work_spec_id")), is(workSpecId0)); + assertThat(tagCursor.getCount(), is(prepopulatedWorkSpecIds.length)); + boolean[] foundWorkSpecId = new boolean[prepopulatedWorkSpecIds.length]; + for (int i = 0; i < prepopulatedWorkSpecIds.length; ++i) { + tagCursor.moveToPosition(i); + assertThat(tagCursor.getString(tagCursor.getColumnIndex("tag")), + is(TestWorker.class.getName())); + String currentId = tagCursor.getString(tagCursor.getColumnIndex("work_spec_id")); + for (int j = 0; j < prepopulatedWorkSpecIds.length; ++j) { + if (prepopulatedWorkSpecIds[j].equals(currentId)) { + foundWorkSpecId[j] = true; + break; + } + } + } + for (int i = 0; i < prepopulatedWorkSpecIds.length; ++i) { + assertThat(foundWorkSpecId[i], is(true)); + } tagCursor.close(); Cursor cursor = database.query(CHECK_SYSTEM_ID_INFO); @@ -161,89 +173,96 @@ public class WorkDatabaseMigrationTest { @Test @MediumTest - public void testMigrationVersion2To1() throws IOException { + public void testMigrationVersion2To3() throws IOException { SupportSQLiteDatabase database = - mMigrationTestHelper.createDatabase(TEST_DATABASE, NEW_VERSION); - - String workSpecId1 = UUID.randomUUID().toString(); - String workSpecId2 = UUID.randomUUID().toString(); - - // insert SystemIdInfo - database.execSQL(INSERT_SYSTEM_ID_INFO, new Object[]{workSpecId1, 1}); - database.execSQL(INSERT_SYSTEM_ID_INFO, new Object[]{workSpecId2, 2}); - - database.close(); - + mMigrationTestHelper.createDatabase(TEST_DATABASE, VERSION_2); + WorkDatabaseMigrations.WorkMigration migration2To3 = + new WorkDatabaseMigrations.WorkMigration(mContext, VERSION_2, VERSION_3); database = mMigrationTestHelper.runMigrationsAndValidate( TEST_DATABASE, - OLD_VERSION, + VERSION_3, VALIDATE_DROPPED_TABLES, - WorkDatabaseMigrations.MIGRATION_2_1); + migration2To3); - Cursor cursor = database.query(CHECK_ALARM_INFO); - assertThat(cursor.getCount(), is(2)); - cursor.moveToFirst(); - assertThat(cursor.getString(cursor.getColumnIndex(COLUMN_WORKSPEC_ID)), is(workSpecId1)); - assertThat(cursor.getInt(cursor.getColumnIndex(COLUMN_ALARM_ID)), is(1)); - cursor.moveToNext(); - assertThat(cursor.getString(cursor.getColumnIndex(COLUMN_WORKSPEC_ID)), is(workSpecId2)); - assertThat(cursor.getInt(cursor.getColumnIndex(COLUMN_ALARM_ID)), is(2)); - cursor.close(); - - assertThat(checkExists(database, TABLE_SYSTEM_ID_INFO), is(false)); - assertThat(checkExists(database, TABLE_WORKSPEC), is(true)); - assertThat(checkExists(database, TABLE_WORKTAG), is(true)); - assertThat(checkExists(database, TABLE_WORKNAME), is(true)); + Preferences preferences = new Preferences(mContext); + assertThat(preferences.needsReschedule(), is(true)); database.close(); } @Test @MediumTest - public void testMigrationVersion1To2To1() throws IOException { + public void testMigrationVersion3To4() throws IOException { SupportSQLiteDatabase database = - mMigrationTestHelper.createDatabase(TEST_DATABASE, OLD_VERSION); + mMigrationTestHelper.createDatabase(TEST_DATABASE, VERSION_3); - String workSpecId1 = UUID.randomUUID().toString(); - String workSpecId2 = UUID.randomUUID().toString(); + String oneTimeWorkSpecId = UUID.randomUUID().toString(); + long scheduleRequestedAt = System.currentTimeMillis(); + ContentValues oneTimeWorkSpecContentValues = contentValues(oneTimeWorkSpecId); + oneTimeWorkSpecContentValues.put("schedule_requested_at", scheduleRequestedAt); - // insert alarmInfos - database.execSQL(INSERT_ALARM_INFO, new Object[]{workSpecId1, 1}); - database.execSQL(INSERT_ALARM_INFO, new Object[]{workSpecId2, 2}); + String periodicWorkSpecId = UUID.randomUUID().toString(); + ContentValues periodicWorkSpecContentValues = contentValues(periodicWorkSpecId); + periodicWorkSpecContentValues.put("interval_duration", 15 * 60 * 1000L); - database.close(); + database.insert("workspec", CONFLICT_FAIL, oneTimeWorkSpecContentValues); + database.insert("workspec", CONFLICT_FAIL, periodicWorkSpecContentValues); database = mMigrationTestHelper.runMigrationsAndValidate( TEST_DATABASE, - NEW_VERSION, + VERSION_4, VALIDATE_DROPPED_TABLES, - WorkDatabaseMigrations.MIGRATION_1_2); - - database.close(); + MIGRATION_3_4); - database = mMigrationTestHelper.runMigrationsAndValidate( - TEST_DATABASE, - OLD_VERSION, - VALIDATE_DROPPED_TABLES, - WorkDatabaseMigrations.MIGRATION_2_1); - - Cursor cursor = database.query(CHECK_ALARM_INFO); + Cursor cursor = database.query("SELECT * from workspec"); assertThat(cursor.getCount(), is(2)); cursor.moveToFirst(); - assertThat(cursor.getString(cursor.getColumnIndex(COLUMN_WORKSPEC_ID)), is(workSpecId1)); - assertThat(cursor.getInt(cursor.getColumnIndex(COLUMN_ALARM_ID)), is(1)); + assertThat(cursor.getString(cursor.getColumnIndex("id")), + is(oneTimeWorkSpecId)); + assertThat(cursor.getLong(cursor.getColumnIndex("schedule_requested_at")), + is(scheduleRequestedAt)); cursor.moveToNext(); - assertThat(cursor.getString(cursor.getColumnIndex(COLUMN_WORKSPEC_ID)), is(workSpecId2)); - assertThat(cursor.getInt(cursor.getColumnIndex(COLUMN_ALARM_ID)), is(2)); - cursor.close(); - - assertThat(checkExists(database, TABLE_SYSTEM_ID_INFO), is(false)); - assertThat(checkExists(database, TABLE_WORKSPEC), is(true)); - assertThat(checkExists(database, TABLE_WORKTAG), is(true)); - assertThat(checkExists(database, TABLE_WORKNAME), is(true)); + assertThat(cursor.getString(cursor.getColumnIndex("id")), + is(periodicWorkSpecId)); + if (Build.VERSION.SDK_INT >= WorkManagerImpl.MIN_JOB_SCHEDULER_API_LEVEL) { + assertThat(cursor.getLong(cursor.getColumnIndex("schedule_requested_at")), + is(0L)); + } else { + assertThat(cursor.getLong(cursor.getColumnIndex("schedule_requested_at")), + is(WorkSpec.SCHEDULE_NOT_REQUESTED_YET)); + } database.close(); } + @NonNull + private ContentValues contentValues(String workSpecId) { + ContentValues contentValues = new ContentValues(); + contentValues.put("id", workSpecId); + contentValues.put("state", WorkTypeConverters.StateIds.ENQUEUED); + contentValues.put("worker_class_name", TestWorker.class.getName()); + contentValues.put("input_merger_class_name", OverwritingInputMerger.class.getName()); + contentValues.put("input", Data.toByteArray(Data.EMPTY)); + contentValues.put("output", Data.toByteArray(Data.EMPTY)); + contentValues.put("initial_delay", 0L); + contentValues.put("interval_duration", 0L); + contentValues.put("flex_duration", 0L); + contentValues.put("required_network_type", false); + contentValues.put("requires_charging", false); + contentValues.put("requires_device_idle", false); + contentValues.put("requires_battery_not_low", false); + contentValues.put("requires_storage_not_low", false); + contentValues.put("content_uri_triggers", + WorkTypeConverters.contentUriTriggersToByteArray(new ContentUriTriggers())); + contentValues.put("run_attempt_count", 0); + contentValues.put("backoff_policy", + WorkTypeConverters.backoffPolicyToInt(BackoffPolicy.EXPONENTIAL)); + contentValues.put("backoff_delay_duration", WorkRequest.DEFAULT_BACKOFF_DELAY_MILLIS); + contentValues.put("period_start_time", 0L); + contentValues.put("minimum_retention_duration", 0L); + contentValues.put("schedule_requested_at", WorkSpec.SCHEDULE_NOT_REQUESTED_YET); + return contentValues; + } + private boolean checkExists(SupportSQLiteDatabase database, String tableName) { Cursor cursor = null; try { diff --git a/work/workmanager/src/androidTest/java/androidx/work/impl/WorkManagerImplLargeExecutorTest.java b/work/workmanager/src/androidTest/java/androidx/work/impl/WorkManagerImplLargeExecutorTest.java index 72ebe944b00..23996eb2367 100644 --- a/work/workmanager/src/androidTest/java/androidx/work/impl/WorkManagerImplLargeExecutorTest.java +++ b/work/workmanager/src/androidTest/java/androidx/work/impl/WorkManagerImplLargeExecutorTest.java @@ -18,6 +18,7 @@ package androidx.work.impl; import static androidx.work.worker.CheckLimitsWorker.KEY_EXCEEDS_SCHEDULER_LIMIT; import static androidx.work.worker.CheckLimitsWorker.KEY_LIMIT_TO_ENFORCE; +import static androidx.work.worker.CheckLimitsWorker.KEY_RECURSIVE; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; @@ -65,8 +66,7 @@ import java.util.concurrent.TimeUnit; @RunWith(AndroidJUnit4.class) public class WorkManagerImplLargeExecutorTest { - private static final int NUM_WORKERS = 500; - private static final int TEST_TIMEOUT_SECONDS = 30; + private static final int NUM_WORKERS = 200; // ThreadPoolExecutor parameters. private static final int MIN_POOL_SIZE = 0; @@ -172,7 +172,66 @@ public class WorkManagerImplLargeExecutorTest { }); continuation.enqueue(); - latch.await(TEST_TIMEOUT_SECONDS, TimeUnit.SECONDS); + latch.await(120L, TimeUnit.SECONDS); + assertThat(latch.getCount(), is(0L)); + } + + @Test + @LargeTest + @SdkSuppress(maxSdkVersion = 22) + public void testSchedulerLimitsRecursive() throws InterruptedException { + List<OneTimeWorkRequest> workRequests = new ArrayList<>(NUM_WORKERS); + final Set<UUID> completed = new HashSet<>(NUM_WORKERS); + final int schedulerLimit = mWorkManagerImpl + .getConfiguration() + .getMaxSchedulerLimit(); + + final Data input = new Data.Builder() + .putBoolean(KEY_RECURSIVE, true) + .putInt(KEY_LIMIT_TO_ENFORCE, schedulerLimit) + .build(); + + for (int i = 0; i < NUM_WORKERS; i++) { + OneTimeWorkRequest request = new OneTimeWorkRequest.Builder(CheckLimitsWorker.class) + .setInputData(input) + .build(); + + workRequests.add(request); + } + + + final CountDownLatch latch = new CountDownLatch(NUM_WORKERS * 2); // recursive + WorkContinuation continuation = mWorkManagerImpl.beginWith(workRequests); + + // There are more workers being enqueued recursively so use implicit tags. + mWorkManagerImpl.getStatusesByTag(CheckLimitsWorker.class.getName()) + .observe(mLifecycleOwner, new Observer<List<WorkStatus>>() { + @Override + public void onChanged(@Nullable List<WorkStatus> workStatuses) { + if (workStatuses == null || workStatuses.isEmpty()) { + return; + } + + for (WorkStatus workStatus: workStatuses) { + if (workStatus.getState().isFinished()) { + + Data output = workStatus.getOutputData(); + + boolean exceededLimits = output.getBoolean( + KEY_EXCEEDS_SCHEDULER_LIMIT, true); + + assertThat(exceededLimits, is(false)); + if (!completed.contains(workStatus.getId())) { + completed.add(workStatus.getId()); + latch.countDown(); + } + } + } + } + }); + + continuation.enqueue(); + latch.await(240L, TimeUnit.SECONDS); assertThat(latch.getCount(), is(0L)); } } diff --git a/work/workmanager/src/androidTest/java/androidx/work/impl/WorkManagerImplTest.java b/work/workmanager/src/androidTest/java/androidx/work/impl/WorkManagerImplTest.java index 3e01164c251..4e1dc8fa8c7 100644 --- a/work/workmanager/src/androidTest/java/androidx/work/impl/WorkManagerImplTest.java +++ b/work/workmanager/src/androidTest/java/androidx/work/impl/WorkManagerImplTest.java @@ -27,6 +27,7 @@ import static androidx.work.State.ENQUEUED; import static androidx.work.State.FAILED; import static androidx.work.State.RUNNING; import static androidx.work.State.SUCCEEDED; +import static androidx.work.impl.model.WorkSpec.SCHEDULE_NOT_REQUESTED_YET; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; @@ -1405,6 +1406,7 @@ public class WorkManagerImplTest { WorkDatabase.generateCleanupCallback().onOpen(db); assertThat(workSpecDao.getState(work.getStringId()), is(ENQUEUED)); + assertThat(work.getWorkSpec().scheduleRequestedAt, is(SCHEDULE_NOT_REQUESTED_YET)); } @Test @@ -1508,7 +1510,7 @@ public class WorkManagerImplTest { WorkSpec workSpec = mDatabase.workSpecDao().getWorkSpec(work.getStringId()); assertThat(workSpec.workerClassName, is(ConstraintTrackingWorker.class.getName())); assertThat(workSpec.input.getString( - ConstraintTrackingWorker.ARGUMENT_CLASS_NAME, null), + ConstraintTrackingWorker.ARGUMENT_CLASS_NAME), is(TestWorker.class.getName())); } @@ -1526,7 +1528,7 @@ public class WorkManagerImplTest { WorkSpec workSpec = mDatabase.workSpecDao().getWorkSpec(work.getStringId()); assertThat(workSpec.workerClassName, is(ConstraintTrackingWorker.class.getName())); assertThat(workSpec.input.getString( - ConstraintTrackingWorker.ARGUMENT_CLASS_NAME, null), + ConstraintTrackingWorker.ARGUMENT_CLASS_NAME), is(TestWorker.class.getName())); } diff --git a/work/workmanager/src/androidTest/java/androidx/work/impl/WorkerWrapperTest.java b/work/workmanager/src/androidTest/java/androidx/work/impl/WorkerWrapperTest.java index 739fcf7b626..44e65f808f9 100644 --- a/work/workmanager/src/androidTest/java/androidx/work/impl/WorkerWrapperTest.java +++ b/work/workmanager/src/androidTest/java/androidx/work/impl/WorkerWrapperTest.java @@ -31,6 +31,7 @@ import static org.hamcrest.Matchers.arrayContaining; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.greaterThan; +import static org.mockito.Mockito.atLeast; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; @@ -38,6 +39,7 @@ import static org.mockito.Mockito.verify; import android.content.Context; import android.net.Uri; +import android.os.Build; import android.support.test.InstrumentationRegistry; import android.support.test.filters.LargeTest; import android.support.test.filters.SmallTest; @@ -507,6 +509,7 @@ public class WorkerWrapperTest extends DatabaseTest { insertWork(periodicWork); new WorkerWrapper.Builder(mContext, mConfiguration, mDatabase, periodicWorkId) .withListener(mMockListener) + .withSchedulers(Collections.singletonList(mMockScheduler)) .build() .run(); @@ -514,6 +517,15 @@ public class WorkerWrapperTest extends DatabaseTest { verify(mMockListener).onExecuted(periodicWorkId, true, false); assertThat(periodicWorkSpecAfterFirstRun.runAttemptCount, is(0)); assertThat(periodicWorkSpecAfterFirstRun.state, is(ENQUEUED)); + // SystemAlarmScheduler needs to reschedule the same worker. + if (Build.VERSION.SDK_INT <= WorkManagerImpl.MAX_PRE_JOB_SCHEDULER_API_LEVEL) { + ArgumentCaptor<WorkSpec> captor = ArgumentCaptor.forClass(WorkSpec.class); + verify(mMockScheduler, atLeast(1)) + .schedule(captor.capture()); + + WorkSpec workSpec = captor.getValue(); + assertThat(workSpec.id, is(periodicWorkId)); + } } @Test @@ -563,16 +575,34 @@ public class WorkerWrapperTest extends DatabaseTest { @Test @SmallTest public void testScheduler() { - OneTimeWorkRequest work = new OneTimeWorkRequest.Builder(TestWorker.class).build(); - insertWork(work); - Scheduler mockScheduler = mock(Scheduler.class); + OneTimeWorkRequest prerequisiteWork = + new OneTimeWorkRequest.Builder(TestWorker.class).build(); + OneTimeWorkRequest work = new OneTimeWorkRequest.Builder(TestWorker.class) + .setInitialState(BLOCKED).build(); + Dependency dependency = new Dependency(work.getStringId(), prerequisiteWork.getStringId()); - new WorkerWrapper.Builder(mContext, mConfiguration, mDatabase, work.getStringId()) - .withSchedulers(Collections.singletonList(mockScheduler)) + mDatabase.beginTransaction(); + try { + insertWork(prerequisiteWork); + insertWork(work); + mDependencyDao.insertDependency(dependency); + mDatabase.setTransactionSuccessful(); + } finally { + mDatabase.endTransaction(); + } + + new WorkerWrapper.Builder( + mContext, + mConfiguration, + mDatabase, + prerequisiteWork.getStringId()) + .withSchedulers(Collections.singletonList(mMockScheduler)) .build() .run(); - verify(mockScheduler).schedule(); + ArgumentCaptor<WorkSpec> captor = ArgumentCaptor.forClass(WorkSpec.class); + verify(mMockScheduler).schedule(captor.capture()); + assertThat(captor.getValue().id, is(work.getStringId())); } @Test @@ -603,7 +633,7 @@ public class WorkerWrapperTest extends DatabaseTest { new Extras(input, Collections.<String>emptyList(), null, 1)); assertThat(worker, is(notNullValue())); - assertThat(worker.getInputData().getString(key, null), is(expectedValue)); + assertThat(worker.getInputData().getString(key), is(expectedValue)); work = new OneTimeWorkRequest.Builder(TestWorker.class).build(); worker = WorkerWrapper.workerFromWorkSpec( diff --git a/work/workmanager/src/androidTest/java/androidx/work/impl/background/systemalarm/SystemAlarmDispatcherTest.java b/work/workmanager/src/androidTest/java/androidx/work/impl/background/systemalarm/SystemAlarmDispatcherTest.java index cfe2cef7db5..2944087cd21 100644 --- a/work/workmanager/src/androidTest/java/androidx/work/impl/background/systemalarm/SystemAlarmDispatcherTest.java +++ b/work/workmanager/src/androidTest/java/androidx/work/impl/background/systemalarm/SystemAlarmDispatcherTest.java @@ -389,6 +389,7 @@ public class SystemAlarmDispatcherTest extends DatabaseTest { // Use a mocked scheduler in this test. Scheduler scheduler = mock(Scheduler.class); doCallRealMethod().when(mWorkManager).rescheduleEligibleWork(); + when(mWorkManager.getApplicationContext()).thenReturn(mContext); when(mWorkManager.getSchedulers()).thenReturn(Collections.singletonList(scheduler)); OneTimeWorkRequest failed = new OneTimeWorkRequest.Builder(TestWorker.class) diff --git a/work/workmanager/src/androidTest/java/androidx/work/impl/background/systemalarm/WorkTimerTest.java b/work/workmanager/src/androidTest/java/androidx/work/impl/background/systemalarm/WorkTimerTest.java index 78986c3bcc3..700071ad784 100644 --- a/work/workmanager/src/androidTest/java/androidx/work/impl/background/systemalarm/WorkTimerTest.java +++ b/work/workmanager/src/androidTest/java/androidx/work/impl/background/systemalarm/WorkTimerTest.java @@ -49,7 +49,7 @@ public class WorkTimerTest { public void testTimer_withListenerAndCleanUp() throws InterruptedException { TestTimeLimitExceededListener listenerSpy = spy(mListener); mWorkTimer.startTimer(WORKSPEC_ID_1, 0, listenerSpy); - Thread.sleep(10); // introduce a small delay + Thread.sleep(100); // introduce a small delay verify(listenerSpy, times(1)).onTimeLimitExceeded(WORKSPEC_ID_1); assertThat(mWorkTimer.getTimerMap().size(), is(0)); assertThat(mWorkTimer.getListeners().size(), is(0)); 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 323757a0010..3d6b4c09251 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 @@ -18,7 +18,6 @@ package androidx.work.impl.utils; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; -import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; @@ -76,37 +75,28 @@ public class ForceStopRunnableTest { @Test public void testReschedulesOnForceStop() { ForceStopRunnable runnable = spy(mRunnable); - when(runnable.shouldCancelPersistedJobs()).thenReturn(false); + when(runnable.shouldRescheduleWorkers()).thenReturn(false); when(runnable.isForceStopped()).thenReturn(true); runnable.run(); verify(mWorkManager, times(1)).rescheduleEligibleWork(); + verify(mWorkManager, times(1)).onForceStopRunnableCompleted(); } @Test public void test_doNothingWhenNotForceStopped() { ForceStopRunnable runnable = spy(mRunnable); - when(runnable.shouldCancelPersistedJobs()).thenReturn(false); + when(runnable.shouldRescheduleWorkers()).thenReturn(false); when(runnable.isForceStopped()).thenReturn(false); runnable.run(); verify(mWorkManager, times(0)).rescheduleEligibleWork(); + verify(mWorkManager, times(1)).onForceStopRunnableCompleted(); } @Test - public void test_cancelAllJobSchedulerJobs() { + public void test_rescheduleWorkers_updatesSharedPreferences() { ForceStopRunnable runnable = spy(mRunnable); - doNothing().when(runnable).cancelAllInJobScheduler(); - when(runnable.shouldCancelPersistedJobs()).thenReturn(true); + when(runnable.shouldRescheduleWorkers()).thenReturn(true); runnable.run(); - verify(runnable, times(1)).cancelAllInJobScheduler(); - verify(mPreferences, times(1)).setMigratedPersistedJobs(); - } - - @Test - public void test_doNothingWhenThereIsNothingToCancel() { - ForceStopRunnable runnable = spy(mRunnable); - doNothing().when(runnable).cancelAllInJobScheduler(); - when(runnable.shouldCancelPersistedJobs()).thenReturn(false); - runnable.run(); - verify(runnable, times(0)).cancelAllInJobScheduler(); + verify(mPreferences, times(1)).setNeedsReschedule(false); } } diff --git a/work/workmanager/src/androidTest/java/androidx/work/worker/CheckLimitsWorker.java b/work/workmanager/src/androidTest/java/androidx/work/worker/CheckLimitsWorker.java index 11763641ed8..f0fb9a1d193 100644 --- a/work/workmanager/src/androidTest/java/androidx/work/worker/CheckLimitsWorker.java +++ b/work/workmanager/src/androidTest/java/androidx/work/worker/CheckLimitsWorker.java @@ -21,6 +21,7 @@ import static androidx.work.Worker.Result.SUCCESS; import android.support.annotation.NonNull; import androidx.work.Data; +import androidx.work.OneTimeWorkRequest; import androidx.work.Worker; import androidx.work.impl.Scheduler; import androidx.work.impl.WorkManagerImpl; @@ -30,6 +31,7 @@ import java.util.List; public class CheckLimitsWorker extends Worker { /* The limit to enforce */ + public static final String KEY_RECURSIVE = "recursive"; public static final String KEY_LIMIT_TO_ENFORCE = "limit"; /* The output key which tells us if we exceeded the scheduler limits. */ @@ -39,6 +41,7 @@ public class CheckLimitsWorker extends Worker { @Override public Result doWork() { Data input = getInputData(); + boolean isRecursive = input.getBoolean(KEY_RECURSIVE, false); int limitToEnforce = input.getInt(KEY_LIMIT_TO_ENFORCE, Scheduler.MAX_SCHEDULER_LIMIT); WorkManagerImpl workManager = WorkManagerImpl.getInstance(); List<WorkSpec> eligibleWorkSpecs = workManager.getWorkDatabase() @@ -49,8 +52,19 @@ public class CheckLimitsWorker extends Worker { Data output = new Data.Builder() .putBoolean(KEY_EXCEEDS_SCHEDULER_LIMIT, exceedsLimits) .build(); - setOutputData(output); + if (isRecursive) { + // kick off another Worker, which is not recursive. + Data newRequestData = new Data.Builder() + .putAll(getInputData()) + .putBoolean(KEY_RECURSIVE, false) + .build(); + + OneTimeWorkRequest newRequest = new OneTimeWorkRequest.Builder(CheckLimitsWorker.class) + .setInputData(newRequestData) + .build(); + workManager.enqueue(newRequest); + } return SUCCESS; } } diff --git a/work/workmanager/src/main/java/androidx/work/Configuration.java b/work/workmanager/src/main/java/androidx/work/Configuration.java index 77f5ffa9b44..a74c4b5945f 100644 --- a/work/workmanager/src/main/java/androidx/work/Configuration.java +++ b/work/workmanager/src/main/java/androidx/work/Configuration.java @@ -103,7 +103,7 @@ public final class Configuration { } } - private Executor createDefaultExecutor() { + private @NonNull Executor createDefaultExecutor() { return Executors.newFixedThreadPool( // This value is the same as the core pool size for AsyncTask#THREAD_POOL_EXECUTOR. Math.max(2, Math.min(Runtime.getRuntime().availableProcessors() - 1, 4))); @@ -125,7 +125,7 @@ public final class Configuration { * @param executor An {@link Executor} for processing work * @return This {@link Builder} instance */ - public Builder setExecutor(@NonNull Executor executor) { + public @NonNull Builder setExecutor(@NonNull Executor executor) { mExecutor = executor; return this; } @@ -139,7 +139,9 @@ public final class Configuration { * @return This {@link Builder} instance * @throws IllegalArgumentException when the size of the range is < 1000 */ - public Builder setJobSchedulerJobIdRange(int minJobSchedulerId, int maxJobSchedulerId) { + public @NonNull Builder setJobSchedulerJobIdRange( + int minJobSchedulerId, + int maxJobSchedulerId) { if ((maxJobSchedulerId - minJobSchedulerId) < 1000) { throw new IllegalArgumentException( "WorkManager needs a range of at least 1000 job ids."); @@ -167,7 +169,7 @@ public final class Configuration { * @throws IllegalArgumentException when the number of jobs < * {@link Configuration#MIN_SCHEDULER_LIMIT} */ - public Builder setMaxSchedulerLimit(int maxSchedulerLimit) { + public @NonNull Builder setMaxSchedulerLimit(int maxSchedulerLimit) { if (maxSchedulerLimit < MIN_SCHEDULER_LIMIT) { throw new IllegalArgumentException( "WorkManager needs to be able to schedule at least 20 jobs in " @@ -185,7 +187,7 @@ public final class Configuration { * @deprecated Use the {@link Configuration.Builder#setExecutor(Executor)} method instead */ @Deprecated - public Builder withExecutor(@NonNull Executor executor) { + public @NonNull Builder withExecutor(@NonNull Executor executor) { mExecutor = executor; return this; } @@ -195,7 +197,7 @@ public final class Configuration { * * @return A {@link Configuration} object with this {@link Builder}'s parameters. */ - public Configuration build() { + public @NonNull Configuration build() { return new Configuration(this); } } diff --git a/work/workmanager/src/main/java/androidx/work/Constraints.java b/work/workmanager/src/main/java/androidx/work/Constraints.java index d6d6ad6ab48..8b8a740d12b 100644 --- a/work/workmanager/src/main/java/androidx/work/Constraints.java +++ b/work/workmanager/src/main/java/androidx/work/Constraints.java @@ -20,6 +20,7 @@ import android.arch.persistence.room.ColumnInfo; import android.net.Uri; import android.os.Build; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.support.annotation.RequiresApi; /** @@ -29,23 +30,24 @@ public final class Constraints { public static final Constraints NONE = new Constraints.Builder().build(); + // TODO(sumir): Need to make this @NonNull, but that requires a db migration. @ColumnInfo(name = "required_network_type") - NetworkType mRequiredNetworkType; + private NetworkType mRequiredNetworkType; @ColumnInfo(name = "requires_charging") - boolean mRequiresCharging; + private boolean mRequiresCharging; @ColumnInfo(name = "requires_device_idle") - boolean mRequiresDeviceIdle; + private boolean mRequiresDeviceIdle; @ColumnInfo(name = "requires_battery_not_low") - boolean mRequiresBatteryNotLow; + private boolean mRequiresBatteryNotLow; @ColumnInfo(name = "requires_storage_not_low") - boolean mRequiresStorageNotLow; + private boolean mRequiresStorageNotLow; @ColumnInfo(name = "content_uri_triggers") - ContentUriTriggers mContentUriTriggers; + private @Nullable ContentUriTriggers mContentUriTriggers; public Constraints() { // stub required for room } @@ -116,12 +118,12 @@ public final class Constraints { } @RequiresApi(24) - public void setContentUriTriggers(ContentUriTriggers mContentUriTriggers) { + public void setContentUriTriggers(@Nullable ContentUriTriggers mContentUriTriggers) { this.mContentUriTriggers = mContentUriTriggers; } @RequiresApi(24) - public ContentUriTriggers getContentUriTriggers() { + public @Nullable ContentUriTriggers getContentUriTriggers() { return mContentUriTriggers; } @@ -130,7 +132,7 @@ public final class Constraints { */ @RequiresApi(24) public boolean hasContentUriTriggers() { - return mContentUriTriggers.size() > 0; + return mContentUriTriggers != null && mContentUriTriggers.size() > 0; } @Override @@ -180,7 +182,7 @@ public final class Constraints { * @param requiresCharging true if device must be plugged in, false otherwise * @return current builder */ - public Builder setRequiresCharging(boolean requiresCharging) { + public @NonNull Builder setRequiresCharging(boolean requiresCharging) { this.mRequiresCharging = requiresCharging; return this; } @@ -193,7 +195,7 @@ public final class Constraints { * @return current builder */ @RequiresApi(23) - public Builder setRequiresDeviceIdle(boolean requiresDeviceIdle) { + public @NonNull Builder setRequiresDeviceIdle(boolean requiresDeviceIdle) { this.mRequiresDeviceIdle = requiresDeviceIdle; return this; } @@ -205,7 +207,7 @@ public final class Constraints { * @param networkType type of network required * @return current builder */ - public Builder setRequiredNetworkType(@NonNull NetworkType networkType) { + public @NonNull Builder setRequiredNetworkType(@NonNull NetworkType networkType) { this.mRequiredNetworkType = networkType; return this; } @@ -218,7 +220,7 @@ public final class Constraints { * false otherwise * @return current builder */ - public Builder setRequiresBatteryNotLow(boolean requiresBatteryNotLow) { + public @NonNull Builder setRequiresBatteryNotLow(boolean requiresBatteryNotLow) { this.mRequiresBatteryNotLow = requiresBatteryNotLow; return this; } @@ -231,7 +233,7 @@ public final class Constraints { * threshold, false otherwise * @return current builder */ - public Builder setRequiresStorageNotLow(boolean requiresStorageNotLow) { + public @NonNull Builder setRequiresStorageNotLow(boolean requiresStorageNotLow) { this.mRequiresStorageNotLow = requiresStorageNotLow; return this; } @@ -246,7 +248,7 @@ public final class Constraints { * @return The current {@link Builder} */ @RequiresApi(24) - public Builder addContentUriTrigger(Uri uri, boolean triggerForDescendants) { + public @NonNull Builder addContentUriTrigger(Uri uri, boolean triggerForDescendants) { mContentUriTriggers.add(uri, triggerForDescendants); return this; } @@ -256,7 +258,7 @@ public final class Constraints { * * @return new {@link Constraints} which can be attached to a {@link WorkRequest} */ - public Constraints build() { + public @NonNull Constraints build() { return new Constraints(this); } } diff --git a/work/workmanager/src/main/java/androidx/work/ContentUriTriggers.java b/work/workmanager/src/main/java/androidx/work/ContentUriTriggers.java index 0702cc05d1c..ca6dd308f98 100644 --- a/work/workmanager/src/main/java/androidx/work/ContentUriTriggers.java +++ b/work/workmanager/src/main/java/androidx/work/ContentUriTriggers.java @@ -27,6 +27,7 @@ import java.util.Set; * Stores a set of {@link Trigger}s */ public final class ContentUriTriggers implements Iterable<ContentUriTriggers.Trigger> { + private final Set<Trigger> mTriggers = new HashSet<>(); /** @@ -35,7 +36,7 @@ public final class ContentUriTriggers implements Iterable<ContentUriTriggers.Tri * @param triggerForDescendants {@code true} if any changes in descendants cause this * {@link WorkRequest} to run */ - public void add(Uri uri, boolean triggerForDescendants) { + public void add(@NonNull Uri uri, boolean triggerForDescendants) { Trigger trigger = new Trigger(uri, triggerForDescendants); mTriggers.add(trigger); } @@ -73,18 +74,15 @@ public final class ContentUriTriggers implements Iterable<ContentUriTriggers.Tri */ public static final class Trigger { - @NonNull - private final Uri mUri; + private final @NonNull Uri mUri; private final boolean mTriggerForDescendants; - public Trigger(@NonNull Uri uri, - boolean triggerForDescendants) { + Trigger(@NonNull Uri uri, boolean triggerForDescendants) { mUri = uri; mTriggerForDescendants = triggerForDescendants; } - @NonNull - public Uri getUri() { + public @NonNull Uri getUri() { return mUri; } diff --git a/work/workmanager/src/main/java/androidx/work/Data.java b/work/workmanager/src/main/java/androidx/work/Data.java index 376a00d97cc..b687a367e15 100644 --- a/work/workmanager/src/main/java/androidx/work/Data.java +++ b/work/workmanager/src/main/java/androidx/work/Data.java @@ -18,6 +18,7 @@ package androidx.work; import android.arch.persistence.room.TypeConverter; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.support.annotation.VisibleForTesting; import java.io.ByteArrayInputStream; @@ -60,7 +61,7 @@ public final class Data { * @param defaultValue The default value to return if the key is not found * @return The value specified by the key if it exists; the default value otherwise */ - public boolean getBoolean(String key, boolean defaultValue) { + public boolean getBoolean(@NonNull String key, boolean defaultValue) { Object value = mValues.get(key); if (value instanceof Boolean) { return (boolean) value; @@ -75,7 +76,7 @@ public final class Data { * @param key The key for the argument * @return The value specified by the key if it exists; {@code null} otherwise */ - public boolean[] getBooleanArray(String key) { + public @NonNull boolean[] getBooleanArray(@NonNull String key) { Object value = mValues.get(key); if (value instanceof Boolean[]) { Boolean[] array = (Boolean[]) value; @@ -97,7 +98,7 @@ public final class Data { * @param defaultValue The default value to return if the key is not found * @return The value specified by the key if it exists; the default value otherwise */ - public int getInt(String key, int defaultValue) { + public int getInt(@NonNull String key, int defaultValue) { Object value = mValues.get(key); if (value instanceof Integer) { return (int) value; @@ -112,7 +113,7 @@ public final class Data { * @param key The key for the argument * @return The value specified by the key if it exists; {@code null} otherwise */ - public int[] getIntArray(String key) { + public @NonNull int[] getIntArray(@NonNull String key) { Object value = mValues.get(key); if (value instanceof Integer[]) { Integer[] array = (Integer[]) value; @@ -133,7 +134,7 @@ public final class Data { * @param defaultValue The default value to return if the key is not found * @return The value specified by the key if it exists; the default value otherwise */ - public long getLong(String key, long defaultValue) { + public long getLong(@NonNull String key, long defaultValue) { Object value = mValues.get(key); if (value instanceof Long) { return (long) value; @@ -148,7 +149,7 @@ public final class Data { * @param key The key for the argument * @return The value specified by the key if it exists; {@code null} otherwise */ - public long[] getLongArray(String key) { + public @Nullable long[] getLongArray(@NonNull String key) { Object value = mValues.get(key); if (value instanceof Long[]) { Long[] array = (Long[]) value; @@ -169,7 +170,7 @@ public final class Data { * @param defaultValue The default value to return if the key is not found * @return The value specified by the key if it exists; the default value otherwise */ - public float getFloat(String key, float defaultValue) { + public float getFloat(@NonNull String key, float defaultValue) { Object value = mValues.get(key); if (value instanceof Float) { return (float) value; @@ -184,7 +185,7 @@ public final class Data { * @param key The key for the argument * @return The value specified by the key if it exists; {@code null} otherwise */ - public float[] getFloatArray(String key) { + public @Nullable float[] getFloatArray(@NonNull String key) { Object value = mValues.get(key); if (value instanceof Float[]) { Float[] array = (Float[]) value; @@ -205,7 +206,7 @@ public final class Data { * @param defaultValue The default value to return if the key is not found * @return The value specified by the key if it exists; the default value otherwise */ - public double getDouble(String key, double defaultValue) { + public double getDouble(@NonNull String key, double defaultValue) { Object value = mValues.get(key); if (value instanceof Double) { return (double) value; @@ -220,7 +221,7 @@ public final class Data { * @param key The key for the argument * @return The value specified by the key if it exists; {@code null} otherwise */ - public double[] getDoubleArray(String key) { + public @Nullable double[] getDoubleArray(@NonNull String key) { Object value = mValues.get(key); if (value instanceof Double[]) { Double[] array = (Double[]) value; @@ -238,15 +239,14 @@ public final class Data { * Get the String value for the given key. * * @param key The key for the argument - * @param defaultValue The default value to return if the key is not found * @return The value specified by the key if it exists; the default value otherwise */ - public String getString(String key, String defaultValue) { + public @Nullable String getString(@NonNull String key) { Object value = mValues.get(key); if (value instanceof String) { return (String) value; } else { - return defaultValue; + return null; } } @@ -256,7 +256,7 @@ public final class Data { * @param key The key for the argument * @return The value specified by the key if it exists; {@code null} otherwise */ - public String[] getStringArray(String key) { + public @Nullable String[] getStringArray(@NonNull String key) { Object value = mValues.get(key); if (value instanceof String[]) { return (String[]) value; @@ -271,7 +271,7 @@ public final class Data { * @return A {@link Map} of key-value pairs for this object; this Map is unmodifiable and should * be used for reads only. */ - public Map<String, Object> getKeyValueMap() { + public @NonNull Map<String, Object> getKeyValueMap() { return Collections.unmodifiableMap(mValues); } @@ -292,7 +292,7 @@ public final class Data { * {@link #MAX_DATA_BYTES} */ @TypeConverter - public static byte[] toByteArray(Data data) throws IllegalStateException { + public static @NonNull byte[] toByteArray(@NonNull Data data) throws IllegalStateException { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); ObjectOutputStream objectOutputStream = null; try { @@ -334,7 +334,7 @@ public final class Data { * @throws IllegalStateException if bytes is bigger than {@link #MAX_DATA_BYTES} */ @TypeConverter - public static Data fromByteArray(byte[] bytes) throws IllegalStateException { + public static @NonNull Data fromByteArray(@NonNull byte[] bytes) throws IllegalStateException { if (bytes.length > MAX_DATA_BYTES) { throw new IllegalStateException( "Data cannot occupy more than " + MAX_DATA_BYTES + "KB when serialized"); @@ -438,7 +438,7 @@ public final class Data { * @param value The value for this argument * @return The {@link Builder} */ - public Builder putBoolean(String key, boolean value) { + public @NonNull Builder putBoolean(@NonNull String key, boolean value) { mValues.put(key, value); return this; } @@ -450,7 +450,7 @@ public final class Data { * @param value The value for this argument * @return The {@link Builder} */ - public Builder putBooleanArray(String key, boolean[] value) { + public @NonNull Builder putBooleanArray(@NonNull String key, boolean[] value) { mValues.put(key, convertPrimitiveBooleanArray(value)); return this; } @@ -462,7 +462,7 @@ public final class Data { * @param value The value for this argument * @return The {@link Builder} */ - public Builder putInt(String key, int value) { + public @NonNull Builder putInt(@NonNull String key, int value) { mValues.put(key, value); return this; } @@ -474,7 +474,7 @@ public final class Data { * @param value The value for this argument * @return The {@link Builder} */ - public Builder putIntArray(String key, int[] value) { + public @NonNull Builder putIntArray(@NonNull String key, int[] value) { mValues.put(key, convertPrimitiveIntArray(value)); return this; } @@ -486,7 +486,7 @@ public final class Data { * @param value The value for this argument * @return The {@link Builder} */ - public Builder putLong(String key, long value) { + public @NonNull Builder putLong(@NonNull String key, long value) { mValues.put(key, value); return this; } @@ -498,7 +498,7 @@ public final class Data { * @param value The value for this argument * @return The {@link Builder} */ - public Builder putLongArray(String key, long[] value) { + public @NonNull Builder putLongArray(@NonNull String key, long[] value) { mValues.put(key, convertPrimitiveLongArray(value)); return this; } @@ -510,7 +510,7 @@ public final class Data { * @param value The value for this argument * @return The {@link Builder} */ - public Builder putFloat(String key, float value) { + public @NonNull Builder putFloat(@NonNull String key, float value) { mValues.put(key, value); return this; } @@ -522,7 +522,7 @@ public final class Data { * @param value The value for this argument * @return The {@link Builder} */ - public Builder putFloatArray(String key, float[] value) { + public @NonNull Builder putFloatArray(String key, float[] value) { mValues.put(key, convertPrimitiveFloatArray(value)); return this; } @@ -534,7 +534,7 @@ public final class Data { * @param value The value for this argument * @return The {@link Builder} */ - public Builder putDouble(String key, double value) { + public @NonNull Builder putDouble(@NonNull String key, double value) { mValues.put(key, value); return this; } @@ -546,7 +546,7 @@ public final class Data { * @param value The value for this argument * @return The {@link Builder} */ - public Builder putDoubleArray(String key, double[] value) { + public @NonNull Builder putDoubleArray(@NonNull String key, double[] value) { mValues.put(key, convertPrimitiveDoubleArray(value)); return this; } @@ -558,7 +558,7 @@ public final class Data { * @param value The value for this argument * @return The {@link Builder} */ - public Builder putString(String key, String value) { + public @NonNull Builder putString(@NonNull String key, String value) { mValues.put(key, value); return this; } @@ -570,7 +570,7 @@ public final class Data { * @param value The value for this argument * @return The {@link Builder} */ - public Builder putStringArray(String key, String[] value) { + public @NonNull Builder putStringArray(@NonNull String key, String[] value) { mValues.put(key, value); return this; } @@ -584,7 +584,7 @@ public final class Data { * @param data {@link Data} containing key-value pairs to add * @return The {@link Builder} */ - public Builder putAll(@NonNull Data data) { + public @NonNull Builder putAll(@NonNull Data data) { putAll(data.mValues); return this; } @@ -597,7 +597,7 @@ public final class Data { * @param values A {@link Map} of key-value pairs to add * @return The {@link Builder} */ - public Builder putAll(Map<String, Object> values) { + public @NonNull Builder putAll(@NonNull Map<String, Object> values) { for (Map.Entry<String, Object> entry : values.entrySet()) { String key = entry.getKey(); Object value = entry.getValue(); @@ -643,7 +643,7 @@ public final class Data { * @return The {@link Data} object containing all key-value pairs specified by this * {@link Builder}. */ - public Data build() { + public @NonNull Data build() { return new Data(mValues); } } diff --git a/work/workmanager/src/main/java/androidx/work/OneTimeWorkRequest.java b/work/workmanager/src/main/java/androidx/work/OneTimeWorkRequest.java index 57849be187d..22778e4ba0e 100644 --- a/work/workmanager/src/main/java/androidx/work/OneTimeWorkRequest.java +++ b/work/workmanager/src/main/java/androidx/work/OneTimeWorkRequest.java @@ -83,7 +83,7 @@ public final class OneTimeWorkRequest extends WorkRequest { * @param timeUnit The units of time for {@code duration} * @return The current {@link Builder} */ - public Builder setInitialDelay(long duration, @NonNull TimeUnit timeUnit) { + public @NonNull Builder setInitialDelay(long duration, @NonNull TimeUnit timeUnit) { mWorkSpec.initialDelay = timeUnit.toMillis(duration); return this; } @@ -95,7 +95,7 @@ public final class OneTimeWorkRequest extends WorkRequest { * @return The current {@link Builder} */ @RequiresApi(26) - public Builder setInitialDelay(Duration duration) { + public @NonNull Builder setInitialDelay(@NonNull Duration duration) { mWorkSpec.initialDelay = duration.toMillis(); return this; } @@ -110,14 +110,14 @@ public final class OneTimeWorkRequest extends WorkRequest { * {@link OneTimeWorkRequest} * @return The current {@link Builder} */ - public Builder setInputMerger(@NonNull Class<? extends InputMerger> inputMerger) { + public @NonNull Builder setInputMerger(@NonNull Class<? extends InputMerger> inputMerger) { mWorkSpec.inputMergerClassName = inputMerger.getName(); return this; } @Override - public OneTimeWorkRequest build() { + public @NonNull OneTimeWorkRequest build() { if (mBackoffCriteriaSet && Build.VERSION.SDK_INT >= 23 && mWorkSpec.constraints.requiresDeviceIdle()) { @@ -128,7 +128,7 @@ public final class OneTimeWorkRequest extends WorkRequest { } @Override - Builder getThis() { + @NonNull Builder getThis() { return this; } } diff --git a/work/workmanager/src/main/java/androidx/work/PeriodicWorkRequest.java b/work/workmanager/src/main/java/androidx/work/PeriodicWorkRequest.java index fe1a3870241..33f54924749 100644 --- a/work/workmanager/src/main/java/androidx/work/PeriodicWorkRequest.java +++ b/work/workmanager/src/main/java/androidx/work/PeriodicWorkRequest.java @@ -151,7 +151,7 @@ public final class PeriodicWorkRequest extends WorkRequest { } @Override - public PeriodicWorkRequest build() { + public @NonNull PeriodicWorkRequest build() { if (mBackoffCriteriaSet && Build.VERSION.SDK_INT >= 23 && mWorkSpec.constraints.requiresDeviceIdle()) { @@ -162,7 +162,7 @@ public final class PeriodicWorkRequest extends WorkRequest { } @Override - Builder getThis() { + @NonNull Builder getThis() { return this; } } diff --git a/work/workmanager/src/main/java/androidx/work/State.java b/work/workmanager/src/main/java/androidx/work/State.java index a654456d056..3541facd009 100644 --- a/work/workmanager/src/main/java/androidx/work/State.java +++ b/work/workmanager/src/main/java/androidx/work/State.java @@ -17,45 +17,45 @@ package androidx.work; /** - * The current status of a unit of work. + * The current state of a unit of work. */ public enum State { /** - * The status for work that is enqueued (hasn't completed and isn't running) + * The state for work that is enqueued (hasn't completed and isn't running) */ ENQUEUED, /** - * The status for work that is currently being executed + * The state for work that is currently being executed */ RUNNING, /** - * The status for work that has completed successfully + * The state for work that has completed successfully */ SUCCEEDED, /** - * The status for work that has completed in a failure state + * The state for work that has completed in a failure state */ FAILED, /** - * The status for work that is currently blocked because its prerequisites haven't finished + * The state for work that is currently blocked because its prerequisites haven't finished * successfully */ BLOCKED, /** - * The status for work that has been cancelled and will not execute + * The state for work that has been cancelled and will not execute */ CANCELLED; /** * Returns {@code true} if this State is considered finished. * - * @return {@code true} for {@link #SUCCEEDED}, {@link #FAILED}, and {@link #CANCELLED} States + * @return {@code true} for {@link #SUCCEEDED}, {@link #FAILED}, and {@link #CANCELLED} states */ public boolean isFinished() { return (this == SUCCEEDED || this == FAILED || this == CANCELLED); diff --git a/work/workmanager/src/main/java/androidx/work/SynchronousWorkContinuation.java b/work/workmanager/src/main/java/androidx/work/SynchronousWorkContinuation.java index 0b270211d7d..3a38ee767e9 100644 --- a/work/workmanager/src/main/java/androidx/work/SynchronousWorkContinuation.java +++ b/work/workmanager/src/main/java/androidx/work/SynchronousWorkContinuation.java @@ -16,6 +16,7 @@ package androidx.work; +import android.support.annotation.NonNull; import android.support.annotation.WorkerThread; import java.util.List; @@ -41,5 +42,5 @@ public interface SynchronousWorkContinuation { * @return A {@link List} of {@link WorkStatus}es */ @WorkerThread - List<WorkStatus> getStatusesSync(); + @NonNull List<WorkStatus> getStatusesSync(); } diff --git a/work/workmanager/src/main/java/androidx/work/SynchronousWorkManager.java b/work/workmanager/src/main/java/androidx/work/SynchronousWorkManager.java index 9f633d8e3e0..3bb6b4d2ff3 100644 --- a/work/workmanager/src/main/java/androidx/work/SynchronousWorkManager.java +++ b/work/workmanager/src/main/java/androidx/work/SynchronousWorkManager.java @@ -17,6 +17,7 @@ package androidx.work; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.support.annotation.WorkerThread; import java.util.List; @@ -124,7 +125,8 @@ public interface SynchronousWorkManager { * own repository that must be updated or deleted in case someone cancels their work without * their prior knowledge. * - * @return The timestamp in milliseconds when a method that cancelled all work was last invoked + * @return The timestamp in milliseconds when a method that cancelled all work was last invoked; + * this timestamp may be {@code 0L} if this never occurred. */ @WorkerThread long getLastCancelAllTimeMillisSync(); @@ -148,10 +150,11 @@ public interface SynchronousWorkManager { * expected to be called from a background thread. * * @param id The id of the work - * @return A {@link WorkStatus} associated with {@code id} + * @return A {@link WorkStatus} associated with {@code id}, or {@code null} if {@code id} is not + * known to WorkManager */ @WorkerThread - WorkStatus getStatusByIdSync(@NonNull UUID id); + @Nullable WorkStatus getStatusByIdSync(@NonNull UUID id); /** * Gets the {@link WorkStatus} for all work with a given tag in a synchronous fashion. This @@ -161,7 +164,7 @@ public interface SynchronousWorkManager { * @return A list of {@link WorkStatus} for work tagged with {@code tag} */ @WorkerThread - List<WorkStatus> getStatusesByTagSync(@NonNull String tag); + @NonNull List<WorkStatus> getStatusesByTagSync(@NonNull String tag); /** * Gets the {@link WorkStatus} for all work for the chain of work with a given unique name in a @@ -171,5 +174,5 @@ public interface SynchronousWorkManager { * @return A list of {@link WorkStatus} for work in the chain named {@code uniqueWorkName} */ @WorkerThread - List<WorkStatus> getStatusesForUniqueWorkSync(@NonNull String uniqueWorkName); + @NonNull List<WorkStatus> getStatusesForUniqueWorkSync(@NonNull String uniqueWorkName); } diff --git a/work/workmanager/src/main/java/androidx/work/WorkContinuation.java b/work/workmanager/src/main/java/androidx/work/WorkContinuation.java index 80acc8284da..5c781b35f92 100644 --- a/work/workmanager/src/main/java/androidx/work/WorkContinuation.java +++ b/work/workmanager/src/main/java/androidx/work/WorkContinuation.java @@ -36,7 +36,7 @@ public abstract class WorkContinuation { * @return A {@link WorkContinuation} that allows for further chaining of dependent * {@link OneTimeWorkRequest} */ - public final WorkContinuation then(@NonNull OneTimeWorkRequest... work) { + public final @NonNull WorkContinuation then(@NonNull OneTimeWorkRequest... work) { return then(Arrays.asList(work)); } @@ -48,7 +48,7 @@ public abstract class WorkContinuation { * @return A {@link WorkContinuation} that allows for further chaining of dependent * {@link OneTimeWorkRequest} */ - public abstract WorkContinuation then(@NonNull List<OneTimeWorkRequest> work); + public abstract @NonNull WorkContinuation then(@NonNull List<OneTimeWorkRequest> work); /** * Returns a {@link LiveData} list of {@link WorkStatus} that provides information about work, @@ -57,7 +57,7 @@ public abstract class WorkContinuation { * * @return A {@link LiveData} containing a list of {@link WorkStatus}es */ - public abstract LiveData<List<WorkStatus>> getStatuses(); + public abstract @NonNull LiveData<List<WorkStatus>> getStatuses(); /** * Enqueues the instance of {@link WorkContinuation} on the background thread. @@ -70,7 +70,7 @@ public abstract class WorkContinuation { * @return A {@link SynchronousWorkContinuation} object, which gives access to synchronous * methods */ - public abstract SynchronousWorkContinuation synchronous(); + public abstract @NonNull SynchronousWorkContinuation synchronous(); /** * Combines multiple {@link WorkContinuation}s to allow for complex chaining. @@ -79,7 +79,7 @@ public abstract class WorkContinuation { * return value * @return A {@link WorkContinuation} that allows further chaining */ - public static WorkContinuation combine(@NonNull WorkContinuation... continuations) { + public static @NonNull WorkContinuation combine(@NonNull WorkContinuation... continuations) { return combine(Arrays.asList(continuations)); } @@ -90,7 +90,7 @@ public abstract class WorkContinuation { * return value * @return A {@link WorkContinuation} that allows further chaining */ - public static WorkContinuation combine(@NonNull List<WorkContinuation> continuations) { + public static @NonNull WorkContinuation combine(@NonNull List<WorkContinuation> continuations) { if (continuations.size() < 2) { throw new IllegalArgumentException( "WorkContinuation.combine() needs at least 2 continuations."); @@ -109,7 +109,7 @@ public abstract class WorkContinuation { * {@link OneTimeWorkRequest} provided. * @return A {@link WorkContinuation} that allows further chaining */ - public static WorkContinuation combine( + public static @NonNull WorkContinuation combine( @NonNull OneTimeWorkRequest work, @NonNull WorkContinuation... continuations) { return combine(work, Arrays.asList(continuations)); @@ -125,7 +125,7 @@ public abstract class WorkContinuation { * {@link OneTimeWorkRequest} provided. * @return A {@link WorkContinuation} that allows further chaining */ - public static WorkContinuation combine( + public static @NonNull WorkContinuation combine( @NonNull OneTimeWorkRequest work, @NonNull List<WorkContinuation> continuations) { return continuations.get(0).combineInternal(work, continuations); @@ -135,7 +135,7 @@ public abstract class WorkContinuation { * @hide */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) - protected abstract WorkContinuation combineInternal( + protected abstract @NonNull WorkContinuation combineInternal( @Nullable OneTimeWorkRequest work, @NonNull List<WorkContinuation> continuations); } diff --git a/work/workmanager/src/main/java/androidx/work/WorkManager.java b/work/workmanager/src/main/java/androidx/work/WorkManager.java index 5960e50fcab..939192a15c8 100644 --- a/work/workmanager/src/main/java/androidx/work/WorkManager.java +++ b/work/workmanager/src/main/java/androidx/work/WorkManager.java @@ -121,15 +121,35 @@ public abstract class WorkManager { /** * Retrieves the {@code default} singleton instance of {@link WorkManager}. * - * @return The singleton instance of {@link WorkManager} + * @return The singleton instance of {@link WorkManager}; this may be {@code null} in unusual + * circumstances where you have disabled automatic initialization and have failed to + * manually call {@link #initialize(Context, Configuration)}. + * @throws IllegalStateException If WorkManager is not initialized properly. This is most + * likely because you disabled the automatic initialization but forgot to manually + * call {@link WorkManager#initialize(Context, Configuration)}. */ - public static WorkManager getInstance() { - return WorkManagerImpl.getInstance(); + public static @NonNull WorkManager getInstance() { + WorkManager workManager = WorkManagerImpl.getInstance(); + if (workManager == null) { + throw new IllegalStateException("WorkManager is not initialized properly. The most " + + "likely cause is that you disabled WorkManagerInitializer in your manifest " + + "but forgot to call WorkManager#initialize in your Application#onCreate or a " + + "ContentProvider."); + } else { + return workManager; + } } /** - * Used to do a one-time initialization of the {@link WorkManager} singleton with the default - * configuration. + * Used to do a one-time initialization of the {@link WorkManager} singleton with a custom + * {@link Configuration}. By default, this method should not be called because WorkManager is + * automatically initialized. To initialize WorkManager yourself, please follow these steps: + * <p><ul> + * <li>Disable {@code androidx.work.impl.WorkManagerInitializer} in your manifest + * <li>In {@code Application#onCreate} or a {@code ContentProvider}, call this method before + * calling {@link WorkManager#getInstance()} + * </ul></p> + * This method has no effect if WorkManager is already initialized. * * @param context A {@link Context} object for configuration purposes. Internally, this class * will call {@link Context#getApplicationContext()}, so you may safely pass in @@ -164,7 +184,7 @@ public abstract class WorkManager { * @return A {@link WorkContinuation} that allows for further chaining of dependent * {@link OneTimeWorkRequest} */ - public final WorkContinuation beginWith(@NonNull OneTimeWorkRequest...work) { + public final @NonNull WorkContinuation beginWith(@NonNull OneTimeWorkRequest...work) { return beginWith(Arrays.asList(work)); } @@ -176,7 +196,7 @@ public abstract class WorkManager { * @return A {@link WorkContinuation} that allows for further chaining of dependent * {@link OneTimeWorkRequest} */ - public abstract WorkContinuation beginWith(@NonNull List<OneTimeWorkRequest> work); + public abstract @NonNull WorkContinuation beginWith(@NonNull List<OneTimeWorkRequest> work); /** * This method allows you to begin unique chains of work for situations where you only want one @@ -201,7 +221,7 @@ public abstract class WorkManager { * as a child of all leaf nodes labelled with {@code uniqueWorkName}. * @return A {@link WorkContinuation} that allows further chaining */ - public final WorkContinuation beginUniqueWork( + public final @NonNull WorkContinuation beginUniqueWork( @NonNull String uniqueWorkName, @NonNull ExistingWorkPolicy existingWorkPolicy, @NonNull OneTimeWorkRequest... work) { @@ -231,7 +251,7 @@ public abstract class WorkManager { * as a child of all leaf nodes labelled with {@code uniqueWorkName}. * @return A {@link WorkContinuation} that allows further chaining */ - public abstract WorkContinuation beginUniqueWork( + public abstract @NonNull WorkContinuation beginUniqueWork( @NonNull String uniqueWorkName, @NonNull ExistingWorkPolicy existingWorkPolicy, @NonNull List<OneTimeWorkRequest> work); @@ -307,17 +327,19 @@ public abstract class WorkManager { * must be updated or deleted in case someone cancels their work without their prior knowledge. * * @return A {@link LiveData} of the timestamp in milliseconds when method that cancelled all - * work was last invoked + * work was last invoked; this timestamp may be {@code 0L} if this never occurred. */ - public abstract LiveData<Long> getLastCancelAllTimeMillis(); + public abstract @NonNull LiveData<Long> getLastCancelAllTimeMillis(); /** * Gets a {@link LiveData} of the {@link WorkStatus} for a given work id. * * @param id The id of the work - * @return A {@link LiveData} of the {@link WorkStatus} associated with {@code id} + * @return A {@link LiveData} of the {@link WorkStatus} associated with {@code id}; note that + * this {@link WorkStatus} may be {@code null} if {@code id} is not known to + * WorkManager. */ - public abstract LiveData<WorkStatus> getStatusById(@NonNull UUID id); + public abstract @NonNull LiveData<WorkStatus> getStatusById(@NonNull UUID id); /** * Gets a {@link LiveData} of the {@link WorkStatus} for all work for a given tag. @@ -325,7 +347,7 @@ public abstract class WorkManager { * @param tag The tag of the work * @return A {@link LiveData} list of {@link WorkStatus} for work tagged with {@code tag} */ - public abstract LiveData<List<WorkStatus>> getStatusesByTag(@NonNull String tag); + public abstract @NonNull LiveData<List<WorkStatus>> getStatusesByTag(@NonNull String tag); /** * Gets a {@link LiveData} of the {@link WorkStatus} for all work in a work chain with a given @@ -335,7 +357,7 @@ public abstract class WorkManager { * @return A {@link LiveData} of the {@link WorkStatus} for work in the chain named * {@code uniqueWorkName} */ - public abstract LiveData<List<WorkStatus>> getStatusesForUniqueWork( + public abstract @NonNull LiveData<List<WorkStatus>> getStatusesForUniqueWork( @NonNull String uniqueWorkName); /** @@ -343,7 +365,7 @@ public abstract class WorkManager { * * @return A {@link SynchronousWorkManager} object, which gives access to synchronous methods */ - public abstract SynchronousWorkManager synchronous(); + public abstract @NonNull SynchronousWorkManager synchronous(); /** * @hide diff --git a/work/workmanager/src/main/java/androidx/work/WorkRequest.java b/work/workmanager/src/main/java/androidx/work/WorkRequest.java index cfb72b8f759..7a9602c73f6 100644 --- a/work/workmanager/src/main/java/androidx/work/WorkRequest.java +++ b/work/workmanager/src/main/java/androidx/work/WorkRequest.java @@ -68,7 +68,7 @@ public abstract class WorkRequest { * * @return The identifier for this unit of work */ - public UUID getId() { + public @NonNull UUID getId() { return mId; } @@ -79,7 +79,7 @@ public abstract class WorkRequest { * @hide */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) - public String getStringId() { + public @NonNull String getStringId() { return mId.toString(); } @@ -90,7 +90,7 @@ public abstract class WorkRequest { * @hide */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) - public WorkSpec getWorkSpec() { + public @NonNull WorkSpec getWorkSpec() { return mWorkSpec; } @@ -101,7 +101,7 @@ public abstract class WorkRequest { * @hide */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) - public Set<String> getTags() { + public @NonNull Set<String> getTags() { return mTags; } @@ -136,7 +136,7 @@ public abstract class WorkRequest { * @param timeUnit The {@link TimeUnit} for {@code backoffDelay} * @return The current {@link Builder} */ - public B setBackoffCriteria( + public @NonNull B setBackoffCriteria( @NonNull BackoffPolicy backoffPolicy, long backoffDelay, @NonNull TimeUnit timeUnit) { @@ -152,7 +152,7 @@ public abstract class WorkRequest { * @param constraints The constraints for the work * @return The current {@link Builder} */ - public B setConstraints(@NonNull Constraints constraints) { + public @NonNull B setConstraints(@NonNull Constraints constraints) { mWorkSpec.constraints = constraints; return getThis(); } @@ -163,7 +163,7 @@ public abstract class WorkRequest { * @param inputData key/value pairs that will be provided to the {@link Worker} class * @return The current {@link Builder} */ - public B setInputData(@NonNull Data inputData) { + public @NonNull B setInputData(@NonNull Data inputData) { mWorkSpec.input = inputData; return getThis(); } @@ -175,7 +175,7 @@ public abstract class WorkRequest { * @param tag A tag for identifying the work in queries. * @return The current {@link Builder} */ - public B addTag(@NonNull String tag) { + public @NonNull B addTag(@NonNull String tag) { mTags.add(tag); return getThis(); } @@ -196,7 +196,7 @@ public abstract class WorkRequest { * @param timeUnit The unit of time for {@code duration} * @return The current {@link Builder} */ - public B keepResultsForAtLeast(long duration, @NonNull TimeUnit timeUnit) { + public @NonNull B keepResultsForAtLeast(long duration, @NonNull TimeUnit timeUnit) { mWorkSpec.minimumRetentionDuration = timeUnit.toMillis(duration); return getThis(); } @@ -216,7 +216,7 @@ public abstract class WorkRequest { * @return The current {@link Builder} */ @RequiresApi(26) - public B keepResultsForAtLeast(@NonNull Duration duration) { + public @NonNull B keepResultsForAtLeast(@NonNull Duration duration) { mWorkSpec.minimumRetentionDuration = duration.toMillis(); return getThis(); } @@ -226,9 +226,9 @@ public abstract class WorkRequest { * * @return The concrete implementation of the work associated with this builder */ - public abstract W build(); + public abstract @NonNull W build(); - abstract B getThis(); + abstract @NonNull B getThis(); /** * Set the initial state for this work. Used in testing only. @@ -239,7 +239,7 @@ public abstract class WorkRequest { */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) @VisibleForTesting - public B setInitialState(@NonNull State state) { + public @NonNull B setInitialState(@NonNull State state) { mWorkSpec.state = state; return getThis(); } @@ -253,7 +253,7 @@ public abstract class WorkRequest { */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) @VisibleForTesting - public B setInitialRunAttemptCount(int runAttemptCount) { + public @NonNull B setInitialRunAttemptCount(int runAttemptCount) { mWorkSpec.runAttemptCount = runAttemptCount; return getThis(); } @@ -268,7 +268,7 @@ public abstract class WorkRequest { */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) @VisibleForTesting - public B setPeriodStartTime(long periodStartTime, @NonNull TimeUnit timeUnit) { + public @NonNull B setPeriodStartTime(long periodStartTime, @NonNull TimeUnit timeUnit) { mWorkSpec.periodStartTime = timeUnit.toMillis(periodStartTime); return getThis(); } @@ -283,7 +283,7 @@ public abstract class WorkRequest { */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) @VisibleForTesting - public B setScheduleRequestedAt( + public @NonNull B setScheduleRequestedAt( long scheduleRequestedAt, @NonNull TimeUnit timeUnit) { mWorkSpec.scheduleRequestedAt = timeUnit.toMillis(scheduleRequestedAt); diff --git a/work/workmanager/src/main/java/androidx/work/impl/Schedulers.java b/work/workmanager/src/main/java/androidx/work/impl/Schedulers.java index 4944549d6fa..041f7880ce8 100644 --- a/work/workmanager/src/main/java/androidx/work/impl/Schedulers.java +++ b/work/workmanager/src/main/java/androidx/work/impl/Schedulers.java @@ -65,41 +65,38 @@ public class Schedulers { @NonNull Configuration configuration, @NonNull WorkDatabase workDatabase, List<Scheduler> schedulers) { - - WorkSpecDao workSpecDao = workDatabase.workSpecDao(); - List<WorkSpec> eligibleWorkSpecs = - workSpecDao.getEligibleWorkForScheduling( - configuration.getMaxSchedulerLimit()); - scheduleInternal(workDatabase, schedulers, eligibleWorkSpecs); - } - - private static void scheduleInternal( - @NonNull WorkDatabase workDatabase, - List<Scheduler> schedulers, - List<WorkSpec> workSpecs) { - - if (workSpecs == null || schedulers == null) { + if (schedulers == null || schedulers.size() == 0) { return; } - long now = System.currentTimeMillis(); WorkSpecDao workSpecDao = workDatabase.workSpecDao(); - // Mark all the WorkSpecs as scheduled. - // Calls to Scheduler#schedule() could potentially result in more schedules - // on a separate thread. Therefore, this needs to be done first. + List<WorkSpec> eligibleWorkSpecs; + workDatabase.beginTransaction(); try { - for (WorkSpec workSpec : workSpecs) { - workSpecDao.markWorkSpecScheduled(workSpec.id, now); + eligibleWorkSpecs = workSpecDao.getEligibleWorkForScheduling( + configuration.getMaxSchedulerLimit()); + if (eligibleWorkSpecs != null && eligibleWorkSpecs.size() > 0) { + long now = System.currentTimeMillis(); + + // Mark all the WorkSpecs as scheduled. + // Calls to Scheduler#schedule() could potentially result in more schedules + // on a separate thread. Therefore, this needs to be done first. + for (WorkSpec workSpec : eligibleWorkSpecs) { + workSpecDao.markWorkSpecScheduled(workSpec.id, now); + } } workDatabase.setTransactionSuccessful(); } finally { workDatabase.endTransaction(); } - WorkSpec[] eligibleWorkSpecsArray = workSpecs.toArray(new WorkSpec[0]); - // Delegate to the underlying scheduler. - for (Scheduler scheduler : schedulers) { - scheduler.schedule(eligibleWorkSpecsArray); + + if (eligibleWorkSpecs != null && eligibleWorkSpecs.size() > 0) { + WorkSpec[] eligibleWorkSpecsArray = eligibleWorkSpecs.toArray(new WorkSpec[0]); + // Delegate to the underlying scheduler. + for (Scheduler scheduler : schedulers) { + scheduler.schedule(eligibleWorkSpecsArray); + } } } diff --git a/work/workmanager/src/main/java/androidx/work/impl/WorkContinuationImpl.java b/work/workmanager/src/main/java/androidx/work/impl/WorkContinuationImpl.java index 3713dca4850..6fdc18634b2 100644 --- a/work/workmanager/src/main/java/androidx/work/impl/WorkContinuationImpl.java +++ b/work/workmanager/src/main/java/androidx/work/impl/WorkContinuationImpl.java @@ -148,7 +148,7 @@ public class WorkContinuationImpl extends WorkContinuation } @Override - public WorkContinuation then(List<OneTimeWorkRequest> work) { + public @NonNull WorkContinuation then(List<OneTimeWorkRequest> work) { // TODO (rahulrav@) We need to decide if we want to allow chaining of continuations after // an initial call to enqueue() return new WorkContinuationImpl(mWorkManagerImpl, @@ -159,12 +159,12 @@ public class WorkContinuationImpl extends WorkContinuation } @Override - public LiveData<List<WorkStatus>> getStatuses() { + public @NonNull LiveData<List<WorkStatus>> getStatuses() { return mWorkManagerImpl.getStatusesById(mAllIds); } @Override - public List<WorkStatus> getStatusesSync() { + public @NonNull List<WorkStatus> getStatusesSync() { if (Looper.getMainLooper().getThread() == Thread.currentThread()) { throw new IllegalStateException("Cannot getStatusesSync on main thread!"); } @@ -201,12 +201,12 @@ public class WorkContinuationImpl extends WorkContinuation } @Override - public SynchronousWorkContinuation synchronous() { + public @NonNull SynchronousWorkContinuation synchronous() { return this; } @Override - protected WorkContinuation combineInternal( + protected @NonNull WorkContinuation combineInternal( @Nullable OneTimeWorkRequest work, @NonNull List<WorkContinuation> continuations) { diff --git a/work/workmanager/src/main/java/androidx/work/impl/WorkDatabase.java b/work/workmanager/src/main/java/androidx/work/impl/WorkDatabase.java index e5d5f9d0f98..b8bc72c3119 100644 --- a/work/workmanager/src/main/java/androidx/work/impl/WorkDatabase.java +++ b/work/workmanager/src/main/java/androidx/work/impl/WorkDatabase.java @@ -16,6 +16,9 @@ package androidx.work.impl; +import static androidx.work.impl.WorkDatabaseMigrations.MIGRATION_3_4; +import static androidx.work.impl.WorkDatabaseMigrations.VERSION_2; +import static androidx.work.impl.WorkDatabaseMigrations.VERSION_3; import static androidx.work.impl.model.WorkTypeConverters.StateIds.COMPLETED_STATES; import static androidx.work.impl.model.WorkTypeConverters.StateIds.ENQUEUED; import static androidx.work.impl.model.WorkTypeConverters.StateIds.RUNNING; @@ -56,12 +59,14 @@ import java.util.concurrent.TimeUnit; WorkTag.class, SystemIdInfo.class, WorkName.class}, - version = 2) + version = 4) @TypeConverters(value = {Data.class, WorkTypeConverters.class}) public abstract class WorkDatabase extends RoomDatabase { private static final String DB_NAME = "androidx.work.workdb"; - private static final String CLEANUP_SQL = "UPDATE workspec SET state=" + ENQUEUED + private static final String CLEANUP_SQL = "UPDATE workspec " + + "SET state=" + ENQUEUED + "," + + " schedule_requested_at=" + WorkSpec.SCHEDULE_NOT_REQUESTED_YET + " WHERE state=" + RUNNING; // Delete rows in the workspec table that... @@ -82,7 +87,7 @@ public abstract class WorkDatabase extends RoomDatabase { /** * Creates an instance of the WorkDatabase. * - * @param context A context (this method will use the application context from it) + * @param context A context (this method will use the application context from it) * @param useTestDatabase {@code true} to generate an in-memory database that allows main thread * access * @return The created WorkDatabase @@ -95,9 +100,13 @@ public abstract class WorkDatabase extends RoomDatabase { } else { builder = Room.databaseBuilder(context, WorkDatabase.class, DB_NAME); } + return builder.addCallback(generateCleanupCallback()) .addMigrations(WorkDatabaseMigrations.MIGRATION_1_2) - .addMigrations(WorkDatabaseMigrations.MIGRATION_2_1) + .addMigrations( + new WorkDatabaseMigrations.WorkMigration(context, VERSION_2, VERSION_3)) + .addMigrations(MIGRATION_3_4) + .fallbackToDestructiveMigration() .build(); } diff --git a/work/workmanager/src/main/java/androidx/work/impl/WorkDatabaseMigrations.java b/work/workmanager/src/main/java/androidx/work/impl/WorkDatabaseMigrations.java index 82a26ac58cd..d75b51473ea 100644 --- a/work/workmanager/src/main/java/androidx/work/impl/WorkDatabaseMigrations.java +++ b/work/workmanager/src/main/java/androidx/work/impl/WorkDatabaseMigrations.java @@ -18,9 +18,15 @@ package androidx.work.impl; import android.arch.persistence.db.SupportSQLiteDatabase; import android.arch.persistence.room.migration.Migration; +import android.content.Context; +import android.os.Build; import android.support.annotation.NonNull; import android.support.annotation.RestrictTo; +import androidx.work.impl.model.WorkSpec; +import androidx.work.impl.model.WorkTypeConverters; +import androidx.work.impl.utils.Preferences; + /** * Migration helpers for {@link androidx.work.impl.WorkDatabase}. * @@ -34,30 +40,27 @@ public class WorkDatabaseMigrations { } // Known WorkDatabase versions - private static final int VERSION_1 = 1; - private static final int VERSION_2 = 2; + public static final int VERSION_1 = 1; + public static final int VERSION_2 = 2; + public static final int VERSION_3 = 3; + public static final int VERSION_4 = 4; private static final String CREATE_SYSTEM_ID_INFO = "CREATE TABLE IF NOT EXISTS `SystemIdInfo` (`work_spec_id` TEXT NOT NULL, `system_id`" + " INTEGER NOT NULL, PRIMARY KEY(`work_spec_id`), FOREIGN KEY(`work_spec_id`)" + " REFERENCES `WorkSpec`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )"; - private static final String CREATE_ALARM_INFO = - "CREATE TABLE IF NOT EXISTS `alarmInfo` (`work_spec_id` TEXT NOT NULL, `alarm_id`" - + " INTEGER NOT NULL, PRIMARY KEY(`work_spec_id`), FOREIGN KEY" - + "(`work_spec_id`) REFERENCES `WorkSpec`(`id`) ON UPDATE CASCADE ON DELETE " - + "CASCADE )"; - private static final String MIGRATE_ALARM_INFO_TO_SYSTEM_ID_INFO = "INSERT INTO SystemIdInfo(work_spec_id, system_id) " + "SELECT work_spec_id, alarm_id AS system_id FROM alarmInfo"; - private static final String MIGRATE_SYSTEM_ID_INFO_TO_ALARM_INFO = - "INSERT INTO alarmInfo(work_spec_id, alarm_id) " - + "SELECT work_spec_id, system_id AS alarm_id FROM SystemIdInfo"; + private static final String PERIODIC_WORK_SET_SCHEDULE_REQUESTED_AT = + "UPDATE workspec SET schedule_requested_at=0" + + " WHERE state NOT IN " + WorkTypeConverters.StateIds.COMPLETED_STATES + + " AND schedule_requested_at=" + WorkSpec.SCHEDULE_NOT_REQUESTED_YET + + " AND interval_duration<>0"; private static final String REMOVE_ALARM_INFO = "DROP TABLE IF EXISTS alarmInfo"; - private static final String REMOVE_SYSTEM_ID_INFO = "DROP TABLE IF EXISTS SystemIdInfo"; /** * Removes the {@code alarmInfo} table and substitutes it for a more general @@ -70,22 +73,39 @@ public class WorkDatabaseMigrations { database.execSQL(CREATE_SYSTEM_ID_INFO); database.execSQL(MIGRATE_ALARM_INFO_TO_SYSTEM_ID_INFO); database.execSQL(REMOVE_ALARM_INFO); - database.execSQL("INSERT INTO worktag(tag, work_spec_id) " + database.execSQL("INSERT OR IGNORE INTO worktag(tag, work_spec_id) " + "SELECT worker_class_name AS tag, id AS work_spec_id FROM workspec"); } }; /** - * Removes the {@code alarmInfo} table and substitutes it for a more general - * {@code SystemIdInfo} table. + * A {@link WorkDatabase} migration that reschedules all eligible Workers. + */ + public static class WorkMigration extends Migration { + final Context mContext; + + public WorkMigration(@NonNull Context context, int startVersion, int endVersion) { + super(startVersion, endVersion); + mContext = context; + } + + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + Preferences preferences = new Preferences(mContext); + preferences.setNeedsReschedule(true); + } + } + + /** + * Marks {@code SCHEDULE_REQUESTED_AT} to something other than + * {@code SCHEDULE_NOT_REQUESTED_AT}. */ - public static Migration MIGRATION_2_1 = new Migration(VERSION_2, VERSION_1) { + public static Migration MIGRATION_3_4 = new Migration(VERSION_3, VERSION_4) { @Override public void migrate(@NonNull SupportSQLiteDatabase database) { - database.execSQL(CREATE_ALARM_INFO); - database.execSQL(MIGRATE_SYSTEM_ID_INFO_TO_ALARM_INFO); - database.execSQL(REMOVE_SYSTEM_ID_INFO); - // Don't remove implicit tags; they may have been added by the developer. + if (Build.VERSION.SDK_INT >= WorkManagerImpl.MIN_JOB_SCHEDULER_API_LEVEL) { + database.execSQL(PERIODIC_WORK_SET_SCHEDULE_REQUESTED_AT); + } } }; } diff --git a/work/workmanager/src/main/java/androidx/work/impl/WorkManagerImpl.java b/work/workmanager/src/main/java/androidx/work/impl/WorkManagerImpl.java index 0dcdda349c6..763a4544437 100644 --- a/work/workmanager/src/main/java/androidx/work/impl/WorkManagerImpl.java +++ b/work/workmanager/src/main/java/androidx/work/impl/WorkManagerImpl.java @@ -18,7 +18,9 @@ package androidx.work.impl; import android.arch.core.util.Function; import android.arch.lifecycle.LiveData; +import android.content.BroadcastReceiver; import android.content.Context; +import android.os.Build; import android.os.Looper; import android.support.annotation.NonNull; import android.support.annotation.Nullable; @@ -37,6 +39,7 @@ import androidx.work.WorkManager; import androidx.work.WorkRequest; import androidx.work.WorkStatus; import androidx.work.impl.background.greedy.GreedyScheduler; +import androidx.work.impl.background.systemjob.SystemJobScheduler; import androidx.work.impl.model.WorkSpec; import androidx.work.impl.model.WorkSpecDao; import androidx.work.impl.utils.CancelWorkRunnable; @@ -72,6 +75,8 @@ public class WorkManagerImpl extends WorkManager implements SynchronousWorkManag private List<Scheduler> mSchedulers; private Processor mProcessor; private Preferences mPreferences; + private boolean mForceStopRunnableCompleted; + private BroadcastReceiver.PendingResult mRescheduleReceiverResult; private static WorkManagerImpl sDelegatedInstance = null; private static WorkManagerImpl sDefaultInstance = null; @@ -97,7 +102,7 @@ public class WorkManagerImpl extends WorkManager implements SynchronousWorkManag * @hide */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) - public static WorkManagerImpl getInstance() { + public static @Nullable WorkManagerImpl getInstance() { synchronized (sLock) { if (sDelegatedInstance != null) { return sDelegatedInstance; @@ -172,6 +177,7 @@ public class WorkManagerImpl extends WorkManager implements SynchronousWorkManag getSchedulers(), configuration.getExecutor()); mPreferences = new Preferences(mContext); + mForceStopRunnableCompleted = false; // Checks for app force stops. mTaskExecutor.executeOnBackgroundThread(new ForceStopRunnable(context, this)); @@ -265,12 +271,12 @@ public class WorkManagerImpl extends WorkManager implements SynchronousWorkManag } @Override - public WorkContinuation beginWith(@NonNull List<OneTimeWorkRequest> work) { + public @NonNull WorkContinuation beginWith(@NonNull List<OneTimeWorkRequest> work) { return new WorkContinuationImpl(this, work); } @Override - public WorkContinuation beginUniqueWork( + public @NonNull WorkContinuation beginUniqueWork( @NonNull String uniqueWorkName, @NonNull ExistingWorkPolicy existingWorkPolicy, @NonNull List<OneTimeWorkRequest> work) { @@ -370,7 +376,7 @@ public class WorkManagerImpl extends WorkManager implements SynchronousWorkManag } @Override - public LiveData<Long> getLastCancelAllTimeMillis() { + public @NonNull LiveData<Long> getLastCancelAllTimeMillis() { return mPreferences.getLastCancelAllTimeMillisLiveData(); } @@ -392,7 +398,7 @@ public class WorkManagerImpl extends WorkManager implements SynchronousWorkManag } @Override - public LiveData<WorkStatus> getStatusById(@NonNull UUID id) { + public @NonNull LiveData<WorkStatus> getStatusById(@NonNull UUID id) { WorkSpecDao dao = mWorkDatabase.workSpecDao(); LiveData<List<WorkSpec.WorkStatusPojo>> inputLiveData = dao.getWorkStatusPojoLiveDataForIds(Collections.singletonList(id.toString())); @@ -423,7 +429,7 @@ public class WorkManagerImpl extends WorkManager implements SynchronousWorkManag } @Override - public LiveData<List<WorkStatus>> getStatusesByTag(@NonNull String tag) { + public @NonNull LiveData<List<WorkStatus>> getStatusesByTag(@NonNull String tag) { WorkSpecDao workSpecDao = mWorkDatabase.workSpecDao(); LiveData<List<WorkSpec.WorkStatusPojo>> inputLiveData = workSpecDao.getWorkStatusPojoLiveDataForTag(tag); @@ -431,7 +437,7 @@ public class WorkManagerImpl extends WorkManager implements SynchronousWorkManag } @Override - public List<WorkStatus> getStatusesByTagSync(@NonNull String tag) { + public @NonNull List<WorkStatus> getStatusesByTagSync(@NonNull String tag) { assertBackgroundThread("Cannot call getStatusesByTagSync on main thread!"); WorkSpecDao workSpecDao = mWorkDatabase.workSpecDao(); List<WorkSpec.WorkStatusPojo> input = workSpecDao.getWorkStatusPojoForTag(tag); @@ -439,7 +445,8 @@ public class WorkManagerImpl extends WorkManager implements SynchronousWorkManag } @Override - public LiveData<List<WorkStatus>> getStatusesForUniqueWork(@NonNull String uniqueWorkName) { + public @NonNull LiveData<List<WorkStatus>> getStatusesForUniqueWork( + @NonNull String uniqueWorkName) { WorkSpecDao workSpecDao = mWorkDatabase.workSpecDao(); LiveData<List<WorkSpec.WorkStatusPojo>> inputLiveData = workSpecDao.getWorkStatusPojoLiveDataForName(uniqueWorkName); @@ -447,7 +454,7 @@ public class WorkManagerImpl extends WorkManager implements SynchronousWorkManag } @Override - public List<WorkStatus> getStatusesForUniqueWorkSync(@NonNull String uniqueWorkName) { + public @NonNull List<WorkStatus> getStatusesForUniqueWorkSync(@NonNull String uniqueWorkName) { assertBackgroundThread("Cannot call getStatusesByNameBlocking on main thread!"); WorkSpecDao workSpecDao = mWorkDatabase.workSpecDao(); List<WorkSpec.WorkStatusPojo> input = workSpecDao.getWorkStatusPojoForName(uniqueWorkName); @@ -455,7 +462,7 @@ public class WorkManagerImpl extends WorkManager implements SynchronousWorkManag } @Override - public SynchronousWorkManager synchronous() { + public @NonNull SynchronousWorkManager synchronous() { return this; } @@ -510,6 +517,11 @@ public class WorkManagerImpl extends WorkManager implements SynchronousWorkManag */ @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public void rescheduleEligibleWork() { + // TODO (rahulrav@) Make every scheduler do its own cancelAll(). + if (Build.VERSION.SDK_INT >= WorkManagerImpl.MIN_JOB_SCHEDULER_API_LEVEL) { + SystemJobScheduler.jobSchedulerCancelAll(getApplicationContext()); + } + // Reset scheduled state. getWorkDatabase().workSpecDao().resetScheduledState(); @@ -519,6 +531,42 @@ public class WorkManagerImpl extends WorkManager implements SynchronousWorkManag Schedulers.schedule(getConfiguration(), getWorkDatabase(), getSchedulers()); } + /** + * A way for {@link ForceStopRunnable} to tell {@link WorkManagerImpl} that it has completed. + * + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + public void onForceStopRunnableCompleted() { + synchronized (sLock) { + mForceStopRunnableCompleted = true; + if (mRescheduleReceiverResult != null) { + mRescheduleReceiverResult.finish(); + mRescheduleReceiverResult = null; + } + } + } + + /** + * This method is invoked by + * {@link androidx.work.impl.background.systemalarm.RescheduleReceiver} + * after a call to {@link BroadcastReceiver#goAsync()}. Once {@link ForceStopRunnable} is done, + * we can safely call {@link BroadcastReceiver.PendingResult#finish()}. + * + * @hide + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + public void setReschedulePendingResult( + @NonNull BroadcastReceiver.PendingResult rescheduleReceiverResult) { + synchronized (sLock) { + mRescheduleReceiverResult = rescheduleReceiverResult; + if (mForceStopRunnableCompleted) { + mRescheduleReceiverResult.finish(); + mRescheduleReceiverResult = null; + } + } + } + private void assertBackgroundThread(String errorMessage) { if (Looper.getMainLooper().getThread() == Thread.currentThread()) { throw new IllegalStateException(errorMessage); diff --git a/work/workmanager/src/main/java/androidx/work/impl/WorkerWrapper.java b/work/workmanager/src/main/java/androidx/work/impl/WorkerWrapper.java index 0b8c44c83e7..87f922fc6bb 100644 --- a/work/workmanager/src/main/java/androidx/work/impl/WorkerWrapper.java +++ b/work/workmanager/src/main/java/androidx/work/impl/WorkerWrapper.java @@ -21,8 +21,10 @@ import static androidx.work.State.ENQUEUED; import static androidx.work.State.FAILED; import static androidx.work.State.RUNNING; import static androidx.work.State.SUCCEEDED; +import static androidx.work.impl.model.WorkSpec.SCHEDULE_NOT_REQUESTED_YET; import android.content.Context; +import android.os.Build; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.RestrictTo; @@ -94,18 +96,28 @@ public class WorkerWrapper implements Runnable { return; } - mWorkSpec = mWorkSpecDao.getWorkSpec(mWorkSpecId); - if (mWorkSpec == null) { - Log.e(TAG, String.format("Didn't find WorkSpec for id %s", mWorkSpecId)); - notifyListener(false, false); - return; - } + mWorkDatabase.beginTransaction(); + try { + mWorkSpec = mWorkSpecDao.getWorkSpec(mWorkSpecId); + if (mWorkSpec == null) { + Log.e(TAG, String.format("Didn't find WorkSpec for id %s", mWorkSpecId)); + notifyListener(false, false); + return; + } - // Do a quick check to make sure we don't need to bail out in case this work is already - // running, finished, or is blocked. - if (mWorkSpec.state != ENQUEUED) { - notifyIncorrectStatus(); - return; + // Do a quick check to make sure we don't need to bail out in case this work is already + // running, finished, or is blocked. + if (mWorkSpec.state != ENQUEUED) { + notifyIncorrectStatus(); + mWorkDatabase.setTransactionSuccessful(); + return; + } + + // Needed for nested transactions, such as when we're in a dependent work request when + // using a SynchronousExecutor. + mWorkDatabase.setTransactionSuccessful(); + } finally { + mWorkDatabase.endTransaction(); } // Merge inputs. This can be potentially expensive code, so this should not be done inside @@ -157,6 +169,11 @@ public class WorkerWrapper implements Runnable { result = mWorker.doWork(); } catch (Exception | Error e) { result = Worker.Result.FAILURE; + Log.e(TAG, + String.format( + "Worker %s failed because it threw an exception/error", + mWorkSpecId), + e); } try { @@ -275,9 +292,9 @@ public class WorkerWrapper implements Runnable { if (currentState == ENQUEUED) { mWorkSpecDao.setState(RUNNING, mWorkSpecId); mWorkSpecDao.incrementWorkSpecRunAttemptCount(mWorkSpecId); - mWorkDatabase.setTransactionSuccessful(); setToRunning = true; } + mWorkDatabase.setTransactionSuccessful(); } finally { mWorkDatabase.endTransaction(); } @@ -339,11 +356,29 @@ public class WorkerWrapper implements Runnable { mWorkSpecDao.setPeriodStartTime(mWorkSpecId, nextPeriodStartTime); mWorkSpecDao.setState(ENQUEUED, mWorkSpecId); mWorkSpecDao.resetWorkSpecRunAttemptCount(mWorkSpecId); + if (Build.VERSION.SDK_INT < WorkManagerImpl.MIN_JOB_SCHEDULER_API_LEVEL) { + // We only need to reset the schedule_requested_at bit for the AlarmManager + // implementation because AlarmManager does not know about periodic WorkRequests. + // Otherwise we end up double scheduling the Worker with an identical jobId, and + // JobScheduler treats it as the first schedule for a PeriodicWorker. With the + // AlarmManager implementation, this is not an problem as AlarmManager only cares + // about the actual alarm itself. + + // We need to tell the schedulers that this WorkSpec is no longer occupying a slot. + mWorkSpecDao.markWorkSpecScheduled(mWorkSpecId, SCHEDULE_NOT_REQUESTED_YET); + } mWorkDatabase.setTransactionSuccessful(); } finally { mWorkDatabase.endTransaction(); notifyListener(isSuccessful, false); } + + // We need to tell the Schedulers to pick up this newly ENQUEUED Worker. + // TODO (rahulrav@) Move this into the Scheduler itself. + if (Build.VERSION.SDK_INT <= WorkManagerImpl.MAX_PRE_JOB_SCHEDULER_API_LEVEL) { + // Reschedule the periodic work. + Schedulers.schedule(mConfiguration, mWorkDatabase, mSchedulers); + } } private void setSucceededAndNotify() { diff --git a/work/workmanager/src/main/java/androidx/work/impl/background/systemalarm/RescheduleReceiver.java b/work/workmanager/src/main/java/androidx/work/impl/background/systemalarm/RescheduleReceiver.java index a331479a5bc..b8adec2ab40 100644 --- a/work/workmanager/src/main/java/androidx/work/impl/background/systemalarm/RescheduleReceiver.java +++ b/work/workmanager/src/main/java/androidx/work/impl/background/systemalarm/RescheduleReceiver.java @@ -20,15 +20,10 @@ import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.os.Build; -import android.os.Handler; -import android.os.Looper; import android.util.Log; -import androidx.work.WorkManager; import androidx.work.impl.WorkManagerImpl; -import java.util.concurrent.TimeUnit; - /** * Reschedules alarms on BOOT_COMPLETED and other similar scenarios. */ @@ -39,22 +34,15 @@ public class RescheduleReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { if (Build.VERSION.SDK_INT >= WorkManagerImpl.MIN_JOB_SCHEDULER_API_LEVEL) { - if (WorkManager.getInstance() == null) { + WorkManagerImpl workManager = WorkManagerImpl.getInstance(); + if (workManager == null) { // WorkManager has not already been initialized. Log.e(TAG, "Cannot reschedule jobs. WorkManager needs to be initialized via a " + "ContentProvider#onCreate() or an Application#onCreate()."); } else { - // This helps set up rescheduling of Jobs with JobScheduler. We are doing nothing - // for 10 seconds, to give ForceStopRunnable a chance to reschedule. - Handler handler = new Handler(Looper.getMainLooper()); final PendingResult pendingResult = goAsync(); - handler.postDelayed(new Runnable() { - @Override - public void run() { - pendingResult.finish(); - } - }, TimeUnit.SECONDS.toMillis(10)); + workManager.setReschedulePendingResult(pendingResult); } } else { Intent reschedule = CommandHandler.createRescheduleIntent(context); diff --git a/work/workmanager/src/main/java/androidx/work/impl/model/WorkSpec.java b/work/workmanager/src/main/java/androidx/work/impl/model/WorkSpec.java index 6f5120da5bf..52d30903aea 100644 --- a/work/workmanager/src/main/java/androidx/work/impl/model/WorkSpec.java +++ b/work/workmanager/src/main/java/androidx/work/impl/model/WorkSpec.java @@ -114,6 +114,14 @@ public class WorkSpec { @ColumnInfo(name = "minimum_retention_duration") public long minimumRetentionDuration; + /** + * This field tells us if this {@link WorkSpec} instance, is actually currently scheduled and + * being counted against the {@code SCHEDULER_LIMIT}. This bit is reset for PeriodicWorkRequests + * in API < 23, because AlarmManager does not know of PeriodicWorkRequests. So for the next + * request to be rescheduled this field has to be reset to {@code SCHEDULE_NOT_REQUESTED_AT}. + * For the JobScheduler implementation, we don't reset this field because JobScheduler natively + * supports PeriodicWorkRequests. + */ @ColumnInfo(name = "schedule_requested_at") public long scheduleRequestedAt = SCHEDULE_NOT_REQUESTED_YET; 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 87de105e455..c01e4fdbd81 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 @@ -20,10 +20,8 @@ import static android.app.AlarmManager.RTC_WAKEUP; import static android.app.PendingIntent.FLAG_NO_CREATE; import static android.app.PendingIntent.FLAG_UPDATE_CURRENT; -import android.annotation.TargetApi; import android.app.AlarmManager; import android.app.PendingIntent; -import android.app.job.JobScheduler; import android.content.ComponentName; import android.content.Context; import android.content.Intent; @@ -34,7 +32,6 @@ import android.support.annotation.VisibleForTesting; import android.util.Log; import androidx.work.impl.WorkManagerImpl; -import androidx.work.impl.background.systemjob.SystemJobScheduler; import java.util.concurrent.TimeUnit; @@ -67,16 +64,16 @@ public class ForceStopRunnable implements Runnable { @Override public void run() { - if (shouldCancelPersistedJobs()) { - cancelAllInJobScheduler(); - Log.d(TAG, "Migrating persisted jobs."); + if (shouldRescheduleWorkers()) { + Log.d(TAG, "Rescheduling Workers."); mWorkManager.rescheduleEligibleWork(); // Mark the jobs as migrated. - mWorkManager.getPreferences().setMigratedPersistedJobs(); + mWorkManager.getPreferences().setNeedsReschedule(false); } else if (isForceStopped()) { Log.d(TAG, "Application was force-stopped, rescheduling."); mWorkManager.rescheduleEligibleWork(); } + mWorkManager.onForceStopRunnableCompleted(); } /** @@ -98,12 +95,11 @@ public class ForceStopRunnable implements Runnable { } /** - * @return {@code true} If persisted jobs in JobScheduler need to be cancelled. + * @return {@code true} If we need to reschedule Workers. */ @VisibleForTesting - public boolean shouldCancelPersistedJobs() { - return Build.VERSION.SDK_INT >= WorkManagerImpl.MIN_JOB_SCHEDULER_API_LEVEL - && mWorkManager.getPreferences().shouldMigratePersistedJobs(); + public boolean shouldRescheduleWorkers() { + return mWorkManager.getPreferences().needsReschedule(); } /** @@ -128,16 +124,7 @@ public class ForceStopRunnable implements Runnable { return intent; } - /** - * Cancels all the persisted jobs in {@link JobScheduler}. - */ - @VisibleForTesting - @TargetApi(WorkManagerImpl.MIN_JOB_SCHEDULER_API_LEVEL) - public void cancelAllInJobScheduler() { - SystemJobScheduler.jobSchedulerCancelAll(mContext); - } - - private void setAlarm(int alarmId) { + void setAlarm(int alarmId) { AlarmManager alarmManager = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE); // Using FLAG_UPDATE_CURRENT, because we only ever want once instance of this alarm. PendingIntent pendingIntent = getPendingIntent(alarmId, FLAG_UPDATE_CURRENT); diff --git a/work/workmanager/src/main/java/androidx/work/impl/utils/Preferences.java b/work/workmanager/src/main/java/androidx/work/impl/utils/Preferences.java index 49cd262ffcb..d8b9484cb27 100644 --- a/work/workmanager/src/main/java/androidx/work/impl/utils/Preferences.java +++ b/work/workmanager/src/main/java/androidx/work/impl/utils/Preferences.java @@ -20,7 +20,9 @@ import android.arch.lifecycle.LiveData; import android.arch.lifecycle.MutableLiveData; import android.content.Context; import android.content.SharedPreferences; +import android.support.annotation.NonNull; import android.support.annotation.RestrictTo; +import android.support.annotation.VisibleForTesting; /** * Preferences for WorkManager. @@ -35,13 +37,17 @@ public class Preferences { private static final String PREFERENCES_FILE_NAME = "androidx.work.util.preferences"; private static final String KEY_LAST_CANCEL_ALL_TIME_MS = "last_cancel_all_time_ms"; - private static final String KEY_MIGRATE_PERSISTED_JOBS = "migrate_persisted_jobs"; + private static final String KEY_RESCHEDULE_NEEDED = "reschedule_needed"; private SharedPreferences mSharedPreferences; - public Preferences(Context context) { - mSharedPreferences = - context.getSharedPreferences(PREFERENCES_FILE_NAME, Context.MODE_PRIVATE); + public Preferences(@NonNull Context context) { + this(context.getSharedPreferences(PREFERENCES_FILE_NAME, Context.MODE_PRIVATE)); + } + + @VisibleForTesting + public Preferences(@NonNull SharedPreferences preferences) { + mSharedPreferences = preferences; } /** @@ -69,20 +75,19 @@ public class Preferences { } /** - * @return {@code true} When we should migrate from persisted jobs to non-persisted jobs in - * {@link android.app.job.JobScheduler} + * @return {@code true} When we should reschedule workers. */ - public boolean shouldMigratePersistedJobs() { + public boolean needsReschedule() { + // This preference is being set by a Room Migration. // TODO Remove this before WorkManager 1.0 beta. - return mSharedPreferences.getBoolean(KEY_MIGRATE_PERSISTED_JOBS, true); + return mSharedPreferences.getBoolean(KEY_RESCHEDULE_NEEDED, false); } /** - * Updates the key which indicates that we have migrated all our persisted jobs in - * {@link android.app.job.JobScheduler}. + * Updates the key which indicates that we have rescheduled jobs. */ - public void setMigratedPersistedJobs() { - mSharedPreferences.edit().putBoolean(KEY_MIGRATE_PERSISTED_JOBS, true).apply(); + public void setNeedsReschedule(boolean needsReschedule) { + mSharedPreferences.edit().putBoolean(KEY_RESCHEDULE_NEEDED, needsReschedule).apply(); } /** diff --git a/work/workmanager/src/main/java/androidx/work/impl/workers/ConstraintTrackingWorker.java b/work/workmanager/src/main/java/androidx/work/impl/workers/ConstraintTrackingWorker.java index 116e96cc3fe..e023fed6ff5 100644 --- a/work/workmanager/src/main/java/androidx/work/impl/workers/ConstraintTrackingWorker.java +++ b/work/workmanager/src/main/java/androidx/work/impl/workers/ConstraintTrackingWorker.java @@ -64,7 +64,7 @@ public class ConstraintTrackingWorker extends Worker implements WorkConstraintsC @Override public @NonNull Result doWork() { - String className = getInputData().getString(ARGUMENT_CLASS_NAME, null); + String className = getInputData().getString(ARGUMENT_CLASS_NAME); if (TextUtils.isEmpty(className)) { Log.d(TAG, "No worker to delegate to."); return Result.FAILURE; diff --git a/work/workmanager/src/schemas/androidx.work.impl.WorkDatabase/3.json b/work/workmanager/src/schemas/androidx.work.impl.WorkDatabase/3.json new file mode 100644 index 00000000000..69e73eb68f0 --- /dev/null +++ b/work/workmanager/src/schemas/androidx.work.impl.WorkDatabase/3.json @@ -0,0 +1,363 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "c45e5fcbdf3824dead9778f19e2fd8af", + "entities": [ + { + "tableName": "Dependency", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`work_spec_id` TEXT NOT NULL, `prerequisite_id` TEXT NOT NULL, PRIMARY KEY(`work_spec_id`, `prerequisite_id`), FOREIGN KEY(`work_spec_id`) REFERENCES `WorkSpec`(`id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`prerequisite_id`) REFERENCES `WorkSpec`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "workSpecId", + "columnName": "work_spec_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "prerequisiteId", + "columnName": "prerequisite_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "work_spec_id", + "prerequisite_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_Dependency_work_spec_id", + "unique": false, + "columnNames": [ + "work_spec_id" + ], + "createSql": "CREATE INDEX `index_Dependency_work_spec_id` ON `${TABLE_NAME}` (`work_spec_id`)" + }, + { + "name": "index_Dependency_prerequisite_id", + "unique": false, + "columnNames": [ + "prerequisite_id" + ], + "createSql": "CREATE INDEX `index_Dependency_prerequisite_id` ON `${TABLE_NAME}` (`prerequisite_id`)" + } + ], + "foreignKeys": [ + { + "table": "WorkSpec", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "work_spec_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "WorkSpec", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "prerequisite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "WorkSpec", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `state` INTEGER NOT NULL, `worker_class_name` TEXT NOT NULL, `input_merger_class_name` TEXT, `input` BLOB NOT NULL, `output` BLOB NOT NULL, `initial_delay` INTEGER NOT NULL, `interval_duration` INTEGER NOT NULL, `flex_duration` INTEGER NOT NULL, `run_attempt_count` INTEGER NOT NULL, `backoff_policy` INTEGER NOT NULL, `backoff_delay_duration` INTEGER NOT NULL, `period_start_time` INTEGER NOT NULL, `minimum_retention_duration` INTEGER NOT NULL, `schedule_requested_at` INTEGER NOT NULL, `required_network_type` INTEGER, `requires_charging` INTEGER NOT NULL, `requires_device_idle` INTEGER NOT NULL, `requires_battery_not_low` INTEGER NOT NULL, `requires_storage_not_low` INTEGER NOT NULL, `content_uri_triggers` BLOB, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workerClassName", + "columnName": "worker_class_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inputMergerClassName", + "columnName": "input_merger_class_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "input", + "columnName": "input", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "output", + "columnName": "output", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "initialDelay", + "columnName": "initial_delay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "intervalDuration", + "columnName": "interval_duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flexDuration", + "columnName": "flex_duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "runAttemptCount", + "columnName": "run_attempt_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "backoffPolicy", + "columnName": "backoff_policy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "backoffDelayDuration", + "columnName": "backoff_delay_duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "periodStartTime", + "columnName": "period_start_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minimumRetentionDuration", + "columnName": "minimum_retention_duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduleRequestedAt", + "columnName": "schedule_requested_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "constraints.mRequiredNetworkType", + "columnName": "required_network_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "constraints.mRequiresCharging", + "columnName": "requires_charging", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "constraints.mRequiresDeviceIdle", + "columnName": "requires_device_idle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "constraints.mRequiresBatteryNotLow", + "columnName": "requires_battery_not_low", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "constraints.mRequiresStorageNotLow", + "columnName": "requires_storage_not_low", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "constraints.mContentUriTriggers", + "columnName": "content_uri_triggers", + "affinity": "BLOB", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_WorkSpec_schedule_requested_at", + "unique": false, + "columnNames": [ + "schedule_requested_at" + ], + "createSql": "CREATE INDEX `index_WorkSpec_schedule_requested_at` ON `${TABLE_NAME}` (`schedule_requested_at`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "WorkTag", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tag` TEXT NOT NULL, `work_spec_id` TEXT NOT NULL, PRIMARY KEY(`tag`, `work_spec_id`), FOREIGN KEY(`work_spec_id`) REFERENCES `WorkSpec`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workSpecId", + "columnName": "work_spec_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "tag", + "work_spec_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_WorkTag_work_spec_id", + "unique": false, + "columnNames": [ + "work_spec_id" + ], + "createSql": "CREATE INDEX `index_WorkTag_work_spec_id` ON `${TABLE_NAME}` (`work_spec_id`)" + } + ], + "foreignKeys": [ + { + "table": "WorkSpec", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "work_spec_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SystemIdInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`work_spec_id` TEXT NOT NULL, `system_id` INTEGER NOT NULL, PRIMARY KEY(`work_spec_id`), FOREIGN KEY(`work_spec_id`) REFERENCES `WorkSpec`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "workSpecId", + "columnName": "work_spec_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "systemId", + "columnName": "system_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "work_spec_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "WorkSpec", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "work_spec_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "WorkName", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `work_spec_id` TEXT NOT NULL, PRIMARY KEY(`name`, `work_spec_id`), FOREIGN KEY(`work_spec_id`) REFERENCES `WorkSpec`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workSpecId", + "columnName": "work_spec_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "name", + "work_spec_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_WorkName_work_spec_id", + "unique": false, + "columnNames": [ + "work_spec_id" + ], + "createSql": "CREATE INDEX `index_WorkName_work_spec_id` ON `${TABLE_NAME}` (`work_spec_id`)" + } + ], + "foreignKeys": [ + { + "table": "WorkSpec", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "work_spec_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"c45e5fcbdf3824dead9778f19e2fd8af\")" + ] + } +}
\ No newline at end of file diff --git a/work/workmanager/src/schemas/androidx.work.impl.WorkDatabase/4.json b/work/workmanager/src/schemas/androidx.work.impl.WorkDatabase/4.json new file mode 100644 index 00000000000..63c3005deda --- /dev/null +++ b/work/workmanager/src/schemas/androidx.work.impl.WorkDatabase/4.json @@ -0,0 +1,363 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "c45e5fcbdf3824dead9778f19e2fd8af", + "entities": [ + { + "tableName": "Dependency", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`work_spec_id` TEXT NOT NULL, `prerequisite_id` TEXT NOT NULL, PRIMARY KEY(`work_spec_id`, `prerequisite_id`), FOREIGN KEY(`work_spec_id`) REFERENCES `WorkSpec`(`id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`prerequisite_id`) REFERENCES `WorkSpec`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "workSpecId", + "columnName": "work_spec_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "prerequisiteId", + "columnName": "prerequisite_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "work_spec_id", + "prerequisite_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_Dependency_work_spec_id", + "unique": false, + "columnNames": [ + "work_spec_id" + ], + "createSql": "CREATE INDEX `index_Dependency_work_spec_id` ON `${TABLE_NAME}` (`work_spec_id`)" + }, + { + "name": "index_Dependency_prerequisite_id", + "unique": false, + "columnNames": [ + "prerequisite_id" + ], + "createSql": "CREATE INDEX `index_Dependency_prerequisite_id` ON `${TABLE_NAME}` (`prerequisite_id`)" + } + ], + "foreignKeys": [ + { + "table": "WorkSpec", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "work_spec_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "WorkSpec", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "prerequisite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "WorkSpec", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `state` INTEGER NOT NULL, `worker_class_name` TEXT NOT NULL, `input_merger_class_name` TEXT, `input` BLOB NOT NULL, `output` BLOB NOT NULL, `initial_delay` INTEGER NOT NULL, `interval_duration` INTEGER NOT NULL, `flex_duration` INTEGER NOT NULL, `run_attempt_count` INTEGER NOT NULL, `backoff_policy` INTEGER NOT NULL, `backoff_delay_duration` INTEGER NOT NULL, `period_start_time` INTEGER NOT NULL, `minimum_retention_duration` INTEGER NOT NULL, `schedule_requested_at` INTEGER NOT NULL, `required_network_type` INTEGER, `requires_charging` INTEGER NOT NULL, `requires_device_idle` INTEGER NOT NULL, `requires_battery_not_low` INTEGER NOT NULL, `requires_storage_not_low` INTEGER NOT NULL, `content_uri_triggers` BLOB, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workerClassName", + "columnName": "worker_class_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "inputMergerClassName", + "columnName": "input_merger_class_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "input", + "columnName": "input", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "output", + "columnName": "output", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "initialDelay", + "columnName": "initial_delay", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "intervalDuration", + "columnName": "interval_duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flexDuration", + "columnName": "flex_duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "runAttemptCount", + "columnName": "run_attempt_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "backoffPolicy", + "columnName": "backoff_policy", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "backoffDelayDuration", + "columnName": "backoff_delay_duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "periodStartTime", + "columnName": "period_start_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minimumRetentionDuration", + "columnName": "minimum_retention_duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "scheduleRequestedAt", + "columnName": "schedule_requested_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "constraints.mRequiredNetworkType", + "columnName": "required_network_type", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "constraints.mRequiresCharging", + "columnName": "requires_charging", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "constraints.mRequiresDeviceIdle", + "columnName": "requires_device_idle", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "constraints.mRequiresBatteryNotLow", + "columnName": "requires_battery_not_low", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "constraints.mRequiresStorageNotLow", + "columnName": "requires_storage_not_low", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "constraints.mContentUriTriggers", + "columnName": "content_uri_triggers", + "affinity": "BLOB", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_WorkSpec_schedule_requested_at", + "unique": false, + "columnNames": [ + "schedule_requested_at" + ], + "createSql": "CREATE INDEX `index_WorkSpec_schedule_requested_at` ON `${TABLE_NAME}` (`schedule_requested_at`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "WorkTag", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`tag` TEXT NOT NULL, `work_spec_id` TEXT NOT NULL, PRIMARY KEY(`tag`, `work_spec_id`), FOREIGN KEY(`work_spec_id`) REFERENCES `WorkSpec`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workSpecId", + "columnName": "work_spec_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "tag", + "work_spec_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_WorkTag_work_spec_id", + "unique": false, + "columnNames": [ + "work_spec_id" + ], + "createSql": "CREATE INDEX `index_WorkTag_work_spec_id` ON `${TABLE_NAME}` (`work_spec_id`)" + } + ], + "foreignKeys": [ + { + "table": "WorkSpec", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "work_spec_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SystemIdInfo", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`work_spec_id` TEXT NOT NULL, `system_id` INTEGER NOT NULL, PRIMARY KEY(`work_spec_id`), FOREIGN KEY(`work_spec_id`) REFERENCES `WorkSpec`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "workSpecId", + "columnName": "work_spec_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "systemId", + "columnName": "system_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "work_spec_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "WorkSpec", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "work_spec_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "WorkName", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `work_spec_id` TEXT NOT NULL, PRIMARY KEY(`name`, `work_spec_id`), FOREIGN KEY(`work_spec_id`) REFERENCES `WorkSpec`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workSpecId", + "columnName": "work_spec_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "name", + "work_spec_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_WorkName_work_spec_id", + "unique": false, + "columnNames": [ + "work_spec_id" + ], + "createSql": "CREATE INDEX `index_WorkName_work_spec_id` ON `${TABLE_NAME}` (`work_spec_id`)" + } + ], + "foreignKeys": [ + { + "table": "WorkSpec", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "work_spec_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"c45e5fcbdf3824dead9778f19e2fd8af\")" + ] + } +}
\ No newline at end of file diff --git a/work/workmanager/src/test/java/androidx/work/DataTest.java b/work/workmanager/src/test/java/androidx/work/DataTest.java index 3573b0a0ad1..04b502b7d62 100644 --- a/work/workmanager/src/test/java/androidx/work/DataTest.java +++ b/work/workmanager/src/test/java/androidx/work/DataTest.java @@ -19,6 +19,7 @@ package androidx.work; import static org.hamcrest.CoreMatchers.equalTo; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.CoreMatchers.nullValue; import static org.hamcrest.MatcherAssert.assertThat; import org.junit.Test; @@ -125,14 +126,14 @@ public class DataTest { Data data = dataBuilder.build(); assertThat(data.getInt("int", 0), is(1)); assertThat(data.getFloat("float", 0f), is(99f)); - assertThat(data.getString("String", null), is("two")); + assertThat(data.getString("String"), is("two")); long[] longArray = data.getLongArray("long array"); assertThat(longArray, is(notNullValue())); assertThat(longArray.length, is(3)); assertThat(longArray[0], is(1L)); assertThat(longArray[1], is(2L)); assertThat(longArray[2], is(3L)); - assertThat(data.getString("null", "dummy"), is("dummy")); + assertThat(data.getString("null"), is(nullValue())); } @Test diff --git a/work/workmanager/src/test/java/androidx/work/OverwritingInputMergerTest.java b/work/workmanager/src/test/java/androidx/work/OverwritingInputMergerTest.java index 40e703c6b61..b210a6ddee2 100644 --- a/work/workmanager/src/test/java/androidx/work/OverwritingInputMergerTest.java +++ b/work/workmanager/src/test/java/androidx/work/OverwritingInputMergerTest.java @@ -42,7 +42,7 @@ public class OverwritingInputMergerTest { Data output = getOutputFor(input); assertThat(output.size(), is(1)); - assertThat(output.getString(key, null), is(value)); + assertThat(output.getString(key), is(value)); } @Test @@ -67,9 +67,9 @@ public class OverwritingInputMergerTest { Data output = getOutputFor(input1, input2); assertThat(output.size(), is(3)); - assertThat(output.getString(key1, null), is(value1a)); - assertThat(output.getString(key2, null), is(value2)); - assertThat(output.getString(key3, null), is(value3)); + assertThat(output.getString(key1), is(value1a)); + assertThat(output.getString(key2), is(value2)); + assertThat(output.getString(key3), is(value3)); } private Data getOutputFor(Data... inputs) { |