|
| 1 | +package com.firebaseui.lint.internal |
| 2 | + |
| 3 | +import com.android.SdkConstants.ANDROID_MANIFEST_XML |
| 4 | +import com.android.SdkConstants.ATTR_NAME |
| 5 | +import com.android.SdkConstants.TAG_PLURALS |
| 6 | +import com.android.SdkConstants.TAG_STRING |
| 7 | +import com.android.SdkConstants.TOOLS_URI |
| 8 | +import com.android.ide.common.resources.configuration.FolderConfiguration |
| 9 | +import com.android.resources.ResourceFolderType |
| 10 | +import com.android.tools.lint.detector.api.Category |
| 11 | +import com.android.tools.lint.detector.api.Context |
| 12 | +import com.android.tools.lint.detector.api.Implementation |
| 13 | +import com.android.tools.lint.detector.api.Issue |
| 14 | +import com.android.tools.lint.detector.api.Location |
| 15 | +import com.android.tools.lint.detector.api.ResourceXmlDetector |
| 16 | +import com.android.tools.lint.detector.api.Scope |
| 17 | +import com.android.tools.lint.detector.api.Severity |
| 18 | +import com.android.tools.lint.detector.api.SourceCodeScanner |
| 19 | +import com.android.tools.lint.detector.api.XmlContext |
| 20 | +import com.android.tools.lint.detector.api.XmlScannerConstants |
| 21 | +import org.w3c.dom.Attr |
| 22 | +import org.w3c.dom.Element |
| 23 | +import java.util.EnumSet |
| 24 | + |
| 25 | +/** |
| 26 | + * Flags a `fui_*` string or plurals resource that nothing in this module references. |
| 27 | + * |
| 28 | + * Android Lint's own `UnusedResources` does not report these, because for a library module it |
| 29 | + * cannot see the consumers that might use them. That blind spot is how 34 dead strings reached |
| 30 | + * `:auth`, carrying 2,603 translations across 84 locale folders, without the build ever saying |
| 31 | + * so: the module runs under `checkAllWarnings` with `warningsAsErrors` and still reported |
| 32 | + * nothing. |
| 33 | + * |
| 34 | + * The policy this encodes is deliberately narrower than `UnusedResources`, and is only correct |
| 35 | + * because of how this library is meant to be customised. `auth/README.md` documents these names |
| 36 | + * as ones a consuming app *overrides*, by declaring the same name in its own `strings.xml`. It |
| 37 | + * does not invite an app to read them through `R.string.`, and an override of a name the library |
| 38 | + * no longer declares is simply an app-owned string, so deleting one can neither break a consumer |
| 39 | + * build nor change behaviour the library never drove. |
| 40 | + * |
| 41 | + * Note that `:auth` ships no `res/values/public.xml`, so as far as AGP is concerned every one of |
| 42 | + * these resources is public API and this check is stricter than the build itself declares. |
| 43 | + * Declaring the intended surface in a `public.xml` would make the policy real rather than |
| 44 | + * conventional; until then this check is the only thing expressing it. |
| 45 | + * |
| 46 | + * Only names beginning `fui_` are considered. That is not much of a narrowing in practice, since |
| 47 | + * `auth/build.gradle.kts` sets `resourcePrefix("fui_")` and the built-in `ResourceName` check |
| 48 | + * then requires the prefix on everything the module declares. A resource that genuinely must be |
| 49 | + * exempt needs `tools:ignore="UnreferencedResource"` at its declaration, the same way `app_name` |
| 50 | + * carries `tools:ignore="ResourceName"`. |
| 51 | + * |
| 52 | + * ### What counts as a reference |
| 53 | + * |
| 54 | + * `R.string.name` and `R.plurals.name` in Kotlin and Java, and `@string/name` or `@plurals/name` |
| 55 | + * in any XML under `res/` or in `AndroidManifest.xml`, including an alias such as |
| 56 | + * `<string name="a">@string/b</string>`. Attributes in the `tools:` namespace are ignored, since |
| 57 | + * `tools:text="@string/x"` is design-time only and does not keep a resource alive at runtime. |
| 58 | + * |
| 59 | + * ### Known limits |
| 60 | + * |
| 61 | + * **Reports a resource used only by tests.** Test sources are not scanned, so a name referenced |
| 62 | + * from `src/test` or `src/androidTest` but nowhere in `src/main` is reported. That is deliberate: |
| 63 | + * a string no production code path reads is dead copy regardless of what a test does with it, and |
| 64 | + * the test should go with it. The message says "main sources" for that reason. |
| 65 | + * |
| 66 | + * **Cannot see other modules.** Lint runs this against `:auth` alone, so a `fui_*` resource that |
| 67 | + * only `:app` or a future sibling module referenced would be reported. Nothing outside `:auth` |
| 68 | + * declares or references a `fui_*` name today. If that changes, the reference needs |
| 69 | + * `tools:ignore` at the declaration, because a library module's lint genuinely cannot see it. |
| 70 | + * |
| 71 | + * The remaining limits all fail towards *not* reporting, so they cannot break a build over a |
| 72 | + * resource that is really in use. Source and manifest files are matched textually, so a reference |
| 73 | + * written through an aliased import (`import ...R as Res`, then `Res.string.name`) is not seen, |
| 74 | + * and a name appearing only in a comment counts as a reference; neither occurs in this repository |
| 75 | + * today. A resource reached only by [android.content.res.Resources.getIdentifier] is invisible |
| 76 | + * here, as it is to `UnusedResources`; there is no such call in the module. And a dead resource |
| 77 | + * that aliases another dead one keeps the second alive, so a chain like that is reported one link |
| 78 | + * per run rather than all at once. |
| 79 | + */ |
| 80 | +class UnreferencedResourceDetector : ResourceXmlDetector(), SourceCodeScanner { |
| 81 | + |
| 82 | + /** Declarations, keyed by resource name. Locale folders translate, they do not declare. */ |
| 83 | + private val declarations = mutableMapOf<String, Location.Handle>() |
| 84 | + |
| 85 | + /** Every `fui_*` name referenced from source, the manifest, or XML anywhere in the module. */ |
| 86 | + private val referenced = mutableSetOf<String>() |
| 87 | + |
| 88 | + // Resources are referenced from every kind of folder, not just values/, so unlike |
| 89 | + // UntranslatedResourceDetector this one reads them all. |
| 90 | + override fun appliesTo(folderType: ResourceFolderType): Boolean = true |
| 91 | + |
| 92 | + override fun getApplicableElements(): List<String> = XmlScannerConstants.ALL |
| 93 | + |
| 94 | + override fun getApplicableAttributes(): List<String> = XmlScannerConstants.ALL |
| 95 | + |
| 96 | + override fun visitElement(context: XmlContext, element: Element) { |
| 97 | + collectReferences(element.textContent) |
| 98 | + |
| 99 | + val tag = element.tagName |
| 100 | + if (tag != TAG_STRING && tag != TAG_PLURALS) return |
| 101 | + |
| 102 | + val folder = context.file.parentFile?.name ?: return |
| 103 | + if (!declaresResources(folder)) return |
| 104 | + |
| 105 | + val name = element.getAttribute(ATTR_NAME) |
| 106 | + if (!name.startsWith(RESOURCE_PREFIX)) return |
| 107 | + |
| 108 | + // First-wins, so the map does not depend on traversal order when the same name is |
| 109 | + // declared in both values/ and a configuration variant such as values-v26/. |
| 110 | + declarations.putIfAbsent(name, context.createLocationHandle(element)) |
| 111 | + } |
| 112 | + |
| 113 | + override fun visitAttribute(context: XmlContext, attribute: Attr) { |
| 114 | + // tools:text and friends are design-time only; they do not keep a resource alive. |
| 115 | + if (attribute.namespaceURI == TOOLS_URI) return |
| 116 | + collectReferences(attribute.value) |
| 117 | + } |
| 118 | + |
| 119 | + override fun beforeCheckFile(context: Context) { |
| 120 | + // The manifest is not under res/, so ResourceXmlDetector never dispatches it to |
| 121 | + // visitAttribute. Reading it here is what stops android:label="@string/fui_x" from |
| 122 | + // looking like a dead resource and failing the build on correct code. |
| 123 | + val name = context.file.name |
| 124 | + val scannable = name.endsWith(".kt") || name.endsWith(".java") || |
| 125 | + name == ANDROID_MANIFEST_XML |
| 126 | + if (!scannable) return |
| 127 | + collectReferences(context.getContents()?.toString() ?: return) |
| 128 | + } |
| 129 | + |
| 130 | + override fun afterCheckRootProject(context: Context) { |
| 131 | + // Nothing is collected when lint runs over a single file, which is the IDE's incremental |
| 132 | + // mode, nor when it runs over a source set that declares no resources of its own, which |
| 133 | + // is how the unit-test and androidTest analysis passes behave. Reporting in either case |
| 134 | + // would call every resource dead. |
| 135 | + if (declarations.isNotEmpty()) { |
| 136 | + for ((name, handle) in declarations) { |
| 137 | + if (name in referenced) continue |
| 138 | + context.report( |
| 139 | + UNREFERENCED_RESOURCE, |
| 140 | + handle.resolve(), |
| 141 | + "\"$name\" is not referenced anywhere in this module's main sources, by " + |
| 142 | + "`R.string.`, `R.plurals.` or `@string/`. Delete it along with its " + |
| 143 | + "translations in every `values-*` folder, or mark it " + |
| 144 | + "`tools:ignore=\"$ISSUE_ID\"` if something reaches it in a way this " + |
| 145 | + "check cannot see." |
| 146 | + ) |
| 147 | + } |
| 148 | + } |
| 149 | + |
| 150 | + declarations.clear() |
| 151 | + referenced.clear() |
| 152 | + } |
| 153 | + |
| 154 | + /** |
| 155 | + * Whether a `values` folder declares resources rather than translating them. |
| 156 | + * |
| 157 | + * Every locale folder repeats the base names, so treating one as a declaration site would |
| 158 | + * report a translation rather than the resource. A configuration variant such as `values-v26` |
| 159 | + * or `values-night` is not a translation, though, and a resource declared only there is just |
| 160 | + * as capable of being dead, so those do count. |
| 161 | + */ |
| 162 | + private fun declaresResources(folder: String): Boolean { |
| 163 | + if (folder == BASE_VALUES_FOLDER) return true |
| 164 | + if (!folder.startsWith("$BASE_VALUES_FOLDER-")) return false |
| 165 | + return FolderConfiguration.getConfigForFolder(folder)?.localeQualifier == null |
| 166 | + } |
| 167 | + |
| 168 | + private fun collectReferences(text: String) { |
| 169 | + if (!text.contains(RESOURCE_PREFIX)) return |
| 170 | + for (pattern in REFERENCE_PATTERNS) { |
| 171 | + for (match in pattern.findAll(text)) { |
| 172 | + referenced += match.groupValues[1] |
| 173 | + } |
| 174 | + } |
| 175 | + } |
| 176 | + |
| 177 | + companion object { |
| 178 | + private const val ISSUE_ID = "UnreferencedResource" |
| 179 | + private const val BASE_VALUES_FOLDER = "values" |
| 180 | + private const val RESOURCE_PREFIX = "fui_" |
| 181 | + |
| 182 | + /** `R.string.fui_x` and `R.plurals.fui_x`, including a package-qualified `R`. */ |
| 183 | + private val CODE_REFERENCE = Regex("""\bR\.(?:string|plurals)\.(fui_[A-Za-z0-9_]+)""") |
| 184 | + |
| 185 | + /** `@string/fui_x` and `@plurals/fui_x`, in any XML attribute or text node. */ |
| 186 | + private val XML_REFERENCE = Regex("""@(?:string|plurals)/(fui_[A-Za-z0-9_]+)""") |
| 187 | + |
| 188 | + private val REFERENCE_PATTERNS = listOf(CODE_REFERENCE, XML_REFERENCE) |
| 189 | + |
| 190 | + val UNREFERENCED_RESOURCE = Issue.create( |
| 191 | + ISSUE_ID, |
| 192 | + "Library string resource nothing references", |
| 193 | + "A `fui_*` string or plurals resource that this module never reads is dead copy. " + |
| 194 | + "It still ships, and it still reaches translators for every locale folder that " + |
| 195 | + "carries it. `UnusedResources` cannot report it, because for a library module " + |
| 196 | + "lint cannot see the consumers that might use the resource.", |
| 197 | + Category.PERFORMANCE, |
| 198 | + 5, |
| 199 | + Severity.ERROR, |
| 200 | + Implementation( |
| 201 | + UnreferencedResourceDetector::class.java, |
| 202 | + EnumSet.of(Scope.ALL_RESOURCE_FILES, Scope.JAVA_FILE, Scope.MANIFEST) |
| 203 | + ) |
| 204 | + ) |
| 205 | + } |
| 206 | +} |
0 commit comments