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/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)
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue