#!/usr/bin/env python3 # # install.py - part of the FDroid server tools # Copyright (C) 2013, Ciaran Gultnieks, ciaran@ciarang.com # Copyright (C) 2013-2014 Daniel Martí # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . import sys import os import glob import locale import logging import termios import tty import defusedxml.ElementTree as XMLElementTree from argparse import ArgumentParser, BooleanOptionalAction from pathlib import Path from urllib.parse import urlencode, urlparse, urlunparse from . import _ from . import common, github, index, net from .exception import FDroidException DEFAULT_IPFS_GATEWAYS = ("https://gateway.ipfs.io/ipfs/",) MAVEN_CENTRAL_MIRRORS = [ { "url": "https://repo1.maven.org/maven2/", "dnsA": ["199.232.16.209"], "worksWithoutSNI": True, }, { "url": "https://repo.maven.apache.org/maven2/", "dnsA": ["199.232.16.215"], "worksWithoutSNI": True, }, { "url": "https://maven-central-asia.storage-download.googleapis.com/maven2/", }, { "url": "https://maven-central-eu.storage-download.googleapis.com/maven2/", }, { "url": "https://maven-central.storage-download.googleapis.com/maven2/", }, ] # pylint: disable=unused-argument def download_apk(appid='org.fdroid.fdroid', privacy_mode=False): """Download an APK from F-Droid via the first mirror that works.""" url = urlunparse( urlparse(common.FDROIDORG_MIRRORS[0]['url'])._replace( query=urlencode({'fingerprint': common.FDROIDORG_FINGERPRINT}) ) ) data, _ignored = index.download_repo_index_v2(url) app = data.get('packages', dict()).get(appid) preferred_version = None for version in app['versions'].values(): if not preferred_version: # if all else fails, use the first one preferred_version = version if not version.get('releaseChannels'): # prefer APK in default release channel preferred_version = version break mirrors = common.append_filename_to_mirrors( preferred_version['file']['name'][1:], common.FDROIDORG_MIRRORS ) ipfsCIDv1 = preferred_version['file'].get('ipfsCIDv1') if ipfsCIDv1: for gateway in DEFAULT_IPFS_GATEWAYS: mirrors.append({'url': os.path.join(gateway, ipfsCIDv1)}) f = net.download_using_mirrors(mirrors) if f and os.path.exists(f): versionCode = preferred_version['manifest']['versionCode'] f = Path(f) return str(f.rename(f.with_stem(f'{appid}_{versionCode}')).resolve()) def download_fdroid_apk(privacy_mode=False): # pylint: disable=unused-argument """Directly download the current F-Droid APK and verify it. This downloads the "download button" link, which is the version that is best tested for new installs. """ mirror = common.FDROIDORG_MIRRORS[0] mirror['url'] = urlunparse(urlparse(mirror['url'])._replace(path='F-Droid.apk')) return net.download_using_mirrors([mirror]) def download_fdroid_apk_from_github(privacy_mode=False): """Download F-Droid.apk from F-Droid's GitHub Releases.""" if common.config and not privacy_mode: token = common.config.get('github_token') else: token = None gh = github.GithubApi(token, 'https://github.com/f-droid/fdroidclient') latest_apk = gh.get_latest_apk() filename = os.path.basename(latest_apk) return net.download_file(latest_apk, os.path.join(common.get_cachedir(), filename)) def download_fdroid_apk_from_ipns(privacy_mode=False): """Download the F-Droid APK from an IPNS repo.""" cid = 'k51qzi5uqu5dl4hbcksbdmplanu9n4hivnqsupqe6vzve1pdbeh418ssptldd3' mirrors = [ {"url": f"https://ipfs.io/ipns/{cid}/F-Droid.apk"}, ] if not privacy_mode: mirrors.append({"url": f"https://{cid}.ipns.dweb.link/F-Droid.apk"}) return net.download_using_mirrors(mirrors) def download_fdroid_apk_from_maven(privacy_mode=False): """Download F-Droid.apk from Maven Central and official mirrors.""" path = 'org/fdroid/fdroid/F-Droid' if privacy_mode: mirrors = MAVEN_CENTRAL_MIRRORS[:2] # skip the Google servers else: mirrors = MAVEN_CENTRAL_MIRRORS metadata = net.download_using_mirrors( common.append_filename_to_mirrors( os.path.join(path, 'maven-metadata.xml'), mirrors ) ) version = XMLElementTree.parse(metadata).getroot().findall('*.//latest')[0].text mirrors = common.append_filename_to_mirrors( os.path.join(path, version, f'F-Droid-{version}.apk'), mirrors ) return net.download_using_mirrors(mirrors) def install_fdroid_apk(privacy_mode=False): """Download and install F-Droid.apk using all tricks we can muster. By default, this first tries to fetch the official install APK which is offered when someone clicks the "download" button on https://f-droid.org/. Then it will try all the mirrors and methods until it gets something successful, or runs out of options. There is privacy_mode which tries to download from mirrors first, so that this downloads from a mirror that has many different kinds of files available, thereby breaking the clear link to F-Droid. Returns ------- None for success or the error message. """ country_code = locale.getlocale()[0].split('_')[-1] if privacy_mode is None and country_code in ('CN', 'HK', 'IR', 'TM'): logging.warning( _('Privacy mode was enabled based on your locale ({country_code}).').format( country_code=country_code ) ) privacy_mode = True if privacy_mode or not (common.config and common.config.get('jarsigner')): download_methods = [ download_fdroid_apk_from_maven, download_fdroid_apk_from_ipns, download_fdroid_apk_from_github, ] else: download_methods = [ download_apk, download_fdroid_apk_from_maven, download_fdroid_apk_from_github, download_fdroid_apk_from_ipns, download_fdroid_apk, ] for method in download_methods: try: f = method(privacy_mode=privacy_mode) break except Exception as e: logging.info(e) else: return _('F-Droid.apk could not be downloaded from any known source!') fingerprint = common.apk_signer_fingerprint(f) if fingerprint.upper() != common.FDROIDORG_FINGERPRINT: return _('{path} has the wrong fingerprint ({fingerprint})!').format( path=f, fingerprint=fingerprint ) install_apk(f) def install_apk(f): if common.config and common.config.get('apksigner'): # TODO this should always verify, but that requires APK sig verification in Python #94 logging.info(_('Verifying package {path} with apksigner.').format(path=f)) common.verify_apk_signature(f) if common.config and common.config.get('adb'): if devices(): install_apks_to_devices([f]) os.remove(f) else: os.remove(f) return _('No devices found for `adb install`! Please plug one in.') def devices(): """Get the list of device serials for use with adb commands.""" p = common.SdkToolsPopen(['adb', "devices"]) if p.returncode != 0: raise FDroidException("An error occured when finding devices: %s" % p.output) serials = list() for line in p.output.splitlines(): columns = line.strip().split("\t", maxsplit=1) if len(columns) == 2: serial, status = columns if status == 'device': serials.append(serial) else: d = {'serial': serial, 'status': status} logging.warning(_('adb reports {serial} is "{status}"!'.format(**d))) return serials def install_apks_to_devices(apks): """Install the list of APKs to all Android devices reported by `adb devices`.""" for apk in apks: # Get device list each time to avoid device not found errors devs = devices() if not devs: raise FDroidException(_("No attached devices found")) logging.info(_("Installing %s...") % apk) for dev in devs: logging.info( _("Installing '{apkfilename}' on {dev}...").format( apkfilename=apk, dev=dev ) ) p = common.SdkToolsPopen(['adb', "-s", dev, "install", apk]) fail = "" for line in p.output.splitlines(): if line.startswith("Failure"): fail = line[9:-1] if not fail: continue if fail == "INSTALL_FAILED_ALREADY_EXISTS": logging.warning( _('"{apkfilename}" is already installed on {dev}.').format( apkfilename=apk, dev=dev ) ) else: raise FDroidException( _("Failed to install '{apkfilename}' on {dev}: {error}").format( apkfilename=apk, dev=dev, error=fail ) ) def read_char(): """Read input from the terminal prompt one char at a time.""" fd = sys.stdin.fileno() old_settings = termios.tcgetattr(fd) try: tty.setraw(fd) ch = sys.stdin.read(1) finally: termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) return ch def strtobool(val): """Convert a localized string representation of truth to True or False.""" return val.lower() in ('', 'y', 'yes', _('yes'), _('true')) # '' is pressing Enter def prompt_user(yes, msg): """Prompt user for yes/no, supporting Enter and Esc as accepted answers.""" run_install = yes if yes is None and sys.stdout.isatty(): print(msg, end=' ', flush=True) answer = '' while True: in_char = read_char() if in_char == '\r': # Enter key break if not in_char.isprintable(): sys.exit(1) print(in_char, end='', flush=True) answer += in_char run_install = strtobool(answer) print() return run_install def main(): parser = ArgumentParser( usage="%(prog)s [options] [APPID[:VERCODE] [APPID[:VERCODE] ...]]" ) common.setup_global_opts(parser) parser.add_argument( "appid", nargs='*', help=_("application ID with optional versionCode in the form APPID[:VERCODE]"), ) parser.add_argument( "-a", "--all", action="store_true", default=False, help=_("Install all signed applications available"), ) parser.add_argument( "-p", "--privacy-mode", action=BooleanOptionalAction, default=None, help=_("Download F-Droid.apk using mirrors that leak less to the network"), ) parser.add_argument( "-y", "--yes", action="store_true", default=None, help=_("Automatic yes to all prompts."), ) parser.add_argument( "-n", "--no", action="store_false", dest='yes', help=_("Automatic no to all prompts."), ) options = common.parse_args(parser) common.set_console_logging(options.verbose, options.color) logging.captureWarnings(True) # for SNIMissingWarning common.get_config() if not options.appid and not options.all: run_install = prompt_user( options.yes, _('Would you like to download and install F-Droid.apk via adb? (YES/no)'), ) if run_install: sys.exit(install_fdroid_apk(options.privacy_mode)) sys.exit(1) output_dir = 'repo' if (options.appid or options.all) and not os.path.isdir(output_dir): logging.error(_("No signed output directory - nothing to do")) run_install = prompt_user( options.yes, _('Would you like to download the app(s) from f-droid.org? (YES/no)'), ) if run_install: for appid in options.appid: f = download_apk(appid) install_apk(f) sys.exit(install_fdroid_apk(options.privacy_mode)) sys.exit(1) if options.appid: vercodes = common.read_pkg_args(options.appid, True) common.get_metadata_files(vercodes) # only check appids apks = {appid: None for appid in vercodes} # Get the signed APK with the highest vercode for apkfile in sorted(glob.glob(os.path.join(output_dir, '*.apk'))): try: appid, vercode = common.publishednameinfo(apkfile) except FDroidException: continue if appid not in apks: continue if vercodes[appid] and vercode not in vercodes[appid]: continue apks[appid] = apkfile for appid, apk in apks.items(): if not apk: raise FDroidException(_("No signed APK available for %s") % appid) install_apks_to_devices(apks.values()) elif options.all: apks = { common.publishednameinfo(apkfile)[0]: apkfile for apkfile in sorted(glob.glob(os.path.join(output_dir, '*.apk'))) } install_apks_to_devices(apks.values()) logging.info('\n' + _('Finished')) if __name__ == "__main__": main()