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.
- apk_patchx/__init__.py +14 -0
- apk_patchx/cli.py +273 -0
- apk_patchx/exceptions.py +41 -0
- apk_patchx/services/__init__.py +1 -0
- apk_patchx/services/adb.py +98 -0
- apk_patchx/services/android_sdk.py +145 -0
- apk_patchx/services/apktool.py +150 -0
- apk_patchx/services/frida.py +311 -0
- apk_patchx/services/patch_dex.py +119 -0
- apk_patchx/services/patch_smali.py +145 -0
- apk_patchx/services/signing.py +106 -0
- apk_patchx/services/split_merge.py +146 -0
- apk_patchx/utils/__init__.py +1 -0
- apk_patchx/utils/core.py +77 -0
- apk_patchx/utils/manifest.py +140 -0
- apk_patchx/utils/py.typed +0 -0
- apk_patchx/utils/versions.py +65 -0
- apk_patchx-7.9.2025.1.dist-info/METADATA +110 -0
- apk_patchx-7.9.2025.1.dist-info/RECORD +22 -0
- apk_patchx-7.9.2025.1.dist-info/WHEEL +4 -0
- apk_patchx-7.9.2025.1.dist-info/entry_points.txt +3 -0
- apk_patchx-7.9.2025.1.dist-info/licenses/LICENSE +21 -0
@@ -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."""
|
apk_patchx/utils/core.py
ADDED
@@ -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
|