Add merged single APK support for XAPK and fix plugin version to 1.2.0
New merge-splits.sh script merges XAPK split APK contents (native libs, resources, manifest cleanup) into the decoded base directory, enabling single APK output installable via standard adb install. rebuild-apk.sh gains --single-apk flag with auto-detection via .merged marker. SKILL.md updated with Phase 5a/5b/5c workflow for XAPK rebuild choice. Fixed plugin.json version from 1.0.0 to 1.2.0 to match marketplace.json. Added versioning guidance to CLAUDE.md to prevent future mismatches. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e70920cf87
commit
3d68cf392f
17
CLAUDE.md
17
CLAUDE.md
|
|
@ -21,7 +21,7 @@ A Claude Code Skill (plugin) for Android reverse engineering, API extraction, an
|
|||
- `plugins/android-reverse-engineering/skills/android-reverse-engineering/` — Core RE skill (5-phase workflow, references, scripts)
|
||||
- `plugins/android-reverse-engineering/skills/tracker-analysis/` — Tracker/analytics SDK detection skill (4-phase workflow, references, find-trackers.sh)
|
||||
- `plugins/android-reverse-engineering/skills/ad-analysis/` — Advertising SDK detection skill (3-phase workflow, references, find-ads.sh)
|
||||
- `plugins/android-reverse-engineering/skills/sdk-neutralizer/` — SDK neutralization skill (6-phase workflow, references, decode-apk.sh, neutralize.sh, rebuild-apk.sh)
|
||||
- `plugins/android-reverse-engineering/skills/sdk-neutralizer/` — SDK neutralization skill (6-phase workflow, references, decode-apk.sh, neutralize.sh, merge-splits.sh, rebuild-apk.sh)
|
||||
|
||||
## Key Scripts
|
||||
|
||||
|
|
@ -70,8 +70,11 @@ bash decode-apk.sh <file.apk|file.xapk> [-o <decoded-dir>]
|
|||
# Neutralize SDK entry points in decoded APK (dry-run first)
|
||||
bash neutralize.sh <decoded-dir> [--ads|--trackers|--all] [--dry-run] [--no-backup] [--no-manifest] [--targets-file <file>] [--replay] [--no-save-manifest]
|
||||
|
||||
# Rebuild and sign neutralized APK (auto-reassembles XAPK if .xapk-origin/ exists)
|
||||
bash rebuild-apk.sh <decoded-dir> [--auto-keystore|--debug-key|--keystore <file>] [-o <output>] [--no-sign] [--no-res] [--zipalign]
|
||||
# Merge XAPK splits into decoded base for single APK output (optional, for XAPK input)
|
||||
bash merge-splits.sh <decoded-dir> [--abi <abi>] [--all-abis] [--skip-resources]
|
||||
|
||||
# Rebuild and sign neutralized APK (auto-reassembles XAPK if .xapk-origin/ exists, or single APK if merged)
|
||||
bash rebuild-apk.sh <decoded-dir> [--auto-keystore|--debug-key|--keystore <file>] [-o <output>] [--no-sign] [--no-res] [--zipalign] [--single-apk]
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
|
@ -97,6 +100,14 @@ bash rebuild-apk.sh <decoded-dir> [--auto-keystore|--debug-key|--keystore <file>
|
|||
|
||||
This is a documentation-and-scripts plugin with no compiled code, no test suite, and no linter configuration. Changes are validated by reading the markdown/bash and testing scripts manually against APK files.
|
||||
|
||||
## Versioning
|
||||
|
||||
The plugin version is declared in **two** files that **must be kept in sync**:
|
||||
- `.claude-plugin/marketplace.json` → `plugins[0].version` (marketplace catalog)
|
||||
- `plugins/android-reverse-engineering/.claude-plugin/plugin.json` → `version` (plugin manifest)
|
||||
|
||||
Claude Code reads the version from `plugin.json` with priority — if that file has a stale version, `/plugin` will show the old number even if `marketplace.json` is updated. **Always bump both files together.**
|
||||
|
||||
## Conventions
|
||||
|
||||
- Line endings are LF (enforced via `.gitattributes` for WSL/Windows compatibility)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "android-reverse-engineering",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.0",
|
||||
"description": "Decompile Android APK/JAR/AAR with jadx, trace call flows, extract APIs, analyze tracker/ad SDKs for privacy auditing, and neutralize SDK entry points for enterprise deployment.",
|
||||
"author": {
|
||||
"name": "Simone Avogadro"
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ For XAPK input, the script also outputs `XAPK_ORIGIN:<path>` and creates:
|
|||
- `.xapk-origin/manifest.json` — original XAPK manifest
|
||||
- `.xapk-origin/splits/` — all split APKs (config.arm64_v8a.apk, config.en.apk, etc.)
|
||||
|
||||
If the input is an XAPK, inform the user that it's a split APK bundle and that all splits will be automatically re-signed during rebuild.
|
||||
If the input is an XAPK, inform the user that it's a split APK bundle. Let them know that in Phase 5 (Rebuild) they will be asked whether to produce a **merged single APK** (easier to install) or keep the **XAPK bundle** (preserves all splits).
|
||||
|
||||
### Phase 3: Identify Targets
|
||||
|
||||
|
|
@ -224,6 +224,42 @@ After a successful (non-dry-run) neutralization, a `neutralize-manifest.json` is
|
|||
|
||||
Rebuild the decoded directory back into a signed APK (or XAPK if the original was an XAPK).
|
||||
|
||||
#### Phase 5a — XAPK Output Format Choice (XAPK input only)
|
||||
|
||||
**If the input was an XAPK**, you **MUST ask the user** how to rebuild:
|
||||
|
||||
> How would you like to rebuild the neutralized app?
|
||||
>
|
||||
> 1. **Merged single APK** (recommended for sideloading) — merges split contents into one APK, installable with standard `adb install`. May be missing some locale/density resources.
|
||||
> 2. **XAPK bundle** (preserves original structure) — requires `adb install-multiple` or SAI app to install. All splits preserved exactly.
|
||||
|
||||
**If the user chooses option 1 (merged single APK):**
|
||||
|
||||
Run `merge-splits.sh` to merge split contents into the decoded base APK directory:
|
||||
|
||||
```bash
|
||||
bash ${CLAUDE_PLUGIN_ROOT}/skills/sdk-neutralizer/scripts/merge-splits.sh <decoded-dir>
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--abi <abi>` — merge only a specific ABI (e.g., `arm64-v8a`)
|
||||
- `--all-abis` — merge all ABIs (larger but universal APK)
|
||||
- `--skip-resources` — skip resource split merge (locale/density)
|
||||
- Default (no flags): picks the most common ABI (`arm64-v8a` > `armeabi-v7a` > `x86_64` > `x86`)
|
||||
|
||||
Parse output for `MERGE_ABI:`, `MERGE_RESOURCES:`, `SKIPPED_RESOURCES:`, `FEATURE_SPLIT_WARNING:`, `MANIFEST_CLEANED:`, and `MERGE_COMPLETE:` lines.
|
||||
|
||||
**Important merge limitations to communicate to the user:**
|
||||
- Resource splits (locale, density) are merged best-effort — compiled `resources.arsc` cannot be fused without `aapt2`. The merged APK uses default resources from the base APK.
|
||||
- Feature module splits (containing DEX code) **cannot** be merged — the script warns about these.
|
||||
- Native library merge changes `android:extractNativeLibs` to `true`, which increases installed size.
|
||||
|
||||
After merge, the rebuild script auto-detects the `.merged` marker and produces a single `.apk`.
|
||||
|
||||
**If the user chooses option 2 (XAPK bundle):** skip `merge-splits.sh` and proceed directly to rebuild — the script will auto-reassemble the XAPK.
|
||||
|
||||
#### Phase 5b — Signing Preference
|
||||
|
||||
**Before calling rebuild**, you **MUST ask the user** their signing preference:
|
||||
|
||||
> How would you like to sign the rebuilt APK?
|
||||
|
|
@ -237,22 +273,31 @@ Map the user's choice to the corresponding flag:
|
|||
- Option 2 → `--keystore <file> --key-alias <alias> --store-pass <pass> --key-pass <pass>`
|
||||
- Option 3 → `--no-sign`
|
||||
|
||||
#### Phase 5c — Run Rebuild
|
||||
|
||||
**Action**: Run the rebuild script with the chosen signing option.
|
||||
|
||||
```bash
|
||||
# Example with auto-keystore (recommended default)
|
||||
# Single merged APK (after merge-splits.sh, auto-detected via .merged marker)
|
||||
bash ${CLAUDE_PLUGIN_ROOT}/skills/sdk-neutralizer/scripts/rebuild-apk.sh <decoded-dir> --auto-keystore
|
||||
|
||||
# Or explicitly force single APK output
|
||||
bash ${CLAUDE_PLUGIN_ROOT}/skills/sdk-neutralizer/scripts/rebuild-apk.sh <decoded-dir> --auto-keystore --single-apk
|
||||
|
||||
# XAPK bundle (default when .xapk-origin/ exists and no .merged marker)
|
||||
bash ${CLAUDE_PLUGIN_ROOT}/skills/sdk-neutralizer/scripts/rebuild-apk.sh <decoded-dir> --auto-keystore
|
||||
```
|
||||
|
||||
Options:
|
||||
- `-o <output>` — custom output path
|
||||
- `--single-apk` — force single APK output (auto-enabled when `.merged` marker exists)
|
||||
- `--auto-keystore` — auto-detect best keystore (recommended)
|
||||
- `--debug-key` — always generate new debug keystore
|
||||
- `--keystore <file>` — use a custom keystore
|
||||
- `--no-sign` — output unsigned APK
|
||||
- `--zipalign` / `--no-zipalign` — control zipalign step
|
||||
|
||||
For XAPK input, the rebuild is automatic: the script detects `.xapk-origin/`, re-signs all split APKs with the same keystore, and produces a `.xapk` output. Parse the output for `KEYSTORE_USED:`, `KEYSTORE_SOURCE:`, `SPLIT_SIGNED:`, and `XAPK_ASSEMBLED:` lines.
|
||||
For XAPK input without merge, the rebuild is automatic: the script detects `.xapk-origin/`, re-signs all split APKs with the same keystore, and produces a `.xapk` output. Parse the output for `KEYSTORE_USED:`, `KEYSTORE_SOURCE:`, `SPLIT_SIGNED:`, and `XAPK_ASSEMBLED:` lines.
|
||||
|
||||
### Phase 6: Verify & Report
|
||||
|
||||
|
|
@ -308,13 +353,29 @@ Modifying APKs may violate the app's EULA, SDK provider agreements, or intellect
|
|||
property laws. This tool is intended for authorized enterprise use, security research,
|
||||
and privacy compliance only.
|
||||
|
||||
## Split Merge Details (if applicable)
|
||||
|
||||
If the original input was an XAPK and the user chose merged single APK output, include this section:
|
||||
|
||||
| Merge Step | Result |
|
||||
|---|---|
|
||||
| ABI splits merged | arm64-v8a (3 native libraries) |
|
||||
| Resource splits | 2 merged (best-effort), 1 skipped |
|
||||
| Feature splits | 0 (or: 1 warning — could not merge) |
|
||||
| Manifest cleanup | isSplitRequired, extractNativeLibs→true, com.android.vending.splits.required |
|
||||
|
||||
**Merge limitations:**
|
||||
- Locale/density resources use defaults from the base APK (compiled `resources.arsc` from splits cannot be fused)
|
||||
- `android:extractNativeLibs` was set to `true` — native libs are extracted on install (uses more disk space)
|
||||
- Feature module splits (if any) were NOT merged and their functionality may be missing
|
||||
|
||||
## Output
|
||||
|
||||
- Sanitized APK/XAPK: `<path>`
|
||||
- Output format: APK (single) / XAPK (split bundle)
|
||||
- Output format: APK (single) / APK (merged from XAPK) / XAPK (split bundle)
|
||||
- Signed with: auto-detected debug key / generated debug key / custom keystore
|
||||
- Keystore used: `<path>` (source: `KEYSTORE_SOURCE:` value)
|
||||
- Install via: `adb install <path>` (APK) or `adb install-multiple <base.apk> <split1.apk> ...` (XAPK)
|
||||
- Install via: `adb install <path>` (APK / merged APK) or `adb install-multiple <base.apk> <split1.apk> ...` (XAPK)
|
||||
- For XAPK: can also use SAI (Split APKs Installer) or unzip and `adb install-multiple *.apk`
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,448 @@
|
|||
#!/usr/bin/env bash
|
||||
# merge-splits.sh — Merge XAPK split APKs into the decoded base APK directory
|
||||
#
|
||||
# Produces a single-APK-ready directory by merging native libraries,
|
||||
# resources (best-effort), and cleaning the manifest of split-related attributes.
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — success (splits merged)
|
||||
# 1 — error (invalid input, missing files)
|
||||
# 2 — not an XAPK (no .xapk-origin/ found)
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: merge-splits.sh <decoded-dir> [OPTIONS]
|
||||
|
||||
Merge XAPK split APK contents into the decoded base APK directory,
|
||||
producing a directory ready to rebuild as a single merged APK.
|
||||
|
||||
Arguments:
|
||||
<decoded-dir> Path to the apktool-decoded APK directory (must contain .xapk-origin/)
|
||||
|
||||
Options:
|
||||
--abi <abi> Merge only this ABI (e.g., arm64-v8a, armeabi-v7a, x86_64, x86)
|
||||
--all-abis Merge all ABIs found in splits (larger but universal)
|
||||
--skip-resources Skip merging resource splits (locale, density)
|
||||
-h, --help Show this help message
|
||||
|
||||
Output (machine-readable):
|
||||
MERGE_ABI:<abi> (<count> native libraries)
|
||||
MERGE_RESOURCES:<split> (<count> files)
|
||||
SKIPPED_RESOURCES:<split>:<reason>
|
||||
FEATURE_SPLIT_WARNING:<split> (feature modules cannot be merged)
|
||||
MANIFEST_CLEANED:<attribute>
|
||||
MERGE_COMPLETE:<decoded-dir>
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# =====================================================================
|
||||
# Argument parsing
|
||||
# =====================================================================
|
||||
|
||||
DECODED_DIR=""
|
||||
TARGET_ABI=""
|
||||
ALL_ABIS=false
|
||||
SKIP_RESOURCES=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--abi)
|
||||
shift
|
||||
if [[ $# -eq 0 ]]; then echo "Error: --abi requires an argument" >&2; exit 1; fi
|
||||
TARGET_ABI="$1"; shift ;;
|
||||
--all-abis) ALL_ABIS=true; shift ;;
|
||||
--skip-resources) SKIP_RESOURCES=true; shift ;;
|
||||
-h|--help) usage ;;
|
||||
-*) echo "Error: Unknown option $1" >&2; usage ;;
|
||||
*) DECODED_DIR="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$DECODED_DIR" ]]; then
|
||||
echo "Error: No decoded directory specified." >&2
|
||||
usage
|
||||
fi
|
||||
|
||||
if [[ ! -d "$DECODED_DIR" ]]; then
|
||||
echo "Error: Directory not found: $DECODED_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# =====================================================================
|
||||
# Validate XAPK origin
|
||||
# =====================================================================
|
||||
|
||||
info() { echo "[INFO] $*"; }
|
||||
ok() { echo "[OK] $*"; }
|
||||
fail() { echo "[FAIL] $*" >&2; }
|
||||
|
||||
XAPK_ORIGIN_DIR="$DECODED_DIR/.xapk-origin"
|
||||
|
||||
if [[ ! -f "$XAPK_ORIGIN_DIR/metadata.json" ]]; then
|
||||
info "Not an XAPK — no .xapk-origin/metadata.json found. Nothing to merge."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -d "$XAPK_ORIGIN_DIR/splits" ]]; then
|
||||
info "No splits directory found in .xapk-origin/. Nothing to merge."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Count split APKs
|
||||
split_count=0
|
||||
for f in "$XAPK_ORIGIN_DIR/splits/"*.apk; do
|
||||
[[ -f "$f" ]] && (( split_count++ )) || true
|
||||
done
|
||||
|
||||
if (( split_count == 0 )); then
|
||||
info "No split APKs found in .xapk-origin/splits/. Nothing to merge."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "=== Merging XAPK Splits ==="
|
||||
echo "Decoded dir: $DECODED_DIR"
|
||||
echo "Split APKs found: $split_count"
|
||||
echo
|
||||
|
||||
# =====================================================================
|
||||
# Step 0: Classify splits (ABI, resource, feature)
|
||||
# =====================================================================
|
||||
|
||||
declare -a abi_splits=()
|
||||
declare -a resource_splits=()
|
||||
declare -a feature_splits=()
|
||||
|
||||
for split_apk in "$XAPK_ORIGIN_DIR/splits/"*.apk; do
|
||||
[[ -f "$split_apk" ]] || continue
|
||||
split_name=$(basename "$split_apk")
|
||||
|
||||
# Check if it's an ABI split by looking for lib/ inside
|
||||
if unzip -l "$split_apk" 2>/dev/null | grep -q "lib/[^/]*/"; then
|
||||
abi_splits+=("$split_apk")
|
||||
# Check if it's a feature split (contains classes.dex or AndroidManifest.xml with split name)
|
||||
elif unzip -l "$split_apk" 2>/dev/null | grep -qE "(classes[0-9]*\.dex|^.*AndroidManifest\.xml)" && \
|
||||
! [[ "$split_name" == config.* ]]; then
|
||||
feature_splits+=("$split_apk")
|
||||
else
|
||||
# Config split (locale, density, etc.)
|
||||
resource_splits+=("$split_apk")
|
||||
fi
|
||||
done
|
||||
|
||||
info "ABI splits: ${#abi_splits[@]}"
|
||||
info "Resource splits: ${#resource_splits[@]}"
|
||||
info "Feature splits: ${#feature_splits[@]}"
|
||||
echo
|
||||
|
||||
# Warn about feature splits — cannot be merged
|
||||
if (( ${#feature_splits[@]} > 0 )); then
|
||||
for fs in "${feature_splits[@]}"; do
|
||||
fname=$(basename "$fs")
|
||||
echo "FEATURE_SPLIT_WARNING:$fname (feature modules cannot be merged — contains DEX code and own manifest)"
|
||||
done
|
||||
echo
|
||||
fi
|
||||
|
||||
# =====================================================================
|
||||
# Step 1: Merge native libraries (ABI splits)
|
||||
# =====================================================================
|
||||
|
||||
echo "=== Step 1: Merge Native Libraries ==="
|
||||
|
||||
if (( ${#abi_splits[@]} == 0 )); then
|
||||
info "No ABI splits found — skipping native library merge."
|
||||
else
|
||||
# Detect available ABIs
|
||||
declare -A abi_to_split=()
|
||||
for split_apk in "${abi_splits[@]}"; do
|
||||
# Extract ABI name from the lib/ structure inside the APK
|
||||
while IFS= read -r abi_name; do
|
||||
abi_to_split["$abi_name"]="$split_apk"
|
||||
done < <(unzip -l "$split_apk" 2>/dev/null | grep -oP 'lib/\K[^/]+(?=/)' | sort -u)
|
||||
done
|
||||
|
||||
info "Available ABIs: ${!abi_to_split[*]}"
|
||||
|
||||
# Determine which ABIs to merge
|
||||
declare -a abis_to_merge=()
|
||||
|
||||
if [[ -n "$TARGET_ABI" ]]; then
|
||||
# User specified a specific ABI
|
||||
if [[ -z "${abi_to_split[$TARGET_ABI]:-}" ]]; then
|
||||
fail "Requested ABI '$TARGET_ABI' not found in splits. Available: ${!abi_to_split[*]}"
|
||||
exit 1
|
||||
fi
|
||||
abis_to_merge=("$TARGET_ABI")
|
||||
elif [[ "$ALL_ABIS" == true ]]; then
|
||||
# Merge all ABIs
|
||||
abis_to_merge=("${!abi_to_split[@]}")
|
||||
else
|
||||
# Default: pick most common ABI by priority
|
||||
for preferred in arm64-v8a armeabi-v7a x86_64 x86; do
|
||||
if [[ -n "${abi_to_split[$preferred]:-}" ]]; then
|
||||
abis_to_merge=("$preferred")
|
||||
break
|
||||
fi
|
||||
done
|
||||
# Fallback: pick the first available
|
||||
if (( ${#abis_to_merge[@]} == 0 )); then
|
||||
for abi in "${!abi_to_split[@]}"; do
|
||||
abis_to_merge=("$abi")
|
||||
break
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
info "Merging ABIs: ${abis_to_merge[*]}"
|
||||
|
||||
# Create lib/ directory in decoded dir if it doesn't exist
|
||||
mkdir -p "$DECODED_DIR/lib"
|
||||
|
||||
TMPDIR_NATIVE=$(mktemp -d "${TMPDIR:-/tmp}/merge-native-XXXXXX")
|
||||
cleanup_native() { rm -rf "$TMPDIR_NATIVE"; }
|
||||
trap cleanup_native EXIT
|
||||
|
||||
for abi in "${abis_to_merge[@]}"; do
|
||||
split_apk="${abi_to_split[$abi]}"
|
||||
split_name=$(basename "$split_apk")
|
||||
|
||||
info "Extracting native libs from $split_name (ABI: $abi)..."
|
||||
|
||||
# Extract lib/ contents
|
||||
rm -rf "$TMPDIR_NATIVE/"*
|
||||
unzip -qo "$split_apk" "lib/*" -d "$TMPDIR_NATIVE" 2>/dev/null || true
|
||||
|
||||
if [[ -d "$TMPDIR_NATIVE/lib/$abi" ]]; then
|
||||
# Copy to decoded dir
|
||||
mkdir -p "$DECODED_DIR/lib/$abi"
|
||||
lib_count=0
|
||||
for so_file in "$TMPDIR_NATIVE/lib/$abi/"*; do
|
||||
[[ -f "$so_file" ]] || continue
|
||||
cp "$so_file" "$DECODED_DIR/lib/$abi/"
|
||||
(( lib_count++ ))
|
||||
done
|
||||
ok "Merged $lib_count native libraries for $abi"
|
||||
echo "MERGE_ABI:$abi ($lib_count native libraries)"
|
||||
else
|
||||
info "No native libs found for ABI $abi in $split_name"
|
||||
fi
|
||||
done
|
||||
|
||||
cleanup_native
|
||||
trap - EXIT
|
||||
fi
|
||||
|
||||
echo
|
||||
|
||||
# =====================================================================
|
||||
# Step 2: Merge resources (locale/density splits) — best-effort
|
||||
# =====================================================================
|
||||
|
||||
echo "=== Step 2: Merge Resources (best-effort) ==="
|
||||
|
||||
if [[ "$SKIP_RESOURCES" == true ]]; then
|
||||
info "Resource merge skipped (--skip-resources)."
|
||||
for rs in "${resource_splits[@]}"; do
|
||||
fname=$(basename "$rs")
|
||||
echo "SKIPPED_RESOURCES:$fname:user-requested"
|
||||
done
|
||||
elif (( ${#resource_splits[@]} == 0 )); then
|
||||
info "No resource splits found — skipping."
|
||||
else
|
||||
TMPDIR_RES=$(mktemp -d "${TMPDIR:-/tmp}/merge-res-XXXXXX")
|
||||
cleanup_res() { rm -rf "$TMPDIR_RES"; }
|
||||
trap cleanup_res EXIT
|
||||
|
||||
for split_apk in "${resource_splits[@]}"; do
|
||||
split_name=$(basename "$split_apk")
|
||||
|
||||
# Extract everything except META-INF/ and resources.arsc (cannot merge compiled resources)
|
||||
rm -rf "$TMPDIR_RES/"*
|
||||
unzip -qo "$split_apk" -d "$TMPDIR_RES" -x "META-INF/*" "resources.arsc" 2>/dev/null || true
|
||||
|
||||
# Count extracted files (excluding directories)
|
||||
file_count=0
|
||||
while IFS= read -r -d '' f; do
|
||||
(( file_count++ ))
|
||||
done < <(find "$TMPDIR_RES" -type f -print0 2>/dev/null)
|
||||
|
||||
if (( file_count == 0 )); then
|
||||
echo "SKIPPED_RESOURCES:$split_name:no-extractable-files"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Copy res/ contents if present
|
||||
res_copied=0
|
||||
if [[ -d "$TMPDIR_RES/res" ]]; then
|
||||
# Copy resource directories (values-*, drawable-*, etc.)
|
||||
for res_subdir in "$TMPDIR_RES/res/"*; do
|
||||
[[ -d "$res_subdir" ]] || continue
|
||||
subdir_name=$(basename "$res_subdir")
|
||||
mkdir -p "$DECODED_DIR/res/$subdir_name"
|
||||
for res_file in "$res_subdir/"*; do
|
||||
[[ -f "$res_file" ]] || continue
|
||||
# Don't overwrite existing resources from the base APK
|
||||
target="$DECODED_DIR/res/$subdir_name/$(basename "$res_file")"
|
||||
if [[ ! -f "$target" ]]; then
|
||||
cp "$res_file" "$target"
|
||||
(( res_copied++ ))
|
||||
fi
|
||||
done
|
||||
done
|
||||
fi
|
||||
|
||||
# Copy assets/ contents if present
|
||||
assets_copied=0
|
||||
if [[ -d "$TMPDIR_RES/assets" ]]; then
|
||||
mkdir -p "$DECODED_DIR/assets"
|
||||
while IFS= read -r -d '' asset_file; do
|
||||
rel_path="${asset_file#$TMPDIR_RES/assets/}"
|
||||
target="$DECODED_DIR/assets/$rel_path"
|
||||
if [[ ! -f "$target" ]]; then
|
||||
mkdir -p "$(dirname "$target")"
|
||||
cp "$asset_file" "$target"
|
||||
(( assets_copied++ ))
|
||||
fi
|
||||
done < <(find "$TMPDIR_RES/assets" -type f -print0 2>/dev/null)
|
||||
fi
|
||||
|
||||
total_copied=$(( res_copied + assets_copied ))
|
||||
if (( total_copied > 0 )); then
|
||||
ok "Merged $total_copied files from $split_name"
|
||||
echo "MERGE_RESOURCES:$split_name ($total_copied files)"
|
||||
else
|
||||
echo "SKIPPED_RESOURCES:$split_name:all-files-already-exist-in-base"
|
||||
fi
|
||||
done
|
||||
|
||||
cleanup_res
|
||||
trap - EXIT
|
||||
fi
|
||||
|
||||
echo
|
||||
|
||||
# =====================================================================
|
||||
# Step 3: Patch AndroidManifest.xml
|
||||
# =====================================================================
|
||||
|
||||
echo "=== Step 3: Patch AndroidManifest.xml ==="
|
||||
|
||||
MANIFEST="$DECODED_DIR/AndroidManifest.xml"
|
||||
|
||||
if [[ ! -f "$MANIFEST" ]]; then
|
||||
fail "AndroidManifest.xml not found in $DECODED_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3a: Remove android:isSplitRequired="true" → set to "false"
|
||||
if grep -q 'android:isSplitRequired="true"' "$MANIFEST"; then
|
||||
sed -i 's/android:isSplitRequired="true"/android:isSplitRequired="false"/g' "$MANIFEST"
|
||||
ok "Patched: isSplitRequired=true → false"
|
||||
echo "MANIFEST_CLEANED:isSplitRequired"
|
||||
fi
|
||||
|
||||
# 3b: Remove split-related <meta-data> elements
|
||||
# Each pattern removes the entire <meta-data ... /> tag (single-line or multi-line)
|
||||
declare -a meta_names=(
|
||||
"com.android.vending.splits.required"
|
||||
"com.android.vending.splits"
|
||||
"com.android.vending.derived.apk.id"
|
||||
"com.android.stamp.source"
|
||||
"com.android.stamp.type"
|
||||
)
|
||||
|
||||
for meta_name in "${meta_names[@]}"; do
|
||||
if grep -q "android:name=\"$meta_name\"" "$MANIFEST"; then
|
||||
# Remove single-line <meta-data ... />
|
||||
sed -i "/<meta-data[^>]*android:name=\"$meta_name\"[^>]*\/>/d" "$MANIFEST"
|
||||
# Remove multi-line <meta-data ... > ... </meta-data> (rare but handle it)
|
||||
# Use a simple approach: remove lines containing the meta-data name within meta-data blocks
|
||||
sed -i "/<meta-data[^>]*android:name=\"$meta_name\"/,/<\/meta-data>/d" "$MANIFEST"
|
||||
ok "Removed: <meta-data android:name=\"$meta_name\">"
|
||||
echo "MANIFEST_CLEANED:$meta_name"
|
||||
fi
|
||||
done
|
||||
|
||||
# 3c: Remove hasFragileUserData if present (split-related cleanup)
|
||||
if grep -q 'android:hasFragileUserData' "$MANIFEST"; then
|
||||
sed -i 's/ *android:hasFragileUserData="[^"]*"//g' "$MANIFEST"
|
||||
info "Removed: android:hasFragileUserData (split-related)"
|
||||
fi
|
||||
|
||||
# 3d: Fix extractNativeLibs — change false to true when we have merged native libs
|
||||
if [[ -d "$DECODED_DIR/lib" ]] && [[ -n "$(ls -A "$DECODED_DIR/lib/" 2>/dev/null)" ]]; then
|
||||
if grep -q 'android:extractNativeLibs="false"' "$MANIFEST"; then
|
||||
sed -i 's/android:extractNativeLibs="false"/android:extractNativeLibs="true"/g' "$MANIFEST"
|
||||
ok "Patched: extractNativeLibs=false → true (required for merged native libs)"
|
||||
echo "MANIFEST_CLEANED:extractNativeLibs→true"
|
||||
elif ! grep -q 'android:extractNativeLibs' "$MANIFEST"; then
|
||||
# Add extractNativeLibs="true" to the <application> tag
|
||||
sed -i 's/<application/<application android:extractNativeLibs="true"/' "$MANIFEST"
|
||||
ok "Added: extractNativeLibs=true to <application>"
|
||||
echo "MANIFEST_CLEANED:extractNativeLibs→true (added)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3e: Remove android:splitTypes attribute if present
|
||||
if grep -q 'android:splitTypes=' "$MANIFEST"; then
|
||||
sed -i 's/ *android:splitTypes="[^"]*"//g' "$MANIFEST"
|
||||
ok "Removed: android:splitTypes"
|
||||
echo "MANIFEST_CLEANED:splitTypes"
|
||||
fi
|
||||
|
||||
# 3f: Remove android:requiredSplitTypes attribute if present
|
||||
if grep -q 'android:requiredSplitTypes=' "$MANIFEST"; then
|
||||
sed -i 's/ *android:requiredSplitTypes="[^"]*"//g' "$MANIFEST"
|
||||
ok "Removed: android:requiredSplitTypes"
|
||||
echo "MANIFEST_CLEANED:requiredSplitTypes"
|
||||
fi
|
||||
|
||||
echo
|
||||
|
||||
# =====================================================================
|
||||
# Step 4: Create merge marker
|
||||
# =====================================================================
|
||||
|
||||
echo "=== Step 4: Finalize ==="
|
||||
|
||||
# Create .merged marker so rebuild-apk.sh knows to produce a single APK
|
||||
touch "$XAPK_ORIGIN_DIR/.merged"
|
||||
ok "Created merge marker: $XAPK_ORIGIN_DIR/.merged"
|
||||
|
||||
# Write merge metadata
|
||||
merged_ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ" 2>/dev/null || date +"%Y-%m-%dT%H:%M:%SZ")
|
||||
{
|
||||
echo "{"
|
||||
echo " \"merged_timestamp\": \"$merged_ts\","
|
||||
echo " \"abi_splits_merged\": ${#abi_splits[@]},"
|
||||
echo " \"resource_splits_merged\": ${#resource_splits[@]},"
|
||||
echo " \"feature_splits_skipped\": ${#feature_splits[@]},"
|
||||
echo " \"skip_resources\": $SKIP_RESOURCES"
|
||||
echo "}"
|
||||
} > "$XAPK_ORIGIN_DIR/.merged-metadata.json"
|
||||
|
||||
echo
|
||||
echo "=== Merge Complete ==="
|
||||
|
||||
# Summary
|
||||
echo
|
||||
echo "Merged splits into: $DECODED_DIR"
|
||||
if (( ${#feature_splits[@]} > 0 )); then
|
||||
echo
|
||||
echo "WARNING: ${#feature_splits[@]} feature module split(s) could NOT be merged."
|
||||
echo " Feature modules contain their own DEX code and manifest entries."
|
||||
echo " The merged APK may be missing functionality from these modules."
|
||||
for fs in "${feature_splits[@]}"; do
|
||||
echo " - $(basename "$fs")"
|
||||
done
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "MERGE_COMPLETE:$DECODED_DIR"
|
||||
echo
|
||||
echo "Next: rebuild with --single-apk flag or let rebuild-apk.sh auto-detect the .merged marker."
|
||||
echo " rebuild-apk.sh $DECODED_DIR --auto-keystore"
|
||||
|
||||
exit 0
|
||||
|
|
@ -20,13 +20,17 @@ Usage: rebuild-apk.sh <decoded-dir> [OPTIONS]
|
|||
|
||||
Rebuild an apktool-decoded APK directory back into a signed APK.
|
||||
If the decoded dir contains .xapk-origin/ (from decode-apk.sh), automatically
|
||||
reassembles a complete XAPK with all split APKs re-signed.
|
||||
reassembles a complete XAPK with all split APKs re-signed — unless --single-apk
|
||||
is used or .xapk-origin/.merged exists (from merge-splits.sh), in which case
|
||||
a single merged APK is produced instead.
|
||||
|
||||
Arguments:
|
||||
<decoded-dir> Path to the apktool-decoded APK directory
|
||||
|
||||
Options:
|
||||
-o, --output <file> Output path (default: <decoded-dir>-neutralized.apk/.xapk)
|
||||
--single-apk Force output as single .apk even if .xapk-origin/ exists
|
||||
(auto-enabled when .xapk-origin/.merged marker is present)
|
||||
--auto-keystore Auto-detect best keystore: ~/.android/debug.keystore,
|
||||
then previous .neutralizer-debug.keystore, then generate new
|
||||
--debug-key Sign with an auto-generated debug keystore (default)
|
||||
|
|
@ -70,6 +74,7 @@ DO_ZIPALIGN=true
|
|||
NO_ZIPALIGN=false
|
||||
FORCE_NO_RES=false
|
||||
BUILD_USED_NO_RES=false
|
||||
SINGLE_APK=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
|
|
@ -95,6 +100,7 @@ while [[ $# -gt 0 ]]; do
|
|||
shift
|
||||
if [[ $# -eq 0 ]]; then echo "Error: --store-pass requires an argument" >&2; exit 1; fi
|
||||
STORE_PASS="$1"; shift ;;
|
||||
--single-apk) SINGLE_APK=true; shift ;;
|
||||
--no-sign) DO_SIGN=false; shift ;;
|
||||
--no-res) FORCE_NO_RES=true; shift ;;
|
||||
--zipalign) DO_ZIPALIGN=true; shift ;;
|
||||
|
|
@ -122,10 +128,16 @@ if [[ -f "$XAPK_ORIGIN_DIR/metadata.json" ]]; then
|
|||
IS_XAPK=true
|
||||
fi
|
||||
|
||||
# Default output name — .xapk if original was XAPK, .apk otherwise
|
||||
# Auto-detect merged marker from merge-splits.sh
|
||||
if [[ -f "$XAPK_ORIGIN_DIR/.merged" ]]; then
|
||||
SINGLE_APK=true
|
||||
echo "[INFO] Detected .xapk-origin/.merged marker — will produce a single merged APK"
|
||||
fi
|
||||
|
||||
# Default output name — .xapk if original was XAPK (and not merging), .apk otherwise
|
||||
if [[ -z "$OUTPUT" ]]; then
|
||||
local_dir="${DECODED_DIR%/}"
|
||||
if [[ "$IS_XAPK" == true ]]; then
|
||||
if [[ "$IS_XAPK" == true ]] && [[ "$SINGLE_APK" == false ]]; then
|
||||
OUTPUT="${local_dir}-neutralized.xapk"
|
||||
else
|
||||
OUTPUT="${local_dir}-neutralized.apk"
|
||||
|
|
@ -435,7 +447,7 @@ fi
|
|||
# Step 5: XAPK assembly (if original was XAPK)
|
||||
# =====================================================================
|
||||
|
||||
if [[ "$IS_XAPK" == true ]] && [[ "$DO_SIGN" == true ]]; then
|
||||
if [[ "$IS_XAPK" == true ]] && [[ "$DO_SIGN" == true ]] && [[ "$SINGLE_APK" == false ]]; then
|
||||
echo
|
||||
echo "=== Assembling XAPK ==="
|
||||
|
||||
|
|
@ -514,8 +526,10 @@ fi
|
|||
echo
|
||||
echo "=== Rebuild Complete ==="
|
||||
|
||||
if [[ "$IS_XAPK" == true ]]; then
|
||||
if [[ "$IS_XAPK" == true ]] && [[ "$SINGLE_APK" == false ]]; then
|
||||
echo "Output XAPK: $OUTPUT"
|
||||
elif [[ "$IS_XAPK" == true ]] && [[ "$SINGLE_APK" == true ]]; then
|
||||
echo "Output APK (merged from XAPK): $OUTPUT"
|
||||
else
|
||||
echo "Output APK: $OUTPUT"
|
||||
fi
|
||||
|
|
@ -543,10 +557,16 @@ fi
|
|||
|
||||
echo
|
||||
echo "WARNING: Play Integrity / SafetyNet will FAIL — expected for enterprise sideloading."
|
||||
if [[ "$IS_XAPK" == true ]]; then
|
||||
if [[ "$IS_XAPK" == true ]] && [[ "$SINGLE_APK" == false ]]; then
|
||||
echo "Install via: adb install-multiple <base.apk> <split1.apk> <split2.apk> ..."
|
||||
echo " or: use a split APK installer (e.g., SAI — Split APKs Installer)"
|
||||
echo " or: unzip the XAPK and run: adb install-multiple *.apk"
|
||||
elif [[ "$IS_XAPK" == true ]] && [[ "$SINGLE_APK" == true ]]; then
|
||||
echo "Install via: adb install $OUTPUT"
|
||||
echo
|
||||
echo "NOTE: This is a merged single APK from an XAPK split bundle."
|
||||
echo " Some locale/density-specific resources may use defaults."
|
||||
echo " Test thoroughly on target devices."
|
||||
else
|
||||
echo "Install via: adb install $OUTPUT"
|
||||
fi
|
||||
|
|
|
|||
Loading…
Reference in New Issue