diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..4e462a8c --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +*.iml +.gradle +/local.properties +/.idea +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +buildSrc/build \ No newline at end of file diff --git a/.travis b/.travis new file mode 100644 index 00000000..ff91a023 --- /dev/null +++ b/.travis @@ -0,0 +1,26 @@ +language: android +jdk: + - oraclejdk8 +android: + components: + - tools + - platform-tools + - build-tools-29.0.3 + - android-29 + - extra-android-m2repository + licenses: + - android-sdk-license-.+ +script: + - "./gradlew assemble" +before_install: + - yes | sdkmanager "platforms;android-29" +branches: + except: + - gh-pages +notifications: + email: false +sudo: false +cache: + directories: + - "$HOME/.m2" + - "$HOME/.gradle" \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..f8883e91 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,84 @@ +# Code of Conduct + +## 1. Purpose + +A primary goal of Store is to be inclusive to the largest number of contributors, with the most varied and diverse backgrounds possible. As such, we are committed to providing a friendly, safe and welcoming environment for all, regardless of gender, sexual orientation, ability, ethnicity, socioeconomic status, and religion (or lack thereof). + +This code of conduct outlines our expectations for all those who participate in our community, as well as the consequences for unacceptable behavior. + +We invite all those who participate in Store to help us create safe and positive experiences for everyone. + +## 2. Open Source Citizenship + +A supplemental goal of this Code of Conduct is to increase open source citizenship by encouraging participants to recognize and strengthen the relationships between our actions and their effects on our community. + +Communities mirror the societies in which they exist and positive action is essential to counteract the many forms of inequality and abuses of power that exist in society. + +If you see someone who is making an extra effort to ensure our community is welcoming, friendly, and encourages all participants to contribute to the fullest extent, we want to know. + +## 3. Expected Behavior + +The following behaviors are expected and requested of all community members: + +* Participate in an authentic and active way. In doing so, you contribute to the health and longevity of this community. +* Exercise consideration and respect in your speech and actions. +* Attempt collaboration before conflict. +* Refrain from demeaning, discriminatory, or harassing behavior and speech. +* Be mindful of your surroundings and of your fellow participants. Alert community leaders if you notice a dangerous situation, someone in distress, or violations of this Code of Conduct, even if they seem inconsequential. +* Remember that community event venues may be shared with members of the public; please be respectful to all patrons of these locations. + +## 4. Unacceptable Behavior + +The following behaviors are considered harassment and are unacceptable within our community: + +* Violence, threats of violence or violent language directed against another person. +* Sexist, racist, homophobic, transphobic, ableist or otherwise discriminatory jokes and language. +* Posting or displaying sexually explicit or violent material. +* Posting or threatening to post other people’s personally identifying information ("doxing"). +* Personal insults, particularly those related to gender, sexual orientation, race, religion, or disability. +* Inappropriate photography or recording. +* Inappropriate physical contact. You should have someone’s consent before touching them. +* Unwelcome sexual attention. This includes, sexualized comments or jokes; inappropriate touching, groping, and unwelcomed sexual advances. +* Deliberate intimidation, stalking or following (online or in person). +* Advocating for, or encouraging, any of the above behavior. +* Sustained disruption of community events, including talks and presentations. + +## 5. Consequences of Unacceptable Behavior + +Unacceptable behavior from any community member, including sponsors and those with decision-making authority, will not be tolerated. + +Anyone asked to stop unacceptable behavior is expected to comply immediately. + +If a community member engages in unacceptable behavior, the community organizers may take any action they deem appropriate, up to and including a temporary ban or permanent expulsion from the community without warning (and without refund in the case of a paid event). + +## 6. Reporting Guidelines + +If you are subject to or witness unacceptable behavior, or have any other concerns, please notify a community organizer as soon as possible. . + + + +Additionally, community organizers are available to help community members engage with local law enforcement or to otherwise help those experiencing unacceptable behavior feel safe. In the context of in-person events, organizers will also provide escorts as desired by the person experiencing distress. + +## 7. Addressing Grievances + +If you feel you have been falsely or unfairly accused of violating this Code of Conduct, you should notify The New York Times with a concise description of your grievance. Your grievance will be handled in accordance with our existing governing policies. + + + +## 8. Scope + +We expect all community participants (contributors, paid or otherwise; sponsors; and other guests) to abide by this Code of Conduct in all community venues–online and in-person–as well as in all one-on-one communications pertaining to community business. + +This code of conduct and its related procedures also applies to unacceptable behavior occurring outside the scope of community activities when such behavior has the potential to adversely affect the safety and well-being of community members. + +## 9. Contact info + + + +## 10. License and attribution + +This Code of Conduct is distributed under a [Creative Commons Attribution-ShareAlike license](http://creativecommons.org/licenses/by-sa/3.0/). + +Portions of text derived from the [Django Code of Conduct](https://www.djangoproject.com/conduct/) and the [Geek Feminism Anti-Harassment Policy](http://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy). + +Retrieved on November 22, 2016 from [http://citizencodeofconduct.org/](http://citizencodeofconduct.org/) \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..6c54b492 --- /dev/null +++ b/LICENSE @@ -0,0 +1,203 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2016-2017 The New York Times Company + + Copyright (c) 2019 Dropbox, Inc. + + 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. \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..083ef380 --- /dev/null +++ b/build.gradle @@ -0,0 +1,30 @@ + + +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + ext.kotlin_version = "1.3.72" + repositories { + google() + jcenter() + } + dependencies { + classpath "com.android.tools.build:gradle:4.1.0" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +apply plugin: "com.dropbox.anakin.AnakinPlugin" + +allprojects { + repositories { + google() + jcenter() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} \ No newline at end of file diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 00000000..adc25703 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2020, Dropbox, Inc. All rights reserved. + */ +plugins { + kotlin("jvm") version "1.4.10" + `java-gradle-plugin` +} + + +repositories { + google() + jcenter() +} diff --git a/buildSrc/src/main/kotlin/com/dropbox/anakin/AffectedModuleDetector.kt b/buildSrc/src/main/kotlin/com/dropbox/anakin/AffectedModuleDetector.kt new file mode 100644 index 00000000..62f3e79d --- /dev/null +++ b/buildSrc/src/main/kotlin/com/dropbox/anakin/AffectedModuleDetector.kt @@ -0,0 +1,551 @@ +/* + * 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. + */ + +/* + * Copyright (c) 2020, Dropbox, Inc. All rights reserved. + */ +@file:JvmName("AffectedModuleDetectorUtil") + +package com.dropbox.anakin + +import java.io.File +import java.util.UUID +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.api.Task +import org.gradle.api.invocation.Gradle +import org.gradle.api.logging.Logger +import com.dropbox.anakin.AffectedModuleDetector.Companion.CHANGED_PROJECTS_ARG +import com.dropbox.anakin.AffectedModuleDetector.Companion.DEPENDENT_PROJECTS_ARG +import com.dropbox.anakin.AffectedModuleDetector.Companion.ENABLE_ARG + +/** + * The subsets we allow the projects to be partitioned into. + * This is to allow more granular testing. Specifically, to enable running large tests on + * CHANGED_PROJECTS, while still only running small and medium tests on DEPENDENT_PROJECTS. + * + * The ProjectSubset specifies which projects we are interested in testing. + * The AffectedModuleDetector determines the minimum set of projects that must be built in + * order to run all the tests along with their runtime dependencies. + * + * The subsets are: + * CHANGED_PROJECTS -- The containing projects for any files that were changed in this CL. + * + * DEPENDENT_PROJECTS -- Any projects that have a dependency on any of the projects + * in the CHANGED_PROJECTS set. + * + * ALL_AFFECTED_PROJECTS -- The union of CHANGED_PROJECTS and DEPENDENT_PROJECTS, + * which encompasses all projects that could possibly break due to the changes. + * + * NONE -- A status to return for a project when it is not supposed to be built. + */ +enum class ProjectSubset { DEPENDENT_PROJECTS, CHANGED_PROJECTS, ALL_AFFECTED_PROJECTS, NONE } + +/** + * A utility class that can discover which files are changed based on git history. + * + * To enable this, you need to pass [ENABLE_ARG] into the build as a command line parameter + * (-P) + * + * Passing [DEPENDENT_PROJECTS_ARG] will result in only DEPENDENT_PROJECTS being returned (see enum) + * Passing [CHANGED_PROJECTS_ARG] will behave likewise. + * + * If neither of those are passed, ALL_AFFECTED_PROJECTS is returned. + * + * [MODULES_ARG] takes a comma delimited list of paths. If this is provided, it will ensure both a + * module is affected per the [ProjectSubset] rules, and included in the list. If it is not + * provided, it will follow [ProjectSubset] + * + * Currently, it checks git logs to find the files changed in the last commit + * + * Since this needs to check project dependency graph to work, it cannot be accessed before + * all projects are loaded. Doing so will throw an exception. + */ +abstract class AffectedModuleDetector { + /** + * Returns whether this project was affected by current changes. + * + * Can only be called during the execution phase + */ + abstract fun shouldInclude(project: Project): Boolean + + /** + * Returns true if at least one project has been affected + * + * Can only be called during the execution phase + */ + abstract fun hasAffectedProjects(): Boolean + + /** + * Returns true if the project was provided via [MODULES_ARG] or no [MODULES_ARG] was set + * + * Can be called during the configuration or execution phase + */ + abstract fun isProjectProvided(path: String): Boolean + + /** + * Returns the set that the project belongs to. The set is one of the ProjectSubset above. + * This is used by the test config generator. + * + * Can be called during the configuration or execution phase + */ + abstract fun getSubset(project: Project): ProjectSubset + + companion object { + private const val ROOT_PROP_NAME = "SelectiveTestingPlugin" + internal const val MODULES_ARG = "affected_module_detector.modules" + private const val DEPENDENT_PROJECTS_ARG = "affected_module_detector.dependentProjects" + private const val CHANGED_PROJECTS_ARG = "affected_module_detector.changedProjects" + private const val ENABLE_ARG = "affected_module_detector.enable" + var isConfigured = false + + @JvmStatic + fun configure(gradle: Gradle, rootProject: Project) { + val enabled = rootProject.hasProperty(ENABLE_ARG) + if (!enabled) { + setInstance( + rootProject, + AcceptAll() + ) + return + } + isConfigured = true + + val subset = when { + rootProject.hasProperty(DEPENDENT_PROJECTS_ARG) -> { + ProjectSubset.DEPENDENT_PROJECTS + } + rootProject.hasProperty(CHANGED_PROJECTS_ARG) -> { + ProjectSubset.CHANGED_PROJECTS + } + else -> { + ProjectSubset.ALL_AFFECTED_PROJECTS + } + } + + val config = + requireNotNull( + rootProject.extensions.findByType(AffectedModuleConfiguration::class.java) + ) { + "Root project ${rootProject.path} must have the AffectedModuleConfiguration " + + "extension added." + } + + val logger = ToStringLogger.createWithLifecycle(gradle) { log -> + config.logFolder?.let { + val distDir = File(it) + if (!distDir.exists()) { + distDir.mkdirs() + } + val outputFile = + distDir.resolve(UUID.randomUUID().toString() + "_" + config.logFilename) + outputFile.writeText(log) + println("Wrote dependency log to ${outputFile.absolutePath}") + } + } + + val modules = getModulesProperty(rootProject) + + AffectedModuleDetectorImpl( + rootProject = rootProject, + logger = logger, + ignoreUnknownProjects = true, + projectSubset = subset, + modules = modules, + config = config + ).also { + logger.info("Using real detector") + setInstance( + rootProject, + it + ) + } + } + + private fun setInstance( + rootProject: Project, + detector: AffectedModuleDetector + ) { + if (!rootProject.isRoot) { + throw IllegalArgumentException( + "This should've been the root project, instead found ${rootProject.path}" + ) + } + rootProject.extensions.add(ROOT_PROP_NAME, detector) + } + + private fun getInstance(project: Project): AffectedModuleDetector? { + val extensions = project.rootProject.extensions + + return extensions.getByName(ROOT_PROP_NAME) as? AffectedModuleDetector + } + + private fun getOrThrow(project: Project): AffectedModuleDetector { + + return getInstance( + project + ) ?: throw GradleException( + """ + Tried to get affected module detector too early. + You cannot access it until all projects are evaluated. + """.trimIndent() + ) + } + + private fun getModulesProperty(project: Project): Set? { + return if (project.hasProperty(MODULES_ARG)) { + val commaDelimited = project.properties[MODULES_ARG] as String + commaDelimited.split(",").toSet() + } else { + null + } + } + + /** + * Call this method to configure the given task to execute only if the owner project + * is affected by current changes + * + * Can be called during the configuration or execution phase + */ + @Throws(GradleException::class) + @JvmStatic + fun configureTaskGuard(task: Task) { + task.onlyIf { + getOrThrow( + task.project + ).shouldInclude(task.project) + } + } + + /** + * Call this method to determine if the project was affected in this change + * + * Can only be called during the execution phase + */ + @JvmStatic + @Throws(GradleException::class) + fun isProjectAffected(project: Project): Boolean { + return getOrThrow(project).shouldInclude(project) + } + + /** + * Call this method to determine if root project has at least one affected project + * + * Can only be called during the execution phase + */ + @Throws(GradleException::class) + fun hasAffectedProjects(project: Project): Boolean { + return getOrThrow(project).hasAffectedProjects() + } + + /** + * Returns true if the project was provided via [MODULES_ARG] or no [MODULES_ARG] was set + * + * Can be called during the configuration or execution phase + */ + @JvmStatic + fun isProjectProvided(project: Project): Boolean { + return getOrThrow(project).isProjectProvided(project.path) + } + } +} + +/** + * Implementation that accepts everything without checking. + */ +private class AcceptAll( + private val wrapped: AffectedModuleDetector? = null, + private val logger: Logger? = null +) : AffectedModuleDetector() { + override fun shouldInclude(project: Project): Boolean { + val wrappedResult = wrapped?.shouldInclude(project) + logger?.info("[AcceptAll] wrapper returned $wrappedResult but I'll return true") + return true + } + + override fun hasAffectedProjects() = true + + override fun isProjectProvided(path: String) = true + + override fun getSubset(project: Project): ProjectSubset { + val wrappedResult = wrapped?.getSubset(project) + logger?.info("[AcceptAll] wrapper returned $wrappedResult but I'll return CHANGED_PROJECTS") + return ProjectSubset.CHANGED_PROJECTS + } +} + +/** + * Real implementation that checks git logs to decide what is affected. + * + * If any file outside a module is changed, we assume everything has changed. + * + * When a file in a module is changed, all modules that depend on it are considered as changed. + */ +class AffectedModuleDetectorImpl constructor( + private val rootProject: Project, + private val logger: Logger?, + // used for debugging purposes when we want to ignore non module files + private val ignoreUnknownProjects: Boolean = false, + private val projectSubset: ProjectSubset = ProjectSubset.ALL_AFFECTED_PROJECTS, + private val injectedGitClient: GitClient? = null, + private val modules: Set? = null, + private val config: AffectedModuleConfiguration +) : AffectedModuleDetector() { + + init { + logger?.info("Modules provided: ${modules?.joinToString(separator = ",")}") + } + + private val git by lazy { + injectedGitClient ?: GitClientImpl( + rootProject.projectDir, + logger + ) + } + + private val dependencyTracker by lazy { + DependencyTracker(rootProject, logger) + } + + private val allProjects by lazy { + rootProject.subprojects.toSet() + } + + private val projectGraph by lazy { + ProjectGraph(rootProject, git.getGitRoot(), logger) + } + + private val affectedProjects by lazy { + findAffectedProjects() + } + + private val changedProjects by lazy { + findChangedProjects() + } + + private val dependentProjects by lazy { + findDependentProjects() + } + + private var unknownFiles: MutableSet = mutableSetOf() + + private val alwaysBuild by lazy { + ALWAYS_BUILD.map { path -> rootProject.project(path) } + } + + override fun shouldInclude(project: Project): Boolean { + return ( + project.isRoot || ( + affectedProjects.contains(project) && + isProjectProvided(project.path) + ) + ).also { + logger?.info( + "checking whether I should include ${project.path} and my answer is $it" + ) + } + } + + override fun hasAffectedProjects() = affectedProjects.isNotEmpty() + + override fun isProjectProvided(path: String): Boolean { + return (modules == null || modules.contains(path)) + } + + override fun getSubset(project: Project): ProjectSubset { + return when { + changedProjects.contains(project) -> { + ProjectSubset.CHANGED_PROJECTS + } + dependentProjects.contains(project) -> { + ProjectSubset.DEPENDENT_PROJECTS + } + else -> { + ProjectSubset.NONE + } + } + } + + /** + * Finds only the set of projects that were directly changed in the commit. + * + * Also populates the unknownFiles var which is used in findAffectedProjects + * + * Returns allProjects if there are no previous merge CLs, which shouldn't happen. + */ + private fun findChangedProjects(): Set { + val lastMergeSha = git.findPreviousMergeCL() ?: return allProjects + val changedFiles = git.findChangedFilesSince( + sha = lastMergeSha, + includeUncommitted = true + ) + + val changedProjects: MutableSet = alwaysBuild.toMutableSet() + + for (filePath in changedFiles) { + val containingProject = findContainingProject(filePath) + if (containingProject == null) { + unknownFiles.add(filePath) + logger?.info( + "Couldn't find containing project for file$filePath. " + + "Adding to unknownFiles." + ) + } else { + changedProjects.add(containingProject) + logger?.info( + "For file $filePath containing project is $containingProject. " + + "Adding to changedProjects." + ) + } + } + + return changedProjects + } + + /** + * Gets all dependent projects from the set of changedProjects. This doesn't include the + * original changedProjects. Always build is still here to ensure at least 1 thing is built + */ + private fun findDependentProjects(): Set { + val dependentProjects = changedProjects.flatMap { + dependencyTracker.findAllDependents(it) + }.toSet() + return dependentProjects + alwaysBuild + } + + /** + * By default, finds all modules that are affected by current changes + * + * With param dependentProjects, finds only modules dependent on directly changed modules + * + * With param changedProjects, finds only directly changed modules + * + * If it cannot determine the containing module for a file (e.g. buildSrc or root), it + * defaults to all projects unless [ignoreUnknownProjects] is set to true. + * + * Also detects modules whose tests are codependent at runtime. + */ + @Suppress("ComplexMethod") + private fun findAffectedProjects(): Set { + // In this case we don't care about any of the logic below, we're only concerned with + // running the changed projects in this test runner + if (projectSubset == ProjectSubset.CHANGED_PROJECTS) { + return changedProjects + } + + var buildAll = false + + // Should only trigger if there are no changedFiles + if (changedProjects.size == alwaysBuild.size && unknownFiles.isEmpty()) buildAll = true + unknownFiles.forEach { + if (affectsAllModules(it)) { + buildAll = true + } + } + logger?.info( + "unknownFiles: $unknownFiles, changedProjects: $changedProjects, buildAll: " + + "$buildAll" + ) + + // If we're in a buildAll state, we return allProjects unless it's the changed target, + // Since the changed target runs all tests and we don't want 3+ hour presubmit runs + if (buildAll) { + logger?.info("Building all projects") + if (unknownFiles.isEmpty()) { + logger?.info("because no changed files were detected") + } else { + logger?.info("because one of the unknown files affects everything in the build") + logger?.info( + """ + The modules detected as affected by changed files are + ${changedProjects + dependentProjects} + """.trimIndent() + ) + } + when (projectSubset) { + ProjectSubset.DEPENDENT_PROJECTS -> return allProjects + ProjectSubset.ALL_AFFECTED_PROJECTS -> return allProjects + else -> { + } + } + } + + return when (projectSubset) { + ProjectSubset.ALL_AFFECTED_PROJECTS -> changedProjects + dependentProjects + ProjectSubset.CHANGED_PROJECTS -> changedProjects + else -> dependentProjects + } + } + + private fun affectsAllModules(file: String): Boolean { + logger?.info("Paths affecting all modules: ${config.pathsAffectingAllModules}") + return config.pathsAffectingAllModules.any { file.startsWith(it) } + } + + private fun findContainingProject(filePath: String): Project? { + return projectGraph.findContainingProject(filePath).also { + logger?.info("search result for $filePath resulted in ${it?.path}") + } + } + + companion object { + // dummy test to ensure no failure due to "no instrumentation. We can eventually remove + // if we resolve b/127819369 + private val ALWAYS_BUILD: Set = setOf() + } +} + +class AffectedModuleConfiguration { + /** + * Folder to place the log in + */ + var logFolder: String? = null + + /** + * Name for the log file. This will be prefixed with a [UUID.randomUUID] to ensure + * multiple runs can be uniquely identified and collected on a CI system. + */ + var logFilename: String = "affected_module_detector.log" + + /** + * Base directory to use for [pathsAffectingAllModules] + */ + var baseDir: String? = null + /** + * Files or folders which if changed will trigger all projects to be considered affected + */ + var pathsAffectingAllModules = setOf() + set(value) { + requireNotNull(baseDir) { + "baseDir must be set to use pathsAffectingAllModules" + } + field = value + } + get() { + field.forEach { path -> + require(File(baseDir, path).exists()) { + "Could not find expected path in pathsAffectingAllModules: $path" + } + } + return field + } + + companion object { + const val name = "affectedModuleDetector" + } +} + +val Project.isRoot get() = this == rootProject diff --git a/buildSrc/src/main/kotlin/com/dropbox/anakin/AnakinPlugin.kt b/buildSrc/src/main/kotlin/com/dropbox/anakin/AnakinPlugin.kt new file mode 100644 index 00000000..3e9b0779 --- /dev/null +++ b/buildSrc/src/main/kotlin/com/dropbox/anakin/AnakinPlugin.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2020, Dropbox, Inc. All rights reserved. + */ + +package com.dropbox.anakin + +import org.gradle.api.Plugin +import org.gradle.api.Project + +class AnakinPlugin : Plugin { + override fun apply(project: Project) { + require(project.isRoot) { + "Must be applied to root project, but was found on ${project.path} instead." + } + project.extensions.add( + AffectedModuleConfiguration.name, + AffectedModuleConfiguration() + ) + AffectedModuleDetector.configure(project.gradle, project) + } +} diff --git a/buildSrc/src/main/kotlin/com/dropbox/anakin/DependencyTracker.kt b/buildSrc/src/main/kotlin/com/dropbox/anakin/DependencyTracker.kt new file mode 100644 index 00000000..31e39f9a --- /dev/null +++ b/buildSrc/src/main/kotlin/com/dropbox/anakin/DependencyTracker.kt @@ -0,0 +1,70 @@ +/* + * 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. + */ + +/* + * Copyright (c) 2020, Dropbox, Inc. All rights reserved. + */ +package com.dropbox.anakin + +import org.gradle.api.Project +import org.gradle.api.artifacts.ProjectDependency +import org.gradle.api.logging.Logger + +/** + * Utility class that traverses all project dependencies and discover which modules depend + * on each other. This is mainly used by [AffectedModuleDetector] to find out which projects + * should be run. + */ +internal class DependencyTracker constructor( + private val rootProject: Project, + private val logger: Logger? +) { + private val dependentList: Map> by lazy { + val result = mutableMapOf>() + rootProject.subprojects.forEach { project -> + logger?.info("checking ${project.path} for dependencies") + project.configurations.forEach { config -> + logger?.info("checking config ${project.path}/$config for dependencies") + config + .dependencies + .filterIsInstance(ProjectDependency::class.java) + .forEach { + logger?.info("there is a dependency from ${project.path} to " + + it.dependencyProject.path) + result.getOrPut(it.dependencyProject) { mutableSetOf() } + .add(project) + } + } + } + result + } + + fun findAllDependents(project: Project): Set { + logger?.info("finding dependents of ${project.path}") + val result = mutableSetOf() + fun addAllDependents(project: Project) { + if (result.add(project)) { + dependentList[project]?.forEach(::addAllDependents) + } + } + addAllDependents(project) + logger?.info("dependents of ${project.path} is ${result.map { + it.path + }}") + // the project isn't a dependent of itself + return result.minus(project) + } +} diff --git a/buildSrc/src/main/kotlin/com/dropbox/anakin/GitClient.kt b/buildSrc/src/main/kotlin/com/dropbox/anakin/GitClient.kt new file mode 100644 index 00000000..0bbe627a --- /dev/null +++ b/buildSrc/src/main/kotlin/com/dropbox/anakin/GitClient.kt @@ -0,0 +1,279 @@ +/* + * 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. + */ + +/* + * Copyright (c) 2020, Dropbox, Inc. All rights reserved. + */ + +package com.dropbox.anakin + +import java.io.File +import java.util.concurrent.TimeUnit +import org.gradle.api.logging.Logger + +interface GitClient { + fun findChangedFilesSince( + sha: String, + top: String = "HEAD", + includeUncommitted: Boolean = false + ): List + fun findPreviousMergeCL(): String? + + fun getGitLog( + gitCommitRange: GitCommitRange, + keepMerges: Boolean, + fullProjectDir: File + ): List + + fun getGitRoot(): File + + /** + * Abstraction for running execution commands for testability + */ + interface CommandRunner { + /** + * Executes the given shell command and returns the stdout as a string. + */ + fun execute(command: String): String + /** + * Executes the given shell command and returns the stdout by lines. + */ + fun executeAndParse(command: String): List + } +} +/** + * A simple git client that uses system process commands to communicate with the git setup in the + * given working directory. + */ +class GitClientImpl( + /** + * The root location for git + */ + private val workingDir: File, + private val logger: Logger?, + private val commandRunner: GitClient.CommandRunner = RealCommandRunner( + workingDir = workingDir, + logger = logger + ) +) : GitClient { + + /** + * Finds changed file paths since the given sha + */ + override fun findChangedFilesSince( + sha: String, + top: String, + includeUncommitted: Boolean + ): List { + // use this if we don't want local changes + return commandRunner.executeAndParse(if (includeUncommitted) { + "$CHANGED_FILES_CMD_PREFIX HEAD..$sha" + } else { + "$CHANGED_FILES_CMD_PREFIX $top $sha" + }) + } + + /** + * checks the history to find the first merge CL. + */ + override fun findPreviousMergeCL(): String? { + return commandRunner.executeAndParse(PREV_MERGE_CMD) + .firstOrNull() + ?.split(" ") + ?.firstOrNull() + } + + private fun findGitDirInParentFilepath(filepath: File): File? { + var curDirectory: File = filepath + while (curDirectory.path != "/") { + if (File("$curDirectory/.git").exists()) { + return curDirectory + } + curDirectory = curDirectory.parentFile + } + return null + } + @Suppress("LongParameterList") + private fun parseCommitLogString( + commitLogString: String, + commitStartDelimiter: String, + commitSHADelimiter: String, + subjectDelimiter: String, + authorEmailDelimiter: String, + localProjectDir: String + ): List { + // Split commits string out into individual commits (note: this removes the deliminter) + val gitLogStringList: List? = commitLogString.split(commitStartDelimiter) + var commitLog: MutableList = mutableListOf() + gitLogStringList?.filter { gitCommit -> + gitCommit.trim() != "" + }?.forEach { gitCommit -> + commitLog.add( + Commit( + gitCommit, + localProjectDir, + commitSHADelimiter = commitSHADelimiter, + subjectDelimiter = subjectDelimiter, + authorEmailDelimiter = authorEmailDelimiter + ) + ) + } + return commitLog.toList() + } + + /** + * Converts a diff log command into a [List] + * + * @param gitCommitRange the [GitCommitRange] that defines the parameters of the git log command + * @param keepMerges boolean for whether or not to add merges to the return [List]. + * @param fullProjectDir a [File] object that represents the full project directory. + */ + override fun getGitLog( + gitCommitRange: GitCommitRange, + keepMerges: Boolean, + fullProjectDir: File + ): List { + val commitStartDelimiter: String = "_CommitStart" + val commitSHADelimiter: String = "_CommitSHA:" + val subjectDelimiter: String = "_Subject:" + val authorEmailDelimiter: String = "_Author:" + val dateDelimiter: String = "_Date:" + val bodyDelimiter: String = "_Body:" + val localProjectDir: String = fullProjectDir.relativeTo(getGitRoot()).toString() + val relativeProjectDir: String = fullProjectDir.relativeTo(workingDir).toString() + + var gitLogOptions: String = + "--pretty=format:$commitStartDelimiter%n" + + "$commitSHADelimiter%H%n" + + "$authorEmailDelimiter%ae%n" + + "$dateDelimiter%ad%n" + + "$subjectDelimiter%s%n" + + "$bodyDelimiter%b" + + if (!keepMerges) { + " --no-merges" + } else { + "" + } + var gitLogCmd: String + if (gitCommitRange.fromExclusive != "") { + gitLogCmd = "$GIT_LOG_CMD_PREFIX $gitLogOptions " + + "${gitCommitRange.fromExclusive}..${gitCommitRange.untilInclusive}" + + " -- ./$relativeProjectDir" + } else { + gitLogCmd = "$GIT_LOG_CMD_PREFIX $gitLogOptions ${gitCommitRange.untilInclusive} -n " + + "${gitCommitRange.n} -- ./$relativeProjectDir" + } + val gitLogString: String = commandRunner.execute(gitLogCmd) + val commits = parseCommitLogString( + gitLogString, + commitStartDelimiter, + commitSHADelimiter, + subjectDelimiter, + authorEmailDelimiter, + localProjectDir + ) + if (commits.isEmpty()) { + // Probably an error; log this + logger?.warn("No git commits found! Ran this command: '" + + gitLogCmd + "' and received this output: '" + gitLogString + "'") + } + return commits + } + + override fun getGitRoot(): File { + return findGitDirInParentFilepath(workingDir) ?: workingDir + } + + private class RealCommandRunner( + private val workingDir: File, + private val logger: Logger? + ) : GitClient.CommandRunner { + override fun execute(command: String): String { + val parts = command.split("\\s".toRegex()) + logger?.info("running command $command in $workingDir") + val proc = ProcessBuilder(*parts.toTypedArray()) + .directory(workingDir) + .redirectOutput(ProcessBuilder.Redirect.PIPE) + .redirectError(ProcessBuilder.Redirect.PIPE) + .start() + + proc.waitFor(5, TimeUnit.MINUTES) + val stdout = proc + .inputStream + .bufferedReader() + .readText() + val stderr = proc + .errorStream + .bufferedReader() + .readText() + val message = stdout + stderr + if (stderr != "") { + logger?.error("Response: $message") + } else { + logger?.info("Response: $message") + } + check(proc.exitValue() == 0) { "Nonzero exit value running git command." } + return stdout + } + override fun executeAndParse(command: String): List { + val response = execute(command) + .split(System.lineSeparator()) + .filterNot { + it.isEmpty() + } + return response + } + } + + companion object { + const val PREV_MERGE_CMD = "git --no-pager rev-parse HEAD~1" + const val CHANGED_FILES_CMD_PREFIX = "git --no-pager diff --name-only" + const val GIT_LOG_CMD_PREFIX = "git --no-pager log --name-only" + } +} + +/** + * Defines the parameters for a git log command + * + * @property fromExclusive the oldest SHA at which the git log starts. Set to an empty string to use + * [n] + * @property untilInclusive the latest SHA included in the git log. Defaults to HEAD + * @property n a count of how many commits to go back to. Only used when [fromExclusive] is an + * empty string + */ +data class GitCommitRange( + val fromExclusive: String = "", + val untilInclusive: String = "HEAD", + val n: Int = 0 +) + +/** + * Class implementation of a git commit. It uses the input delimiters to parse the commit + * + * @property gitCommit a string representation of a git commit + * @property projectDir the project directory for which to parse file paths from a commit + * @property commitSHADelimiter the term to use to search for the commit SHA + * @property subjectDelimiter the term to use to search for the subject (aka commit summary) + * message + * @property authorEmailDelimiter the term to use to search for the author email + */ +data class Commit( + val gitCommit: String, + val projectDir: String, + private val commitSHADelimiter: String = "_CommitSHA:", + private val subjectDelimiter: String = "_Subject:", + private val authorEmailDelimiter: String = "_Author:" +) diff --git a/buildSrc/src/main/kotlin/com/dropbox/anakin/ProjectGraph.kt b/buildSrc/src/main/kotlin/com/dropbox/anakin/ProjectGraph.kt new file mode 100644 index 00000000..3e54f11d --- /dev/null +++ b/buildSrc/src/main/kotlin/com/dropbox/anakin/ProjectGraph.kt @@ -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. + */ + + /* + * Copyright (c) 2020, Dropbox, Inc. All rights reserved. + */ + +package com.dropbox.anakin + +import java.io.File +import org.gradle.api.Project +import org.gradle.api.logging.Logger + +/** + * Creates a project graph for fast lookup by file path + */ +class ProjectGraph(project: Project, val gitRoot: File, val logger: Logger? = null) { + private val rootNode: Node + private val rootProjectDir: File + + init { + logger?.info("initializing ProjectGraph") + rootNode = Node(logger) + rootProjectDir = project.getSupportRootFolder().canonicalFile + project.subprojects.forEach { + logger?.info("creating node for ${it.path}") + val relativePath = it.projectDir.canonicalFile.toRelativeString(rootProjectDir) + val sections = relativePath.split(File.separatorChar) + logger?.info("relative path: $relativePath , sections: $sections") + val leaf = sections.fold(rootNode) { left, right -> + left.getOrCreateNode(right) + } + leaf.project = it + } + logger?.info("finished creating ProjectGraph $rootNode") + } + + /** + * Finds the project that contains the given file. + * The file's path prefix should match the project's path. + */ + fun findContainingProject(filePath: String): Project? { + val sections = filePath.split(File.separatorChar) + val realSections = sections.toMutableList() + val projectRelativeDir = findProjectRelativeDir() + for (dir in projectRelativeDir) { + if (realSections.isNotEmpty() && dir == realSections.first()) { + realSections.removeAt(0) + } else { + break + } + } + + logger?.info("finding containing project for $filePath , sections: $realSections") + return rootNode.find(realSections, 0) + } + + private fun findProjectRelativeDir(): List { + return rootProjectDir.toRelativeString(gitRoot).split(File.separatorChar) + } + + private class Node(val logger: Logger? = null) { + var project: Project? = null + private val children = mutableMapOf() + + fun getOrCreateNode(key: String): Node { + return children.getOrPut(key) { + Node( + logger + ) + } + } + + fun find(sections: List, index: Int): Project? { + logger?.info("finding $sections with index $index in ${project?.path ?: "root"}") + if (sections.size <= index) { + logger?.info("nothing") + return project + } + val child = children[sections[index]] + return if (child == null) { + logger?.info("no child found, returning ${project?.path ?: "root"}") + project + } else { + child.find(sections, index + 1) + } + } + } +} + +/** + * Returns the path to the canonical root project directory, e.g. {@code frameworks/support}. + */ +fun Project.getSupportRootFolder(): File = project.rootDir diff --git a/buildSrc/src/main/kotlin/com/dropbox/anakin/ToStringLogger.kt b/buildSrc/src/main/kotlin/com/dropbox/anakin/ToStringLogger.kt new file mode 100644 index 00000000..df0ff6ce --- /dev/null +++ b/buildSrc/src/main/kotlin/com/dropbox/anakin/ToStringLogger.kt @@ -0,0 +1,72 @@ +/* + * 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. + */ + +/* + * Copyright (c) 2020, Dropbox, Inc. All rights reserved. + */ + +package com.dropbox.anakin + +import org.gradle.BuildAdapter +import org.gradle.BuildResult +import org.gradle.api.invocation.Gradle +import org.gradle.api.logging.LogLevel +import org.gradle.api.logging.Logger +import org.gradle.internal.logging.slf4j.OutputEventListenerBackedLogger +import org.gradle.internal.logging.slf4j.OutputEventListenerBackedLoggerContext +import org.gradle.internal.time.Clock + +/** + * Gradle logger that logs to a string. + */ +class ToStringLogger( + private val stringBuilder: StringBuilder = StringBuilder() +) : OutputEventListenerBackedLogger( + "my_logger", + OutputEventListenerBackedLoggerContext( + Clock { + System.currentTimeMillis() + } + ).also { + it.level = LogLevel.DEBUG + it.setOutputEventListener { + stringBuilder.appendln(it.toString()) + } + }, + Clock { + System.currentTimeMillis() + } +) { + /** + * Returns the current log. + */ + fun buildString() = stringBuilder.toString() + + companion object { + fun createWithLifecycle( + gradle: Gradle, + onComplete: (String) -> Unit + ): Logger { + val logger = ToStringLogger() + gradle.addBuildListener(object : BuildAdapter() { + override fun buildFinished(result: BuildResult) { + onComplete(logger.buildString()) + } + }) + return logger + } + } +} diff --git a/buildSrc/src/main/resources/META-INF/gradle-plugins/com.dropbox.anakin.AnakinPlugin.properties b/buildSrc/src/main/resources/META-INF/gradle-plugins/com.dropbox.anakin.AnakinPlugin.properties new file mode 100644 index 00000000..b0f7430e --- /dev/null +++ b/buildSrc/src/main/resources/META-INF/gradle-plugins/com.dropbox.anakin.AnakinPlugin.properties @@ -0,0 +1 @@ +implementation-class=com.dropbox.anakin.AnakinPlugin diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..98bed167 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,21 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app"s APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..f6b961fd Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..1cc659f5 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Thu Oct 15 15:49:37 PDT 2020 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..cccdd3d5 --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 00000000..e95643d6 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/sample/.gitignore b/sample/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/sample/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/sample/build.gradle b/sample/build.gradle new file mode 100644 index 00000000..bab19f38 --- /dev/null +++ b/sample/build.gradle @@ -0,0 +1,47 @@ +import com.dropbox.anakin.AffectedModuleDetector + +plugins { + id 'com.android.application' + id 'kotlin-android' +} + +android { + compileSdkVersion 30 + buildToolsVersion "30.0.2" + + defaultConfig { + applicationId "com.dropbox.anakin.sample" + minSdkVersion 23 + targetSdkVersion 30 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } +} + +dependencies { + + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation 'androidx.core:core-ktx:1.2.0' + implementation 'androidx.appcompat:appcompat:1.1.0' + implementation 'com.google.android.material:material:1.1.0' + implementation 'androidx.constraintlayout:constraintlayout:1.1.3' + testImplementation 'junit:junit:4.+' + androidTestImplementation 'androidx.test.ext:junit:1.1.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' +} \ No newline at end of file diff --git a/sample/proguard-rules.pro b/sample/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/sample/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/sample/src/androidTest/java/com/dropbox/anakin/sample/ExampleInstrumentedTest.kt b/sample/src/androidTest/java/com/dropbox/anakin/sample/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..aab2289d --- /dev/null +++ b/sample/src/androidTest/java/com/dropbox/anakin/sample/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.dropbox.anakin.sample + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.dropbox.anakin.sample", appContext.packageName) + } +} \ No newline at end of file diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml new file mode 100644 index 00000000..41d85f98 --- /dev/null +++ b/sample/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sample/src/main/java/com/dropbox/anakin/sample/MainActivity.kt b/sample/src/main/java/com/dropbox/anakin/sample/MainActivity.kt new file mode 100644 index 00000000..16003c36 --- /dev/null +++ b/sample/src/main/java/com/dropbox/anakin/sample/MainActivity.kt @@ -0,0 +1,11 @@ +package com.dropbox.anakin.sample + +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + } +} \ No newline at end of file diff --git a/sample/src/main/res/drawable-v24/ic_launcher_foreground.xml b/sample/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 00000000..2b068d11 --- /dev/null +++ b/sample/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/sample/src/main/res/drawable/ic_launcher_background.xml b/sample/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..07d5da9c --- /dev/null +++ b/sample/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sample/src/main/res/layout/activity_main.xml b/sample/src/main/res/layout/activity_main.xml new file mode 100644 index 00000000..4fc24441 --- /dev/null +++ b/sample/src/main/res/layout/activity_main.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..eca70cfe --- /dev/null +++ b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..eca70cfe --- /dev/null +++ b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/sample/src/main/res/mipmap-hdpi/ic_launcher.png b/sample/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..a571e600 Binary files /dev/null and b/sample/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/sample/src/main/res/mipmap-hdpi/ic_launcher_round.png b/sample/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 00000000..61da551c Binary files /dev/null and b/sample/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/sample/src/main/res/mipmap-mdpi/ic_launcher.png b/sample/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..c41dd285 Binary files /dev/null and b/sample/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/sample/src/main/res/mipmap-mdpi/ic_launcher_round.png b/sample/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 00000000..db5080a7 Binary files /dev/null and b/sample/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/sample/src/main/res/mipmap-xhdpi/ic_launcher.png b/sample/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..6dba46da Binary files /dev/null and b/sample/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 00000000..da31a871 Binary files /dev/null and b/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/sample/src/main/res/mipmap-xxhdpi/ic_launcher.png b/sample/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..15ac6817 Binary files /dev/null and b/sample/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..b216f2d3 Binary files /dev/null and b/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..f25a4197 Binary files /dev/null and b/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 00000000..e96783cc Binary files /dev/null and b/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/sample/src/main/res/values-night/themes.xml b/sample/src/main/res/values-night/themes.xml new file mode 100644 index 00000000..08d96a52 --- /dev/null +++ b/sample/src/main/res/values-night/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/sample/src/main/res/values/colors.xml b/sample/src/main/res/values/colors.xml new file mode 100644 index 00000000..f8c6127d --- /dev/null +++ b/sample/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml new file mode 100644 index 00000000..82aa71e1 --- /dev/null +++ b/sample/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + sample + \ No newline at end of file diff --git a/sample/src/main/res/values/themes.xml b/sample/src/main/res/values/themes.xml new file mode 100644 index 00000000..4599222b --- /dev/null +++ b/sample/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/sample/src/test/java/com/dropbox/anakin/sample/ExampleUnitTest.kt b/sample/src/test/java/com/dropbox/anakin/sample/ExampleUnitTest.kt new file mode 100644 index 00000000..b58e4069 --- /dev/null +++ b/sample/src/test/java/com/dropbox/anakin/sample/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.dropbox.anakin.sample + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..fa75ba83 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +include ':sample' +rootProject.name = "sample" \ No newline at end of file