resopt/
android_project.rs1use 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 pub level: u32,
16 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 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}