"""Config-driven Android project configurator.
Turns the bundled ``android_template`` into a concrete, buildable Gradle
project for a specific [`AppConfig`][pythonnative.project.config.AppConfig]:
- **Package relocation.** The template lives under
``com.pythonnative.android_template``; this module rewrites and moves
it to the app's own ``application_id`` so each app ships a distinct
package. The PythonNative Android runtime resolves its helper classes
(``Navigator``) via ``getPackageName()``, so the relocation needs no
runtime configuration.
- **Identity & SDKs.** ``applicationId``, ``versionCode``/``versionName``,
``minSdk``/``targetSdk``/``compileSdk``, ABI filters, and the embedded
CPython version are written into ``app/build.gradle``.
- **Permissions.** ```` entries (derived from
``[permissions]``) and the launch orientation are written into
``AndroidManifest.xml``.
- **Signing.** A release ``signingConfig`` is injected when a keystore is
configured (passwords are read from the environment at build time).
- **Branding.** Launcher icons and an Android 12+ splash screen are
generated from the configured assets.
- **Python sources.** The user's ``app/`` and (in a dev checkout) the
in-repo ``pythonnative`` package are staged into Chaquopy's source set,
and ``requirements.txt`` is generated from ``[requirements].packages``.
Everything here is plain file manipulation, which keeps it fully unit
testable without an Android toolchain.
"""
from __future__ import annotations
import os
import re
import shutil
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, List, Optional
from . import icons
from .config import AppConfig
TEMPLATE_PACKAGE = "com.pythonnative.android_template"
"""The fixed package the bundled Android template ships under."""
TEXT_SUFFIXES = {".kt", ".java", ".gradle", ".xml", ".pro", ".properties", ".cfg"}
_SOURCE_ROOTS = (
("app", "src", "main", "java"),
("app", "src", "test", "java"),
("app", "src", "androidTest", "java"),
)
Logger = Callable[[str], None]
@dataclass
class AndroidLayout:
"""Resolved paths within a configured Android project.
Attributes:
project_dir: The Gradle project root (``.../android_template``).
application_id: The final Android application id.
python_root: Chaquopy Python source root
(``app/src/main/python``).
"""
project_dir: Path
application_id: str
python_root: Path
def configure(
project_dir: Path,
config: AppConfig,
*,
dev_lib_root: Optional[Path] = None,
log: Optional[Logger] = None,
) -> AndroidLayout:
"""Fully configure a staged Android template for ``config``.
Args:
project_dir: The staged ``android_template`` directory.
config: The validated app configuration.
dev_lib_root: Path to an in-repo ``pythonnative`` package to
bundle (dev checkout); ``None`` to rely on the PyPI install.
log: Optional progress logger.
Returns:
An [`AndroidLayout`][pythonnative.project.android.AndroidLayout]
describing the configured project.
"""
emit: Logger = log or (lambda _message: None)
project_dir = Path(project_dir)
relocate_package(project_dir, TEMPLATE_PACKAGE, config.application_id)
configure_gradle(project_dir, config)
configure_settings_gradle(project_dir, config)
configure_strings(project_dir, config)
configure_manifest(project_dir, config)
write_requirements(project_dir, config)
_apply_branding(project_dir, config, emit)
python_root = stage_python_sources(project_dir, config, dev_lib_root=dev_lib_root)
emit(f"Configured Android project ({config.application_id}).")
return AndroidLayout(project_dir=project_dir, application_id=config.application_id, python_root=python_root)
# ======================================================================
# Package relocation
# ======================================================================
def relocate_package(project_dir: Path, old_package: str, new_package: str) -> None:
"""Rewrite and move the template's Java/Kotlin package.
Replaces every occurrence of ``old_package`` with ``new_package`` in
all text sources, then moves the source directories from the old
package path to the new one. A no-op when the packages are equal.
Args:
project_dir: The staged Android project root.
old_package: The dotted package currently in the template.
new_package: The desired dotted package (the app's id).
"""
if old_package == new_package:
return
_replace_in_tree(project_dir, old_package, new_package)
old_rel = old_package.replace(".", os.sep)
new_rel = new_package.replace(".", os.sep)
for parts in _SOURCE_ROOTS:
source_root = project_dir.joinpath(*parts)
old_dir = source_root / old_rel
if not old_dir.is_dir():
continue
new_dir = source_root / new_rel
new_dir.mkdir(parents=True, exist_ok=True)
for entry in old_dir.iterdir():
shutil.move(str(entry), str(new_dir / entry.name))
_prune_empty_dirs(source_root, old_rel)
def _replace_in_tree(root: Path, needle: str, replacement: str) -> None:
for path in root.rglob("*"):
if not path.is_file() or path.suffix not in TEXT_SUFFIXES:
continue
try:
text = path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
continue
if needle in text:
path.write_text(text.replace(needle, replacement), encoding="utf-8")
def _prune_empty_dirs(source_root: Path, relative: str) -> None:
current = source_root / relative
while current != source_root and current.is_dir() and not any(current.iterdir()):
current.rmdir()
current = current.parent
# ======================================================================
# Gradle / manifest / resources
# ======================================================================
def configure_gradle(project_dir: Path, config: AppConfig) -> None:
"""Write identity, SDK levels, ABIs, Python version, and signing.
Args:
project_dir: The staged Android project root.
config: The validated app configuration.
"""
gradle_path = project_dir / "app" / "build.gradle"
content = gradle_path.read_text(encoding="utf-8")
content = re.sub(r"versionCode\s+\d+", f"versionCode {config.build}", content)
content = re.sub(r'versionName\s+"[^"]*"', f'versionName "{config.version}"', content)
content = re.sub(r"minSdk\s+\d+", f"minSdk {config.android.min_sdk}", content)
content = re.sub(r"targetSdk\s+\d+", f"targetSdk {config.android.target_sdk}", content)
content = re.sub(r"compileSdk\s+\d+", f"compileSdk {config.android.compile_sdk}", content)
content = re.sub(r'version\s+"3\.\d+"', f'version "{config.python_version}"', content)
abi_csv = ", ".join(f'"{abi}"' for abi in config.android.abi_filters)
content = re.sub(r"abiFilters[^\n]*", f"abiFilters {abi_csv}", content)
if config.android.signing.is_configured:
content = _inject_signing(content, config)
gradle_path.write_text(content, encoding="utf-8")
def _inject_signing(content: str, config: AppConfig) -> str:
signing = config.android.signing
assert signing.keystore is not None and signing.key_alias is not None
keystore_path = config.resolve_path(signing.keystore).as_posix()
block = (
" signingConfigs {\n"
" release {\n"
f" storeFile file('{keystore_path}')\n"
f' storePassword System.getenv("{signing.store_password_env}")\n'
f" keyAlias '{signing.key_alias}'\n"
f' keyPassword System.getenv("{signing.key_password_env}")\n'
" }\n"
" }\n"
)
if "signingConfigs {" not in content:
content = content.replace(" buildTypes {", block + " buildTypes {", 1)
if "signingConfig signingConfigs.release" not in content:
content = content.replace(
" release {\n minifyEnabled false",
" release {\n signingConfig signingConfigs.release\n minifyEnabled false",
1,
)
return content
def configure_settings_gradle(project_dir: Path, config: AppConfig) -> None:
"""Update ``rootProject.name`` to the configured project name.
Args:
project_dir: The staged Android project root.
config: The validated app configuration.
"""
settings_path = project_dir / "settings.gradle"
if not settings_path.is_file():
return
content = settings_path.read_text(encoding="utf-8")
safe_name = re.sub(r"[^A-Za-z0-9_]", "_", config.name) or "app"
content = re.sub(r'rootProject\.name\s*=\s*"[^"]*"', f'rootProject.name = "{safe_name}"', content)
settings_path.write_text(content, encoding="utf-8")
def configure_strings(project_dir: Path, config: AppConfig) -> None:
"""Set the ``app_name`` string resource to the display name.
Args:
project_dir: The staged Android project root.
config: The validated app configuration.
"""
strings_path = project_dir / "app" / "src" / "main" / "res" / "values" / "strings.xml"
if not strings_path.is_file():
return
content = strings_path.read_text(encoding="utf-8")
escaped = _xml_escape(config.display_name)
content = re.sub(
r'()(.*?)()',
rf"\g{escaped}\g",
content,
flags=re.DOTALL,
)
strings_path.write_text(content, encoding="utf-8")
def configure_manifest(project_dir: Path, config: AppConfig) -> None:
"""Inject permissions, the launch orientation, and deep-link filters.
Args:
project_dir: The staged Android project root.
config: The validated app configuration.
"""
manifest_path = project_dir / "app" / "src" / "main" / "AndroidManifest.xml"
content = manifest_path.read_text(encoding="utf-8")
permissions = config.resolved_permissions().android_permissions
if permissions:
lines = "".join(f' \n' for name in permissions)
content = content.replace(" None:
activity = project_dir / "app" / "src" / "main" / "java" / config.android_package_path / "MainActivity.kt"
if not activity.is_file():
return
content = activity.read_text(encoding="utf-8")
if "installSplashScreen" in content:
return
content = content.replace(
"import androidx.appcompat.app.AppCompatActivity",
"import androidx.appcompat.app.AppCompatActivity\n"
"import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen",
1,
)
content = content.replace(
" super.onCreate(savedInstanceState)",
" installSplashScreen()\n super.onCreate(savedInstanceState)",
1,
)
activity.write_text(content, encoding="utf-8")
# ======================================================================
# Python source staging
# ======================================================================
def stage_python_sources(
project_dir: Path,
config: AppConfig,
*,
dev_lib_root: Optional[Path] = None,
) -> Path:
"""Copy the user's ``app/`` and (optionally) the in-repo library.
Args:
project_dir: The staged Android project root.
config: The validated app configuration.
dev_lib_root: Path to an in-repo ``pythonnative`` package to
bundle, or ``None``.
Returns:
The Chaquopy Python source root (``app/src/main/python``).
"""
python_root = project_dir / "app" / "src" / "main" / "python"
python_root.mkdir(parents=True, exist_ok=True)
app_src = config.project_root / "app"
if app_src.is_dir():
shutil.copytree(app_src, python_root / "app", dirs_exist_ok=True)
if dev_lib_root and dev_lib_root.is_dir():
shutil.copytree(
dev_lib_root,
python_root / "pythonnative",
dirs_exist_ok=True,
ignore=LIB_IGNORE,
)
return python_root
LIB_IGNORE = shutil.ignore_patterns("templates", "__pycache__", "*.pyc", "*.pyo")
"""Ignore rules for bundling the ``pythonnative`` package (skips templates)."""
# ======================================================================
# Small XML/text helpers
# ======================================================================
def _xml_escape(value: str) -> str:
return value.replace("&", "&").replace("", ">")
def _upsert_color(colors_path: Path, name: str, value: str) -> None:
if not colors_path.is_file():
colors_path.parent.mkdir(parents=True, exist_ok=True)
colors_path.write_text(
'\n\n\n',
encoding="utf-8",
)
content = colors_path.read_text(encoding="utf-8")
entry = f' {value}\n'
if f'name="{name}"' in content:
content = re.sub(
rf'()(.*?)()',
rf"\g{value}\g",
content,
)
else:
content = content.replace("", f"{entry}", 1)
colors_path.write_text(content, encoding="utf-8")
def _insert_style(themes_path: Path, style_block: str) -> None:
if not themes_path.is_file():
return
content = themes_path.read_text(encoding="utf-8")
if "Theme.App.Starting" in content:
return
content = content.replace("", f"{style_block}", 1)
themes_path.write_text(content, encoding="utf-8")
def _add_gradle_dependency(gradle_path: Path, dependency: str) -> None:
content = gradle_path.read_text(encoding="utf-8")
if dependency in content:
return
content = content.replace("dependencies {\n", f"dependencies {{\n {dependency}\n", 1)
gradle_path.write_text(content, encoding="utf-8")
def collect_logcat_filters() -> List[str]:
"""Return the logcat tag filters used when streaming device logs.
Returns:
A list of ``tag:level`` filter specs ending with ``*:S`` to
silence everything else.
"""
return [
"python.stdout:V",
"python.stderr:V",
"MainActivity:V",
"ScreenFragment:V",
"Navigator:V",
"PythonNative:V",
"AndroidRuntime:E",
"System.err:W",
"*:S",
]