''' Android target, based on python-for-android project ''' import sys if sys.platform == 'win32': raise NotImplementedError('Windows platform not yet working for Android') from platform import uname WSL = 'microsoft' in uname()[2].lower() ANDROID_API = '31' ANDROID_MINAPI = '21' APACHE_ANT_VERSION = '1.9.4' # This constant should *not* be updated, it is used only in the case # that python-for-android cannot provide a recommendation, which in # turn only happens if the python-for-android is old and probably # doesn't support any newer NDK. DEFAULT_ANDROID_NDK_VERSION = '17c' import traceback import os import io import re import ast from sys import platform, executable from buildozer import BuildozerException, USE_COLOR from buildozer.target import Target from os import environ from os.path import exists, join, realpath, expanduser, basename, relpath from platform import architecture from shutil import copyfile, rmtree, which import shlex import pexpect from glob import glob from time import sleep from buildozer.libs.version import parse from distutils.version import LooseVersion # buildozer.spec tokens that used to exist but are now ignored DEPRECATED_TOKENS = (('app', 'android.sdk'), ) # Default SDK tag to download. This is not a configurable option # because it doesn't seem to matter much, it is normally correct to # download once then update all the components as buildozer already # does. DEFAULT_SDK_TAG = '6514223' DEFAULT_ARCHS = ['arm64-v8a', 'armeabi-v7a'] MSG_P4A_RECOMMENDED_NDK_ERROR = ( "WARNING: Unable to find recommended Android NDK for current " "installation of python-for-android, defaulting to the default " "version r{android_ndk}".format(android_ndk=DEFAULT_ANDROID_NDK_VERSION) ) class TargetAndroid(Target): targetname = 'android' p4a_directory_name = "python-for-android" p4a_fork = 'kivy' p4a_branch = 'master' p4a_commit = 'HEAD' p4a_recommended_ndk_version = None extra_p4a_args = '' def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if self.buildozer.config.has_option( "app", "android.arch" ) and not self.buildozer.config.has_option("app", "android.archs"): self.buildozer.error("`android.archs` not detected, instead `android.arch` is present.") self.buildozer.error("`android.arch` will be removed and ignored in future.") self.buildozer.error("If you're seeing this error, please migrate to `android.archs`.") self._archs = self.buildozer.config.getlist( 'app', 'android.arch', DEFAULT_ARCHS) else: self._archs = self.buildozer.config.getlist( 'app', 'android.archs', DEFAULT_ARCHS) self._build_dir = join( self.buildozer.platform_dir, 'build-{}'.format(self.archs_snake)) executable = sys.executable or 'python' self._p4a_cmd = [executable, "-m", "pythonforandroid.toolchain"] self._p4a_bootstrap = self.buildozer.config.getdefault( 'app', 'p4a.bootstrap', 'sdl2') color = 'always' if USE_COLOR else 'never' self.extra_p4a_args = [f"--color={color}", f"--storage-dir={self._build_dir}"] # minapi should match ndk-api, so can use the same default if # nothing is specified ndk_api = self.buildozer.config.getdefault( 'app', 'android.ndk_api', self.android_minapi) self.extra_p4a_args.append(f"--ndk-api={ndk_api}") hook = self.buildozer.config.getdefault("app", "p4a.hook", None) if hook is not None: self.extra_p4a_args.append(f"--hook={realpath(expanduser(hook))}") port = self.buildozer.config.getdefault('app', 'p4a.port', None) if port is not None: self.extra_p4a_args.append(f"--port={port}") setup_py = self.buildozer.config.getdefault('app', 'p4a.setup_py', False) if setup_py: self.extra_p4a_args.append("--use-setup-py") else: self.extra_p4a_args.append("--ignore-setup-py") activity_class_name = self.buildozer.config.getdefault( 'app', 'android.activity_class_name', 'org.kivy.android.PythonActivity') if activity_class_name != 'org.kivy.android.PythonActivity': self.extra_p4a_args.append(f"--activity-class-name={activity_class_name}") if self.buildozer.log_level >= 2: self.extra_p4a_args.append("--debug") user_extra_p4a_args = self.buildozer.config.getdefault('app', 'p4a.extra_args', "") self.extra_p4a_args.extend(shlex.split(user_extra_p4a_args)) self.warn_on_deprecated_tokens() def warn_on_deprecated_tokens(self): for section, token in DEPRECATED_TOKENS: value = self.buildozer.config.getdefault(section, token, None) if value is not None: error = ('WARNING: Config token {} {} is deprecated and ignored, ' 'but you set value {}').format(section, token, value) self.buildozer.error(error) def _p4a(self, cmd, **kwargs): kwargs.setdefault('cwd', self.p4a_dir) return self.buildozer.cmd([*self._p4a_cmd, *cmd, *self.extra_p4a_args], **kwargs) @property def p4a_dir(self): """The directory where python-for-android is/will be installed.""" # Default p4a dir p4a_dir = join(self.buildozer.platform_dir, self.p4a_directory_name) # Possibly overridden by user setting system_p4a_dir = self.buildozer.config.getdefault('app', 'p4a.source_dir') if system_p4a_dir: p4a_dir = expanduser(system_p4a_dir) return p4a_dir @property def p4a_recommended_android_ndk(self): """ Return the p4a's recommended android's NDK version, depending on the p4a version used for our buildozer build. In case that we don't find it, we will return the buildozer's recommended one, defined by global variable `DEFAULT_ANDROID_NDK_VERSION`. """ # make sure to read p4a version only the first time if self.p4a_recommended_ndk_version is not None: return self.p4a_recommended_ndk_version # check p4a's recommendation file, and in case that exists find the # recommended android's NDK version, otherwise return buildozer's one ndk_version = DEFAULT_ANDROID_NDK_VERSION rec_file = join(self.p4a_dir, "pythonforandroid", "recommendations.py") if not os.path.isfile(rec_file): self.buildozer.error(MSG_P4A_RECOMMENDED_NDK_ERROR) return ndk_version for line in open(rec_file, "r"): if line.startswith("RECOMMENDED_NDK_VERSION ="): ndk_version = line.replace( "RECOMMENDED_NDK_VERSION =", "") # clean version of unwanted characters for i in {"'", '"', "\n", " "}: ndk_version = ndk_version.replace(i, "") self.buildozer.info( "Recommended android's NDK version by p4a is: {}".format( ndk_version ) ) self.p4a_recommended_ndk_version = ndk_version break return ndk_version def _sdkmanager(self, *args, **kwargs): """Call the sdkmanager in our Android SDK with the given arguments.""" # Use the android-sdk dir as cwd by default android_sdk_dir = self.android_sdk_dir kwargs['cwd'] = kwargs.get('cwd', android_sdk_dir) command = [self.sdkmanager_path, f"--sdk_root={android_sdk_dir}", *args] if kwargs.pop('return_child', False): return self.buildozer.cmd_expect(command, **kwargs) else: kwargs['get_stdout'] = kwargs.get('get_stdout', True) return self.buildozer.cmd(command, **kwargs) @property def android_ndk_version(self): return self.buildozer.config.getdefault('app', 'android.ndk', self.p4a_recommended_android_ndk) @property def android_api(self): return self.buildozer.config.getdefault('app', 'android.api', ANDROID_API) @property def android_minapi(self): return self.buildozer.config.getdefault('app', 'android.minapi', ANDROID_MINAPI) @property def android_sdk_dir(self): directory = expanduser(self.buildozer.config.getdefault( 'app', 'android.sdk_path', '')) if directory: return realpath(directory) return join(self.buildozer.global_platform_dir, 'android-sdk') @property def android_ndk_dir(self): directory = expanduser(self.buildozer.config.getdefault( 'app', 'android.ndk_path', '')) if directory: return realpath(directory) version = self.buildozer.config.getdefault('app', 'android.ndk', self.android_ndk_version) return join(self.buildozer.global_platform_dir, 'android-ndk-r{0}'.format(version)) @property def apache_ant_dir(self): directory = expanduser(self.buildozer.config.getdefault( 'app', 'android.ant_path', '')) if directory: return realpath(directory) version = self.buildozer.config.getdefault('app', 'android.ant', APACHE_ANT_VERSION) return join(self.buildozer.global_platform_dir, 'apache-ant-{0}'.format(version)) @property def sdkmanager_path(self): sdkmanager_path = join( self.android_sdk_dir, 'tools', 'bin', 'sdkmanager') if not os.path.isfile(sdkmanager_path): raise BuildozerException( ('sdkmanager path "{}" does not exist, sdkmanager is not' 'installed'.format(sdkmanager_path))) return sdkmanager_path @property def archs_snake(self): return "_".join(self._archs) def check_requirements(self): if platform in ('win32', 'cygwin'): try: self._set_win32_java_home() except: traceback.print_exc() self.adb_executable = join(self.android_sdk_dir, 'platform-tools', 'adb.exe') self.javac_cmd = self._locate_java('javac.exe') self.keytool_cmd = self._locate_java('keytool.exe') # darwin, linux, freebsd else: self.adb_executable = join(self.android_sdk_dir, 'platform-tools', 'adb') self.javac_cmd = self._locate_java('javac') self.keytool_cmd = self._locate_java('keytool') # Check for C header . is_debian_like = which("dpkg") is not None if is_debian_like and \ not self.buildozer.file_exists('/usr/include/zlib.h'): raise BuildozerException( 'zlib headers must be installed, ' 'run: sudo apt-get install zlib1g-dev') # Override the OS which `sdkmanager` should download the packages for. # This enables download and use of Linux binaries on FreeBSD. if platform.startswith('freebsd'): os.environ['REPO_OS_OVERRIDE'] = 'linux' # Adb arguments: adb_args = self.buildozer.config.getdefault( "app", "android.adb_args", "") self.adb_args = shlex.split(adb_args) # Need to add internally installed ant to path for external tools # like adb to use path = [join(self.apache_ant_dir, 'bin')] if 'PATH' in self.buildozer.environ: path.append(self.buildozer.environ['PATH']) else: path.append(os.environ['PATH']) self.buildozer.environ['PATH'] = ':'.join(path) checkbin = self.buildozer.checkbin checkbin('Git (git)', 'git') checkbin('Cython (cython)', 'cython') checkbin('Java compiler (javac)', self.javac_cmd) checkbin('Java keytool (keytool)', self.keytool_cmd) def _p4a_have_aab_support(self): returncode = self._p4a(["aab", "-h"], break_on_error=False)[2] if returncode == 0: return True else: return False def _set_win32_java_home(self): if 'JAVA_HOME' in self.buildozer.environ: return import _winreg with _winreg.OpenKey( _winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\JavaSoft\Java Development Kit") as jdk: # @UndefinedVariable current_version, _type = _winreg.QueryValueEx( jdk, "CurrentVersion") # @UndefinedVariable with _winreg.OpenKey(jdk, current_version) as cv: # @UndefinedVariable java_home, _type = _winreg.QueryValueEx( cv, "JavaHome") # @UndefinedVariable self.buildozer.environ['JAVA_HOME'] = java_home def _locate_java(self, s): '''If JAVA_HOME is in the environ, return $JAVA_HOME/bin/s. Otherwise, return s. ''' if 'JAVA_HOME' in self.buildozer.environ: return join(self.buildozer.environ['JAVA_HOME'], 'bin', s) else: return s def _install_apache_ant(self): ant_dir = self.apache_ant_dir if self.buildozer.file_exists(ant_dir): self.buildozer.info('Apache ANT found at {0}'.format(ant_dir)) return ant_dir if not os.path.exists(ant_dir): os.makedirs(ant_dir) self.buildozer.info('Android ANT is missing, downloading') archive = 'apache-ant-{0}-bin.tar.gz'.format(APACHE_ANT_VERSION) url = 'https://archive.apache.org/dist/ant/binaries/' self.buildozer.download(url, archive, cwd=ant_dir) self.buildozer.file_extract(archive, cwd=ant_dir) self.buildozer.info('Apache ANT installation done.') return ant_dir def _install_android_sdk(self): sdk_dir = self.android_sdk_dir if self.buildozer.file_exists(sdk_dir): self.buildozer.info('Android SDK found at {0}'.format(sdk_dir)) return sdk_dir self.buildozer.info('Android SDK is missing, downloading') if platform in ('win32', 'cygwin'): archive = 'commandlinetools-win-{}_latest.zip'.format(DEFAULT_SDK_TAG) elif platform in ('darwin', ): archive = 'commandlinetools-mac-{}_latest.zip'.format(DEFAULT_SDK_TAG) elif platform.startswith('linux') or platform.startswith('freebsd'): archive = 'commandlinetools-linux-{}_latest.zip'.format(DEFAULT_SDK_TAG) else: raise SystemError('Unsupported platform: {0}'.format(platform)) if not os.path.exists(sdk_dir): os.makedirs(sdk_dir) url = 'https://dl.google.com/android/repository/' self.buildozer.download(url, archive, cwd=sdk_dir) self.buildozer.info('Unpacking Android SDK') self.buildozer.file_extract(archive, cwd=sdk_dir) self.buildozer.info('Android SDK tools base installation done.') return sdk_dir def _install_android_ndk(self): ndk_dir = self.android_ndk_dir if self.buildozer.file_exists(ndk_dir): self.buildozer.info('Android NDK found at {0}'.format(ndk_dir)) return ndk_dir import re _version = int(re.search(r'(\d+)', self.android_ndk_version).group(1)) self.buildozer.info('Android NDK is missing, downloading') # Welcome to the NDK URL hell! # a list of all NDK URLs up to level 14 can be found here: # https://gist.github.com/roscopecoltran/43861414fbf341adac3b6fa05e7fad08 # it seems that from level 11 on the naming schema is consistent # from 10e on the URLs can be looked up at # https://developer.android.com/ndk/downloads/older_releases is_darwin = platform == 'darwin' is_linux = platform.startswith('linux') is_freebsd = platform.startswith('freebsd') if platform in ('win32', 'cygwin'): # Checking of 32/64 bits at Windows from: https://stackoverflow.com/a/1405971/798575 import struct archive = 'android-ndk-r{0}-windows-{1}.zip' is_64 = (8 * struct.calcsize("P") == 64) elif is_darwin or is_linux or is_freebsd: _platform = 'linux' if (is_linux or is_freebsd) else 'darwin' if self.android_ndk_version in ['10c', '10d', '10e']: ext = 'bin' elif _version <= 10: ext = 'tar.bz2' else: ext = 'zip' archive = 'android-ndk-r{0}-' + _platform + '{1}.' + ext is_64 = ('64' in os.uname()[4]) else: raise SystemError('Unsupported platform: {}'.format(platform)) architecture = 'x86_64' if is_64 else 'x86' architecture = '' if _version >= 23 else f'-{architecture}' unpacked = 'android-ndk-r{0}' archive = archive.format(self.android_ndk_version, architecture) unpacked = unpacked.format(self.android_ndk_version) if _version >= 11: url = 'https://dl.google.com/android/repository/' else: url = 'https://dl.google.com/android/ndk/' self.buildozer.download(url, archive, cwd=self.buildozer.global_platform_dir) self.buildozer.info('Unpacking Android NDK') self.buildozer.file_extract(archive, cwd=self.buildozer.global_platform_dir) self.buildozer.file_rename(unpacked, ndk_dir, cwd=self.buildozer.global_platform_dir) self.buildozer.info('Android NDK installation done.') return ndk_dir def _android_list_build_tools_versions(self): available_packages = self._sdkmanager('--list') lines = available_packages[0].split('\n') build_tools_versions = [] for line in lines: if not line.strip().startswith('build-tools;'): continue package_name = line.strip().split(' ')[0] assert package_name.count(';') == 1, ( 'could not parse package "{}"'.format(package_name)) version = package_name.split(';')[1] build_tools_versions.append(parse(version)) return build_tools_versions def _android_update_sdk(self, *sdkmanager_commands): """Update the tools and package-tools if possible""" auto_accept_license = self.buildozer.config.getbooldefault( 'app', 'android.accept_sdk_license', False) kwargs = {} if auto_accept_license: kwargs["return_child"] = True else: kwargs['show_output'] = True ret_child = self._sdkmanager(*sdkmanager_commands, **kwargs) if auto_accept_license: while ret_child.isalive(): pexp_match = ret_child.expect( ["(y/N)", pexpect.EOF, pexpect.TIMEOUT], timeout=300 ) if pexp_match == 0: ret_child.sendline("y") def _read_version_subdir(self, *args): versions = [] if not os.path.exists(join(*args)): self.buildozer.debug('build-tools folder not found {}'.format(join( *args))) return parse("0") for v in os.listdir(join(*args)): try: versions.append(parse(v)) except: pass if not versions: self.buildozer.error( 'Unable to find the latest version for {}'.format(join(*args))) return parse("0") return max(versions) def _find_latest_package(self, packages, key): package_versions = [] for p in packages: if not p.startswith(key): continue version_string = p.split(key)[-1] version = parse(version_string) package_versions.append(version) if not package_versions: return return max(package_versions) def _install_android_packages(self): # if any of theses value change into the buildozer.spec, retry the # update cache_key = 'android:sdk_installation' cache_value = [ self.android_api, self.android_minapi, self.android_ndk_version, self.android_sdk_dir, self.android_ndk_dir ] if self.buildozer.state.get(cache_key, None) == cache_value: return True # 1. update the platform-tools package if needed skip_upd = self.buildozer.config.getbooldefault( 'app', 'android.skip_update', False) if not skip_upd: self.buildozer.info('Installing/updating SDK platform tools if necessary') # just calling sdkmanager with the items will install them if necessary self._android_update_sdk('platform-tools') self._android_update_sdk('--update') else: self.buildozer.info('Skipping Android SDK update due to spec file setting') self.buildozer.info('Note: this also prevents installing missing ' 'SDK components') # 2. install the latest build tool self.buildozer.info('Updating SDK build tools if necessary') installed_v_build_tools = self._read_version_subdir(self.android_sdk_dir, 'build-tools') available_v_build_tools = self._android_list_build_tools_versions() if not available_v_build_tools: self.buildozer.error('Did not find any build tools available to download') latest_v_build_tools = sorted(available_v_build_tools)[-1] if latest_v_build_tools > installed_v_build_tools: if not skip_upd: self._android_update_sdk(f"build-tools;{latest_v_build_tools}") installed_v_build_tools = latest_v_build_tools else: self.buildozer.info( 'Skipping update to build tools {} due to spec setting'.format( latest_v_build_tools)) # 2. check aidl can be run self._check_aidl(installed_v_build_tools) # 3. finally, install the android for the current api self.buildozer.info('Downloading platform api target if necessary') android_platform = join(self.android_sdk_dir, 'platforms', 'android-{}'.format(self.android_api)) if not self.buildozer.file_exists(android_platform): if not skip_upd: self._sdkmanager(f"platforms;android-{self.android_api}") else: self.buildozer.info( 'Skipping install API {} platform tools due to spec setting'.format( self.android_api)) self.buildozer.info('Android packages installation done.') self.buildozer.state[cache_key] = cache_value self.buildozer.state.sync() def _check_aidl(self, v_build_tools): self.buildozer.debug('Check that aidl can be executed') v_build_tools = self._read_version_subdir(self.android_sdk_dir, 'build-tools') aidl_cmd = join(self.android_sdk_dir, 'build-tools', str(v_build_tools), 'aidl') self.buildozer.checkbin('Aidl', aidl_cmd) _, _, returncode = self.buildozer.cmd(aidl_cmd, break_on_error=False, show_output=False) if returncode != 1: self.buildozer.error('Aidl cannot be executed') if architecture()[0] == '64bit': self.buildozer.error('') self.buildozer.error( 'You might have missed to install 32bits libs') self.buildozer.error( 'Check https://buildozer.readthedocs.org/en/latest/installation.html') self.buildozer.error('') else: self.buildozer.error('') self.buildozer.error( 'In case of a bug report, please add a full log with log_level = 2') self.buildozer.error('') raise BuildozerException() def install_platform(self): self._install_p4a() self._install_apache_ant() self._install_android_sdk() self._install_android_ndk() self._install_android_packages() # ultimate configuration check. # some of our configuration cannot be check without platform. self.check_configuration_tokens() if not self._p4a_have_aab_support(): self.buildozer.error( "This buildozer version requires a python-for-android version with AAB (Android App Bundle) support. " "Please update your pinned version accordingly." ) raise BuildozerException() self.buildozer.environ.update({ 'PACKAGES_PATH': self.buildozer.global_packages_dir, 'ANDROIDSDK': self.android_sdk_dir, 'ANDROIDNDK': self.android_ndk_dir, 'ANDROIDAPI': self.android_api, 'ANDROIDMINAPI': self.android_minapi, }) def _install_p4a(self): cmd = self.buildozer.cmd p4a_fork = self.buildozer.config.getdefault( 'app', 'p4a.fork', self.p4a_fork ) p4a_url = self.buildozer.config.getdefault( 'app', 'p4a.url', f'https://github.com/{p4a_fork}/python-for-android.git' ) p4a_branch = self.buildozer.config.getdefault( 'app', 'p4a.branch', self.p4a_branch ) p4a_commit = self.buildozer.config.getdefault( 'app', 'p4a.commit', self.p4a_commit ) p4a_dir = self.p4a_dir system_p4a_dir = self.buildozer.config.getdefault('app', 'p4a.source_dir') if system_p4a_dir: # Don't install anything, just check that the dir does exist if not self.buildozer.file_exists(p4a_dir): self.buildozer.error( 'Path for p4a.source_dir does not exist') self.buildozer.error('') raise BuildozerException() else: # check that url/branch has not been changed if self.buildozer.file_exists(p4a_dir): cur_url = cmd( ["git", "config", "--get", "remote.origin.url"], get_stdout=True, cwd=p4a_dir, )[0].strip() cur_branch = cmd( ["git", "branch", "-vv"], get_stdout=True, cwd=p4a_dir )[0].split()[1] if any([cur_url != p4a_url, cur_branch != p4a_branch]): self.buildozer.info( f"Detected old url/branch ({cur_url}/{cur_branch}), deleting..." ) rmtree(p4a_dir) if not self.buildozer.file_exists(p4a_dir): cmd( [ "git", "clone", "-b", p4a_branch, "--single-branch", p4a_url, self.p4a_directory_name, ], cwd=self.buildozer.platform_dir, ) elif self.platform_update: cmd(["git", "clean", "-dxf"], cwd=p4a_dir) current_branch = cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"], get_stdout=True, cwd=p4a_dir)[0].strip() if current_branch == p4a_branch: cmd(["git", "pull"], cwd=p4a_dir) else: cmd(["git", "fetch", "--tags", "origin", "{0}:{0}".format(p4a_branch)], cwd=p4a_dir) cmd(["git", "checkout", p4a_branch], cwd=p4a_dir) if p4a_commit != 'HEAD': cmd(["git", "reset", "--hard", p4a_commit], cwd=p4a_dir) # also install dependencies (currently, only setup.py knows about it) # let's extract them. try: with open(join(self.p4a_dir, "setup.py")) as fd: setup = fd.read() deps = re.findall(r"^\s*install_reqs = (\[[^\]]*\])", setup, re.DOTALL | re.MULTILINE)[0] deps = ast.literal_eval(deps) except IOError: self.buildozer.error('Failed to read python-for-android setup.py at {}'.format( join(self.p4a_dir, 'setup.py'))) sys.exit(1) # in virtualenv or conda env options = ["--user"] if "VIRTUAL_ENV" in os.environ or "CONDA_PREFIX" in os.environ: options = [] cmd([executable, "-m", "pip", "install", "-q", *options, *deps]) def compile_platform(self): app_requirements = self.buildozer.config.getlist( 'app', 'requirements', '') dist_name = self.buildozer.config.get('app', 'package.name') local_recipes = self.get_local_recipes_dir() requirements = ','.join(app_requirements) options = [] source_dirs = { 'P4A_{}_DIR'.format(name[20:]): realpath(expanduser(value)) for name, value in self.buildozer.config.items('app') if name.startswith('requirements.source.') } if source_dirs: self.buildozer.environ.update(source_dirs) self.buildozer.info('Using custom source dirs:\n {}'.format( '\n '.join(['{} = {}'.format(k, v) for k, v in source_dirs.items()]))) if self.buildozer.config.getbooldefault('app', 'android.copy_libs', True): options.append("--copy-libs") # support for recipes in a local directory within the project if local_recipes: options.append('--local-recipes') options.append(local_recipes) p4a_create = ["create", f"--dist_name={dist_name}", f"--bootstrap={self._p4a_bootstrap}", f"--requirements={requirements}"] for arch in self._archs: p4a_create.append(f"--arch={arch}") p4a_create.extend(options) self._p4a(p4a_create, get_stdout=True)[0] def get_available_packages(self): return True def get_dist_dir(self, dist_name): """Find the dist dir with the given name if one already exists, otherwise return a new dist_dir name. """ # If the expected dist name does exist, simply use that expected_dist_dir = join(self._build_dir, 'dists', dist_name) if exists(expected_dist_dir): return expected_dist_dir # If no directory has been found yet, our dist probably # doesn't exist yet, so use the expected name return expected_dist_dir def get_local_recipes_dir(self): local_recipes = self.buildozer.config.getdefault('app', 'p4a.local_recipes') return realpath(expanduser(local_recipes)) if local_recipes else None def execute_build_package(self, build_cmd): # wrapper from previous old_toolchain to new toolchain dist_name = self.buildozer.config.get('app', 'package.name') local_recipes = self.get_local_recipes_dir() cmd = [self.artifact_format, "--bootstrap", self._p4a_bootstrap, "--dist_name", dist_name] for args in build_cmd: option, values = args[0], args[1:] if option == "debug": continue elif option == "release": cmd.append("--release") if self.check_p4a_sign_env(True): cmd.append("--sign") continue if option == "--window": cmd.append("--window") elif option == "--sdk": cmd.append("--android_api") cmd.extend(values) else: cmd.extend(args) # support for presplash background color presplash_color = self.buildozer.config.getdefault('app', 'android.presplash_color', None) if presplash_color: cmd.append('--presplash-color') cmd.append("{}".format(presplash_color)) # support for services services = self.buildozer.config.getlist('app', 'services', []) for service in services: cmd.append("--service") cmd.append(service) # support for copy-libs if self.buildozer.config.getbooldefault('app', 'android.copy_libs', True): cmd.append("--copy-libs") # support for recipes in a local directory within the project if local_recipes: cmd.append('--local-recipes') cmd.append(local_recipes) # support for blacklist/whitelist filename whitelist_src = self.buildozer.config.getdefault('app', 'android.whitelist_src', None) blacklist_src = self.buildozer.config.getdefault('app', 'android.blacklist_src', None) if whitelist_src: cmd.append('--whitelist') cmd.append(realpath(expanduser(whitelist_src))) if blacklist_src: cmd.append('--blacklist') cmd.append(realpath(expanduser(blacklist_src))) # support for java directory javadirs = self.buildozer.config.getlist('app', 'android.add_src', []) for javadir in javadirs: cmd.append('--add-source') cmd.append(realpath(expanduser(javadir))) # support for aars aars = self.buildozer.config.getlist('app', 'android.add_aars', []) for aar in aars: cmd.append('--add-aar') cmd.append(realpath(expanduser(aar))) # support for assets folder assets = self.buildozer.config.getlist('app', 'android.add_assets', []) for asset in assets: cmd.append('--add-asset') if ':' in asset: asset_src, asset_dest = asset.split(":") else: asset_src = asset asset_dest = asset cmd.append(realpath(expanduser(asset_src)) + ':' + asset_dest) # support for res folder resources = self.buildozer.config.getlist('app', 'android.add_resources', []) for resource in resources: cmd.append('--add-resource') if ':' in resource: resource_src, resource_dest = resource.split(":") else: resource_src = resource resource_dest = "" cmd.append(realpath(expanduser(resource_src)) + ':' + resource_dest) # support for uses-lib uses_library = self.buildozer.config.getlist( 'app', 'android.uses_library', '') for lib in uses_library: cmd.append('--uses-library={}'.format(lib)) # support for activity-class-name activity_class_name = self.buildozer.config.getdefault( 'app', 'android.activity_class_name', 'org.kivy.android.PythonActivity') if activity_class_name != 'org.kivy.android.PythonActivity': cmd.append('--activity-class-name={}'.format(activity_class_name)) # support for service-class-name service_class_name = self.buildozer.config.getdefault( 'app', 'android.service_class_name', 'org.kivy.android.PythonService') if service_class_name != 'org.kivy.android.PythonService': cmd.append('--service-class-name={}'.format(service_class_name)) # support for extra-manifest-xml extra_manifest_xml = self.buildozer.config.getdefault( 'app', 'android.extra_manifest_xml', '') if extra_manifest_xml: cmd.append('--extra-manifest-xml="{}"'.format(open(extra_manifest_xml, 'rt').read())) # support for extra-manifest-application-arguments extra_manifest_application_arguments = self.buildozer.config.getdefault( 'app', 'android.extra_manifest_application_arguments', '') if extra_manifest_application_arguments: args_body = open(extra_manifest_application_arguments, 'rt').read().replace('"', '\\"').replace('\n', ' ').replace('\t', ' ') cmd.append('--extra-manifest-application-arguments="{}"'.format(args_body)) # support for gradle dependencies gradle_dependencies = self.buildozer.config.getlist('app', 'android.gradle_dependencies', []) for gradle_dependency in gradle_dependencies: cmd.append('--depend') cmd.append(gradle_dependency) # support for manifestPlaceholders manifest_placeholders = self.buildozer.config.getdefault('app', 'android.manifest_placeholders', None) if manifest_placeholders: cmd.append('--manifest-placeholders') cmd.append("{}".format(manifest_placeholders)) # support disabling of byte compile for .py files no_byte_compile = self.buildozer.config.getdefault('app', 'android.no-byte-compile-python', False) if no_byte_compile: cmd.append('--no-byte-compile-python') for arch in self._archs: cmd.append('--arch') cmd.append(arch) self._p4a(cmd) def get_release_mode(self): # aab, also if unsigned is named as *-release if self.check_p4a_sign_env() or self.artifact_format in ["aab", "aar"]: return "release" return "release-unsigned" def check_p4a_sign_env(self, error=False): keys = ["KEYALIAS", "KEYSTORE_PASSWD", "KEYSTORE", "KEYALIAS_PASSWD"] check = True for key in keys: key = "P4A_RELEASE_{}".format(key) if key not in os.environ: if error: self.buildozer.error( ("Asking for release but {} is missing" "--sign will not be passed").format(key)) check = False return check def cmd_run(self, *args): entrypoint = self.buildozer.config.getdefault( 'app', 'android.entrypoint') if not entrypoint: self.buildozer.config.set('app', 'android.entrypoint', 'org.kivy.android.PythonActivity') super().cmd_run(*args) entrypoint = self.buildozer.config.getdefault( 'app', 'android.entrypoint', 'org.kivy.android.PythonActivity') package = self._get_package() # push on the device for serial in self.serials: self.buildozer.environ['ANDROID_SERIAL'] = serial self.buildozer.info('Run on {}'.format(serial)) self.buildozer.cmd( [ self.adb_executable, *self.adb_args, "shell", "am", "start", "-n", f"{package}/{entrypoint}", "-a", entrypoint, ], cwd=self.buildozer.global_platform_dir, ) self.buildozer.environ.pop('ANDROID_SERIAL', None) while True: if self._get_pid(): break sleep(.1) self.buildozer.info('Waiting for application to start.') self.buildozer.info('Application started.') def cmd_p4a(self, *args): ''' Run p4a commands. Args must come after --, or use --alias to make an alias ''' self.check_requirements() self.install_platform() args = args[0] if args and args[0] == '--alias': print('To set up p4a in this shell session, execute:') print(' alias p4a=$(buildozer {} p4a --alias 2>&1 >/dev/null)' .format(self.targetname)) sys.stderr.write('PYTHONPATH={} {}\n'.format(self.p4a_dir, self._p4a_cmd)) else: self._p4a(args) def cmd_clean(self, *args): ''' Clean the build and distribution ''' self._p4a(["clean_builds"]) self._p4a(["clean_dists"]) def _get_package(self): config = self.buildozer.config package_domain = config.getdefault('app', 'package.domain', '') package = config.get('app', 'package.name') if package_domain: package = package_domain + '.' + package return package.lower() def _generate_whitelist(self, dist_dir): p4a_whitelist = self.buildozer.config.getlist( 'app', 'android.whitelist') or [] whitelist_fn = join(dist_dir, 'whitelist.txt') with open(whitelist_fn, 'w') as fd: for wl in p4a_whitelist: fd.write(wl + '\n') def build_package(self): dist_name = self.buildozer.config.get('app', 'package.name') dist_dir = self.get_dist_dir(dist_name) config = self.buildozer.config package = self._get_package() version = self.buildozer.get_version() # add extra libs/armeabi files in dist/default/libs/armeabi # (same for armeabi-v7a, arm64-v8a, x86, mips) for config_key, lib_dir in ( ('android.add_libs_armeabi', 'armeabi'), ('android.add_libs_armeabi_v7a', 'armeabi-v7a'), ('android.add_libs_arm64_v8a', 'arm64-v8a'), ('android.add_libs_x86', 'x86'), ('android.add_libs_mips', 'mips')): patterns = config.getlist('app', config_key, []) if not patterns: continue if lib_dir not in self._archs: continue self.buildozer.debug('Search and copy libs for {}'.format(lib_dir)) for fn in self.buildozer.file_matches(patterns): self.buildozer.file_copy( join(self.buildozer.root_dir, fn), join(dist_dir, 'libs', lib_dir, basename(fn))) # update the project.properties libraries references self._update_libraries_references(dist_dir) # generate the whitelist if needed self._generate_whitelist(dist_dir) # build the app build_cmd = [ ("--name", config.get('app', 'title')), ("--version", version), ("--package", package), ("--minsdk", config.getdefault('app', 'android.minapi', self.android_minapi)), ("--ndk-api", config.getdefault('app', 'android.minapi', self.android_minapi)), ] is_private_storage = config.getbooldefault( 'app', 'android.private_storage', True) if is_private_storage: build_cmd += [("--private", self.buildozer.app_dir)] else: build_cmd += [("--dir", self.buildozer.app_dir)] # add permissions permissions = config.getlist('app', 'android.permissions', []) for permission in permissions: build_cmd += [("--permission", permission)] # add features features = config.getlist('app', 'android.features', []) for feature in features: build_cmd += [("--feature", feature)] # add res_xml xmlfiles = config.getlist('app', 'android.res_xml', []) for xmlfile in xmlfiles: build_cmd += [("--res_xml", join(self.buildozer.root_dir, xmlfile))] # android.entrypoint entrypoint = config.getdefault('app', 'android.entrypoint', 'org.kivy.android.PythonActivity') build_cmd += [('--android-entrypoint', entrypoint)] # android.apptheme apptheme = config.getdefault('app', 'android.apptheme', '@android:style/Theme.NoTitleBar') build_cmd += [('--android-apptheme', apptheme)] # android.compile_options compile_options = config.getlist('app', 'android.add_compile_options', []) for option in compile_options: build_cmd += [('--add-compile-option', option)] # android.add_gradle_repositories repos = config.getlist('app', 'android.add_gradle_repositories', []) for repo in repos: build_cmd += [('--add-gradle-repository', repo)] # android packaging options pkgoptions = config.getlist('app', 'android.add_packaging_options', []) for pkgoption in pkgoptions: build_cmd += [('--add-packaging-option', pkgoption)] # meta-data meta_datas = config.getlistvalues('app', 'android.meta_data', []) for meta in meta_datas: key, value = meta.split('=', 1) meta = '{}={}'.format(key.strip(), value.strip()) build_cmd += [("--meta-data", meta)] # add extra Java jar files add_jars = config.getlist('app', 'android.add_jars', []) for pattern in add_jars: pattern = join(self.buildozer.root_dir, pattern) matches = glob(expanduser(pattern.strip())) if matches: for jar in matches: build_cmd += [("--add-jar", jar)] else: raise SystemError('Failed to find jar file: {}'.format( pattern)) # add Java activity add_activities = config.getlist('app', 'android.add_activities', []) for activity in add_activities: build_cmd += [("--add-activity", activity)] # add presplash, lottie animation or static presplash = config.getdefault('app', 'android.presplash_lottie', '') if presplash: build_cmd += [("--presplash-lottie", join(self.buildozer.root_dir, presplash))] else: presplash = config.getdefault('app', 'presplash.filename', '') if presplash: build_cmd += [("--presplash", join(self.buildozer.root_dir, presplash))] # add icon icon = config.getdefault('app', 'icon.filename', '') if icon: build_cmd += [("--icon", join(self.buildozer.root_dir, icon))] icon_fg = config.getdefault('app', 'icon.adaptive_foreground.filename', '') icon_bg = config.getdefault('app', 'icon.adaptive_background.filename', '') if icon_fg and icon_bg: build_cmd += [("--icon-fg", join(self.buildozer.root_dir, icon_fg))] build_cmd += [("--icon-bg", join(self.buildozer.root_dir, icon_bg))] # OUYA Console support ouya_category = config.getdefault('app', 'android.ouya.category', '').upper() if ouya_category: if ouya_category not in ('GAME', 'APP'): raise SystemError( 'Invalid android.ouya.category: "{}" must be one of GAME or APP'.format( ouya_category)) # add icon ouya_icon = config.getdefault('app', 'android.ouya.icon.filename', '') build_cmd += [("--ouya-category", ouya_category)] build_cmd += [("--ouya-icon", join(self.buildozer.root_dir, ouya_icon))] if config.getdefault('app', 'p4a.bootstrap', 'sdl2') != 'service_only': # add orientation orientation = config.getlist('app', 'orientation', ['landscape']) for orient in orientation: build_cmd += [("--orientation", orient)] # fullscreen ? fullscreen = config.getbooldefault('app', 'fullscreen', True) if not fullscreen: build_cmd += [("--window", )] # wakelock ? wakelock = config.getbooldefault('app', 'android.wakelock', False) if wakelock: build_cmd += [("--wakelock", )] # AndroidX ? enable_androidx = config.getbooldefault('app', 'android.enable_androidx', self.android_api > "28") if enable_androidx: build_cmd += [("--enable-androidx", )] # intent filters intent_filters = config.getdefault( 'app', 'android.manifest.intent_filters', '') if intent_filters: build_cmd += [("--intent-filters", join(self.buildozer.root_dir, intent_filters))] # activity launch mode launch_mode = config.getdefault( 'app', 'android.manifest.launch_mode', '') if launch_mode: build_cmd += [("--activity-launch-mode", launch_mode)] # screenOrientation manifest_orientation = config.getdefault( 'app', 'android.manifest.orientation', '') if manifest_orientation: build_cmd += [("--manifest-orientation", manifest_orientation)] # numeric version numeric_version = config.getdefault('app', 'android.numeric_version') if numeric_version: build_cmd += [("--numeric-version", numeric_version)] # android.allow_backup allow_backup = config.getbooldefault('app', 'android.allow_backup', True) if not allow_backup: build_cmd += [('--allow-backup', 'false')] # android.backup_rules backup_rules = config.getdefault('app', 'android.backup_rules', '') if backup_rules: build_cmd += [("--backup-rules", join(self.buildozer.root_dir, backup_rules))] # build only in debug right now. if self.build_mode == 'debug': build_cmd += [("debug", )] mode = 'debug' mode_sign = mode else: build_cmd += [("release", )] mode_sign = "release" mode = self.get_release_mode() self.execute_build_package(build_cmd) try: self.buildozer.hook("android_pre_build_apk") self.execute_build_package(build_cmd) self.buildozer.hook("android_post_build_apk") except: # maybe the hook fail because the apk is not pass build_tools_versions = os.listdir(join(self.android_sdk_dir, "build-tools")) build_tools_versions = sorted(build_tools_versions, key=LooseVersion) build_tools_version = build_tools_versions[-1] gradle_files = ["build.gradle", "gradle", "gradlew"] is_gradle_build = build_tools_version >= "25.0" and any( (exists(join(dist_dir, x)) for x in gradle_files)) packagename = config.get('app', 'package.name') if is_gradle_build: # on gradle build, the apk use the package name, and have no version packagename_src = basename(dist_dir) # gradle specifically uses the folder name artifact = u'{packagename}-{mode}.{artifact_format}'.format( packagename=packagename_src, mode=mode, artifact_format=self.artifact_format) if self.artifact_format == "apk": artifact_dir = join(dist_dir, "build", "outputs", "apk", mode_sign) elif self.artifact_format == "aab": artifact_dir = join(dist_dir, "build", "outputs", "bundle", mode_sign) elif self.artifact_format == "aar": artifact_dir = join(dist_dir, "build", "outputs", "aar") else: # on ant, the apk use the title, and have version bl = u'\'" ,' apptitle = config.get('app', 'title') if hasattr(apptitle, 'decode'): apptitle = apptitle.decode('utf-8') apktitle = ''.join([x for x in apptitle if x not in bl]) artifact = u'{title}-{version}-{mode}.apk'.format( title=apktitle, version=version, mode=mode) artifact_dir = join(dist_dir, "bin") artifact_dest = u'{packagename}-{version}-{arch}-{mode}.{artifact_format}'.format( packagename=packagename, mode=mode, version=version, arch=self.archs_snake, artifact_format=self.artifact_format) # copy to our place copyfile(join(artifact_dir, artifact), join(self.buildozer.bin_dir, artifact_dest)) self.buildozer.info('Android packaging done!') self.buildozer.info( u'APK {0} available in the bin directory'.format(artifact_dest)) self.buildozer.state['android:latestapk'] = artifact_dest self.buildozer.state['android:latestmode'] = self.build_mode def _update_libraries_references(self, dist_dir): # ensure the project.properties exist project_fn = join(dist_dir, 'project.properties') if not self.buildozer.file_exists(project_fn): content = [ 'target=android-{}\n'.format(self.android_api), 'APP_PLATFORM={}\n'.format(self.android_minapi)] else: with io.open(project_fn, encoding='utf-8') as fd: content = fd.readlines() # extract library reference references = [] for line in content[:]: if not line.startswith('android.library.reference.'): continue content.remove(line) # convert our references to relative path app_references = self.buildozer.config.getlist( 'app', 'android.library_references', []) source_dir = realpath(expanduser(self.buildozer.config.getdefault( 'app', 'source.dir', '.'))) for cref in app_references: # get the full path of the current reference ref = realpath(join(source_dir, cref)) if not self.buildozer.file_exists(ref): self.buildozer.error( 'Invalid library reference (path not found): {}'.format( cref)) sys.exit(1) # get a relative path from the project file ref = relpath(ref, realpath(expanduser(dist_dir))) # ensure the reference exists references.append(ref) # recreate the project.properties with io.open(project_fn, 'w', encoding='utf-8') as fd: try: fd.writelines((line.decode('utf-8') for line in content)) except: fd.writelines(content) if content and not content[-1].endswith(u'\n'): fd.write(u'\n') for index, ref in enumerate(references): fd.write(u'android.library.reference.{}={}\n'.format(index + 1, ref)) self.buildozer.debug('project.properties updated') @property def serials(self): if hasattr(self, '_serials'): return self._serials serial = environ.get('ANDROID_SERIAL') if serial: return serial.split(',') lines = self.buildozer.cmd( [self.adb_executable, *self.adb_args, "devices"], get_stdout=True )[0].splitlines() serials = [] for serial in lines: if not serial: continue if serial.startswith('*') or serial.startswith('List '): continue serials.append(serial.split()[0]) self._serials = serials return serials def cmd_adb(self, *args): ''' Run adb from the Android SDK. Args must come after --, or use --alias to make an alias ''' self.check_requirements() self.install_platform() args = args[0] if args and args[0] == '--alias': print('To set up ADB in this shell session, execute:') print(' alias adb=$(buildozer {} adb --alias 2>&1 >/dev/null)' .format(self.targetname)) sys.stderr.write(self.adb_executable + '\n') else: self.buildozer.cmd([self.adb_executable, *self.adb_args, *args]) def cmd_deploy(self, *args): super().cmd_deploy(*args) state = self.buildozer.state if 'android:latestapk' not in state: self.buildozer.error('No APK built yet. Run "debug" first.') if state.get('android:latestmode', '') != 'debug': self.buildozer.error('Only debug APK are supported for deploy') return # search the APK in the bin dir apk = state['android:latestapk'] full_apk = join(self.buildozer.bin_dir, apk) if not self.buildozer.file_exists(full_apk): self.buildozer.error( 'Unable to found the latest APK. Please run "debug" again.') # push on the device for serial in self.serials: self.buildozer.environ['ANDROID_SERIAL'] = serial self.buildozer.info('Deploy on {}'.format(serial)) self.buildozer.cmd( [self.adb_executable, *self.adb_args, "install", "-r", full_apk], cwd=self.buildozer.global_platform_dir, ) self.buildozer.environ.pop('ANDROID_SERIAL', None) self.buildozer.info('Application pushed.') def _get_pid(self): pid, *_ = self.buildozer.cmd( [ self.adb_executable, *self.adb_args, "shell", "pidof", self._get_package(), ], get_stdout=True, show_output=False, break_on_error=False, quiet=True, ) if pid: return pid.strip() return False def cmd_logcat(self, *args): '''Show the log from the device ''' self.check_requirements() serial = self.serials[0:] if not serial: return filters = self.buildozer.config.getrawdefault( "app", "android.logcat_filters", "", section_sep=":", split_char=" ") filters = " ".join(filters) self.buildozer.environ['ANDROID_SERIAL'] = serial[0] extra_args = [] pid = None if self.buildozer.config.getdefault('app', 'android.logcat_pid_only'): pid = self._get_pid() if pid: extra_args.extend(('--pid', pid)) self.buildozer.cmd( [self.adb_executable, *self.adb_args, "logcat", filters, *extra_args], cwd=self.buildozer.global_platform_dir, show_output=True, run_condition=self._get_pid if pid else None, break_on_error=False, ) self.buildozer.info(f"{self._get_package()} terminated") self.buildozer.environ.pop('ANDROID_SERIAL', None) def get_target(buildozer): buildozer.targetname = "android" return TargetAndroid(buildozer)