Skip to main content

waterui_cli/android/
backend.rs

1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::{
6    android::platform::{AndroidAbi, AndroidPlatform, clean_android, is_android_platform},
7    backend::Backend,
8    build::BuildOptions,
9    device::Artifact,
10    platform::{PackageOptions, TargetBackend, TargetPlatform},
11    project::Project,
12    templates::{self, TemplateContext},
13};
14
15/// Configuration for the Android backend in a `WaterUI` project.
16///
17/// `[backends.android]` in `Water.toml`
18#[derive(Debug, Serialize, Deserialize, Clone)]
19pub struct AndroidBackend {
20    #[serde(
21        default = "default_android_project_path",
22        skip_serializing_if = "is_default_android_project_path"
23    )]
24    project_path: PathBuf,
25    #[serde(skip_serializing_if = "Option::is_none")]
26    version: Option<String>,
27    /// Path to a local `android-backend` checkout used as the runtime source.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    backend_path: Option<String>,
30}
31
32impl AndroidBackend {
33    /// Create a new Android backend configuration with default settings.
34    #[must_use]
35    pub fn new() -> Self {
36        Self {
37            project_path: default_android_project_path(),
38            version: None,
39            backend_path: None,
40        }
41    }
42
43    /// Set a custom project path (defaults to "android").
44    #[must_use]
45    pub fn with_project_path(mut self, path: impl Into<PathBuf>) -> Self {
46        self.project_path = path.into();
47        self
48    }
49
50    /// Set the local `android-backend` checkout used as the runtime source.
51    #[must_use]
52    pub fn with_backend_path(mut self, path: impl Into<String>) -> Self {
53        self.backend_path = Some(path.into());
54        self
55    }
56
57    /// Get the path to the Android project within the `WaterUI` project.
58    #[must_use]
59    pub const fn project_path(&self) -> &PathBuf {
60        &self.project_path
61    }
62
63    /// Get the local `android-backend` checkout used as the runtime source.
64    #[must_use]
65    pub fn backend_path(&self) -> Option<&str> {
66        self.backend_path.as_deref()
67    }
68
69    /// Whether this entry configures backend-project scaffolding — anything
70    /// beyond `backend_path`, which only selects the runtime's source.
71    #[must_use]
72    pub fn configures_project(&self) -> bool {
73        self.project_path != default_android_project_path() || self.version.is_some()
74    }
75
76    /// Get the path to the Gradle wrapper script within the Android project.
77    #[must_use]
78    pub fn gradlew_path(&self) -> PathBuf {
79        let base = &self.project_path;
80        if cfg!(windows) {
81            base.join("gradlew.bat")
82        } else {
83            base.join("gradlew")
84        }
85    }
86}
87
88impl Default for AndroidBackend {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94impl Backend for AndroidBackend {
95    const DEFAULT_PATH: &'static str = "android";
96
97    // Preserve Gradle build caches during re-scaffolding
98    const CACHE_PATHS: &'static [&'static str] = &[".gradle", "build", "app"];
99
100    fn path(&self) -> &Path {
101        &self.project_path
102    }
103
104    async fn init(project: &Project) -> Result<Self, crate::backend::FailToInitBackend> {
105        let manifest = project.manifest();
106
107        // Derive app name from the display name (remove spaces for filesystem)
108        let app_name = manifest
109            .package
110            .name
111            .chars()
112            .filter(|c| c.is_alphanumeric())
113            .collect::<String>();
114
115        // Android is where a missing declaration actually breaks things, so
116        // surface anything a dependency needs that the app has not enabled.
117        match crate::assets::scan_required_permissions(project).await {
118            Ok(required) => crate::assets::warn_missing_permissions(project, &required, |key| {
119                key.android_permission_name().is_some()
120            }),
121            Err(error) => tracing::debug!("skipped permission audit: {error}"),
122        }
123
124        // Extract enabled permissions from the manifest
125        let android_permissions = manifest
126            .permissions
127            .iter()
128            .filter(|(_, entry)| entry.is_enabled())
129            .filter_map(|(key, _)| {
130                key.android_permission_name()
131                    .map(|name| templates::AndroidPermissionTemplateEntry { name })
132            })
133            .collect();
134
135        let ctx = TemplateContext::for_project_manifest(
136            manifest,
137            project.crate_name().clone(),
138            app_name,
139            &project
140                .resolved_framework()
141                .await
142                .map_err(crate::backend::FailToInitBackend::Config)?,
143        )
144        .with_backend_project_path(project.backend_path::<Self>())
145        .with_project_root_path(project.root().to_path_buf())
146        .with_android_permissions(android_permissions);
147
148        templates::android::scaffold(&project.backend_path::<Self>(), &ctx)
149            .await
150            .map_err(crate::backend::FailToInitBackend::Io)?;
151
152        let existing = manifest.backends.android();
153        Ok(Self {
154            project_path: existing.map_or_else(default_android_project_path, |backend| {
155                backend.project_path.clone()
156            }),
157            version: existing.and_then(|backend| backend.version.clone()),
158            backend_path: existing.and_then(|backend| backend.backend_path.clone()),
159        })
160    }
161
162    fn supports(&self, platform: TargetPlatform) -> bool {
163        is_android_platform(platform)
164    }
165
166    async fn build(
167        &self,
168        project: &Project,
169        platform: TargetPlatform,
170        options: BuildOptions,
171    ) -> eyre::Result<PathBuf> {
172        debug_assert_eq!(platform, TargetPlatform::Android);
173        project
174            .browser_runtime_plan(platform, TargetBackend::Android)
175            .await?;
176        AndroidPlatform::arm64().build(project, options).await
177    }
178
179    async fn package(
180        &self,
181        project: &Project,
182        platform: TargetPlatform,
183        options: PackageOptions,
184    ) -> eyre::Result<Artifact> {
185        debug_assert_eq!(platform, TargetPlatform::Android);
186        AndroidPlatform::package_with_abis(project, options, &[AndroidAbi::Arm64V8a]).await
187    }
188
189    async fn clean(&self, project: &Project, _platform: TargetPlatform) -> eyre::Result<()> {
190        clean_android(project).await
191    }
192}
193
194fn default_android_project_path() -> PathBuf {
195    PathBuf::from("android")
196}
197
198fn is_default_android_project_path(s: &Path) -> bool {
199    s == Path::new("android")
200}