• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kivy / python-for-android / 12265488240

10 Dec 2024 10:02PM UTC coverage: 59.265% (+0.07%) from 59.195%
12265488240

Pull #3091

github

web-flow
Merge ceca3da75 into 11b73f9a8
Pull Request #3091: :green_heart: Fix sphinx documentation build errors

1053 of 2363 branches covered (44.56%)

Branch coverage included in aggregate %.

4899 of 7680 relevant lines covered (63.79%)

2.54 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

23.0
/pythonforandroid/bootstraps/common/build/build.py
1
#!/usr/bin/env python3
2

3
from gzip import GzipFile
4✔
4
import hashlib
4✔
5
import json
4✔
6
from os.path import (
4✔
7
    dirname, join, isfile, realpath,
8
    relpath, split, exists, basename
9
)
10
from os import environ, listdir, makedirs, remove
4✔
11
import os
4✔
12
import shlex
4✔
13
import shutil
4✔
14
import subprocess
4✔
15
import sys
4✔
16
import tarfile
4✔
17
import tempfile
4✔
18
import time
4✔
19

20
from fnmatch import fnmatch
4✔
21
import jinja2
4✔
22

23
from pythonforandroid.util import rmdir, ensure_dir, max_build_tool_version
4✔
24

25

26
def get_dist_info_for(key, error_if_missing=True):
4✔
27
    try:
×
28
        with open(join(dirname(__file__), 'dist_info.json'), 'r') as fileh:
×
29
            info = json.load(fileh)
×
30
        value = info[key]
×
31
    except (OSError, KeyError) as e:
×
32
        if not error_if_missing:
×
33
            return None
×
34
        print("BUILD FAILURE: Couldn't extract the key `" + key + "` " +
×
35
              "from dist_info.json: " + str(e))
36
        sys.exit(1)
×
37
    return value
×
38

39

40
def get_hostpython():
4✔
41
    return get_dist_info_for('hostpython')
×
42

43

44
def get_bootstrap_name():
4✔
45
    return get_dist_info_for('bootstrap')
×
46

47

48
if os.name == 'nt':
4!
49
    ANDROID = 'android.bat'
×
50
    ANT = 'ant.bat'
×
51
else:
52
    ANDROID = 'android'
4✔
53
    ANT = 'ant'
4✔
54

55
curdir = dirname(__file__)
4✔
56

57
BLACKLIST_PATTERNS = [
4✔
58
    # code versioning
59
    '^*.hg/*',
60
    '^*.git/*',
61
    '^*.bzr/*',
62
    '^*.svn/*',
63

64
    # temp files
65
    '~',
66
    '*.bak',
67
    '*.swp',
68

69
    # Android artifacts
70
    '*.apk',
71
    '*.aab',
72
]
73

74
WHITELIST_PATTERNS = []
4✔
75

76
if os.environ.get("P4A_BUILD_IS_RUNNING_UNITTESTS", "0") != "1":
4!
77
    PYTHON = get_hostpython()
×
78
    _bootstrap_name = get_bootstrap_name()
×
79
else:
80
    PYTHON = "python3"
4✔
81
    _bootstrap_name = "sdl2"
4✔
82

83
if PYTHON is not None and not exists(PYTHON):
4!
84
    PYTHON = None
4✔
85

86
if _bootstrap_name in ('sdl2', 'webview', 'service_only', 'qt'):
4!
87
    WHITELIST_PATTERNS.append('pyconfig.h')
4✔
88

89
environment = jinja2.Environment(loader=jinja2.FileSystemLoader(
4✔
90
    join(curdir, 'templates')))
91

92

93
DEFAULT_PYTHON_ACTIVITY_JAVA_CLASS = 'org.kivy.android.PythonActivity'
4✔
94
DEFAULT_PYTHON_SERVICE_JAVA_CLASS = 'org.kivy.android.PythonService'
4✔
95

96

97
def render(template, dest, **kwargs):
4✔
98
    '''Using jinja2, render `template` to the filename `dest`, supplying the
99

100
    keyword arguments as template parameters.
101
    '''
102

103
    dest_dir = dirname(dest)
×
104
    if dest_dir and not exists(dest_dir):
×
105
        makedirs(dest_dir)
×
106

107
    template = environment.get_template(template)
×
108
    text = template.render(**kwargs)
×
109

110
    f = open(dest, 'wb')
×
111
    f.write(text.encode('utf-8'))
×
112
    f.close()
×
113

114

115
def is_whitelist(name):
4✔
116
    return match_filename(WHITELIST_PATTERNS, name)
×
117

118

119
def is_blacklist(name):
4✔
120
    if is_whitelist(name):
×
121
        return False
×
122
    return match_filename(BLACKLIST_PATTERNS, name)
×
123

124

125
def match_filename(pattern_list, name):
4✔
126
    for pattern in pattern_list:
×
127
        if pattern.startswith('^'):
×
128
            pattern = pattern[1:]
×
129
        else:
130
            pattern = '*/' + pattern
×
131
        if fnmatch(name, pattern):
×
132
            return True
×
133

134

135
def listfiles(d):
4✔
136
    basedir = d
×
137
    subdirlist = []
×
138
    for item in os.listdir(d):
×
139
        fn = join(d, item)
×
140
        if isfile(fn):
×
141
            yield fn
×
142
        else:
143
            subdirlist.append(join(basedir, item))
×
144
    for subdir in subdirlist:
×
145
        for fn in listfiles(subdir):
×
146
            yield fn
×
147

148

149
def make_tar(tfn, source_dirs, byte_compile_python=False, optimize_python=True):
4✔
150
    '''
151
    Make a zip file `fn` from the contents of source_dis.
152
    '''
153

154
    def clean(tinfo):
×
155
        """cleaning function (for reproducible builds)"""
156
        tinfo.uid = tinfo.gid = 0
×
157
        tinfo.uname = tinfo.gname = ''
×
158
        tinfo.mtime = 0
×
159
        return tinfo
×
160

161
    # get the files and relpath file of all the directory we asked for
162
    files = []
×
163
    for sd in source_dirs:
×
164
        sd = realpath(sd)
×
165
        for fn in listfiles(sd):
×
166
            if is_blacklist(fn):
×
167
                continue
×
168
            if fn.endswith('.py') and byte_compile_python:
×
169
                fn = compile_py_file(fn, optimize_python=optimize_python)
×
170
            files.append((fn, relpath(realpath(fn), sd)))
×
171
    files.sort()  # deterministic
×
172

173
    # create tar.gz of those files
174
    gf = GzipFile(tfn, 'wb', mtime=0)  # deterministic
×
175
    tf = tarfile.open(None, 'w', gf, format=tarfile.USTAR_FORMAT)
×
176
    dirs = []
×
177
    for fn, afn in files:
×
178
        dn = dirname(afn)
×
179
        if dn not in dirs:
×
180
            # create every dirs first if not exist yet
181
            d = ''
×
182
            for component in split(dn):
×
183
                d = join(d, component)
×
184
                if d.startswith('/'):
×
185
                    d = d[1:]
×
186
                if d == '' or d in dirs:
×
187
                    continue
×
188
                dirs.append(d)
×
189
                tinfo = tarfile.TarInfo(d)
×
190
                tinfo.type = tarfile.DIRTYPE
×
191
                clean(tinfo)
×
192
                tf.addfile(tinfo)
×
193

194
        # put the file
195
        tf.add(fn, afn, filter=clean)
×
196
    tf.close()
×
197
    gf.close()
×
198

199

200
def compile_py_file(python_file, optimize_python=True):
4✔
201
    '''
202
    Compile python_file to *.pyc and return the filename of the *.pyc file.
203
    '''
204

205
    if PYTHON is None:
×
206
        return
×
207

208
    args = [PYTHON, '-m', 'compileall', '-b', '-f', python_file]
×
209
    if optimize_python:
×
210
        # -OO = strip docstrings
211
        args.insert(1, '-OO')
×
212
    return_code = subprocess.call(args)
×
213

214
    if return_code != 0:
×
215
        print('Error while running "{}"'.format(' '.join(args)))
×
216
        print('This probably means one of your Python files has a syntax '
×
217
              'error, see logs above')
218
        exit(1)
×
219

220
    return ".".join([os.path.splitext(python_file)[0], "pyc"])
×
221

222

223
def make_package(args):
4✔
224
    # If no launcher is specified, require a main.py/main.pyc:
225
    if (get_bootstrap_name() != "sdl" or args.launcher is None) and \
×
226
            get_bootstrap_name() not in ["webview", "service_library"]:
227
        # (webview doesn't need an entrypoint, apparently)
228
        if args.private is None or (
×
229
                not exists(join(realpath(args.private), 'main.py')) and
230
                not exists(join(realpath(args.private), 'main.pyc'))):
231
            print('''BUILD FAILURE: No main.py(c) found in your app directory. This
×
232
file must exist to act as the entry point for you app. If your app is
233
started by a file with a different name, rename it to main.py or add a
234
main.py that loads it.''')
235
            sys.exit(1)
×
236

237
    assets_dir = "src/main/assets"
×
238

239
    # Delete the old assets.
240
    rmdir(assets_dir, ignore_errors=True)
×
241
    ensure_dir(assets_dir)
×
242

243
    # Add extra environment variable file into tar-able directory:
244
    env_vars_tarpath = tempfile.mkdtemp(prefix="p4a-extra-env-")
×
245
    with open(os.path.join(env_vars_tarpath, "p4a_env_vars.txt"), "w") as f:
×
246
        if hasattr(args, "window"):
×
247
            f.write("P4A_IS_WINDOWED=" + str(args.window) + "\n")
×
248
        if hasattr(args, "sdl_orientation_hint"):
×
249
            f.write("KIVY_ORIENTATION=" + str(args.sdl_orientation_hint) + "\n")
×
250
        f.write("P4A_NUMERIC_VERSION=" + str(args.numeric_version) + "\n")
×
251
        f.write("P4A_MINSDK=" + str(args.min_sdk_version) + "\n")
×
252

253
    # Package up the private data (public not supported).
254
    use_setup_py = get_dist_info_for("use_setup_py",
×
255
                                     error_if_missing=False) is True
256
    private_tar_dirs = [env_vars_tarpath]
×
257
    _temp_dirs_to_clean = []
×
258
    try:
×
259
        if args.private:
×
260
            if not use_setup_py or (
×
261
                    not exists(join(args.private, "setup.py")) and
262
                    not exists(join(args.private, "pyproject.toml"))
263
                    ):
264
                print('No setup.py/pyproject.toml used, copying '
×
265
                      'full private data into .apk.')
266
                private_tar_dirs.append(args.private)
×
267
            else:
268
                print("Copying main.py's ONLY, since other app data is "
×
269
                      "expected in site-packages.")
270
                main_py_only_dir = tempfile.mkdtemp()
×
271
                _temp_dirs_to_clean.append(main_py_only_dir)
×
272

273
                # Check all main.py files we need to copy:
274
                copy_paths = ["main.py", join("service", "main.py")]
×
275
                for copy_path in copy_paths:
×
276
                    variants = [
×
277
                        copy_path,
278
                        copy_path.partition(".")[0] + ".pyc",
279
                    ]
280
                    # Check in all variants with all possible endings:
281
                    for variant in variants:
×
282
                        if exists(join(args.private, variant)):
×
283
                            # Make sure surrounding directly exists:
284
                            dir_path = os.path.dirname(variant)
×
285
                            if (len(dir_path) > 0 and
×
286
                                    not exists(
287
                                        join(main_py_only_dir, dir_path)
288
                                    )):
289
                                ensure_dir(join(main_py_only_dir, dir_path))
×
290
                            # Copy actual file:
291
                            shutil.copyfile(
×
292
                                join(args.private, variant),
293
                                join(main_py_only_dir, variant),
294
                            )
295

296
                # Append directory with all main.py's to result apk paths:
297
                private_tar_dirs.append(main_py_only_dir)
×
298
        if get_bootstrap_name() == "webview":
×
299
            for asset in listdir('webview_includes'):
×
300
                shutil.copy(join('webview_includes', asset), join(assets_dir, asset))
×
301

302
        for asset in args.assets:
×
303
            asset_src, asset_dest = asset.split(":")
×
304
            if isfile(realpath(asset_src)):
×
305
                ensure_dir(dirname(join(assets_dir, asset_dest)))
×
306
                shutil.copy(realpath(asset_src), join(assets_dir, asset_dest))
×
307
            else:
308
                shutil.copytree(realpath(asset_src), join(assets_dir, asset_dest))
×
309

310
        if args.private or args.launcher:
×
311
            for arch in get_dist_info_for("archs"):
×
312
                libs_dir = f"libs/{arch}"
×
313
                make_tar(
×
314
                    join(libs_dir, "libpybundle.so"),
315
                    [f"_python_bundle__{arch}"],
316
                    byte_compile_python=args.byte_compile_python,
317
                    optimize_python=args.optimize_python,
318
                )
319
            make_tar(
×
320
                join(assets_dir, "private.tar"),
321
                private_tar_dirs,
322
                byte_compile_python=args.byte_compile_python,
323
                optimize_python=args.optimize_python,
324
            )
325
    finally:
326
        for directory in _temp_dirs_to_clean:
×
327
            rmdir(directory)
×
328

329
    # Remove extra env vars tar-able directory:
330
    rmdir(env_vars_tarpath)
×
331

332
    # Prepare some variables for templating process
333
    res_dir = "src/main/res"
×
334
    res_dir_initial = "src/res_initial"
×
335
    # make res_dir stateless
336
    if exists(res_dir_initial):
×
337
        rmdir(res_dir, ignore_errors=True)
×
338
        shutil.copytree(res_dir_initial, res_dir)
×
339
    else:
340
        shutil.copytree(res_dir, res_dir_initial)
×
341

342
    # Add user resources
343
    for resource in args.resources:
×
344
        resource_src, resource_dest = resource.split(":")
×
345
        if isfile(realpath(resource_src)):
×
346
            ensure_dir(dirname(join(res_dir, resource_dest)))
×
347
            shutil.copy(realpath(resource_src), join(res_dir, resource_dest))
×
348
        else:
349
            shutil.copytree(realpath(resource_src),
×
350
                            join(res_dir, resource_dest), dirs_exist_ok=True)
351

352
    default_icon = 'templates/kivy-icon.png'
×
353
    default_presplash = 'templates/kivy-presplash.jpg'
×
354
    shutil.copy(
×
355
        args.icon or default_icon,
356
        join(res_dir, 'mipmap/icon.png')
357
    )
358
    if args.icon_fg and args.icon_bg:
×
359
        shutil.copy(args.icon_fg, join(res_dir, 'mipmap/icon_foreground.png'))
×
360
        shutil.copy(args.icon_bg, join(res_dir, 'mipmap/icon_background.png'))
×
361
        with open(join(res_dir, 'mipmap-anydpi-v26/icon.xml'), "w") as fd:
×
362
            fd.write("""<?xml version="1.0" encoding="utf-8"?>
×
363
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
364
    <background android:drawable="@mipmap/icon_background"/>
365
    <foreground android:drawable="@mipmap/icon_foreground"/>
366
</adaptive-icon>
367
""")
368
    elif args.icon_fg or args.icon_bg:
×
369
        print("WARNING: Received an --icon_fg or an --icon_bg argument, but not both. "
×
370
              "Ignoring.")
371

372
    if get_bootstrap_name() != "service_only":
×
373
        lottie_splashscreen = join(res_dir, 'raw/splashscreen.json')
×
374
        if args.presplash_lottie:
×
375
            shutil.copy(
×
376
                'templates/lottie.xml',
377
                join(res_dir, 'layout/lottie.xml')
378
            )
379
            ensure_dir(join(res_dir, 'raw'))
×
380
            shutil.copy(
×
381
                args.presplash_lottie,
382
                join(res_dir, 'raw/splashscreen.json')
383
            )
384
        else:
385
            if exists(lottie_splashscreen):
×
386
                remove(lottie_splashscreen)
×
387
                remove(join(res_dir, 'layout/lottie.xml'))
×
388

389
            shutil.copy(
×
390
                args.presplash or default_presplash,
391
                join(res_dir, 'drawable/presplash.jpg')
392
            )
393

394
    # If extra Java jars were requested, copy them into the libs directory
395
    jars = []
×
396
    if args.add_jar:
×
397
        for jarname in args.add_jar:
×
398
            if not exists(jarname):
×
399
                print('Requested jar does not exist: {}'.format(jarname))
×
400
                sys.exit(-1)
×
401
            shutil.copy(jarname, 'src/main/libs')
×
402
            jars.append(basename(jarname))
×
403

404
    # If extra aar were requested, copy them into the libs directory
405
    aars = []
×
406
    if args.add_aar:
×
407
        ensure_dir("libs")
×
408
        for aarname in args.add_aar:
×
409
            if not exists(aarname):
×
410
                print('Requested aar does not exists: {}'.format(aarname))
×
411
                sys.exit(-1)
×
412
            shutil.copy(aarname, 'libs')
×
413
            aars.append(basename(aarname).rsplit('.', 1)[0])
×
414

415
    versioned_name = (args.name.replace(' ', '').replace('\'', '') +
×
416
                      '-' + args.version)
417

418
    version_code = 0
×
419
    if not args.numeric_version:
×
420
        """
421
        Set version code in format (10 + minsdk + app_version)
422
        Historically versioning was (arch + minsdk + app_version),
423
        with arch expressed with a single digit from 6 to 9.
424
        Since the multi-arch support, has been changed to 10.
425
        """
426
        min_sdk = args.min_sdk_version
×
427
        for i in args.version.split('.'):
×
428
            version_code *= 100
×
429
            version_code += int(i)
×
430
        args.numeric_version = "{}{}{}".format("10", min_sdk, version_code)
×
431

432
    if args.intent_filters:
×
433
        with open(args.intent_filters) as fd:
×
434
            args.intent_filters = fd.read()
×
435

436
    if not args.add_activity:
×
437
        args.add_activity = []
×
438

439
    if not args.activity_launch_mode:
×
440
        args.activity_launch_mode = ''
×
441

442
    if args.extra_source_dirs:
×
443
        esd = []
×
444
        for spec in args.extra_source_dirs:
×
445
            if ':' in spec:
×
446
                specdir, specincludes = spec.split(':')
×
447
                print('WARNING: Currently gradle builds only support including source '
×
448
                      'directories, so when building using gradle all files in '
449
                      '{} will be included.'.format(specdir))
450
            else:
451
                specdir = spec
×
452
                specincludes = '**'
×
453
            esd.append((realpath(specdir), specincludes))
×
454
        args.extra_source_dirs = esd
×
455
    else:
456
        args.extra_source_dirs = []
×
457

458
    service = False
×
459
    if args.private:
×
460
        service_main = join(realpath(args.private), 'service', 'main.py')
×
461
        if exists(service_main) or exists(service_main + 'o'):
×
462
            service = True
×
463

464
    service_names = []
×
465
    base_service_class = args.service_class_name.split('.')[-1]
×
466
    for sid, spec in enumerate(args.services):
×
467
        spec = spec.split(':')
×
468
        name = spec[0]
×
469
        entrypoint = spec[1]
×
470
        options = spec[2:]
×
471

472
        foreground = 'foreground' in options
×
473
        sticky = 'sticky' in options
×
474

475
        service_names.append(name)
×
476
        service_target_path =\
×
477
            'src/main/java/{}/Service{}.java'.format(
478
                args.package.replace(".", "/"),
479
                name.capitalize()
480
            )
481
        render(
×
482
            'Service.tmpl.java',
483
            service_target_path,
484
            name=name,
485
            entrypoint=entrypoint,
486
            args=args,
487
            foreground=foreground,
488
            sticky=sticky,
489
            service_id=sid + 1,
490
            base_service_class=base_service_class,
491
        )
492

493
    # Find the SDK directory and target API
494
    with open('project.properties', 'r') as fileh:
×
495
        target = fileh.read().strip()
×
496
    android_api = target.split('-')[1]
×
497

498
    if android_api.isdigit():
×
499
        android_api = int(android_api)
×
500
    else:
501
        raise ValueError(
×
502
            "failed to extract the Android API level from " +
503
            "build.properties. expected int, got: '" +
504
            str(android_api) + "'"
505
        )
506

507
    with open('local.properties', 'r') as fileh:
×
508
        sdk_dir = fileh.read().strip()
×
509
    sdk_dir = sdk_dir[8:]
×
510

511
    # Try to build with the newest available build tools
512
    ignored = {".DS_Store", ".ds_store"}
×
513
    build_tools_versions = [x for x in listdir(join(sdk_dir, 'build-tools')) if x not in ignored]
×
514
    build_tools_version = max_build_tool_version(build_tools_versions)
×
515

516
    # Folder name for launcher (used by SDL2 bootstrap)
517
    url_scheme = 'kivy'
×
518

519
    # Copy backup rules file if specified and update the argument
520
    res_xml_dir = join(res_dir, 'xml')
×
521
    if args.backup_rules:
×
522
        ensure_dir(res_xml_dir)
×
523
        shutil.copy(join(args.private, args.backup_rules), res_xml_dir)
×
524
        args.backup_rules = split(args.backup_rules)[1][:-4]
×
525

526
    # Copy res_xml files to src/main/res/xml
527
    if args.res_xmls:
×
528
        ensure_dir(res_xml_dir)
×
529
        for xmlpath in args.res_xmls:
×
530
            if not os.path.exists(xmlpath):
×
531
                xmlpath = join(args.private, xmlpath)
×
532
            shutil.copy(xmlpath, res_xml_dir)
×
533

534
    # Render out android manifest:
535
    manifest_path = "src/main/AndroidManifest.xml"
×
536
    render_args = {
×
537
        "args": args,
538
        "service": service,
539
        "service_names": service_names,
540
        "android_api": android_api,
541
        "debug": "debug" in args.build_mode,
542
        "native_services": args.native_services
543
    }
544
    if get_bootstrap_name() == "sdl2":
×
545
        render_args["url_scheme"] = url_scheme
×
546

547
    render(
×
548
        'AndroidManifest.tmpl.xml',
549
        manifest_path,
550
        **render_args)
551

552
    # Copy the AndroidManifest.xml to the dist root dir so that ant
553
    # can also use it
554
    if exists('AndroidManifest.xml'):
×
555
        remove('AndroidManifest.xml')
×
556
    shutil.copy(manifest_path, 'AndroidManifest.xml')
×
557

558
    # gradle build templates
559
    render(
×
560
        'build.tmpl.gradle',
561
        'build.gradle',
562
        args=args,
563
        aars=aars,
564
        jars=jars,
565
        android_api=android_api,
566
        build_tools_version=build_tools_version,
567
        debug_build="debug" in args.build_mode,
568
        is_library=(get_bootstrap_name() == 'service_library'),
569
        )
570

571
    # gradle properties
572
    render(
×
573
        'gradle.tmpl.properties',
574
        'gradle.properties',
575
        args=args,
576
        bootstrap_name=get_bootstrap_name())
577

578
    # ant build templates
579
    render(
×
580
        'build.tmpl.xml',
581
        'build.xml',
582
        args=args,
583
        versioned_name=versioned_name)
584

585
    # String resources:
586
    timestamp = time.time()
×
587
    if 'SOURCE_DATE_EPOCH' in environ:
×
588
        # for reproducible builds
589
        timestamp = int(environ['SOURCE_DATE_EPOCH'])
×
590
    private_version = "{} {} {}".format(
×
591
        args.version,
592
        args.numeric_version,
593
        timestamp
594
    )
595
    render_args = {
×
596
        "args": args,
597
        "private_version": hashlib.sha1(private_version.encode()).hexdigest()
598
    }
599
    if get_bootstrap_name() == "sdl2":
×
600
        render_args["url_scheme"] = url_scheme
×
601
    render(
×
602
        'strings.tmpl.xml',
603
        join(res_dir, 'values/strings.xml'),
604
        **render_args)
605

606
    # Library resources from Qt
607
    # These are referred by QtLoader.java in Qt6AndroidBindings.jar
608
    # qt_libs and load_local_libs are loaded at App startup
609
    if get_bootstrap_name() == "qt":
×
610
        qt_libs = args.qt_libs.split(",")
×
611
        load_local_libs = args.load_local_libs.split(",")
×
612
        init_classes = args.init_classes
×
613
        if init_classes:
×
614
            init_classes = init_classes.split(",")
×
615
            init_classes = ":".join(init_classes)
×
616
        arch = get_dist_info_for("archs")[0]
×
617
        render(
×
618
            'libs.tmpl.xml',
619
            join(res_dir, 'values/libs.xml'),
620
            qt_libs=qt_libs,
621
            load_local_libs=load_local_libs,
622
            init_classes=init_classes,
623
            arch=arch
624
        )
625

626
    if exists(join("templates", "custom_rules.tmpl.xml")):
×
627
        render(
×
628
            'custom_rules.tmpl.xml',
629
            'custom_rules.xml',
630
            args=args)
631

632
    if get_bootstrap_name() == "webview":
×
633
        render('WebViewLoader.tmpl.java',
×
634
               'src/main/java/org/kivy/android/WebViewLoader.java',
635
               args=args)
636

637
    if args.sign:
×
638
        render('build.properties', 'build.properties')
×
639
    else:
640
        if exists('build.properties'):
×
641
            os.remove('build.properties')
×
642

643
    # Apply java source patches if any are present:
644
    if exists(join('src', 'patches')):
×
645
        print("Applying Java source code patches...")
×
646
        for patch_name in os.listdir(join('src', 'patches')):
×
647
            patch_path = join('src', 'patches', patch_name)
×
648
            print("Applying patch: " + str(patch_path))
×
649

650
            # -N: insist this is FORWARD patch, don't reverse apply
651
            # -p1: strip first path component
652
            # -t: batch mode, don't ask questions
653
            patch_command = ["patch", "-N", "-p1", "-t", "-i", patch_path]
×
654

655
            try:
×
656
                # Use a dry run to establish whether the patch is already applied.
657
                # If we don't check this, the patch may be partially applied (which is bad!)
658
                subprocess.check_output(patch_command + ["--dry-run"])
×
659
            except subprocess.CalledProcessError as e:
×
660
                if e.returncode == 1:
×
661
                    # Return code 1 means not all hunks could be applied, this usually
662
                    # means the patch is already applied.
663
                    print("Warning: failed to apply patch (exit code 1), "
×
664
                          "assuming it is already applied: ",
665
                          str(patch_path))
666
                else:
667
                    raise e
×
668
            else:
669
                # The dry run worked, so do the real thing
670
                subprocess.check_output(patch_command)
×
671

672

673
def parse_permissions(args_permissions):
4✔
674
    if args_permissions and isinstance(args_permissions[0], list):
4!
675
        args_permissions = [p for perm in args_permissions for p in perm]
4✔
676

677
    def _is_advanced_permission(permission):
4✔
678
        return permission.startswith("(") and permission.endswith(")")
4✔
679

680
    def _decode_advanced_permission(permission):
4✔
681
        SUPPORTED_PERMISSION_PROPERTIES = ["name", "maxSdkVersion", "usesPermissionFlags"]
4✔
682
        _permission_args = permission[1:-1].split(";")
4✔
683
        _permission_args = (arg.split("=") for arg in _permission_args)
4✔
684
        advanced_permission = dict(_permission_args)
4✔
685

686
        if "name" not in advanced_permission:
4!
687
            raise ValueError("Advanced permission must have a name property")
×
688

689
        for key in advanced_permission.keys():
4✔
690
            if key not in SUPPORTED_PERMISSION_PROPERTIES:
4✔
691
                raise ValueError(
4✔
692
                    f"Property '{key}' is not supported. "
693
                    "Advanced permission only supports: "
694
                    f"{', '.join(SUPPORTED_PERMISSION_PROPERTIES)} properties"
695
                )
696

697
        return advanced_permission
4✔
698

699
    _permissions = []
4✔
700
    for permission in args_permissions:
4✔
701
        if _is_advanced_permission(permission):
4✔
702
            _permissions.append(_decode_advanced_permission(permission))
4✔
703
        else:
704
            if "." in permission:
4✔
705
                _permissions.append(dict(name=permission))
4✔
706
            else:
707
                _permissions.append(dict(name=f"android.permission.{permission}"))
4✔
708
    return _permissions
4✔
709

710

711
def get_sdl_orientation_hint(orientations):
4✔
712
    SDL_ORIENTATION_MAP = {
4✔
713
        "landscape": "LandscapeLeft",
714
        "portrait": "Portrait",
715
        "portrait-reverse": "PortraitUpsideDown",
716
        "landscape-reverse": "LandscapeRight",
717
    }
718
    return " ".join(
4✔
719
        [SDL_ORIENTATION_MAP[x] for x in orientations if x in SDL_ORIENTATION_MAP]
720
    )
721

722

723
def get_manifest_orientation(orientations, manifest_orientation=None):
4✔
724
    # If the user has specifically set an orientation to use in the manifest,
725
    # use that.
726
    if manifest_orientation is not None:
4✔
727
        return manifest_orientation
4✔
728

729
    # If multiple or no orientations are specified, use unspecified in the manifest,
730
    # as we can only specify one orientation in the manifest.
731
    if len(orientations) != 1:
4✔
732
        return "unspecified"
4✔
733

734
    # Convert the orientation to a value that can be used in the manifest.
735
    # If the specified orientation is not supported, use unspecified.
736
    MANIFEST_ORIENTATION_MAP = {
4✔
737
        "landscape": "landscape",
738
        "portrait": "portrait",
739
        "portrait-reverse": "reversePortrait",
740
        "landscape-reverse": "reverseLandscape",
741
    }
742
    return MANIFEST_ORIENTATION_MAP.get(orientations[0], "unspecified")
4✔
743

744

745
def get_dist_ndk_min_api_level():
4✔
746
    # Get the default minsdk, equal to the NDK API that this dist is built against
747
    try:
4✔
748
        with open('dist_info.json', 'r') as fileh:
4!
749
            info = json.load(fileh)
×
750
            ndk_api = int(info['ndk_api'])
×
751
    except (OSError, KeyError, ValueError, TypeError):
4✔
752
        print('WARNING: Failed to read ndk_api from dist info, defaulting to 12')
4✔
753
        ndk_api = 12  # The old default before ndk_api was introduced
4✔
754
    return ndk_api
4✔
755

756

757
def create_argument_parser():
4✔
758
    ndk_api = get_dist_ndk_min_api_level()
4✔
759
    import argparse
4✔
760
    ap = argparse.ArgumentParser(description='''\
4✔
761
Package a Python application for Android (using
762
bootstrap ''' + get_bootstrap_name() + ''').
763

764
For this to work, Java and Ant need to be in your path, as does the
765
tools directory of the Android SDK.
766
''')
767

768
    # --private is required unless for sdl2, where there's also --launcher
769
    ap.add_argument('--private', dest='private',
4✔
770
                    help='the directory with the app source code files' +
771
                         ' (containing your main.py entrypoint)',
772
                    required=(get_bootstrap_name() != "sdl2"))
773
    ap.add_argument('--package', dest='package',
4✔
774
                    help=('The name of the java package the project will be'
775
                          ' packaged under.'),
776
                    required=True)
777
    ap.add_argument('--name', dest='name',
4✔
778
                    help=('The human-readable name of the project.'),
779
                    required=True)
780
    ap.add_argument('--numeric-version', dest='numeric_version',
4✔
781
                    help=('The numeric version number of the project. If not '
782
                          'given, this is automatically computed from the '
783
                          'version.'))
784
    ap.add_argument('--version', dest='version',
4✔
785
                    help=('The version number of the project. This should '
786
                          'consist of numbers and dots, and should have the '
787
                          'same number of groups of numbers as previous '
788
                          'versions.'),
789
                    required=True)
790
    if get_bootstrap_name() == "sdl2":
4!
791
        ap.add_argument('--launcher', dest='launcher', action='store_true',
4✔
792
                        help=('Provide this argument to build a multi-app '
793
                              'launcher, rather than a single app.'))
794
        ap.add_argument('--home-app', dest='home_app', action='store_true', default=False,
4✔
795
                        help=('Turn your application into a home app (launcher)'))
796
    ap.add_argument('--display-cutout', dest='display_cutout', default='never',
4✔
797
                    help=('Enables display-cutout that renders around the area (notch) on '
798
                          'some devices that extends into the display surface'))
799
    ap.add_argument('--permission', dest='permissions', action='append', default=[],
4✔
800
                    help='The permissions to give this app.', nargs='+')
801
    ap.add_argument('--meta-data', dest='meta_data', action='append', default=[],
4✔
802
                    help='Custom key=value to add in application metadata')
803
    ap.add_argument('--uses-library', dest='android_used_libs', action='append', default=[],
4✔
804
                    help='Used shared libraries included using <uses-library> tag in AndroidManifest.xml')
805
    ap.add_argument('--asset', dest='assets',
4✔
806
                    action="append", default=[],
807
                    metavar="/path/to/source:dest",
808
                    help='Put this in the assets folder at assets/dest')
809
    ap.add_argument('--resource', dest='resources',
4✔
810
                    action="append", default=[],
811
                    metavar="/path/to/source:kind/asset",
812
                    help='Put this in the res folder at res/kind')
813
    ap.add_argument('--icon', dest='icon',
4✔
814
                    help=('A png file to use as the icon for '
815
                          'the application.'))
816
    ap.add_argument('--icon-fg', dest='icon_fg',
4✔
817
                    help=('A png file to use as the foreground of the adaptive icon '
818
                          'for the application.'))
819
    ap.add_argument('--icon-bg', dest='icon_bg',
4✔
820
                    help=('A png file to use as the background of the adaptive icon '
821
                          'for the application.'))
822
    ap.add_argument('--service', dest='services', action='append', default=[],
4✔
823
                    help='Declare a new service entrypoint: '
824
                         'NAME:PATH_TO_PY[:foreground]')
825
    ap.add_argument('--native-service', dest='native_services', action='append', default=[],
4✔
826
                    help='Declare a new native service: '
827
                         'package.name.service')
828
    if get_bootstrap_name() != "service_only":
4!
829
        ap.add_argument('--presplash', dest='presplash',
4✔
830
                        help=('A jpeg file to use as a screen while the '
831
                              'application is loading.'))
832
        ap.add_argument('--presplash-lottie', dest='presplash_lottie',
4✔
833
                        help=('A lottie (json) file to use as an animation while the '
834
                              'application is loading.'))
835
        ap.add_argument('--presplash-color',
4✔
836
                        dest='presplash_color',
837
                        default='#000000',
838
                        help=('A string to set the loading screen '
839
                              'background color. '
840
                              'Supported formats are: '
841
                              '#RRGGBB #AARRGGBB or color names '
842
                              'like red, green, blue, etc.'))
843
        ap.add_argument('--window', dest='window', action='store_true',
4✔
844
                        default=False,
845
                        help='Indicate if the application will be windowed')
846
        ap.add_argument('--manifest-orientation', dest='manifest_orientation',
4✔
847
                        help=('The orientation that will be set in the '
848
                              'android:screenOrientation attribute of the activity '
849
                              'in the AndroidManifest.xml file. If not set, '
850
                              'the value will be synthesized from the --orientation option.'))
851
        ap.add_argument('--orientation', dest='orientation',
4✔
852
                        action="append", default=[],
853
                        choices=['portrait', 'landscape', 'landscape-reverse', 'portrait-reverse'],
854
                        help=('The orientations that the app will display in. '
855
                              'Since Android ignores android:screenOrientation '
856
                              'when in multi-window mode (Which is the default on Android 12+), '
857
                              'this option will also set the window orientation hints '
858
                              'for apps using the (default) SDL bootstrap.'
859
                              'If multiple orientations are given, android:screenOrientation '
860
                              'will be set to "unspecified"'))
861

862
    ap.add_argument('--enable-androidx', dest='enable_androidx',
4✔
863
                    action='store_true',
864
                    help=('Enable the AndroidX support library, '
865
                          'requires api = 28 or greater'))
866
    ap.add_argument('--android-entrypoint', dest='android_entrypoint',
4✔
867
                    default=DEFAULT_PYTHON_ACTIVITY_JAVA_CLASS,
868
                    help='Defines which java class will be used for startup, usually a subclass of PythonActivity')
869
    ap.add_argument('--android-apptheme', dest='android_apptheme',
4✔
870
                    default='@android:style/Theme.NoTitleBar',
871
                    help='Defines which app theme should be selected for the main activity')
872
    ap.add_argument('--add-compile-option', dest='compile_options', default=[],
4✔
873
                    action='append', help='add compile options to gradle.build')
874
    ap.add_argument('--add-gradle-repository', dest='gradle_repositories',
4✔
875
                    default=[],
876
                    action='append',
877
                    help='Ddd a repository for gradle')
878
    ap.add_argument('--add-packaging-option', dest='packaging_options',
4✔
879
                    default=[],
880
                    action='append',
881
                    help='Dndroid packaging options')
882

883
    ap.add_argument('--wakelock', dest='wakelock', action='store_true',
4✔
884
                    help=('Indicate if the application needs the device '
885
                          'to stay on'))
886
    ap.add_argument('--blacklist', dest='blacklist',
4✔
887
                    default=join(curdir, 'blacklist.txt'),
888
                    help=('Use a blacklist file to match unwanted file in '
889
                          'the final APK'))
890
    ap.add_argument('--whitelist', dest='whitelist',
4✔
891
                    default=join(curdir, 'whitelist.txt'),
892
                    help=('Use a whitelist file to prevent blacklisting of '
893
                          'file in the final APK'))
894
    ap.add_argument('--release', dest='build_mode', action='store_const',
4✔
895
                    const='release', default='debug',
896
                    help='Build your app as a non-debug release build. '
897
                         '(Disables gdb debugging among other things)')
898
    ap.add_argument('--with-debug-symbols', dest='with_debug_symbols',
4✔
899
                    action='store_const', const=True, default=False,
900
                    help='Will keep debug symbols from `.so` files.')
901
    ap.add_argument('--add-jar', dest='add_jar', action='append',
4✔
902
                    help=('Add a Java .jar to the libs, so you can access its '
903
                          'classes with pyjnius. You can specify this '
904
                          'argument more than once to include multiple jars'))
905
    ap.add_argument('--add-aar', dest='add_aar', action='append',
4✔
906
                    help=('Add an aar dependency manually'))
907
    ap.add_argument('--depend', dest='depends', action='append',
4✔
908
                    help=('Add a external dependency '
909
                          '(eg: com.android.support:appcompat-v7:19.0.1)'))
910
    # The --sdk option has been removed, it is ignored in favour of
911
    # --android-api handled by toolchain.py
912
    ap.add_argument('--sdk', dest='sdk_version', default=-1,
4✔
913
                    type=int, help=('Deprecated argument, does nothing'))
914
    ap.add_argument('--minsdk', dest='min_sdk_version',
4✔
915
                    default=ndk_api, type=int,
916
                    help=('Minimum Android SDK version that the app supports. '
917
                          'Defaults to {}.'.format(ndk_api)))
918
    ap.add_argument('--allow-minsdk-ndkapi-mismatch', default=False,
4✔
919
                    action='store_true',
920
                    help=('Allow the --minsdk argument to be different from '
921
                          'the discovered ndk_api in the dist'))
922
    ap.add_argument('--intent-filters', dest='intent_filters',
4✔
923
                    help=('Add intent-filters xml rules to the '
924
                          'AndroidManifest.xml file. The argument is a '
925
                          'filename containing xml. The filename should be '
926
                          'located relative to the python-for-android '
927
                          'directory'))
928
    ap.add_argument('--res_xml', dest='res_xmls', action='append', default=[],
4✔
929
                    help='Add files to res/xml directory (for example device-filters)', nargs='+')
930
    ap.add_argument('--with-billing', dest='billing_pubkey',
4✔
931
                    help='If set, the billing service will be added (not implemented)')
932
    ap.add_argument('--add-source', dest='extra_source_dirs', action='append',
4✔
933
                    help='Include additional source dirs in Java build')
934
    if get_bootstrap_name() == "webview":
4!
935
        ap.add_argument('--port',
×
936
                        help='The port on localhost that the WebView will access',
937
                        default='5000')
938
    ap.add_argument('--try-system-python-compile', dest='try_system_python_compile',
4✔
939
                    action='store_true',
940
                    help='Use the system python during compileall if possible.')
941
    ap.add_argument('--sign', action='store_true',
4✔
942
                    help=('Try to sign the APK with your credentials. You must set '
943
                          'the appropriate environment variables.'))
944
    ap.add_argument('--add-activity', dest='add_activity', action='append',
4✔
945
                    help='Add this Java class as an Activity to the manifest.')
946
    ap.add_argument('--activity-launch-mode',
4✔
947
                    dest='activity_launch_mode',
948
                    default='singleTask',
949
                    help='Set the launch mode of the main activity in the manifest.')
950
    ap.add_argument('--allow-backup', dest='allow_backup', default='true',
4✔
951
                    help="if set to 'false', then android won't backup the application.")
952
    ap.add_argument('--backup-rules', dest='backup_rules', default='',
4✔
953
                    help=('Backup rules for Android Auto Backup. Argument is a '
954
                          'filename containing xml. The filename should be '
955
                          'located relative to the private directory containing your source code '
956
                          'files (containing your main.py entrypoint). '
957
                          'See https://developer.android.com/guide/topics/data/'
958
                          'autobackup#IncludingFiles for more information'))
959
    ap.add_argument('--no-byte-compile-python', dest='byte_compile_python',
4✔
960
                    action='store_false', default=True,
961
                    help='Skip byte compile for .py files.')
962
    ap.add_argument('--no-optimize-python', dest='optimize_python',
4✔
963
                    action='store_false', default=True,
964
                    help=('Whether to compile to optimised .pyc files, using -OO '
965
                          '(strips docstrings and asserts)'))
966
    ap.add_argument('--extra-manifest-xml', default='',
4✔
967
                    help=('Extra xml to write directly inside the <manifest> element of'
968
                          'AndroidManifest.xml'))
969
    ap.add_argument('--extra-manifest-application-arguments', default='',
4✔
970
                    help='Extra arguments to be added to the <manifest><application> tag of'
971
                         'AndroidManifest.xml')
972
    ap.add_argument('--manifest-placeholders', dest='manifest_placeholders',
4✔
973
                    default='[:]', help=('Inject build variables into the manifest '
974
                                         'via the manifestPlaceholders property'))
975
    ap.add_argument('--service-class-name', dest='service_class_name', default=DEFAULT_PYTHON_SERVICE_JAVA_CLASS,
4✔
976
                    help='Use that parameter if you need to implement your own PythonServive Java class')
977
    ap.add_argument('--activity-class-name', dest='activity_class_name', default=DEFAULT_PYTHON_ACTIVITY_JAVA_CLASS,
4✔
978
                    help='The full java class name of the main activity')
979
    if get_bootstrap_name() == "qt":
4!
980
        ap.add_argument('--qt-libs', dest='qt_libs', required=True,
×
981
                        help='comma separated list of Qt libraries to be loaded')
982
        ap.add_argument('--load-local-libs', dest='load_local_libs', required=True,
×
983
                        help='comma separated list of Qt plugin libraries to be loaded')
984
        ap.add_argument('--init-classes', dest='init_classes', default='',
×
985
                        help='comma separated list of java class names to be loaded from the Qt jar files, '
986
                             'specified through add_jar cli option')
987

988
    return ap
4✔
989

990

991
def parse_args_and_make_package(args=None):
4✔
992
    global BLACKLIST_PATTERNS, WHITELIST_PATTERNS, PYTHON
993

994
    ndk_api = get_dist_ndk_min_api_level()
×
995
    ap = create_argument_parser()
×
996

997
    # Put together arguments, and add those from .p4a config file:
998
    if args is None:
×
999
        args = sys.argv[1:]
×
1000

1001
    def _read_configuration():
×
1002
        if not exists(".p4a"):
×
1003
            return
×
1004
        print("Reading .p4a configuration")
×
1005
        with open(".p4a") as fd:
×
1006
            lines = fd.readlines()
×
1007
        lines = [shlex.split(line)
×
1008
                 for line in lines if not line.startswith("#")]
1009
        for line in lines:
×
1010
            for arg in line:
×
1011
                args.append(arg)
×
1012
    _read_configuration()
×
1013

1014
    args = ap.parse_args(args)
×
1015

1016
    if args.name and args.name[0] == '"' and args.name[-1] == '"':
×
1017
        args.name = args.name[1:-1]
×
1018

1019
    if ndk_api != args.min_sdk_version:
×
1020
        print(('WARNING: --minsdk argument does not match the api that is '
×
1021
               'compiled against. Only proceed if you know what you are '
1022
               'doing, otherwise use --minsdk={} or recompile against api '
1023
               '{}').format(ndk_api, args.min_sdk_version))
1024
        if not args.allow_minsdk_ndkapi_mismatch:
×
1025
            print('You must pass --allow-minsdk-ndkapi-mismatch to build '
×
1026
                  'with --minsdk different to the target NDK api from the '
1027
                  'build step')
1028
            sys.exit(1)
×
1029
        else:
1030
            print('Proceeding with --minsdk not matching build target api')
×
1031

1032
    if args.billing_pubkey:
×
1033
        print('Billing not yet supported!')
×
1034
        sys.exit(1)
×
1035

1036
    if args.sdk_version != -1:
×
1037
        print('WARNING: Received a --sdk argument, but this argument is '
×
1038
              'deprecated and does nothing.')
1039
        args.sdk_version = -1  # ensure it is not used
×
1040

1041
    args.permissions = parse_permissions(args.permissions)
×
1042

1043
    args.manifest_orientation = get_manifest_orientation(
×
1044
        args.orientation, args.manifest_orientation
1045
    )
1046

1047
    if get_bootstrap_name() == "sdl2":
×
1048
        args.sdl_orientation_hint = get_sdl_orientation_hint(args.orientation)
×
1049

1050
    if args.res_xmls and isinstance(args.res_xmls[0], list):
×
1051
        args.res_xmls = [x for res in args.res_xmls for x in res]
×
1052

1053
    if args.try_system_python_compile:
×
1054
        # Hardcoding python2.7 is okay for now, as python3 skips the
1055
        # compilation anyway
1056
        python_executable = 'python2.7'
×
1057
        try:
×
1058
            subprocess.call([python_executable, '--version'])
×
1059
        except (OSError, subprocess.CalledProcessError):
×
1060
            pass
×
1061
        else:
1062
            PYTHON = python_executable
×
1063

1064
    if args.blacklist:
×
1065
        with open(args.blacklist) as fd:
×
1066
            patterns = [x.strip() for x in fd.read().splitlines()
×
1067
                        if x.strip() and not x.strip().startswith('#')]
1068
        BLACKLIST_PATTERNS += patterns
×
1069

1070
    if args.whitelist:
×
1071
        with open(args.whitelist) as fd:
×
1072
            patterns = [x.strip() for x in fd.read().splitlines()
×
1073
                        if x.strip() and not x.strip().startswith('#')]
1074
        WHITELIST_PATTERNS += patterns
×
1075

1076
    if args.private is None and \
×
1077
            get_bootstrap_name() == 'sdl2' and args.launcher is None:
1078
        print('Need --private directory or ' +
×
1079
              '--launcher (SDL2 bootstrap only)' +
1080
              'to have something to launch inside the .apk!')
1081
        sys.exit(1)
×
1082
    make_package(args)
×
1083

1084
    return args
×
1085

1086

1087
if __name__ == "__main__":
1088
    parse_args_and_make_package()
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc