Skip to main content

resopt/
android_project.rs

1//! Best-effort `minSdk` detection from Gradle build files and version catalogs.
2//!
3//! Gradle builds are programs, so this only recognizes literal declarations and
4//! version-catalog lookups. When nothing is found the level stays unknown and
5//! API-gated conversions are blocked until `--android-min-sdk` is given.
6use crate::{resources::bounded_read, scan_options::ScanFilter};
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9use std::{collections::BTreeMap, path::Path, sync::LazyLock};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(deny_unknown_fields)]
13pub struct MinSdk {
14    /// Lowest level declared anywhere in the project.
15    pub level: u32,
16    /// Project-relative file the level was read from, or `--android-min-sdk`.
17    pub source: String,
18}
19
20static LITERAL: LazyLock<Regex> = LazyLock::new(|| {
21    Regex::new(r"(?m)^\s*(?:minSdk|minSdkVersion)\s*(?:=|\s|\()\s*(\d{1,3})\b").unwrap()
22});
23static CATALOG_LOOKUP: LazyLock<Regex> = LazyLock::new(|| {
24    Regex::new(
25        r"(?m)^\s*(?:minSdk|minSdkVersion)\b[^\n]*?libs\.versions\.([A-Za-z0-9_.]+?)\.get\(\)",
26    )
27    .unwrap()
28});
29static CATALOG_ENTRY: LazyLock<Regex> = LazyLock::new(|| {
30    Regex::new(r#"(?m)^\s*([A-Za-z0-9_.-]+)\s*=\s*"(\d{1,3})"\s*(?:#.*)?$"#).unwrap()
31});
32
33fn catalog_key(name: &str) -> String {
34    name.chars()
35        .filter(|c| c.is_ascii_alphanumeric())
36        .collect::<String>()
37        .to_ascii_lowercase()
38}
39
40pub(crate) fn detect_min_sdk(root: &Path, filter: &ScanFilter) -> Option<MinSdk> {
41    let mut files: Vec<_> = filter
42        .paths()
43        .filter(|path| {
44            let name = path.file_name().unwrap_or_default().to_string_lossy();
45            name == "build.gradle" || name == "build.gradle.kts" || name == "libs.versions.toml"
46        })
47        .filter_map(|path| Some((path.strip_prefix(root).ok()?.to_path_buf(), path)))
48        .collect();
49    files.sort();
50    let read = |path: &Path| {
51        bounded_read(path)
52            .ok()
53            .and_then(|bytes| String::from_utf8(bytes).ok())
54    };
55    let mut catalog = BTreeMap::new();
56    for (relative, path) in &files {
57        if relative
58            .file_name()
59            .is_some_and(|n| n == "libs.versions.toml")
60            && let Some(text) = read(path)
61        {
62            for entry in CATALOG_ENTRY.captures_iter(&text) {
63                if let Ok(level) = entry[2].parse::<u32>() {
64                    catalog.insert(catalog_key(&entry[1]), (level, relative.clone()));
65                }
66            }
67        }
68    }
69    let mut found: Vec<MinSdk> = Vec::new();
70    for (relative, path) in &files {
71        if relative.extension().is_some_and(|e| e == "toml") {
72            continue;
73        }
74        let Some(text) = read(path) else { continue };
75        for capture in LITERAL.captures_iter(&text) {
76            if let Ok(level) = capture[1].parse() {
77                found.push(MinSdk {
78                    level,
79                    source: relative.to_string_lossy().replace('\\', "/"),
80                });
81            }
82        }
83        for capture in CATALOG_LOOKUP.captures_iter(&text) {
84            if let Some((level, source)) = catalog.get(&catalog_key(&capture[1])) {
85                found.push(MinSdk {
86                    level: *level,
87                    source: source.to_string_lossy().replace('\\', "/"),
88                });
89            }
90        }
91    }
92    // Convention plugins hide the assignment; a catalog entry named like minSdk
93    // is still the project's declared floor.
94    if found.is_empty() {
95        for (key, (level, source)) in &catalog {
96            if key == "minsdk" || key == "minsdkversion" || key == "androidminsdk" {
97                found.push(MinSdk {
98                    level: *level,
99                    source: source.to_string_lossy().replace('\\', "/"),
100                });
101            }
102        }
103    }
104    found
105        .into_iter()
106        .filter(|sdk| (1..=99).contains(&sdk.level))
107        .min_by(|a, b| a.level.cmp(&b.level).then(a.source.cmp(&b.source)))
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use std::fs;
114
115    fn detect(files: &[(&str, &str)]) -> Option<MinSdk> {
116        let dir = tempfile::tempdir().unwrap();
117        let root = fs::canonicalize(dir.path()).unwrap();
118        for (path, text) in files {
119            let file = root.join(path);
120            fs::create_dir_all(file.parent().unwrap()).unwrap();
121            fs::write(file, text).unwrap();
122        }
123        let filter = ScanFilter::new(&root, crate::ScanOptions::default()).unwrap();
124        detect_min_sdk(&root, &filter)
125    }
126
127    #[test]
128    fn literal_declarations_use_the_lowest_module_level() {
129        let sdk = detect(&[
130            (
131                "app/build.gradle.kts",
132                "android {\n  defaultConfig {\n    minSdk = 24\n  }\n}",
133            ),
134            (
135                "lib/build.gradle",
136                "android { defaultConfig {\n minSdkVersion 21\n targetSdkVersion 34 } }",
137            ),
138        ])
139        .unwrap();
140        assert_eq!(sdk.level, 21);
141        assert_eq!(sdk.source, "lib/build.gradle");
142    }
143
144    #[test]
145    fn version_catalog_lookups_and_convention_plugin_fallback() {
146        let catalog = "[versions]\nagp = \"8.5.0\"\nminSdk = \"23\"\ncompileSdk = \"35\"\n";
147        let sdk = detect(&[
148            ("gradle/libs.versions.toml", catalog),
149            (
150                "app/build.gradle.kts",
151                "android { defaultConfig {\n minSdk = libs.versions.minSdk.get().toInt()\n} }",
152            ),
153        ])
154        .unwrap();
155        assert_eq!(
156            (sdk.level, sdk.source.as_str()),
157            (23, "gradle/libs.versions.toml")
158        );
159        let fallback = detect(&[
160            ("gradle/libs.versions.toml", catalog),
161            (
162                "app/build.gradle.kts",
163                "plugins { id(\"com.example.android.app\") }",
164            ),
165        ])
166        .unwrap();
167        assert_eq!(fallback.level, 23);
168    }
169
170    #[test]
171    fn unknown_stays_unknown_instead_of_guessing() {
172        assert_eq!(
173            detect(&[
174                (
175                    "app/build.gradle",
176                    "android { compileSdk 35\n // minSdk 21\n }"
177                ),
178                (
179                    "gradle/libs.versions.toml",
180                    "[versions]\ncompileSdk = \"35\"\n"
181                ),
182            ]),
183            None
184        );
185    }
186}