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:
Simone Avogadro 2026-03-09 15:16:51 +01:00
parent e70920cf87
commit 3d68cf392f
5 changed files with 555 additions and 15 deletions

View File

@ -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/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/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/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 ## 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) # 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] 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) # Merge XAPK splits into decoded base for single APK output (optional, for XAPK input)
bash rebuild-apk.sh <decoded-dir> [--auto-keystore|--debug-key|--keystore <file>] [-o <output>] [--no-sign] [--no-res] [--zipalign] 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 ## 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. 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 ## Conventions
- Line endings are LF (enforced via `.gitattributes` for WSL/Windows compatibility) - Line endings are LF (enforced via `.gitattributes` for WSL/Windows compatibility)

View File

@ -1,6 +1,6 @@
{ {
"name": "android-reverse-engineering", "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.", "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": { "author": {
"name": "Simone Avogadro" "name": "Simone Avogadro"

View File

@ -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/manifest.json` — original XAPK manifest
- `.xapk-origin/splits/` — all split APKs (config.arm64_v8a.apk, config.en.apk, etc.) - `.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 ### 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). 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: **Before calling rebuild**, you **MUST ask the user** their signing preference:
> How would you like to sign the rebuilt APK? > 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 2 → `--keystore <file> --key-alias <alias> --store-pass <pass> --key-pass <pass>`
- Option 3 → `--no-sign` - Option 3 → `--no-sign`
#### Phase 5c — Run Rebuild
**Action**: Run the rebuild script with the chosen signing option. **Action**: Run the rebuild script with the chosen signing option.
```bash ```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 bash ${CLAUDE_PLUGIN_ROOT}/skills/sdk-neutralizer/scripts/rebuild-apk.sh <decoded-dir> --auto-keystore
``` ```
Options: Options:
- `-o <output>` — custom output path - `-o <output>` — custom output path
- `--single-apk` — force single APK output (auto-enabled when `.merged` marker exists)
- `--auto-keystore` — auto-detect best keystore (recommended) - `--auto-keystore` — auto-detect best keystore (recommended)
- `--debug-key` — always generate new debug keystore - `--debug-key` — always generate new debug keystore
- `--keystore <file>` — use a custom keystore - `--keystore <file>` — use a custom keystore
- `--no-sign` — output unsigned APK - `--no-sign` — output unsigned APK
- `--zipalign` / `--no-zipalign` — control zipalign step - `--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 ### 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, property laws. This tool is intended for authorized enterprise use, security research,
and privacy compliance only. 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 ## Output
- Sanitized APK/XAPK: `<path>` - 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 - Signed with: auto-detected debug key / generated debug key / custom keystore
- Keystore used: `<path>` (source: `KEYSTORE_SOURCE:` value) - 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` - For XAPK: can also use SAI (Split APKs Installer) or unzip and `adb install-multiple *.apk`
``` ```

View File

@ -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

View File

@ -20,13 +20,17 @@ Usage: rebuild-apk.sh <decoded-dir> [OPTIONS]
Rebuild an apktool-decoded APK directory back into a signed APK. Rebuild an apktool-decoded APK directory back into a signed APK.
If the decoded dir contains .xapk-origin/ (from decode-apk.sh), automatically 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: Arguments:
<decoded-dir> Path to the apktool-decoded APK directory <decoded-dir> Path to the apktool-decoded APK directory
Options: Options:
-o, --output <file> Output path (default: <decoded-dir>-neutralized.apk/.xapk) -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, --auto-keystore Auto-detect best keystore: ~/.android/debug.keystore,
then previous .neutralizer-debug.keystore, then generate new then previous .neutralizer-debug.keystore, then generate new
--debug-key Sign with an auto-generated debug keystore (default) --debug-key Sign with an auto-generated debug keystore (default)
@ -70,6 +74,7 @@ DO_ZIPALIGN=true
NO_ZIPALIGN=false NO_ZIPALIGN=false
FORCE_NO_RES=false FORCE_NO_RES=false
BUILD_USED_NO_RES=false BUILD_USED_NO_RES=false
SINGLE_APK=false
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
@ -95,6 +100,7 @@ while [[ $# -gt 0 ]]; do
shift shift
if [[ $# -eq 0 ]]; then echo "Error: --store-pass requires an argument" >&2; exit 1; fi if [[ $# -eq 0 ]]; then echo "Error: --store-pass requires an argument" >&2; exit 1; fi
STORE_PASS="$1"; shift ;; STORE_PASS="$1"; shift ;;
--single-apk) SINGLE_APK=true; shift ;;
--no-sign) DO_SIGN=false; shift ;; --no-sign) DO_SIGN=false; shift ;;
--no-res) FORCE_NO_RES=true; shift ;; --no-res) FORCE_NO_RES=true; shift ;;
--zipalign) DO_ZIPALIGN=true; shift ;; --zipalign) DO_ZIPALIGN=true; shift ;;
@ -122,10 +128,16 @@ if [[ -f "$XAPK_ORIGIN_DIR/metadata.json" ]]; then
IS_XAPK=true IS_XAPK=true
fi 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 if [[ -z "$OUTPUT" ]]; then
local_dir="${DECODED_DIR%/}" local_dir="${DECODED_DIR%/}"
if [[ "$IS_XAPK" == true ]]; then if [[ "$IS_XAPK" == true ]] && [[ "$SINGLE_APK" == false ]]; then
OUTPUT="${local_dir}-neutralized.xapk" OUTPUT="${local_dir}-neutralized.xapk"
else else
OUTPUT="${local_dir}-neutralized.apk" OUTPUT="${local_dir}-neutralized.apk"
@ -435,7 +447,7 @@ fi
# Step 5: XAPK assembly (if original was XAPK) # 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
echo "=== Assembling XAPK ===" echo "=== Assembling XAPK ==="
@ -514,8 +526,10 @@ fi
echo echo
echo "=== Rebuild Complete ===" echo "=== Rebuild Complete ==="
if [[ "$IS_XAPK" == true ]]; then if [[ "$IS_XAPK" == true ]] && [[ "$SINGLE_APK" == false ]]; then
echo "Output XAPK: $OUTPUT" echo "Output XAPK: $OUTPUT"
elif [[ "$IS_XAPK" == true ]] && [[ "$SINGLE_APK" == true ]]; then
echo "Output APK (merged from XAPK): $OUTPUT"
else else
echo "Output APK: $OUTPUT" echo "Output APK: $OUTPUT"
fi fi
@ -543,10 +557,16 @@ fi
echo echo
echo "WARNING: Play Integrity / SafetyNet will FAIL — expected for enterprise sideloading." 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 "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: use a split APK installer (e.g., SAI — Split APKs Installer)"
echo " or: unzip the XAPK and run: adb install-multiple *.apk" 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 else
echo "Install via: adb install $OUTPUT" echo "Install via: adb install $OUTPUT"
fi fi