Files
2025-05-27 16:07:21 +00:00

624 lines
24 KiB
Python

#!/usr/bin/python3
# -*- coding: utf-8 -*-
import json
import os
import platform
import shutil
import sys
from zipfile import ZipFile
from subprocess import call, STDOUT, Popen, list2cmdline
try:
from subprocess import DEVNULL
except ImportError:
import os
DEVNULL = open(os.devnull, 'wb')
const_dir_tmp = ".xapktoapk"
const_file_target_file = "target"
const_ext_apk = ".apk"
const_ext_xapk = ".xapk"
const_ext_zip = ".zip"
const_file_xapk_manifest = "manifest.json"
const_file_xapk_manifest_key_package_name = "package_name"
const_prefix_apk_split_type_config = "config"
const_suffix_apk_split_type_dpi = "dpi"
const_values_apk_split_type_arch = [ "arm64_v8a", "armeabi_v7a", "armeabi", "x86", "x86_64" ]
const_split_apk_type_main = "main"
const_split_apk_type_arch = "arch"
const_split_apk_type_dpi = "dpi"
const_split_apk_type_locale = "locale"
const_apk_file_apktool_config = 'apktool.yml'
const_apk_dir_lib = 'lib'
const_sign_config_properties_file = 'xapktoapk.sign.properties'
def print_help():
print("")
print("XapkToApk is a tool that converts .xapk file into .apk file")
print("Can be useful if you want to build a classic fat apk from splitted app bundle")
print("Usage: python xapktoapk.py PATH_TO_FILE.xapk")
print("")
def get_param_xapk_file_name():
return sys.argv[1]
def get_param_xapk_abs_path():
return os.path.abspath(get_param_xapk_file_name())
def check_sys_args():
if len(sys.argv) != 2:
return False
xapk_file_name = get_param_xapk_file_name()
if not xapk_file_name.endswith(const_ext_xapk):
return False
abspath_to_xapk_file = os.path.abspath(xapk_file_name)
if not os.path.exists(abspath_to_xapk_file):
return False
return True
def execute_command_os_system(command):
rc = os.system(command)
return rc
def execute_command_subprocess(command_tokens_list):
rc = call(command_tokens_list, stdout=DEVNULL, stderr=STDOUT)
return rc
def is_windows():
return platform.system() == "Windows"
def windows_hide_file(file_path):
execute_command_subprocess(["attrib", "+h", file_path])
def create_or_recreate_dir(dir_path):
if os.path.exists(dir_path):
if os.path.isdir(dir_path):
shutil.rmtree(dir_path)
else:
os.remove(dir_path)
os.mkdir(dir_path)
if is_windows():
windows_hide_file(dir_path)
def check_if_executable_exists_in_path(executable):
path_to_cmd = shutil.which(executable)
return path_to_cmd is not None
def get_executable_in_path(executable):
return shutil.which(executable)
def get_path_to_batch(batch):
paths = os.environ['PATH'].split(os.pathsep)
name = batch + ".bat"
for path in paths:
if os.path.isfile(os.path.join(path, name)):
return os.path.join(path, name)
return None
def create_tmp_dir(working_dir):
path_dir_tmp = os.path.abspath(os.path.join(working_dir, const_dir_tmp))
create_or_recreate_dir(path_dir_tmp)
return path_dir_tmp
def file_split_name_and_extension(file_path):
split = os.path.splitext(file_path)
return split[0], split[1]
def determine_split_type_by_apk_file_name(apk_file_name, xapk_package_name):
apk_type = None
try:
if (xapk_package_name + const_ext_apk) == apk_file_name or 'base.apk' == apk_file_name:
apk_type = const_split_apk_type_main
elif apk_file_name.startswith(const_prefix_apk_split_type_config):
clear_file_name = os.path.splitext(apk_file_name)[0]
clear_file_name_splitted = clear_file_name.split('.')
config_name = str(clear_file_name_splitted[1])
if config_name.endswith(const_suffix_apk_split_type_dpi):
apk_type = const_split_apk_type_dpi
elif config_name in const_values_apk_split_type_arch:
apk_type = const_split_apk_type_arch
else:
apk_type = const_split_apk_type_locale
else:
apk_type = const_split_apk_type_locale
except:
pass
return apk_type
def get_apks_of_type(target_apks, type):
result = list()
for key in target_apks.keys():
entry = target_apks[key]
if entry['apk_split_type'] == type:
result.append(entry)
return result
def get_main_apk(target_apks):
return get_apks_of_type(target_apks, const_split_apk_type_main)[0]
def get_do_not_compress_lines(config_file_lines):
index_start = -1
index_end = -1
result = list()
start_block_literal = 'doNotCompress:'
prefix_target_line = '- '
opened = False
for index, line in enumerate(config_file_lines):
if not opened and line.startswith(start_block_literal):
opened = True
if index_end == -1 and index_start == -1:
index_start = index + 1
elif opened and line.startswith(prefix_target_line):
result.append(line)
elif opened and not line.startswith(prefix_target_line):
if index_start != -1 and index_end == -1:
index_end = index - 1
break
result.sort()
return result, index_start, index_end
def parse_apktool_config(config_file_path):
config_file_lines = list()
with open(config_file_path, 'r', encoding='UTF-8') as file:
config_file_lines = file.readlines()
do_not_compress_lines, do_not_compress_index_start, do_not_compress_index_end = get_do_not_compress_lines(config_file_lines)
properties = dict()
properties['lines_all'] = config_file_lines
properties['lines_do_not_compress'] = do_not_compress_lines
properties['lines_do_not_compress_index_start'] = do_not_compress_index_start
properties['lines_do_not_compress_index_end'] = do_not_compress_index_end
return properties
def insert_new_lines_do_not_compress(config_file_path, lines_to_insert):
file_apktool_config = parse_apktool_config(config_file_path)
do_not_compress_lines_original = file_apktool_config['lines_do_not_compress']
do_not_compress_lines_updated = set()
do_not_compress_lines_updated.update(do_not_compress_lines_original)
do_not_compress_lines_updated.update(lines_to_insert)
do_not_compress_lines_updated = list(do_not_compress_lines_updated)
do_not_compress_lines_updated.sort()
config_file_lines_original = file_apktool_config['lines_all']
config_file_lines_index_start = file_apktool_config['lines_do_not_compress_index_start']
config_file_lines_index_end = file_apktool_config['lines_do_not_compress_index_end']
config_file_lines_updated = list()
for config_file_line in config_file_lines_original:
config_file_lines_updated.append(config_file_line)
config_file_lines_updated[config_file_lines_index_start:config_file_lines_index_end] = do_not_compress_lines_updated
with open(config_file_path, 'w', encoding='UTF-8') as file:
file.writelines(config_file_lines_updated)
def merge_apk_arch(dir_apk_main, dir_apk_arch):
path_libs_src = os.path.join(dir_apk_arch, const_apk_dir_lib)
path_libs_dst = os.path.join(dir_apk_main, const_apk_dir_lib)
if not os.path.exists(path_libs_dst):
os.mkdir(path_libs_dst)
for dir in os.listdir(path_libs_src):
src = os.path.join(path_libs_src, dir)
dst = os.path.join(path_libs_dst, dir)
shutil.copytree(src, dst)
path_file_config_src = os.path.join(dir_apk_arch, const_apk_file_apktool_config)
path_file_config_dst = os.path.join(dir_apk_main, const_apk_file_apktool_config)
config_src = parse_apktool_config(path_file_config_src)
insert_new_lines_do_not_compress(path_file_config_dst, config_src['lines_do_not_compress'])
def merge_apk_resources(dir_apk_main, dir_apk_with_resources):
target_res_dir = os.path.join(dir_apk_main, 'res')
res_dir = os.path.join(dir_apk_with_resources, 'res')
skip_suffix = 'values/public.xml'
files_to_copy = list()
for root, dirs, files in os.walk(res_dir):
for dir in dirs:
target_res_subdir = os.path.join(target_res_dir, dir)
if not os.path.exists(target_res_subdir):
os.mkdir(target_res_subdir)
for file in files:
res_file_path_abs = os.path.join(root, file)
if res_file_path_abs.endswith(skip_suffix):
continue
res_file_path_rel = res_file_path_abs[len(res_dir):].lstrip('/')
res_file_path_target = os.path.join(target_res_dir, res_file_path_rel)
files_to_copy.append((res_file_path_abs, res_file_path_rel, res_file_path_target))
for path_src, path_rel, path_dst in files_to_copy:
if os.path.exists(path_dst):
if path_rel.startswith('drawable'):
continue
# todo handle merge xmls ?
# print("DEBUG: skipping file %s - already exists (right now trying to copy from %s)" % (path_rel, res_dir))
continue
target_subdir_abspath = os.path.abspath(os.path.dirname(path_dst))
if not os.path.exists(target_subdir_abspath):
os.mkdir(target_subdir_abspath)
shutil.copy(path_src, path_dst)
def merge_apk_assets(dir_apk_main, dir_apk_with_asset_pack):
target_assets_dir = os.path.join(dir_apk_main, 'assets')
target_asset_pack_dir = os.path.join(target_assets_dir, 'assetpack')
assets_dir = os.path.join(dir_apk_with_asset_pack, 'assets')
asset_pack_dir = os.path.join(assets_dir, 'assetpack')
if not os.path.exists(asset_pack_dir):
return
if not os.path.exists(target_assets_dir):
os.mkdir(target_assets_dir)
if not os.path.exists(target_asset_pack_dir):
os.mkdir(target_asset_pack_dir)
files_to_copy = list()
for root, dirs, files in os.walk(asset_pack_dir):
for dir in dirs:
target_res_subdir = os.path.join(target_asset_pack_dir, dir)
if not os.path.exists(target_res_subdir):
os.mkdir(target_res_subdir)
for file in files:
asset_pack_file_path_abs = os.path.join(root, file)
asset_pack_file_path_rel = asset_pack_file_path_abs[len(asset_pack_dir):].lstrip('/')
asset_pack_file_path_target = os.path.join(target_asset_pack_dir, asset_pack_file_path_rel)
files_to_copy.append((asset_pack_file_path_abs, asset_pack_file_path_rel, asset_pack_file_path_target))
for path_src, path_rel, path_dst in files_to_copy:
if os.path.exists(path_dst):
continue
target_subdir_abspath = os.path.abspath(os.path.dirname(path_dst))
if not os.path.exists(target_subdir_abspath):
os.mkdir(target_subdir_abspath)
shutil.copy(path_src, path_dst)
path_file_config_src = os.path.join(dir_apk_with_asset_pack, const_apk_file_apktool_config)
path_file_config_dst = os.path.join(dir_apk_main, const_apk_file_apktool_config)
config_src = parse_apktool_config(path_file_config_src)
insert_new_lines_do_not_compress(path_file_config_dst, config_src['lines_do_not_compress'])
def unpack_apk(path_dir_tmp, apk_file, number_current, number_total):
print('[*] unpacking %d of %d' % (number_current, number_total))
os.chdir(path_dir_tmp)
apktool = get_executable_in_path('apktool')
if not apktool is None:
rc = execute_command_subprocess([apktool, 'd', '-s', apk_file])
if rc != 0:
raise Exception("failed to unpack %s" % apk_file)
else:
params = [get_path_to_batch('apktool'), 'd', '-s', apk_file]
p = Popen(list2cmdline(params))
_, stderr = p.communicate()
if stderr is not None:
raise Exception("failed to unpack %s" % apk_file)
os.remove(os.path.join(path_dir_tmp, apk_file))
def pack_apk(path_dir_tmp, main_apk_dir):
print('[*] repack apk')
os.chdir(path_dir_tmp)
apktool = get_executable_in_path('apktool')
if not apktool is None:
rc = execute_command_subprocess([apktool, 'b', main_apk_dir])
if rc != 0:
raise Exception("failed to pack apk")
else:
params = [get_path_to_batch('apktool'), 'b', main_apk_dir]
p = Popen(list2cmdline(params))
_, stderr = p.communicate()
if stderr is not None:
raise Exception("failed to pack %s" % main_apk_dir)
built_apk_file_path = os.path.join(path_dir_tmp, main_apk_dir, 'dist', '%s%s' % (os.path.basename(main_apk_dir), const_ext_apk))
if not os.path.exists(built_apk_file_path):
raise Exception("result apk not found")
build_apk_target_file = os.path.join(path_dir_tmp, '%s%s' % (const_file_target_file, const_ext_apk))
if os.path.exists(build_apk_target_file):
os.remove(build_apk_target_file)
shutil.copy(built_apk_file_path, build_apk_target_file)
def zipalign_apk(path_dir_tmp):
print('[*] zipalign apk')
os.chdir(path_dir_tmp)
built_apk_file_path = os.path.join(path_dir_tmp, const_file_target_file + const_ext_apk)
if not os.path.exists(built_apk_file_path):
raise Exception("result apk not found")
prefix_aligned = 'aligned_'
built_apk_file_aligned_path = os.path.join(path_dir_tmp, prefix_aligned + const_file_target_file + const_ext_apk)
if os.path.exists(built_apk_file_aligned_path):
os.remove(built_apk_file_aligned_path)
rc = execute_command_subprocess(['zipalign', '-p', '-f', '4', built_apk_file_path, built_apk_file_aligned_path])
if rc != 0:
raise Exception("failed to zipalign apk")
if not os.path.exists(built_apk_file_aligned_path):
raise Exception("failed to zipalign apk")
os.remove(built_apk_file_path)
shutil.move(built_apk_file_aligned_path, built_apk_file_path)
def sign_apk(path_dir_tmp, sign_config):
build_apk_target_file = os.path.join(path_dir_tmp, '%s%s' % (const_file_target_file, const_ext_apk))
if not os.path.exists(build_apk_target_file):
raise Exception("result apk not found")
print('[*] resign apk')
os.chdir(path_dir_tmp)
if check_if_executable_exists_in_path('apksigner'):
rc = execute_command_subprocess(['apksigner', 'sign', '--ks', os.path.expanduser(sign_config['sign.keystore.file']), '--ks-pass', 'pass:%s' % sign_config['sign.keystore.password'], '--ks-key-alias', sign_config['sign.key.alias'], '--key-pass', 'pass:%s' % sign_config['sign.key.password'], build_apk_target_file])
if rc != 0:
raise Exception("failed to sign apk file")
else:
params = [get_path_to_batch('apksigner'), 'sign', '--ks', os.path.expanduser(sign_config['sign.keystore.file']), '--ks-pass', 'pass:%s' % sign_config['sign.keystore.password'], '--ks-key-alias', sign_config['sign.key.alias'], '--key-pass', 'pass:%s' % sign_config['sign.key.password'], build_apk_target_file]
p = Popen(list2cmdline(params))
_, stderr = p.communicate()
if stderr is not None:
raise Exception("failed to sign apk file")
def delete_file_if_exists(path_to_file):
if os.path.exists(path_to_file):
os.remove(path_to_file)
def delete_signature_related_files(path_to_main_apk):
# delete_file_if_exists(os.path.join(path_to_main_apk, 'unknown', 'stamp-cert-sha256'))
# delete_file_if_exists(os.path.join(path_to_main_apk, 'original', 'stamp-cert-sha256'))
delete_file_if_exists(os.path.join(path_to_main_apk, 'original', 'META-INF', 'BNDLTOOL.RSA'))
delete_file_if_exists(os.path.join(path_to_main_apk, 'original', 'META-INF', 'BNDLTOOL.SF'))
delete_file_if_exists(os.path.join(path_to_main_apk, 'original', 'META-INF', 'MANIFEST.MF'))
def update_main_manifest_file(path_main_apk):
path_manifest = os.path.join(path_main_apk, 'AndroidManifest.xml')
data = None
replacements = {
'<meta-data android:name="com.google.firebase.messaging.default_notification_icon" android:resource="@null"/>': '',
'android:isSplitRequired="true" ': '',
'android:requiredSplitTypes="base__abi,base__density" ': '',
'android:splitTypes="" ': '',
'android:value="STAMP_TYPE_DISTRIBUTION_APK"': 'android:value="STAMP_TYPE_STANDALONE_APK"',
'<meta-data android:name="com.android.vending.splits.required" android:value="true"/>': '',
'<meta-data android:name="com.android.vending.splits" android:resource="@xml/splits0"/>': ''
}
with open(path_manifest, 'r', encoding='UTF-8') as file:
data = file.read()
for from_str, to_str in replacements.items():
data = data.replace(from_str, to_str)
with open(path_manifest, 'w', encoding='UTF-8') as file:
file.write(data)
def load_sign_properties():
path_sign_config_file = os.path.abspath(os.path.join(os.getcwd(), const_sign_config_properties_file))
if not os.path.exists(path_sign_config_file):
path_sign_config_file = os.path.abspath(os.path.join(os.path.expanduser('~'), const_sign_config_properties_file))
if not os.path.exists(path_sign_config_file):
return None
sign_config_file_lines = list()
with open(path_sign_config_file, 'r', encoding='UTF-8') as sign_config_file:
sign_config_file_lines = sign_config_file.readlines()
properties = dict()
for line in sign_config_file_lines:
checked_line = line.strip().replace('\r', '').replace('\n', '')
if checked_line is None or checked_line == '' or line.startswith('#'):
continue
line_parts = checked_line.split('=')
if len(line_parts) != 2:
continue
property_key = line_parts[0].strip()
property_value = line_parts[1].strip()
properties[property_key] = property_value
if not 'sign.enabled' in properties.keys() or properties['sign.enabled'].lower() != 'true':
return None
if 'sign.keystore.file' not in properties.keys() or 'sign.keystore.password' not in properties.keys() or 'sign.key.alias' not in properties.keys() or 'sign.key.password' not in properties.keys():
return None
keystore_file = os.path.expanduser(properties['sign.keystore.file'])
if keystore_file == '' or not os.path.exists(keystore_file) or os.path.isdir(keystore_file):
return None
if properties['sign.keystore.password'] == '' or properties['sign.key.alias'] == '' or properties['sign.key.password'] == '':
return None
return properties
def build_single_apk(path_to_tmp_dir, path_to_main_apk_dir, should_sign_apk, sign_config):
pack_apk(path_to_tmp_dir, path_to_main_apk_dir)
zipalign_apk(path_to_tmp_dir)
if should_sign_apk:
sign_apk(path_to_tmp_dir, sign_config)
else:
print('[*] skip signing apk')
def copy_single_apk_to_working_dir(path_to_tmp_dir, path_to_working_dir, target_name):
file_src = os.path.join(path_to_tmp_dir, const_file_target_file + const_ext_apk)
if not os.path.exists(file_src) or os.path.isdir(file_src):
raise Exception("result apk file not found")
file_dst = os.path.join(path_to_working_dir, target_name + const_ext_apk)
if os.path.exists(file_dst):
if os.path.isdir(file_dst):
shutil.rmtree(file_dst)
else:
os.remove(file_dst)
shutil.copy(file_src, file_dst)
def prioritize_dpi_apk_list_rev_sort(apks_dpi):
apks_dpi_prioritzed = sorted(apks_dpi, key=lambda x: x['apk_file_name'], reverse=True)
return apks_dpi_prioritzed
def prioritize_dpi_apk_list(apks_dpi):
preferrable_order = [ 'config.xxxhdpi', 'config.xxhdpi', 'config.xhdpi', 'config.hdpi', 'config.mdpi', 'config.ldpi', 'config.nodpi', 'config.tvdpi' ]
apks_dpi_map = dict()
for apk in apks_dpi:
apks_dpi_map[apk['apk_dir_name']] = apk
apks_dpi_prioritzed = list()
for item in preferrable_order:
if item in apks_dpi_map.keys():
apks_dpi_prioritzed.append(apks_dpi_map[item])
del apks_dpi_map[item]
if len(apks_dpi_map.keys()) > 0:
rev_sorted = prioritize_dpi_apk_list_rev_sort(apks_dpi_map.values())
for apk in rev_sorted:
apks_dpi_prioritzed.append(apk)
return apks_dpi_prioritzed
def main():
if not check_sys_args():
print_help()
exit(-1)
tested_binary = "apktool"
if not check_if_executable_exists_in_path(tested_binary) and get_path_to_batch(tested_binary) is None:
print("executable %s not found in $PATH, please install it before running xapktoapk" % tested_binary)
exit(-2)
tested_binary = "zipalign"
if not check_if_executable_exists_in_path(tested_binary):
print("executable %s not found in $PATH, please install it before running xapktoapk" % tested_binary)
exit(-2)
sign_properties = load_sign_properties()
should_sign_apk = sign_properties is not None
if should_sign_apk:
tested_binary = "apksigner"
if not check_if_executable_exists_in_path(tested_binary) and get_path_to_batch(tested_binary) is None:
print("executable %s not found in $PATH, please install it before running xapktoapk" % tested_binary)
exit(-2)
xapk_file_name = get_param_xapk_file_name()
xapk_file_abs_path = get_param_xapk_abs_path()
original_file_name, original_file_extension = file_split_name_and_extension(xapk_file_name)
print('[*] start')
cwd = os.path.abspath(os.path.curdir)
path_dir_tmp = create_tmp_dir(cwd)
path_target_file_xapk = os.path.join(path_dir_tmp, const_file_target_file + const_ext_xapk)
shutil.copy(xapk_file_abs_path, path_target_file_xapk)
target_file_name_zip = const_file_target_file + const_ext_zip
path_target_file_zip = os.path.join(path_dir_tmp, target_file_name_zip)
os.rename(path_target_file_xapk, path_target_file_zip)
print('[*] unpacking xapk')
with ZipFile(path_target_file_zip, 'r') as zip_file:
zip_file.extractall(path=path_dir_tmp)
os.remove(path_target_file_zip)
xapk_manifest_data = None
path_xapk_manifest = os.path.join(path_dir_tmp, const_file_xapk_manifest)
with open(path_xapk_manifest, 'r') as file:
xapk_manifest_data = json.load(file)
xapk_package_name = xapk_manifest_data[const_file_xapk_manifest_key_package_name]
target_apk_file_names = list()
for file in os.listdir(path_dir_tmp):
if file.endswith(const_ext_apk):
file_abspath = os.path.abspath(os.path.join(path_dir_tmp, file))
if not os.path.isdir(file_abspath):
target_apk_file_names.append(file)
target_apks = dict()
for apk_file_name in target_apk_file_names:
apk_type = determine_split_type_by_apk_file_name(apk_file_name, xapk_package_name)
if apk_type is None:
raise Exception("failed to determine split type of %s" % apk_file_name)
properties = dict()
properties['apk_file_name'] = apk_file_name
properties['apk_file_path'] = os.path.abspath(os.path.join(path_dir_tmp, properties['apk_file_name']))
properties['apk_dir_name'] = os.path.splitext(apk_file_name)[0]
properties['apk_dir_path'] = os.path.abspath(os.path.join(path_dir_tmp, properties['apk_dir_name']))
properties['apk_split_type'] = apk_type
target_apks[apk_file_name] = properties
print('[*] xapk file unpacked. %d parts discovered' % len(target_apk_file_names))
unpack_number_total = len(target_apks.keys())
for index, apk_file_key in enumerate(target_apks.keys()):
apk_entry = target_apks[apk_file_key]
apk_file = apk_entry['apk_file_name']
unpack_apk(path_dir_tmp, apk_file, index + 1, unpack_number_total)
apk_main = get_main_apk(target_apks)
apks_arch = get_apks_of_type(target_apks, const_split_apk_type_arch)
apks_dpi = get_apks_of_type(target_apks, const_split_apk_type_dpi)
apks_locale = get_apks_of_type(target_apks, const_split_apk_type_locale)
for apk_arch in apks_arch:
merge_apk_arch(apk_main['apk_dir_path'], apk_arch['apk_dir_path'])
apks_dpi_prioritzed = prioritize_dpi_apk_list(apks_dpi)
for apk_dpi in apks_dpi_prioritzed:
merge_apk_resources(apk_main['apk_dir_path'], apk_dpi['apk_dir_path'])
for apk_locale in apks_locale:
merge_apk_resources(apk_main['apk_dir_path'], apk_locale['apk_dir_path'])
merge_apk_assets(apk_main['apk_dir_path'], apk_locale['apk_dir_path'])
delete_signature_related_files(apk_main['apk_dir_path'])
update_main_manifest_file(apk_main['apk_dir_path'])
build_single_apk(path_dir_tmp, apk_main['apk_dir_path'], should_sign_apk, sign_properties)
copy_single_apk_to_working_dir(path_dir_tmp, cwd, original_file_name)
os.chdir(cwd)
shutil.rmtree(path_dir_tmp)
print('[*] complete')
if __name__ == '__main__':
main()