Created
October 19, 2015 22:31
-
-
Save apsun/bd197281804d2aa222f8 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python | |
# | |
# HearthPhone: Allows the installation of Hearthstone on Android phones. | |
# Copyright (c) 2015 @crossbowffs | |
# | |
# This program is not affiliated with Blizzard Entertainment, Inc. | |
# No promises are made about the effects of this program. It may do | |
# anything from discarding your entire hand to summoning 7 Ragnaroses | |
# for your opponent. Use it at your own risk. | |
# | |
# Requirements: | |
# - Java (https://www.java.com/en/download/manual.jsp) | |
# - Android SDK (http://developer.android.com/sdk/index.html) | |
# - zipalign | |
# - adb | |
# - apktool (https://code.google.com/p/android-apktool) | |
# - signapk (https://code.google.com/p/signapk) | |
# | |
# Usage: | |
# python patch.py unpatched.apk [-o patched.apk] [-i] [-f] | |
from __future__ import print_function | |
import argparse | |
import errno | |
import mmap | |
import os | |
import re | |
import shutil | |
import subprocess | |
import sys | |
from xml.etree import ElementTree | |
# ----------------------------------------------------------------------- | |
# ------------------------- Begin configuration ------------------------- | |
# Cross-platform dependencies | |
APKTOOL_PATH = os.path.join("tools", "common", "apktool", "apktool.jar") | |
SIGNAPK_PATH = os.path.join("tools", "common", "signapk", "signapk.jar") | |
# Platform-dependent dependencies | |
if sys.platform == "win32": | |
JAVA_PATH = "java.exe" | |
ZIPALIGN_PATH = os.path.join("tools", "windows", "zipalign.exe") | |
ADB_PATH = os.path.join("tools", "windows", "adb.exe") | |
elif sys.platform == "darwin": | |
JAVA_PATH = "java" | |
ZIPALIGN_PATH = os.path.join("tools", "mac", "zipalign") | |
ADB_PATH = os.path.join("tools", "mac", "adb") | |
elif sys.platform == "linux2": | |
JAVA_PATH = "java" | |
ZIPALIGN_PATH = os.path.join("tools", "linux", "zipalign") | |
ADB_PATH = os.path.join("tools", "linux", "adb") | |
else: | |
raise OSError("Unsupported OS") | |
# APK signing keys | |
# By default, we use the keys from APK Studio for compatibility | |
# If you want, you can change these to your own keys | |
PUBLIC_KEY_PATH = os.path.join("tools", "common", "signapk", "testkey.x509.pem") | |
PRIVATE_KEY_PATH = os.path.join("tools", "common", "signapk", "testkey.pk8") | |
# Where to save temporary files | |
TEMP_PATH = "temp" | |
TEMP_DECOMPILED_PATH = os.path.join(TEMP_PATH, "hearthstone-temp-decompiled") | |
TEMP_UNSIGNED_APK_PATH = os.path.join(TEMP_PATH, "hearthstone-temp-unsigned.apk") | |
TEMP_UNALIGNED_APK_PATH = os.path.join(TEMP_PATH, "hearthstone-temp-unaligned.apk") | |
# Set this to true to preserve the intermediary files | |
# This might be useful for debugging purposes | |
# Note: They will still be overwritten on the next run | |
DO_NOT_DELETE_TEMP = False | |
# -------------------------- End configuration -------------------------- | |
# ----------------------------------------------------------------------- | |
def print_error(msg): | |
print(msg, file=sys.stderr) | |
def create_directory(path): | |
try: | |
os.makedirs(path) | |
except OSError as e: | |
if e.errno != errno.EEXIST and not os.path.isdir(path): | |
raise | |
def get_dest_path(src_path, output_path): | |
# Set the output path to a default if necessary | |
if not output_path: | |
output_path = os.path.dirname(src_path) | |
# If the output path is a directory, set it to a | |
# file within that directory | |
if output_path[-1] in (os.sep, os.altsep) or os.path.isdir(output_path): | |
src_filename = os.path.splitext(os.path.basename(src_path)) | |
dest_filename = src_filename[0] + "-patched" + src_filename[1] | |
output_path = os.path.join(output_path, dest_filename) | |
return os.path.abspath(output_path) | |
def decompile_apk(src_apk_path): | |
return subprocess.call([ | |
JAVA_PATH, | |
"-Djava.awt.headless=true", | |
"-jar", APKTOOL_PATH, | |
"-f", | |
"d", src_apk_path, | |
"-o", TEMP_DECOMPILED_PATH | |
]) == 0 | |
def print_apk_info(): | |
info_path = os.path.join(TEMP_DECOMPILED_PATH, "apktool.yml") | |
print("-" * 30) | |
print("APK information:") | |
print("-" * 30) | |
with open(info_path, "r") as f: | |
print(f.read().rstrip("\n")) | |
print("-" * 30) | |
def patch_manifest(): | |
manifest_path = os.path.join(TEMP_DECOMPILED_PATH, "AndroidManifest.xml") | |
namespace_url = "http://schemas.android.com/apk/res/android" | |
ElementTree.register_namespace("android", namespace_url) | |
tree = ElementTree.parse(manifest_path) | |
root = tree.getroot() | |
screens = root.find("supports-screens") | |
small_screens_attrname = "{{{0}}}smallScreens".format(namespace_url) | |
normal_screens_attrname = "{{{0}}}normalScreens".format(namespace_url) | |
print("Small screens supported:", screens.get(small_screens_attrname)) | |
print("Normal screens supported:", screens.get(normal_screens_attrname)) | |
screens.set(small_screens_attrname, "true") | |
screens.set(normal_screens_attrname, "true") | |
tree.write(manifest_path, encoding="utf-8", xml_declaration=True) | |
return True | |
def patch_assembly(): | |
assembly_path = os.path.join(TEMP_DECOMPILED_PATH, | |
"assets", "bin", "Data", "Managed", "Assembly-CSharp.dll") | |
# This is the bytecode pattern of IsAndroidDeviceTabletSized() | |
# Wildcards represent the address of other methods, which change | |
# across different assembly builds. Also use a wildcard for the | |
# diagonalInches check, because the most common patch currently | |
# changes 6f to 1f instead of patching the return value. | |
magic = ( | |
# if (AndroidDeviceSettings.Get().diagonalInches >= 6f) return true; | |
r"\x28...\x06\x7B...\x04\x22....\x44\x02\x00\x00\x00\x17\x2A" + | |
# if (AndroidDeviceSettings.Get().isExtraLarge) return true; | |
r"\x28...\x06\x7B...\x04\x39\x02\x00\x00\x00\x17\x2A" + | |
# if (AndroidDeviceSettings.Get().isOnTabletWhitelist) return true; | |
r"\x28...\x06\x7B...\x04\x39\x02\x00\x00\x00\x17\x2A" + | |
# return false; | |
r"(\x16|\x17)\x2A" | |
) | |
# Work-around for Python 2 compatibility, since concatenating | |
# raw binary strings is a syntax error, apparently. | |
if sys.version_info.major >= 3: | |
magic = bytes(magic, "utf-8") | |
with open(assembly_path, "r+b") as f: | |
mm = mmap.mmap(f.fileno(), 0) | |
match = re.search(magic, mm) | |
if not match: | |
print_error("Could not find screen size check code") | |
return False | |
start_index = match.start(1) | |
print("Found screen size check at offset:", hex(start_index)) | |
ret_value = ord(match.group(1)) | |
print("Got return value:", hex(ret_value)) | |
if ret_value == 0x17: | |
print("Assembly already patched, no need to re-patch") | |
return True | |
mm.seek(start_index) | |
mm.write(b"\x17") | |
mm.close() | |
return True | |
def recompile_apk(): | |
return subprocess.call([ | |
JAVA_PATH, | |
"-Djava.awt.headless=true", | |
"-jar", APKTOOL_PATH, | |
"-f", | |
"b", TEMP_DECOMPILED_PATH, | |
"-o", TEMP_UNSIGNED_APK_PATH | |
]) == 0 | |
def sign_apk(): | |
return subprocess.call([ | |
JAVA_PATH, | |
"-jar", SIGNAPK_PATH, | |
PUBLIC_KEY_PATH, | |
PRIVATE_KEY_PATH, | |
TEMP_UNSIGNED_APK_PATH, | |
TEMP_UNALIGNED_APK_PATH | |
]) == 0 | |
def zipalign_apk(dest_apk_path, force_overwrite): | |
args = [ZIPALIGN_PATH] | |
if force_overwrite: | |
args.append("-f") | |
args += [ | |
"-v", "4", | |
TEMP_UNALIGNED_APK_PATH, | |
dest_apk_path | |
] | |
return subprocess.call(args) == 0 | |
def install_apk(dest_apk_path): | |
return subprocess.call([ | |
ADB_PATH, | |
"install", | |
"-r", | |
dest_apk_path | |
]) == 0 | |
def main(): | |
parser = argparse.ArgumentParser( | |
description="Patches Hearthstone to run on small-screened devices") | |
parser.add_argument( | |
"input", | |
metavar="src_path", | |
help="the path to the unpatched APK file") | |
parser.add_argument( | |
"-o", "--output", | |
metavar="dest_path", | |
help="where to save the patched APK file") | |
parser.add_argument( | |
"-i", "--install", | |
action="store_true", | |
help="whether to install the APK via ADB when done") | |
parser.add_argument( | |
"-f", "--force", | |
action="store_true", | |
help="whether to overwrite the patched APK file if it exists") | |
args = parser.parse_args() | |
src_apk_path = os.path.abspath(args.input) | |
dest_apk_path = get_dest_path(src_apk_path, args.output) | |
do_install = args.install | |
force_overwrite = args.force | |
if not force_overwrite and os.path.isfile(dest_apk_path): | |
print_error("Destination file already exists, use -f to overwrite") | |
return 1 | |
create_directory(TEMP_PATH) | |
create_directory(os.path.dirname(dest_apk_path)) | |
# Now the real fun begins! | |
print("Decompiling APK...") | |
if not decompile_apk(src_apk_path): | |
print_error("Failed to decompile APK") | |
return 1 | |
print_apk_info() | |
print("Patching AndroidManifest.xml...") | |
if not patch_manifest(): | |
print_error("Failed to patch AndroidManifest.xml") | |
return 1 | |
print("Patching Assembly-CSharp.dll...") | |
if not patch_assembly(): | |
print_error("Failed to patch Assembly-CSharp.dll") | |
return 1 | |
print("Recompiling APK...") | |
if not recompile_apk(): | |
print_error("Failed to recompile APK") | |
return 1 | |
print("Signing APK...") | |
if not sign_apk(): | |
print_error("Failed to sign APK") | |
return 1 | |
print("Zipaligning APK...") | |
if not zipalign_apk(dest_apk_path, force_overwrite): | |
print_error("Failed to zipalign APK") | |
return 1 | |
if not DO_NOT_DELETE_TEMP: | |
print("Cleaning up temporary files...") | |
shutil.rmtree(TEMP_PATH, True) | |
if do_install: | |
print("Installing APK...") | |
if not install_apk(dest_apk_path): | |
print_error("Failed to install APK") | |
return 1 | |
print("Done!") | |
return 0 | |
if __name__ == "__main__": | |
sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment