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.
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.