Skip to main content

reinhardt_deeplink/config/
android.rs

1//! Android App Links configuration.
2//!
3//! This module provides types and builders for generating Digital Asset Links (assetlinks.json) files.
4
5use serde::Serialize;
6
7use crate::error::{DeeplinkError, validate_fingerprint, validate_package_name};
8
9/// Android App Links configuration.
10///
11/// This struct represents a collection of Digital Asset Links statements.
12/// When serialized to JSON, it produces the file that should be served at
13/// `/.well-known/assetlinks.json`.
14///
15/// # Example
16///
17/// ```rust
18/// use reinhardt_deeplink::AndroidConfig;
19///
20/// let config = AndroidConfig::builder()
21///     .package_name("com.example.app")
22///     .sha256_fingerprint("FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C")
23///     .build()
24///     .unwrap();
25/// ```
26#[derive(Debug, Clone)]
27pub struct AndroidConfig {
28	/// Digital Asset Links statements.
29	pub statements: Vec<AssetStatement>,
30}
31
32impl Serialize for AndroidConfig {
33	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
34	where
35		S: serde::Serializer,
36	{
37		// Android assetlinks.json is a JSON array of statements
38		self.statements.serialize(serializer)
39	}
40}
41
42/// A Digital Asset Links statement.
43#[derive(Debug, Clone, Serialize)]
44pub struct AssetStatement {
45	/// Relations this statement establishes.
46	pub relation: Vec<String>,
47
48	/// Target application or website.
49	pub target: AssetTarget,
50}
51
52/// Target of an asset statement.
53#[derive(Debug, Clone, Serialize)]
54pub struct AssetTarget {
55	/// Namespace (e.g., `android_app`).
56	pub namespace: String,
57
58	/// Android package name.
59	pub package_name: String,
60
61	/// SHA256 certificate fingerprints.
62	pub sha256_cert_fingerprints: Vec<String>,
63}
64
65impl AndroidConfig {
66	/// Creates a new builder for Android configuration.
67	pub fn builder() -> AndroidConfigBuilder {
68		AndroidConfigBuilder::new()
69	}
70}
71
72/// Builder for Android App Links configuration.
73#[derive(Debug, Default)]
74pub struct AndroidConfigBuilder {
75	package_name: Option<String>,
76	fingerprints: Vec<String>,
77	additional_packages: Vec<(String, Vec<String>)>,
78}
79
80impl AndroidConfigBuilder {
81	/// Creates a new builder.
82	pub fn new() -> Self {
83		Self::default()
84	}
85
86	/// Sets the Android package name.
87	///
88	/// # Arguments
89	///
90	/// * `name` - The package name (e.g., `com.example.app`)
91	pub fn package_name(mut self, name: impl Into<String>) -> Self {
92		self.package_name = Some(name.into());
93		self
94	}
95
96	/// Adds a SHA256 certificate fingerprint.
97	///
98	/// The fingerprint should be in the format of 32 colon-separated hex bytes.
99	///
100	/// # Example
101	///
102	/// ```rust
103	/// use reinhardt_deeplink::AndroidConfig;
104	///
105	/// let config = AndroidConfig::builder()
106	///     .package_name("com.example.app")
107	///     .sha256_fingerprint("FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C")
108	///     .build()
109	///     .unwrap();
110	/// ```
111	pub fn sha256_fingerprint(mut self, fingerprint: impl Into<String>) -> Self {
112		self.fingerprints.push(fingerprint.into());
113		self
114	}
115
116	/// Adds multiple SHA256 certificate fingerprints.
117	pub fn sha256_fingerprints(mut self, fingerprints: &[&str]) -> Self {
118		self.fingerprints
119			.extend(fingerprints.iter().map(|s| (*s).to_string()));
120		self
121	}
122
123	/// Adds an additional package with its own fingerprints.
124	///
125	/// Use this when multiple apps should be associated with the same domain.
126	pub fn additional_package(mut self, package: impl Into<String>, fingerprints: &[&str]) -> Self {
127		self.additional_packages.push((
128			package.into(),
129			fingerprints.iter().map(|s| (*s).to_string()).collect(),
130		));
131		self
132	}
133
134	/// Validates the configuration.
135	///
136	/// # Errors
137	///
138	/// Returns an error if:
139	/// - No package name is set
140	/// - Package name has invalid format
141	/// - No fingerprints are provided
142	/// - Any fingerprint has an invalid format
143	/// - Any additional package has empty fingerprints
144	pub fn validate(&self) -> Result<(), DeeplinkError> {
145		if self.package_name.is_none() {
146			return Err(DeeplinkError::MissingPackageName);
147		}
148
149		// Validate package name format (Java package naming conventions)
150		if let Some(ref name) = self.package_name {
151			validate_package_name(name)?;
152		}
153
154		if self.fingerprints.is_empty() {
155			return Err(DeeplinkError::MissingFingerprint);
156		}
157
158		for fingerprint in &self.fingerprints {
159			validate_fingerprint(fingerprint)?;
160		}
161
162		// Validate additional package names and their fingerprints
163		for (pkg_name, fps) in &self.additional_packages {
164			validate_package_name(pkg_name)?;
165			// Each additional package must have at least one fingerprint;
166			// an empty fingerprints list produces a semantically invalid
167			// Asset Links entry that Android will never match.
168			if fps.is_empty() {
169				return Err(DeeplinkError::MissingFingerprint);
170			}
171			for fingerprint in fps {
172				validate_fingerprint(fingerprint)?;
173			}
174		}
175
176		Ok(())
177	}
178
179	/// Builds the Android configuration after validation.
180	///
181	/// # Errors
182	///
183	/// Returns a `DeeplinkError` if validation fails.
184	pub fn build(self) -> Result<AndroidConfig, DeeplinkError> {
185		self.validate()?;
186		Ok(self.build_unchecked())
187	}
188
189	/// Builds the Android configuration without validation.
190	///
191	/// Use [`build`](Self::build) for validated builds. This method is intended
192	/// for advanced use cases where validation has already been performed.
193	pub fn build_unchecked(self) -> AndroidConfig {
194		let mut statements = Vec::new();
195
196		// Build primary statement
197		if let Some(package_name) = self.package_name {
198			statements.push(AssetStatement {
199				relation: vec!["delegate_permission/common.handle_all_urls".to_string()],
200				target: AssetTarget {
201					namespace: "android_app".to_string(),
202					package_name,
203					sha256_cert_fingerprints: self.fingerprints,
204				},
205			});
206		}
207
208		// Add additional packages
209		for (package_name, fingerprints) in self.additional_packages {
210			statements.push(AssetStatement {
211				relation: vec!["delegate_permission/common.handle_all_urls".to_string()],
212				target: AssetTarget {
213					namespace: "android_app".to_string(),
214					package_name,
215					sha256_cert_fingerprints: fingerprints,
216				},
217			});
218		}
219
220		AndroidConfig { statements }
221	}
222}
223
224#[cfg(test)]
225mod tests {
226	use rstest::rstest;
227
228	use super::*;
229
230	const VALID_FINGERPRINT: &str = "FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C";
231
232	#[rstest]
233	fn test_basic_android_config() {
234		let config = AndroidConfig::builder()
235			.package_name("com.example.app")
236			.sha256_fingerprint(VALID_FINGERPRINT)
237			.build()
238			.unwrap();
239
240		let json = serde_json::to_string_pretty(&config).unwrap();
241		assert!(json.contains("delegate_permission/common.handle_all_urls"));
242		assert!(json.contains("android_app"));
243		assert!(json.contains("com.example.app"));
244		assert!(json.contains(VALID_FINGERPRINT));
245	}
246
247	#[rstest]
248	fn test_android_config_json_array() {
249		let config = AndroidConfig::builder()
250			.package_name("com.example.app")
251			.sha256_fingerprint(VALID_FINGERPRINT)
252			.build()
253			.unwrap();
254
255		let json = serde_json::to_string(&config).unwrap();
256		// Should be a JSON array
257		assert!(json.starts_with('['));
258		assert!(json.ends_with(']'));
259	}
260
261	#[rstest]
262	fn test_multiple_fingerprints() {
263		let fp1 = "00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00";
264		let fp2 = "11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11:11";
265
266		let config = AndroidConfig::builder()
267			.package_name("com.example.app")
268			.sha256_fingerprints(&[fp1, fp2])
269			.build()
270			.unwrap();
271
272		let json = serde_json::to_string_pretty(&config).unwrap();
273		assert!(json.contains(fp1));
274		assert!(json.contains(fp2));
275	}
276
277	#[rstest]
278	fn test_additional_packages() {
279		let config = AndroidConfig::builder()
280			.package_name("com.example.app")
281			.sha256_fingerprint(VALID_FINGERPRINT)
282			.additional_package("com.example.app2", &[VALID_FINGERPRINT])
283			.build()
284			.unwrap();
285
286		assert_eq!(config.statements.len(), 2);
287		let json = serde_json::to_string_pretty(&config).unwrap();
288		assert!(json.contains("com.example.app"));
289		assert!(json.contains("com.example.app2"));
290	}
291
292	#[rstest]
293	fn test_validation_missing_package() {
294		let builder = AndroidConfigBuilder::new().sha256_fingerprint(VALID_FINGERPRINT);
295		assert!(matches!(
296			builder.validate(),
297			Err(DeeplinkError::MissingPackageName)
298		));
299	}
300
301	#[rstest]
302	fn test_validation_missing_fingerprint() {
303		let builder = AndroidConfigBuilder::new().package_name("com.example.app");
304		assert!(matches!(
305			builder.validate(),
306			Err(DeeplinkError::MissingFingerprint)
307		));
308	}
309
310	#[rstest]
311	fn test_validation_invalid_fingerprint() {
312		let builder = AndroidConfigBuilder::new()
313			.package_name("com.example.app")
314			.sha256_fingerprint("invalid");
315		assert!(matches!(
316			builder.validate(),
317			Err(DeeplinkError::InvalidFingerprint(_))
318		));
319	}
320
321	#[rstest]
322	fn test_validation_success() {
323		let builder = AndroidConfigBuilder::new()
324			.package_name("com.example.app")
325			.sha256_fingerprint(VALID_FINGERPRINT);
326		assert!(builder.validate().is_ok());
327	}
328
329	#[rstest]
330	fn test_validation_additional_package_empty_fingerprints() {
331		// Arrange
332		let builder = AndroidConfigBuilder::new()
333			.package_name("com.example.app")
334			.sha256_fingerprint(VALID_FINGERPRINT)
335			.additional_package("com.example.app2", &[]);
336
337		// Act
338		let result = builder.validate();
339
340		// Assert
341		assert!(
342			matches!(result, Err(DeeplinkError::MissingFingerprint)),
343			"additional_package with empty fingerprints should be rejected"
344		);
345	}
346}