[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/andevsr/pythonnative/main/src/pythonnative/project/android.py [Back]  [Original]

"""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",
    ]

Web Proxy Viewer  |  New URL  |  Original Page