apk-patchx 7.9.2025.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,145 @@
1
+ """Service for smali code patching."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import List, Optional
6
+
7
+ from ..exceptions import FridaPatchError
8
+
9
+
10
+ class SmaliPatcher:
11
+ """Service for patching smali code."""
12
+
13
+ @staticmethod
14
+ def inject_load_library(smali_file: Path, library_name: str = "frida-gadget") -> bool:
15
+ """Inject System.loadLibrary call into smali file.
16
+
17
+ Returns:
18
+ bool: True if injection was successful, False otherwise.
19
+ """
20
+ if not smali_file.exists():
21
+ return False
22
+
23
+ try:
24
+ lines = smali_file.read_text().splitlines()
25
+ new_lines = []
26
+ injected = False
27
+ i = 0
28
+
29
+ while i < len(lines):
30
+ line = lines[i]
31
+ new_lines.append(line)
32
+
33
+ # Look for existing static constructor
34
+ if line.strip().startswith(".method static constructor"):
35
+ injected = SmaliPatcher._inject_into_constructor(
36
+ lines, new_lines, i, library_name
37
+ )
38
+ # Skip processed lines
39
+ i = len([l for l in new_lines if l]) - 1
40
+ i += 1
41
+
42
+ if not injected:
43
+ # Create new static constructor
44
+ injected = SmaliPatcher._create_constructor(
45
+ new_lines, library_name
46
+ )
47
+
48
+ if injected:
49
+ smali_file.write_text("\n".join(new_lines))
50
+ return True
51
+
52
+ except Exception as e:
53
+ raise FridaPatchError(f"Failed to patch smali file {smali_file}: {e}")
54
+
55
+ return False
56
+
57
+ @staticmethod
58
+ def _inject_into_constructor(lines: List[str], new_lines: List[str],
59
+ start_index: int, library_name: str) -> bool:
60
+ """Inject into existing static constructor."""
61
+ i = start_index + 1
62
+
63
+ # Find .locals line
64
+ while i < len(lines) and not lines[i].strip().startswith(".locals"):
65
+ new_lines.append(lines[i])
66
+ i += 1
67
+
68
+ if i >= len(lines):
69
+ return False
70
+
71
+ # Process .locals line
72
+ locals_line = lines[i]
73
+ new_lines.append(locals_line)
74
+
75
+ # Update locals count
76
+ match = re.search(r"\.locals (\d+)", locals_line)
77
+ if match:
78
+ locals_count = int(match.group(1)) + 1
79
+ new_lines[-1] = f" .locals {locals_count}"
80
+
81
+ # Inject loadLibrary call
82
+ new_lines.extend([
83
+ "",
84
+ f' const-string v0, "{library_name}"',
85
+ " invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V",
86
+ ""
87
+ ])
88
+
89
+ # Add remaining lines
90
+ i += 1
91
+ while i < len(lines):
92
+ new_lines.append(lines[i])
93
+ i += 1
94
+
95
+ return True
96
+
97
+ @staticmethod
98
+ def _create_constructor(lines: List[str], library_name: str) -> bool:
99
+ """Create new static constructor."""
100
+ # Find insertion point (before first method or before .end class)
101
+ insert_index = -1
102
+
103
+ for i, line in enumerate(lines):
104
+ if (line.strip().startswith(".method") and
105
+ not line.strip().startswith(".method static constructor")):
106
+ insert_index = i
107
+ break
108
+
109
+ if insert_index == -1:
110
+ # Insert before .end class
111
+ for i in range(len(lines) - 1, -1, -1):
112
+ if lines[i].strip() == ".end class":
113
+ insert_index = i
114
+ break
115
+
116
+ if insert_index != -1:
117
+ constructor_lines = [
118
+ "",
119
+ ".method static constructor <clinit>()V",
120
+ " .locals 1",
121
+ "",
122
+ f' const-string v0, "{library_name}"',
123
+ " invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V",
124
+ "",
125
+ " return-void",
126
+ ".end method",
127
+ ""
128
+ ]
129
+
130
+ lines[insert_index:insert_index] = constructor_lines
131
+ return True
132
+
133
+ return False
134
+
135
+ @staticmethod
136
+ def is_already_patched(smali_file: Path, library_name: str = "frida-gadget") -> bool:
137
+ """Check if smali file is already patched."""
138
+ if not smali_file.exists():
139
+ return False
140
+
141
+ try:
142
+ content = smali_file.read_text()
143
+ return f'"{library_name}"' in content and "loadLibrary" in content
144
+ except Exception:
145
+ return False
@@ -0,0 +1,106 @@
1
+ """APK signing service."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from ..exceptions import SigningError, ToolNotFoundError
8
+ from ..utils.core import run_command, get_apkpatcher_home
9
+ from .android_sdk import AndroidSDKService
10
+
11
+
12
+ class SigningService:
13
+ """Service for APK signing operations."""
14
+
15
+ def __init__(self, verbose: bool = False):
16
+ self.verbose = verbose
17
+ self.sdk_service = AndroidSDKService(verbose=verbose)
18
+ self._keystore_path: Optional[Path] = None
19
+
20
+ @property
21
+ def keystore_path(self) -> Path:
22
+ """Get debug keystore path."""
23
+ if self._keystore_path is None:
24
+ keystore_path = get_apkpatcher_home() / "debug.keystore"
25
+ if not keystore_path.exists():
26
+ self._generate_debug_keystore(keystore_path)
27
+ self._keystore_path = keystore_path
28
+ return self._keystore_path
29
+
30
+ def _generate_debug_keystore(self, keystore_path: Path) -> None:
31
+ """Generate debug keystore."""
32
+ if self.verbose:
33
+ print("Generating debug keystore...")
34
+
35
+ cmd = [
36
+ "keytool", "-genkey", "-v",
37
+ "-keystore", str(keystore_path),
38
+ "-alias", "apkpatcher_debug",
39
+ "-keyalg", "RSA",
40
+ "-keysize", "2048",
41
+ "-validity", "10000",
42
+ "-storepass", "apkpatcher",
43
+ "-keypass", "apkpatcher",
44
+ "-dname", "CN=APKPatcher Debug, OU=Debug, O=APKPatcher, C=US",
45
+ "-noprompt"
46
+ ]
47
+
48
+ result = run_command(cmd)
49
+ if result.returncode != 0:
50
+ raise SigningError("Failed to generate debug keystore")
51
+
52
+ def sign_apk(self, apk_path: Path, output_path: Optional[Path] = None) -> Path:
53
+ """Sign APK file."""
54
+ if output_path is None:
55
+ output_path = apk_path.parent / f"{apk_path.stem}_signed.apk"
56
+
57
+ # First align the APK
58
+ aligned_path = self._zipalign_apk(apk_path)
59
+
60
+ # Then sign it
61
+ signed_path = self._apksigner_sign(aligned_path, output_path)
62
+
63
+ # Clean up aligned file if it's temporary
64
+ if aligned_path != apk_path:
65
+ aligned_path.unlink(missing_ok=True)
66
+
67
+ return signed_path
68
+
69
+ def _zipalign_apk(self, apk_path: Path) -> Path:
70
+ """Align APK using zipalign."""
71
+ zipalign = self.sdk_service.get_tool_path("zipalign")
72
+ aligned_path = apk_path.parent / f"{apk_path.stem}_aligned.apk"
73
+
74
+ cmd = [str(zipalign), "-p", "4", str(apk_path), str(aligned_path)]
75
+
76
+ if self.verbose:
77
+ print(f"Aligning {apk_path}...")
78
+
79
+ result = run_command(cmd)
80
+ if result.returncode != 0:
81
+ raise SigningError(f"Failed to align APK: {apk_path}")
82
+
83
+ return aligned_path
84
+
85
+ def _apksigner_sign(self, apk_path: Path, output_path: Path) -> Path:
86
+ """Sign APK using apksigner."""
87
+ apksigner = self.sdk_service.get_tool_path("apksigner")
88
+
89
+ cmd = [
90
+ str(apksigner), "sign",
91
+ "--ks", str(self.keystore_path),
92
+ "--ks-key-alias", "apkpatcher_debug",
93
+ "--ks-pass", "pass:apkpatcher",
94
+ "--key-pass", "pass:apkpatcher",
95
+ "--out", str(output_path),
96
+ str(apk_path)
97
+ ]
98
+
99
+ if self.verbose:
100
+ print(f"Signing {apk_path}...")
101
+
102
+ result = run_command(cmd)
103
+ if result.returncode != 0:
104
+ raise SigningError(f"Failed to sign APK: {apk_path}")
105
+
106
+ return output_path
@@ -0,0 +1,146 @@
1
+ """Service for handling split APK merging."""
2
+
3
+ from pathlib import Path
4
+ from typing import List
5
+
6
+ from ..exceptions import BuildError, ValidationError
7
+ from .apktool import ApktoolService
8
+ from .signing import SigningService
9
+
10
+
11
+ class SplitMergeService:
12
+ """Service for merging split APKs into single APK."""
13
+
14
+ def __init__(self, verbose: bool = False):
15
+ self.verbose = verbose
16
+ self.apktool_service = ApktoolService(verbose=verbose)
17
+ self.signing_service = SigningService(verbose=verbose)
18
+
19
+ def merge_split_apks(self, apk_paths: List[Path], net: bool = False) -> Path:
20
+ """Merge multiple split APKs into a single APK."""
21
+ if not apk_paths:
22
+ raise ValidationError("No APK paths provided")
23
+
24
+ if len(apk_paths) == 1:
25
+ # Single APK, just sign and return
26
+ return self.signing_service.sign_apk(apk_paths[0])
27
+
28
+ # Find base APK
29
+ base_apk = None
30
+ split_apks = []
31
+
32
+ for apk_path in apk_paths:
33
+ if apk_path.name == "base.apk":
34
+ base_apk = apk_path
35
+ else:
36
+ split_apks.append(apk_path)
37
+
38
+ if base_apk is None:
39
+ base_apk = apk_paths[0] # Use first as base
40
+ split_apks = apk_paths[1:]
41
+
42
+ if self.verbose:
43
+ print(f"Merging {len(apk_paths)} split APKs...")
44
+
45
+ # Decode all APKs
46
+ base_dir = self.apktool_service.decode(base_apk, no_resources=False, no_sources=True)
47
+
48
+ split_dirs = []
49
+ for split_apk in split_apks:
50
+ split_dir = self.apktool_service.decode(
51
+ split_apk,
52
+ no_resources=False,
53
+ no_sources=True,
54
+ extra_args="--resource-mode dummy"
55
+ )
56
+ split_dirs.append(split_dir)
57
+
58
+ # Merge content from split APKs into base
59
+ self._merge_split_content(base_dir, split_dirs)
60
+
61
+ # Fix dummy resource identifiers
62
+ self._fix_dummy_resources(base_dir, split_dirs)
63
+
64
+ # Update manifest to disable APK splitting
65
+ self._disable_apk_splitting(base_dir)
66
+
67
+ # Build merged APK
68
+ output_path = base_apk.parent / "merged.apk"
69
+ built_path = self.apktool_service.build(base_dir, output_path, add_network_config=net)
70
+
71
+ # Sign merged APK
72
+ signed_path = self.signing_service.sign_apk(built_path)
73
+
74
+ if self.verbose:
75
+ print(f"Merged APK created: {signed_path}")
76
+
77
+ return signed_path
78
+
79
+ def _merge_split_content(self, base_dir: Path, split_dirs: List[Path]) -> None:
80
+ """Merge content from split directories into base directory."""
81
+ import shutil
82
+
83
+ for split_dir in split_dirs:
84
+ # Copy all directories except AndroidManifest.xml, apktool.yml, and original/
85
+ for item in split_dir.iterdir():
86
+ if item.name in ["AndroidManifest.xml", "apktool.yml", "original"]:
87
+ continue
88
+
89
+ target = base_dir / item.name
90
+
91
+ if item.is_dir():
92
+ if item.name == "res":
93
+ # Special handling for res directory - only copy non-XML files
94
+ self._merge_res_directory(item, target)
95
+ else:
96
+ # Copy entire directory
97
+ if target.exists():
98
+ shutil.rmtree(target)
99
+ shutil.copytree(item, target)
100
+ else:
101
+ # Copy file
102
+ shutil.copy2(item, target)
103
+
104
+ def _merge_res_directory(self, source_res: Path, target_res: Path) -> None:
105
+ """Merge res directories, excluding XML files to avoid conflicts."""
106
+ import shutil
107
+
108
+ for item in source_res.rglob("*"):
109
+ if item.is_file() and not item.suffix == ".xml":
110
+ relative_path = item.relative_to(source_res)
111
+ target_file = target_res / relative_path
112
+
113
+ target_file.parent.mkdir(parents=True, exist_ok=True)
114
+ shutil.copy2(item, target_file)
115
+
116
+ def _fix_dummy_resources(self, base_dir: Path, split_dirs: List[Path]) -> None:
117
+ """Fix APKTOOL_DUMMY resource identifiers."""
118
+ # This is a simplified implementation
119
+ # In a full implementation, you would:
120
+ # 1. Find all DUMMY resource IDs in base/res/values/public.xml
121
+ # 2. Find the real names in split APK public.xml files
122
+ # 3. Replace all references to DUMMY names with real names
123
+
124
+ if self.verbose:
125
+ print("Fixing dummy resource identifiers...")
126
+
127
+ public_xml = base_dir / "res" / "values" / "public.xml"
128
+ if not public_xml.exists():
129
+ return
130
+
131
+ # Simple approach: remove DUMMY entries (they'll be regenerated)
132
+ try:
133
+ content = public_xml.read_text()
134
+ lines = content.splitlines()
135
+ filtered_lines = [line for line in lines if "APKTOOL_DUMMY_" not in line]
136
+ public_xml.write_text("\n".join(filtered_lines))
137
+ except Exception:
138
+ pass # Continue without fixing if there's an issue
139
+
140
+ def _disable_apk_splitting(self, base_dir: Path) -> None:
141
+ """Disable APK splitting in AndroidManifest.xml."""
142
+ from ..utils.manifest import ManifestUtils
143
+
144
+ manifest_path = base_dir / "AndroidManifest.xml"
145
+ if manifest_path.exists():
146
+ ManifestUtils.disable_apk_splitting(manifest_path)
@@ -0,0 +1 @@
1
+ """Utilities package for APKPatcher."""
@@ -0,0 +1,77 @@
1
+ """Core utility functions for APKPatcher."""
2
+
3
+ import logging
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Optional
8
+ import requests
9
+ from tqdm import tqdm
10
+
11
+ from ..exceptions import NetworkError
12
+
13
+
14
+ def setup_logging(verbose: bool = False) -> None:
15
+ """Setup logging configuration."""
16
+ level = logging.DEBUG if verbose else logging.INFO
17
+ logging.basicConfig(
18
+ level=level,
19
+ format='%(levelname)s: %(message)s'
20
+ )
21
+
22
+
23
+ def get_apkpatcher_home() -> Path:
24
+ """Get APKPatcher home directory."""
25
+ return Path.home() / ".apkpatcher"
26
+
27
+
28
+ def run_command(cmd, capture_output: bool = False, text: bool = True, **kwargs):
29
+ """Run command with proper error handling."""
30
+ try:
31
+ return subprocess.run(cmd, capture_output=capture_output, text=text, **kwargs)
32
+ except FileNotFoundError as e:
33
+ from ..exceptions import ToolNotFoundError
34
+ raise ToolNotFoundError(f"Command not found: {cmd[0]}")
35
+
36
+
37
+ def download_file(url: str, output_path: Path, chunk_size: int = 8192) -> None:
38
+ """Download file with progress bar."""
39
+ try:
40
+ response = requests.get(url, stream=True)
41
+ response.raise_for_status()
42
+
43
+ total_size = int(response.headers.get('content-length', 0))
44
+
45
+ with open(output_path, 'wb') as f:
46
+ with tqdm(total=total_size, unit='B', unit_scale=True, desc=output_path.name) as pbar:
47
+ for chunk in response.iter_content(chunk_size=chunk_size):
48
+ if chunk:
49
+ f.write(chunk)
50
+ pbar.update(len(chunk))
51
+
52
+ except requests.RequestException as e:
53
+ raise NetworkError(f"Failed to download {url}: {e}")
54
+ except Exception as e:
55
+ raise NetworkError(f"Download error: {e}")
56
+
57
+
58
+ def is_java_available() -> bool:
59
+ """Check if Java is available."""
60
+ try:
61
+ result = subprocess.run(['java', '-version'], capture_output=True)
62
+ return result.returncode == 0
63
+ except FileNotFoundError:
64
+ return False
65
+
66
+
67
+ def validate_package_name(package_name: str) -> bool:
68
+ """Validate Android package name format."""
69
+ import re
70
+ pattern = r'^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$'
71
+ return bool(re.match(pattern, package_name))
72
+
73
+
74
+ def ensure_directory(path: Path) -> Path:
75
+ """Ensure directory exists and return path."""
76
+ path.mkdir(parents=True, exist_ok=True)
77
+ return path
@@ -0,0 +1,140 @@
1
+ """Android manifest manipulation utilities."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Optional
6
+ import xml.etree.ElementTree as ET
7
+
8
+ from ..exceptions import ValidationError
9
+
10
+
11
+ class ManifestUtils:
12
+ """Utilities for AndroidManifest.xml manipulation."""
13
+
14
+ @staticmethod
15
+ def add_internet_permission(manifest_path: Path) -> None:
16
+ """Add INTERNET permission to manifest if not present."""
17
+ content = manifest_path.read_text()
18
+
19
+ if "android.permission.INTERNET" in content:
20
+ return # Already present
21
+
22
+ # Find manifest tag and add permission
23
+ lines = content.splitlines()
24
+ manifest_found = False
25
+
26
+ for i, line in enumerate(lines):
27
+ if "<manifest" in line:
28
+ manifest_found = True
29
+ elif manifest_found and ("<application" in line or "<uses-" in line):
30
+ # Insert before application or other uses- tags
31
+ lines.insert(i, ' <uses-permission android:name="android.permission.INTERNET" />')
32
+ break
33
+ else:
34
+ # If no good insertion point found, add after manifest opening
35
+ for i, line in enumerate(lines):
36
+ if "<manifest" in line and ">" in line:
37
+ lines.insert(i + 1, ' <uses-permission android:name="android.permission.INTERNET" />')
38
+ break
39
+
40
+ manifest_path.write_text("\n".join(lines))
41
+
42
+ @staticmethod
43
+ def set_extract_native_libs(manifest_path: Path, extract: bool = True) -> None:
44
+ """Set android:extractNativeLibs attribute."""
45
+ content = manifest_path.read_text()
46
+
47
+ extract_value = "true" if extract else "false"
48
+
49
+ # Replace existing attribute or add it
50
+ if "android:extractNativeLibs" in content:
51
+ content = re.sub(
52
+ r'android:extractNativeLibs="[^"]*"',
53
+ f'android:extractNativeLibs="{extract_value}"',
54
+ content
55
+ )
56
+ else:
57
+ # Add to application tag
58
+ content = re.sub(
59
+ r'(<application[^>]*)',
60
+ rf'\1 android:extractNativeLibs="{extract_value}"',
61
+ content
62
+ )
63
+
64
+ manifest_path.write_text(content)
65
+
66
+ @staticmethod
67
+ def add_network_security_config(manifest_path: Path) -> None:
68
+ """Add network security config to application."""
69
+ content = manifest_path.read_text()
70
+
71
+ if "android:networkSecurityConfig" in content:
72
+ return # Already present
73
+
74
+ # Add to application tag
75
+ content = re.sub(
76
+ r'(<application[^>]*)',
77
+ r'\1 android:networkSecurityConfig="@xml/network_security_config"',
78
+ content
79
+ )
80
+
81
+ manifest_path.write_text(content)
82
+
83
+ @staticmethod
84
+ def disable_apk_splitting(manifest_path: Path) -> None:
85
+ """Disable APK splitting in manifest."""
86
+ content = manifest_path.read_text()
87
+
88
+ # Set isSplitRequired to false
89
+ content = re.sub(
90
+ r'android:isSplitRequired="true"',
91
+ 'android:isSplitRequired="false"',
92
+ content
93
+ )
94
+
95
+ # Set com.android.vending.splits.required to false
96
+ content = re.sub(
97
+ r'(<meta-data[^>]*android:name="com\.android\.vending\.splits\.required"[^>]*android:value=")[^"]*(")',
98
+ r'\1false\2',
99
+ content
100
+ )
101
+
102
+ manifest_path.write_text(content)
103
+
104
+ @staticmethod
105
+ def get_package_name(manifest_path: Path) -> Optional[str]:
106
+ """Extract package name from manifest."""
107
+ try:
108
+ content = manifest_path.read_text()
109
+ match = re.search(r'package="([^"]*)"', content)
110
+ return match.group(1) if match else None
111
+ except Exception:
112
+ return None
113
+
114
+ @staticmethod
115
+ def get_main_activity(manifest_path: Path) -> Optional[str]:
116
+ """Extract main activity name from manifest."""
117
+ try:
118
+ content = manifest_path.read_text()
119
+
120
+ # Look for activity with MAIN action and LAUNCHER category
121
+ pattern = (
122
+ r'<activity[^>]*android:name="([^"]*)"[^>]*>.*?'
123
+ r'<action android:name="android\.intent\.action\.MAIN".*?'
124
+ r'<category android:name="android\.intent\.category\.LAUNCHER"'
125
+ )
126
+ match = re.search(pattern, content, re.DOTALL)
127
+
128
+ if match:
129
+ activity_name = match.group(1)
130
+ if activity_name.startswith("."):
131
+ # Relative name, prepend package
132
+ package = ManifestUtils.get_package_name(manifest_path)
133
+ if package:
134
+ activity_name = package + activity_name
135
+ return activity_name
136
+
137
+ except Exception:
138
+ pass
139
+
140
+ return None
File without changes
@@ -0,0 +1,65 @@
1
+ """Version management utilities."""
2
+
3
+ import requests
4
+ from typing import Optional
5
+
6
+ from ..exceptions import NetworkError
7
+
8
+
9
+ def get_latest_github_release(owner: str, repo: str) -> str:
10
+ """Get the latest release version from GitHub."""
11
+ url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
12
+
13
+ try:
14
+ response = requests.get(url, timeout=30)
15
+ response.raise_for_status()
16
+
17
+ data = response.json()
18
+ tag_name = data.get("tag_name", "")
19
+
20
+ # Remove 'v' prefix if present
21
+ if tag_name.startswith("v"):
22
+ tag_name = tag_name[1:]
23
+
24
+ return tag_name
25
+
26
+ except requests.RequestException as e:
27
+ raise NetworkError(f"Failed to get latest release for {owner}/{repo}: {e}")
28
+ except KeyError:
29
+ raise NetworkError(f"Invalid response format from GitHub API")
30
+
31
+
32
+ def compare_versions(version1: str, version2: str) -> int:
33
+ """Compare two version strings.
34
+
35
+ Returns:
36
+ -1 if version1 < version2
37
+ 0 if version1 == version2
38
+ 1 if version1 > version2
39
+ """
40
+ def version_tuple(v):
41
+ return tuple(map(int, v.split('.')))
42
+
43
+ try:
44
+ v1_tuple = version_tuple(version1)
45
+ v2_tuple = version_tuple(version2)
46
+
47
+ if v1_tuple < v2_tuple:
48
+ return -1
49
+ elif v1_tuple > v2_tuple:
50
+ return 1
51
+ else:
52
+ return 0
53
+ except ValueError:
54
+ # If version parsing fails, do string comparison
55
+ if version1 < version2:
56
+ return -1
57
+ elif version1 > version2:
58
+ return 1
59
+ else:
60
+ return 0
61
+
62
+
63
+ def is_version_newer(current: str, latest: str) -> bool:
64
+ """Check if latest version is newer than current."""
65
+ return compare_versions(current, latest) < 0