Skip to content

Instantly share code, notes, and snippets.

@apsun
Created October 19, 2015 22:31
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save apsun/bd197281804d2aa222f8 to your computer and use it in GitHub Desktop.
Save apsun/bd197281804d2aa222f8 to your computer and use it in GitHub Desktop.
#!/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