Skip to main content

normalize_package_index/index/
fdroid.rs

1//! F-Droid package index fetcher (Android FOSS).
2//!
3//! Fetches package metadata from F-Droid repositories.
4//!
5//! ## API Strategy
6//! - **fetch**: `f-droid.org/api/v1/packages/{name}` - Official F-Droid JSON API
7//! - **fetch_versions**: Same API, extracts packages array
8//! - **search**: `search.f-droid.org/api/v1/?q=` - F-Droid search API
9//! - **fetch_all**: `f-droid.org/api/v1/packages` (all packages)
10//!
11//! ## Multi-repo Support
12//! ```rust,ignore
13//! use normalize_packages::index::fdroid::{FDroid, FDroidRepo};
14//!
15//! // All repos (default)
16//! let all = FDroid::all();
17//!
18//! // Main repo only
19//! let main = FDroid::main();
20//!
21//! // Privacy-focused repos
22//! let privacy = FDroid::privacy();
23//! ```
24
25use super::{IndexError, PackageIndex, PackageMeta, VersionMeta};
26use std::collections::HashMap;
27
28/// Available F-Droid repositories.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30pub enum FDroidRepo {
31    /// Main F-Droid repository
32    Main,
33    /// F-Droid Archive - older app versions
34    Archive,
35    /// IzzyOnDroid - third-party repo with additional apps
36    IzzyOnDroid,
37    /// Guardian Project - privacy/security focused apps
38    Guardian,
39}
40
41impl FDroidRepo {
42    /// Get the repository name for tagging.
43    pub fn name(&self) -> &'static str {
44        match self {
45            Self::Main => "fdroid",
46            Self::Archive => "fdroid-archive",
47            Self::IzzyOnDroid => "izzyondroid",
48            Self::Guardian => "guardian",
49        }
50    }
51
52    /// All available repositories.
53    pub fn all() -> &'static [FDroidRepo] {
54        &[Self::Main, Self::Archive, Self::IzzyOnDroid, Self::Guardian]
55    }
56
57    /// Main F-Droid repo only.
58    pub fn main() -> &'static [FDroidRepo] {
59        &[Self::Main]
60    }
61
62    /// Main + Archive repos.
63    pub fn official() -> &'static [FDroidRepo] {
64        &[Self::Main, Self::Archive]
65    }
66
67    /// Privacy-focused repos (Main + Guardian).
68    pub fn privacy() -> &'static [FDroidRepo] {
69        &[Self::Main, Self::Guardian]
70    }
71
72    /// Extended repos (Main + IzzyOnDroid).
73    pub fn extended() -> &'static [FDroidRepo] {
74        &[Self::Main, Self::IzzyOnDroid]
75    }
76}
77
78/// F-Droid package index fetcher with configurable repositories.
79pub struct FDroid {
80    repos: Vec<FDroidRepo>,
81}
82
83impl FDroid {
84    /// F-Droid search API.
85    const SEARCH_API: &'static str = "https://search.f-droid.org/api";
86
87    /// Create a fetcher with all repositories.
88    pub fn all() -> Self {
89        Self {
90            repos: FDroidRepo::all().to_vec(),
91        }
92    }
93
94    /// Create a fetcher with main repo only.
95    pub fn main() -> Self {
96        Self {
97            repos: FDroidRepo::main().to_vec(),
98        }
99    }
100
101    /// Create a fetcher with official repos (main + archive).
102    pub fn official() -> Self {
103        Self {
104            repos: FDroidRepo::official().to_vec(),
105        }
106    }
107
108    /// Create a fetcher with privacy-focused repos.
109    pub fn privacy() -> Self {
110        Self {
111            repos: FDroidRepo::privacy().to_vec(),
112        }
113    }
114
115    /// Create a fetcher with extended repos (main + IzzyOnDroid).
116    pub fn extended() -> Self {
117        Self {
118            repos: FDroidRepo::extended().to_vec(),
119        }
120    }
121
122    /// Create a fetcher with custom repository selection.
123    pub fn with_repos(repos: &[FDroidRepo]) -> Self {
124        Self {
125            repos: repos.to_vec(),
126        }
127    }
128
129    /// Fetch from the main F-Droid API.
130    fn fetch_from_api(name: &str) -> Result<(PackageMeta, FDroidRepo), IndexError> {
131        let url = format!("https://f-droid.org/api/v1/packages/{}", name);
132        let response: serde_json::Value = ureq::get(&url)
133            .call()
134            .map_err(|_| IndexError::NotFound(name.to_string()))?
135            .into_json()?;
136
137        let packages = response["packages"]
138            .as_array()
139            .ok_or_else(|| IndexError::NotFound(name.to_string()))?;
140
141        let suggested_code = response["suggestedVersionCode"].as_u64();
142        let latest = packages
143            .iter()
144            .find(|p| p["versionCode"].as_u64() == suggested_code)
145            .or_else(|| packages.first())
146            .ok_or_else(|| IndexError::NotFound(name.to_string()))?;
147
148        let mut extra = HashMap::new();
149        extra.insert(
150            "source_repo".to_string(),
151            serde_json::Value::String("fdroid".to_string()),
152        );
153
154        Ok((
155            PackageMeta {
156                name: response["packageName"].as_str().unwrap_or(name).to_string(),
157                version: latest["versionName"]
158                    .as_str()
159                    .unwrap_or("unknown")
160                    .to_string(),
161                description: None,
162                homepage: Some(format!("https://f-droid.org/packages/{}", name)),
163                repository: None,
164                license: None,
165                binaries: Vec::new(),
166                keywords: Vec::new(),
167                maintainers: Vec::new(),
168                published: None,
169                downloads: None,
170                archive_url: None,
171                checksum: None,
172                extra,
173            },
174            FDroidRepo::Main,
175        ))
176    }
177
178    /// Fetch versions from the main F-Droid API.
179    fn fetch_versions_from_api(name: &str) -> Result<Vec<VersionMeta>, IndexError> {
180        let url = format!("https://f-droid.org/api/v1/packages/{}", name);
181        let response: serde_json::Value = ureq::get(&url)
182            .call()
183            .map_err(|_| IndexError::NotFound(name.to_string()))?
184            .into_json()?;
185
186        let packages = response["packages"]
187            .as_array()
188            .ok_or_else(|| IndexError::NotFound(name.to_string()))?;
189
190        Ok(packages
191            .iter()
192            .filter_map(|p| {
193                Some(VersionMeta {
194                    version: format!("{} (fdroid)", p["versionName"].as_str()?),
195                    released: None,
196                    yanked: false,
197                })
198            })
199            .collect())
200    }
201
202    /// Search using the F-Droid search API.
203    fn search_api(query: &str) -> Result<Vec<PackageMeta>, IndexError> {
204        let url = format!("{}/search_apps?q={}", Self::SEARCH_API, query);
205        let response: serde_json::Value = ureq::get(&url).call()?.into_json()?;
206
207        let apps = response["apps"]
208            .as_array()
209            .ok_or_else(|| IndexError::Parse("Invalid search response".into()))?;
210
211        let mut extra = HashMap::new();
212        extra.insert(
213            "source_repo".to_string(),
214            serde_json::Value::String("fdroid".to_string()),
215        );
216
217        Ok(apps
218            .iter()
219            .filter_map(|app| {
220                let url = app["url"].as_str()?;
221                let package_name = url.rsplit('/').next()?;
222
223                Some(PackageMeta {
224                    name: package_name.to_string(),
225                    version: "latest".to_string(),
226                    description: app["summary"].as_str().map(String::from),
227                    homepage: Some(url.to_string()),
228                    repository: None,
229                    license: None,
230                    binaries: Vec::new(),
231                    keywords: Vec::new(),
232                    maintainers: Vec::new(),
233                    published: None,
234                    downloads: None,
235                    archive_url: None,
236                    checksum: None,
237                    extra: extra.clone(),
238                })
239            })
240            .collect())
241    }
242}
243
244impl PackageIndex for FDroid {
245    fn ecosystem(&self) -> &'static str {
246        "fdroid"
247    }
248
249    fn display_name(&self) -> &'static str {
250        "F-Droid (Android FOSS)"
251    }
252
253    fn fetch(&self, name: &str) -> Result<PackageMeta, IndexError> {
254        // Try main API first if Main repo is configured
255        if self.repos.contains(&FDroidRepo::Main) {
256            if let Ok((pkg, _)) = Self::fetch_from_api(name) {
257                return Ok(pkg);
258            }
259        }
260
261        // For other repos, we'd need to parse their index files
262        // For now, return not found if main API fails
263        Err(IndexError::NotFound(name.to_string()))
264    }
265
266    fn fetch_versions(&self, name: &str) -> Result<Vec<VersionMeta>, IndexError> {
267        let mut all_versions = Vec::new();
268
269        // Try main API if configured
270        if self.repos.contains(&FDroidRepo::Main) {
271            if let Ok(versions) = Self::fetch_versions_from_api(name) {
272                all_versions.extend(versions);
273            }
274        }
275
276        if all_versions.is_empty() {
277            return Err(IndexError::NotFound(name.to_string()));
278        }
279
280        Ok(all_versions)
281    }
282
283    fn search(&self, query: &str) -> Result<Vec<PackageMeta>, IndexError> {
284        // F-Droid search API searches across main repo
285        if self.repos.contains(&FDroidRepo::Main) {
286            return Self::search_api(query);
287        }
288
289        // Other repos don't have search APIs
290        Err(IndexError::Parse(
291            "Search only available for main F-Droid repo".into(),
292        ))
293    }
294}