Re: PSA: How to archive your EXACT homescreen(s) apps to Windows for perfect cross-platform backup/restore sans the net
Maria Sophia <[email protected]>
| Newsgroups | comp.mobile.android,alt.comp.microsoft.windows,alt.comp.os.windows-10 |
|---|---|
| Organization | BWH Usenet Archive (https://usenet.blueworldhosting.com) |
| Message-ID | <[email protected]> |
Maria Sophia wrote:
> I added the version in a manner that is easily stripped out later
> during the re-installation process, where the goal is to be able
> to replicate the *exact* homescreen and app hierarchy on any phone.
# -------------------------------------------------------------------
# apkhome.py
# Parse nova.db, build homescreen folder/app skeleton and pull APKs
# python apkhome.py
# -------------------------------------------------------------------
# v1p9 20260819 Added missing/uninstalled app tracking list and report
# v1p8 20260819 Added _versio_ naming convention for package versioning
# v1p7 20260819 Added end-of-run execution summary and tracking metrics
# v1p6 20260819 Added optional cleanup prompt for existing ./apkhome directory
# v1p5 20260819 Added custom fallback path for scrcpy's bundled adb.exe
# v1p4 20260819 Added log file output (apkhome.log) alongside console
# v1p3 20260819 Added Ctrl+C (KeyboardInterrupt) handling & cleanup
# v1p2 20260819 Added interactive debug mode toggle for dry runs
# v1p1 20260819 Added automated ADB split/monolithic APK pulling
# v1p0 20260819 Combined SQL parsing and direct directory tree generation
# -------------------------------------------------------------------
# Prerequisite: "nova.db" SQL database file created using the
# last-known-good-version of the Tesla Coil Nova Launcher 7.0.57
# <https://mobile.softpedia.com/apk/nova-launcher/7.0.57/>
#
# A. On Android, Nova Settings > Backup & restore > Backup > Save
# /storage/emulated/0/0000/bck/nova_backup/2026-08-19_11-31.novabackup
# B. Copy the nova backup to the desktop & rename it to a zip extension
# adb pull 2026-08-19_11-31.novabackup 2026-08-19_11-31.novabackup.zip
# C. Unzip using 7-zip (or equivalent)
# 08/19/2026 11:31 AM 7,430,144 nova.db
# 08/19/2026 11:31 AM 6,455 nova.xml
# 08/19/2026 11:31 AM 4,818 supportDetails.txt
#
# Tested on Android 13 USA-spec Samsung Galaxy A32-5G (locked bootloader)
# Tested on Windows 10, with the phone on the LAN over Wi-Fi (not USB)
# -------------------------------------------------------------------
# Creates a duplicate inventory of Android homescreen panels, folders,
# app icons, loose desktop shortcuts and Android application packages
# extracted from the Nova Launcher internal databases (nova.db).
#
# Reads nova.db, re-creates the homescreen folder and package hierarchy,
# checks for a connected ADB device, runs adb shell pm path <package>
# to dynamically locate and pull component files (base.apk plus splits)
# feeding results into the newly created matching homescreen hierarchy.
# -------------------------------------------------------------------
# Nova Launcher is a third-party customization interface for Android
# devices that replaces the default homescreen. Nova organizes apps
# into custom desktop grids, user-defined folders and dedicated dock
# panels. This archive maps out that exact physical layout and group
# structure for long-term preservation and system migration reference.
# -------------------------------------------------------------------
# WIP:
# Need to consider whether to rename the base.apk to the package name
# Need to consider whether to zip up splits
# Both might complicate the "adb install multiple" process though
# -------------------------------------------------------------------
# Sample output
# com.simplemobiletools.clock_versio_5.11.2
# 08/19/2026 09:04 PM 10,004,630 base.apk
# 1 File(s) 10,004,630 bytes
#
# com.trianguloy.instantintent_versio_0.1
# 08/19/2026 09:06 PM 47,835 base.apk
# 08/19/2026 09:06 PM 10,221 split_config.xhdpi.apk
# 2 File(s) 58,056 bytes
#
# com.chibatching.worldclockwidget_versio_20260316.0
# 08/19/2026 09:04 PM 12,027,785 base.apk
# 08/19/2026 09:04 PM 57,672 split_config.arm64_v8a.apk
# 08/19/2026 09:04 PM 58,511 split_config.xhdpi.apk
# 3 File(s) 12,143,968 bytes
#
# com.neuracle.zulutime_versio_1.5
# 08/19/2026 09:03 PM 21,619,972 base.apk
# 08/19/2026 09:03 PM 25,650,697 split_config.arm64_v8a.apk
# 08/19/2026 09:03 PM 45,465 split_config.en.apk
# 08/19/2026 09:03 PM 67,159 split_config.xhdpi.apk
# 4 File(s) 47,383,293 bytes
#
# pixer.worldclock_versio_1.0.29
# 08/19/2026 09:03 PM 6,161,133 base.apk
# 08/19/2026 09:03 PM 14,546,665 split_config.arm64_v8a.apk
# 08/19/2026 09:03 PM 29,018 split_config.en.apk
# 08/19/2026 09:03 PM 16,730 split_config.es.apk
# 08/19/2026 09:03 PM 68,714 split_config.xhdpi.apk
# 5 File(s) 20,822,260 bytes
# -------------------------------------------------------------------
import os
import re
import shutil
import sqlite3
import subprocess
db_file = "nova.db"
base_dir = "./apkhome"
log_filename = "apkhome.log"
# Explicit fallback path for ADB based on a typical scrcpy setup
CUSTOM_ADB_PATH = r"C:\app\editor\android\scrcpy\adb.exe"
# Tracking metrics for summary
stats = {
"screens_processed": 0,
"folders_processed": 0,
"loose_apps_processed": 0,
"apps_attempted": 0,
"apps_success": 0,
"apps_failed": 0
}
# List to track missing/uninstalled packages for the final report
missing_apps_list = []
if not os.path.exists(db_file):
print(f"Error: {db_file} not found. Please place it in the working directory.")
exit(1)
# Open log file for writing
log_file = open(log_filename, "w", encoding="utf-8")
def log_print(message):
"""Prints to console and writes to the log file simultaneously."""
print(message)
log_file.write(message + "\n")
log_file.flush()
# Helper to find the correct adb executable command/path
def get_adb_binary():
# 1. Try global 'adb' command first
try:
subprocess.run(["adb", "version"], capture_output=True, text=True, check=True)
return "adb"
except (subprocess.SubprocessError, FileNotFoundError):
pass
# 2. Try the custom scrcpy path fallback
if os.path.exists(CUSTOM_ADB_PATH):
return CUSTOM_ADB_PATH
return None
def get_app_version(binary, pkg):
"""Queries package version name via ADB, returns 'unknown' if it fails."""
try:
res = subprocess.run(
[binary, "shell", "dumpsys", "package", pkg],
capture_output=True, text=True, timeout=5
)
for line in res.stdout.splitlines():
if "versionName=" in line:
parts = line.strip().split("versionName=")
if len(parts) > 1:
raw_ver = parts[1].split()[0]
return re.sub(r'[\\/*?:"<>|]', "", raw_ver)
except Exception:
pass
return "unknown"
# --- Setup & modes prompt ---
log_print("--- Setup Mode ---")
mode_choice = input("Run in [D]ebug mode (mock files, no phone needed) or [L]ive mode (real ADB pull)? [d/l]: ").strip().lower()
log_file.write(f"User selected mode choice: {mode_choice}\n")
DEBUG_MODE = mode_choice.startswith('d')
# --- Existing directory handling ---
if os.path.exists(base_dir):
clean_choice = input(f"Existing '{base_dir}' folder detected. [C]lean/wipe it completely or [K]eep existing files? [c/k]: ").strip().lower()
log_file.write(f"User selected directory cleanup choice: {clean_choice}\n")
if clean_choice.startswith('c'):
try:
shutil.rmtree(base_dir)
log_print(f"[i] Successfully wiped existing '{base_dir}' directory.")
except Exception as e:
log_print(f"[!] Warning: Failed to fully remove '{base_dir}': {e}")
else:
log_print(f"[i] Keeping existing '{base_dir}' directory (merging/updating content).")
adb_binary = None
adb_active = False
if DEBUG_MODE:
log_print("\n[!] Running in DEBUG mode. ADB will be bypassed, and dummy APKs will be created.")
else:
log_print("\n[i] Running in LIVE mode. Locating ADB binary...")
adb_binary = get_adb_binary()
if not adb_binary:
log_print(f"Warning: 'adb' not found in PATH or at custom path ({CUSTOM_ADB_PATH}). Skipping APK pulling.")
adb_active = False
else:
log_print(f"Using ADB binary at: {adb_binary}")
try:
adb_check = subprocess.run([adb_binary, "devices"], capture_output=True, text=True, check=True)
devices = [line for line in adb_check.stdout.splitlines() if "\tdevice" in line]
if not devices:
log_print("Warning: No active ADB devices found. Directory skeleton will be built, but APK pulling will be skipped.")
adb_active = False
else:
log_print(f"ADB device detected: {devices[0].split()[0]}")
adb_active = True
except (subprocess.SubprocessError, FileNotFoundError):
log_print("Warning: Failed to execute ADB. Skipping APK pulling.")
adb_active = False
log_print(f"\nConnecting to {db_file} and processing layout...")
conn = sqlite3.connect(db_file)
cursor = conn.cursor()
try:
# 1. Fetch distinct screen indices associated with desktop items
cursor.execute(
"SELECT DISTINCT screen FROM favorites WHERE container = -100 ORDER BY screen ASC;"
)
screens = cursor.fetchall()
for (screen_num,) in screens:
stats["screens_processed"] += 1
current_panel = f"panel_{screen_num}"
log_print(f"\n--- Processing: {current_panel} ---")
# 2. Find folders located directly on this specific screen (ignoring blank titles)
cursor.execute(
"SELECT _id, title FROM favorites WHERE container = -100 AND screen = ?"
" AND intent IS NULL AND title IS NOT NULL AND TRIM(title) != '';",
(screen_num,),
)
folders = cursor.fetchall()
for folder_id, folder_name in folders:
stats["folders_processed"] += 1
clean_folder_name = re.sub(r'[\\/*?:"<>|]', "", folder_name).strip()
if not clean_folder_name:
clean_folder_name = "unnamed_folder"
cursor.execute(
"SELECT title, intent FROM favorites WHERE container = ?;",
(folder_id,),
)
items = cursor.fetchall()
if items:
for app_title, intent in items:
stats["apps_attempted"] += 1
pkg = "Unknown"
if intent and "component=" in intent:
try:
part = intent.split("component=")[1]
pkg = part.split("/")[0]
except IndexError:
pkg = intent
# Fetch version if possible to build _versio_ suffix
version_str = "unknown"
if pkg != "Unknown":
if DEBUG_MODE:
version_str = "1.0.0-debug"
elif adb_active:
version_str = get_app_version(adb_binary, pkg)
pkg_folder_name = f"{pkg}_versio_{version_str}" if pkg != "Unknown" else pkg
target_path = os.path.join(
base_dir, current_panel, clean_folder_name, pkg_folder_name
)
os.makedirs(target_path, exist_ok=True)
log_print(f" [Folder] {clean_folder_name} -> App: {pkg} (v:{version_str})")
# Pull APK or generate mock file depending on mode
if DEBUG_MODE and pkg != "Unknown":
dummy_apk_path = os.path.join(target_path, "base.apk")
with open(dummy_apk_path, "w") as f:
f.write("mock apk content")
log_print(f" [Mock ADB] Created test file at {dummy_apk_path}")
stats["apps_success"] += 1
elif adb_active and pkg != "Unknown":
path_cmd = subprocess.run(
[adb_binary, "shell", "pm", "path", pkg],
capture_output=True, text=True
)
pulled_any = False
for line in path_cmd.stdout.splitlines():
if line.startswith("package:"):
remote_path = line.replace("package:", "").strip()
if remote_path:
pulled_any = True
subprocess.run(
[adb_binary, "pull", remote_path, target_path],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
if pulled_any:
stats["apps_success"] += 1
else:
stats["apps_failed"] += 1
missing_apps_list.append(f"{current_panel} / {clean_folder_name} / {pkg}")
log_print(f" [ADB] Warning: Could not find path for package (may be uninstalled): {pkg}")
else:
stats["apps_failed"] += 1
else:
target_path = os.path.join(base_dir, current_panel, clean_folder_name)
os.makedirs(target_path, exist_ok=True)
# 3. Find loose/top-level apps sitting directly on this screen
cursor.execute(
"SELECT title, intent FROM favorites WHERE container = -100 AND screen = ?"
" AND intent IS NOT NULL;",
(screen_num,),
)
loose_apps = cursor.fetchall()
if loose_apps:
for app_title, intent in loose_apps:
stats["loose_apps_processed"] += 1
stats["apps_attempted"] += 1
pkg = "Unknown"
if intent and "component=" in intent:
try:
part = intent.split("component=")[1]
pkg = part.split("/")[0]
except IndexError:
pkg = intent
version_str = "unknown"
if pkg != "Unknown":
if DEBUG_MODE:
version_str = "1.0.0-debug"
elif adb_active:
version_str = get_app_version(adb_binary, pkg)
pkg_folder_name = f"{pkg}_versio_{version_str}" if pkg != "Unknown" else pkg
target_path = os.path.join(
base_dir, current_panel, "loose_apps", pkg_folder_name
)
os.makedirs(target_path, exist_ok=True)
log_print(f" [Loose App] {pkg} (v:{version_str})")
if DEBUG_MODE and pkg != "Unknown":
dummy_apk_path = os.path.join(target_path, "base.apk")
with open(dummy_apk_path, "w") as f:
f.write("mock apk content")
log_print(f" [Mock ADB] Created test file at {dummy_apk_path}")
stats["apps_success"] += 1
elif adb_active and pkg != "Unknown":
path_cmd = subprocess.run(
[adb_binary, "shell", "pm", "path", pkg],
capture_output=True, text=True
)
pulled_any = False
for line in path_cmd.stdout.splitlines():
if line.startswith("package:"):
remote_path = line.replace("package:", "").strip()
if remote_path:
pulled_any = True
subprocess.run(
[adb_binary, "pull", remote_path, target_path],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
if pulled_any:
stats["apps_success"] += 1
else:
stats["apps_failed"] += 1
missing_apps_list.append(f"{current_panel} / loose_apps / {pkg}")
log_print(f" [ADB] Warning: Could not find path for package (may be uninstalled): {pkg}")
else:
stats["apps_failed"] += 1
# --- Execution summary block ---
summary_text = (
"\n"
"========================================\n"
" Execution summary\n"
"========================================\n"
f"Panels/Screens Processed: {stats['screens_processed']}\n"
f"Folders Discovered: {stats['folders_processed']}\n"
f"Loose Apps Discovered: {stats['loose_apps_processed']}\n"
f"----------------------------------------\n"
f"Total App References: {stats['apps_attempted']}\n"
f"Successfully Pulled APKs: {stats['apps_success']}\n"
f"Missing / Uninstalled: {stats['apps_failed']}\n"
"========================================\n"
)
log_print(summary_text)
# --- Missing apps detailed report ---
if missing_apps_list:
missing_report = "Missing / Uninstalled Apps Breakdown:\n"
for item in missing_apps_list:
missing_report += f" - {item}\n"
missing_report += "========================================\n"
log_print(missing_report)
except KeyboardInterrupt:
log_print("\n\n[!] Operation cancelled by user (Ctrl+C). Exiting safely...")
finally:
# Ensure database connection and log file are cleanly closed
conn.close()
log_file.close()
# end of apkhome.py
--
As far as I'm aware, this functionality is needed by everyone on Android.