|
#!/usr/bin/env python |
|
from typing import List, TypedDict, Optional |
|
import subprocess |
|
import csv |
|
import argparse |
|
import logging |
|
import os |
|
|
|
logging.basicConfig(level=logging.INFO) |
|
logger = logging.getLogger(__name__) |
|
|
|
BIN_ADB = "$HOME/.local/lib/android-sdk/platform-tools/adb" |
|
BIN_AAPT = "$HOME/.local/lib/android-sdk/build-tools/30.0.3/aapt" |
|
|
|
class ShellRunRes(TypedDict): |
|
stdout: Optional[List[str]] |
|
stderr: Optional[List[str]] |
|
return_code: int |
|
|
|
def _shell_run( |
|
cmd: List[str], |
|
non_zero_exception: Optional[Exception]=None, |
|
require_stdout_exception: Optional[Exception]=None, |
|
) -> ShellRunRes: |
|
proc_args = [ |
|
"bash", |
|
"-c", |
|
" ".join(cmd), |
|
] |
|
logger.debug("shell run %s", proc_args) |
|
|
|
proc = subprocess.Popen( |
|
proc_args, |
|
stdout=subprocess.PIPE, |
|
stderr=subprocess.PIPE, |
|
) |
|
|
|
raw_stdout, raw_stderr = proc.communicate() |
|
|
|
stdout = raw_stdout.decode().split("\n") if raw_stdout is not None else None |
|
stderr = raw_stderr.decode().split("\n") if raw_stderr is not None else None |
|
|
|
if proc.returncode != 0 and non_zero_exception is not None: |
|
raise non_zero_exception(stderr) |
|
|
|
if stdout is None or len(stdout) == 0 and require_stdout_exception is not None: |
|
raise require_stdout_exception("stdout empty") |
|
|
|
return ShellRunRes( |
|
stdout=stdout, |
|
stderr=stderr, |
|
return_code=proc.returncode, |
|
) |
|
|
|
class ListThirdPartyAppsError(Exception): |
|
pass |
|
|
|
def list_third_party_apps() -> List[str]: |
|
res = _shell_run( |
|
[ |
|
BIN_ADB, |
|
"shell", |
|
"pm", |
|
"list", |
|
"package", |
|
"-3", |
|
], |
|
non_zero_exception=ListThirdPartyAppsError, |
|
require_stdout_exception=ListThirdPartyAppsError, |
|
) |
|
|
|
return list(map(lambda line: line.replace("package:", ""), res["stdout"])) |
|
|
|
class GetDeviceAPKPathError(Exception): |
|
pass |
|
|
|
def get_device_apk_paths(pkg: str) -> List[str]: |
|
res = _shell_run( |
|
[ |
|
BIN_ADB, |
|
"shell", |
|
"pm", |
|
"path", |
|
pkg, |
|
], |
|
non_zero_exception=GetDeviceAPKPathError, |
|
require_stdout_exception=GetDeviceAPKPathError, |
|
) |
|
|
|
return list(map(lambda line: line.replace("package:", ""), res["stdout"])) |
|
|
|
class DownloadAPKToHostError(Exception): |
|
pass |
|
|
|
def download_apk_to_host(device_apk_path: str, host_apk_path: str) -> None: |
|
_shell_run( |
|
[ |
|
BIN_ADB, |
|
"pull", |
|
device_apk_path, |
|
host_apk_path, |
|
], |
|
non_zero_exception=DownloadAPKToHostError, |
|
) |
|
|
|
|
|
class GetAPKApplicationLabelError(Exception): |
|
pass |
|
|
|
def get_apk_application_label(host_apk_path: str) -> Optional[str]: |
|
res = _shell_run( |
|
[ |
|
BIN_AAPT, |
|
"d", |
|
"-c", |
|
"application-label", |
|
"badging", |
|
host_apk_path, |
|
], |
|
require_stdout_exception=GetAPKApplicationLabelError, |
|
) |
|
|
|
if res["return_code"] != 0: |
|
if res["stderr"] is not None and "ERROR: dump failed because no AndroidManifest.xml found" in "\n".join(res["stderr"]): |
|
return None |
|
else: |
|
raise GetAPKApplicationLabelError(res["stderr"]) |
|
|
|
for line in res["stdout"]: |
|
if "application-label" in line: |
|
return line.replace("application-label:", "") |
|
|
|
raise GetAPKApplicationLabelError("Could not find application label") |
|
|
|
def download_pkg_apk_with_application_label(app_pkg: str, host_apk_path: str) -> Optional[str]: |
|
device_apk_paths = get_device_apk_paths(app_pkg) |
|
|
|
for device_apk_path in device_apk_paths: |
|
download_apk_to_host(device_apk_path, host_apk_path) |
|
|
|
app_name = get_apk_application_label(host_apk_path) |
|
if app_name is not None: |
|
return app_name |
|
|
|
return None |
|
|
|
def main(): |
|
# Parse arguments |
|
arg_parser = argparse.ArgumentParser(description="Generates a list of installed app names for Android devices connected via debug mode") |
|
arg_parser.add_argument( |
|
"-o", "--output-csv", |
|
help="Path to CSV file to write results", |
|
default="apps-list.csv", |
|
) |
|
|
|
arg_parser.add_argument( |
|
"-d", "--download-apks-dir", |
|
help="Directory in which APK downloads will be placed", |
|
default="./apks", |
|
) |
|
|
|
arg_parser.add_argument( |
|
"-v", "--verbose", |
|
help="Show verbose logging", |
|
action='store_true', |
|
default=False, |
|
) |
|
|
|
args = arg_parser.parse_args() |
|
|
|
# Setup logging |
|
if args.verbose: |
|
logger.setLevel(logging.DEBUG) |
|
|
|
# Check APK downloads dir exists |
|
if not os.path.isdir(args.download_apks_dir): |
|
os.makedirs(args.download_apks_dir) |
|
|
|
# List third party apps |
|
app_pkgs = list_third_party_apps() |
|
|
|
with open(args.output_csv, "w") as output_f: |
|
output_writer = csv.DictWriter(output_f, fieldnames=["name", "package", "playstore", "errors"]) |
|
output_writer.writeheader() |
|
|
|
app_pkg_i = 0 |
|
for app_pkg in app_pkgs: |
|
logger.info("Processing %s (%d/%d)", app_pkg, app_pkg_i+1, len(app_pkgs)) |
|
app_errors = [] |
|
|
|
# Download APK from device |
|
host_apk_path = os.path.join(args.download_apks_dir, f"{app_pkg}.apk") |
|
|
|
app_name = None |
|
try: |
|
if not os.path.isfile(host_apk_path): |
|
app_name = download_pkg_apk_with_application_label(app_pkg, host_apk_path) |
|
else: |
|
app_name = get_apk_application_label(host_apk_path) |
|
except GetAPKApplicationLabelError as e: |
|
app_errors.append(f"failed to get app name: {e}") |
|
|
|
app_link = f"https://play.google.com/store/apps/details?id={app_pkg}" |
|
|
|
if app_name is None: |
|
logger.warning("Could not find app name for %s", app_pkg) |
|
app_errors.append("could not find app name") |
|
|
|
output_writer.writerow({ |
|
"name": app_name if app_name is not None else app_pkg, |
|
"package": app_pkg, |
|
"playstore": app_link, |
|
"errors": ",".join(app_errors), |
|
}) |
|
|
|
app_pkg_i += 1 |
|
|
|
|
|
if __name__ == '__main__': |
|
main() |