"""Generate isolated Android CGE targets. No KaM stubs or rewritten game loaders.""" from __future__ import annotations import hashlib, io, os, re, shutil, xml.etree.ElementTree as ET from pathlib import Path from common import ROOT, ProbeError UPSTREAM_DEFINES=['DBG_SKIP_SECURE_AUTH'] # Existing upstream switch; private multiplayer authentication is out of scope. COMPILER_OPTIONS=['-CX-','-XX-','-k--no-undefined','-k--no-gc-sections'] _ANDROID_SERVICE_NAME=re.compile(r'[A-Za-z][A-Za-z0-9_]*\Z') _PROJECT_ATTRIBUTES={ 'name':'kam_mobile', 'caption':'KaM Remake Mobile', 'author':'KaM Mobile', 'qualified_name':'org.kammobile.kamremake', 'game_units':'GameInitialize', 'compiler':'fpc', 'screen_orientation':'landscape', 'fullscreen_immersive':'true', } def _one_child(parent: ET.Element, tag: str) -> ET.Element: children=parent.findall(tag) if len(children)!=1:raise ProbeError(f'Manifest requires exactly one <{tag}>') return children[0] def _manifest_path(path: object, project: Path, kind: str) -> str: try: resolved=Path(path).resolve() if not resolved.is_dir():raise ProbeError(f'{kind} path missing: {resolved}') relative=os.path.relpath(resolved,project).replace(os.sep,'/') if not any(char.isspace() for char in relative):return relative alias=project/'.source-paths'/hashlib.sha256(str(resolved).encode()).hexdigest() alias.parent.mkdir(exist_ok=True) if alias.exists() or alias.is_symlink(): if not alias.is_symlink() or alias.resolve()!=resolved:raise ProbeError(f'{kind} path alias collision: {alias}') else:alias.symlink_to(resolved,target_is_directory=True) return os.path.relpath(alias,project).replace(os.sep,'/') except ProbeError: raise except (OSError, TypeError, ValueError, RuntimeError) as error: raise ProbeError(f'Cannot represent {kind} path {path!r}: {error}') from error def _permanent_manifest() -> tuple[ET.ElementTree, ET.Element]: path=ROOT/'app/CastleEngineManifest.xml' try: tree=ET.parse(path) except (OSError, ET.ParseError) as error: raise ProbeError(f'Cannot read permanent manifest: {error}') from error root=tree.getroot() if root.tag!='project' or root.attrib!=_PROJECT_ATTRIBUTES: raise ProbeError('Invalid permanent application identity') version=_one_child(root,'version') if version.get('value')!='0.4.0' or version.get('code')!='30': raise ProbeError('Invalid permanent application version') options=_one_child(root,'compiler_options') if [child.tag for child in options] != ['search_paths','defines','custom_options','library_paths']: raise ProbeError('Invalid permanent compiler policy order') search_paths,defines,custom_options,library_paths=( _one_child(options,tag) for tag in ('search_paths','defines','custom_options','library_paths')) if search_paths.findall('path') or library_paths.findall('path'): raise ProbeError('Permanent manifest path slots must be empty') if [item.text for item in defines.findall('define')]!=UPSTREAM_DEFINES: raise ProbeError('Invalid permanent compiler defines') if [item.text for item in custom_options.findall('option')]!=COMPILER_OPTIONS: raise ProbeError('Invalid permanent compiler options') return tree,root def android_service_roots(root: Path) -> tuple[Path,...]: """Return validated immediate project-owned Android service directories.""" try: if not root.exists(): return () if root.is_symlink() or not root.is_dir(): raise ProbeError('Android services root must be a real directory: '+str(root)) resolved_root=root.resolve() services=[] for service in sorted(root.iterdir(),key=lambda path:path.name): if service.is_symlink(): raise ProbeError('Android service must not be a symlink: '+str(service)) if not service.is_dir(): continue if not _ANDROID_SERVICE_NAME.fullmatch(service.name): raise ProbeError('Unsafe Android service name: '+repr(service.name)) resolved_service=service.resolve() if not resolved_service.is_relative_to(resolved_root): raise ProbeError('Android service escapes its root: '+str(service)) for path in service.rglob('*'): if path.is_symlink(): raise ProbeError('Android service contains a symlink: '+str(path)) services.append(service) return tuple(services) except ProbeError: raise except (OSError, ValueError, RuntimeError) as error: raise ProbeError(f'Cannot inspect Android services: {error}') from error def materialize_application_project(project: Path, source_resolution: dict) -> dict: """Refresh project inputs while preserving compatible compiler outputs.""" if project.is_symlink():raise ProbeError('Target directory must not be a symlink: '+str(project)) try: compiler_paths=tuple(source_resolution['compiler_source_paths']) library_paths=tuple(source_resolution['library_paths']) except (KeyError, TypeError) as error: raise ProbeError(f'Invalid source resolution: {error}') from error tree,root=_permanent_manifest() project.mkdir(parents=True,exist_ok=True) data_root=project/'data' if data_root.is_symlink():raise ProbeError('Project data must not be a symlink') data_root.mkdir(exist_ok=True) options=_one_child(root,'compiler_options') for tag,paths,kind in (('search_paths',compiler_paths,'Source'),('library_paths',library_paths,'Native library')): slot=_one_child(options,tag) for child in list(slot):slot.remove(child) for path in paths:ET.SubElement(slot,'path',value=_manifest_path(path,project,kind)) ET.indent(tree) rendered=io.BytesIO() tree.write(rendered,encoding='utf-8',xml_declaration=True) manifest=project/'CastleEngineManifest.xml' if manifest.is_symlink():raise ProbeError('Generated manifest must not be a symlink') if not manifest.exists() or manifest.read_bytes()!=rendered.getvalue(): manifest.write_bytes(rendered.getvalue()) source_services=ROOT/'app/android_services' services=android_service_roots(source_services) destination_root=project/'android_services' android_service_roots(destination_root) if destination_root.exists():shutil.rmtree(destination_root) if services: destination_root.mkdir() for service in services: destination=destination_root/service.name if destination.exists() or destination.is_symlink(): raise ProbeError('Android service destination already exists: '+str(destination)) try: shutil.copytree(service,destination) except OSError as error: raise ProbeError(f'Cannot copy Android service {service.name}: {error}') from error version=_one_child(root,'version') return {'project_dir':project,'data_root':data_root,'name':root.get('name'),'caption':root.get('caption'), 'package':root.get('qualified_name'),'version':version.get('value'),'version_code':int(version.get('code')), 'game_unit':root.get('game_units'),'compiler_source_paths':compiler_paths,'library_paths':library_paths}