Skip to content

Instantly share code, notes, and snippets.

@dw5
Created July 10, 2026 14:07
Show Gist options
  • Select an option

  • Save dw5/2918bba090bc9b7478676b0e2f3f1aef to your computer and use it in GitHub Desktop.

Select an option

Save dw5/2918bba090bc9b7478676b0e2f3f1aef to your computer and use it in GitHub Desktop.
#!/usr/bin/env python3
"""
Convert a Lawnchair backup into a Launcher314 restore file.
Reads the `favorites` table out of the Launcher3 SQLite database inside a
.lawnchairbackup zip and emits the JSON document that Launcher314's
`BackupManager.importAll` consumes (Settings -> Backup & Restore -> Restore).
Nothing is written to the phone and the backup is opened read-only.
python lawnchair2l314.py \
--backup "Lawnchair_Backup Jul 10, 2026 13_44_27.lawnchairbackup" \
--base Launcher314-backup.json \
--out Launcher314-restore.json
`--base` should be a backup exported from Launcher314 itself. Restoring clears
every SharedPreferences file it finds in the document, so merging into a real
export is what keeps your icon size, fonts and wallpaper settings intact. It
doubles as the rollback file.
"""
import argparse
import json
import re
import sqlite3
import sys
import tempfile
import uuid
import zipfile
from pathlib import Path
from xml.etree import ElementTree
# --- Launcher3 favorites constants -----------------------------------------
CONTAINER_DESKTOP = -100
CONTAINER_HOTSEAT = -101
ITEM_APPLICATION = 0
ITEM_FOLDER = 2
ITEM_DEEP_SHORTCUT = 6
# --- Launcher314 schema ------------------------------------------------------
# Mirrors the Kotlin data classes. The launcher decodes with the *default*
# kotlinx Json instance (ignoreUnknownKeys = false) inside a try/catch that
# returns empty data on any failure -- so an unknown key or a missing required
# field yields a blank home screen with no error. Hence the strict validator.
#
# data/HomeModels.kt:57-102, data/AppCustomization.kt:9-75
REQUIRED = object() # marker: property has no Kotlin default
# name -> {property: (python types, default-or-REQUIRED)}
SCHEMA = {
"HomeScreenApp": {
"packageName": ((str,), REQUIRED),
"position": ((int,), 0),
"page": ((int,), 0),
"userSerial": ((int, type(None)), None),
},
"DockApp": {
"packageName": ((str,), REQUIRED),
"position": ((int,), REQUIRED),
"page": ((int,), 0),
"userSerial": ((int, type(None)), None),
},
"HomeFolder": {
"id": ((str,), ""),
"name": ((str,), REQUIRED),
"position": ((int,), REQUIRED),
"page": ((int,), 0),
"appPackageNames": ((list,), []),
},
"DockFolder": {
"id": ((str,), ""),
"name": ((str,), REQUIRED),
"position": ((int,), REQUIRED),
"appPackageNames": ((list,), []),
"page": ((int,), 0),
},
"HomeScreenData": {
"apps": ((list,), []),
"dockApps": ((list,), []),
"folders": ((list,), []),
"dockFolders": ((list,), []),
},
}
# HomeScreenApp.position actually has no default in Kotlin; fix the marker.
SCHEMA["HomeScreenApp"]["position"] = ((int,), REQUIRED)
# Every declared property of AppCustomization (data/AppCustomization.kt:10-70).
# All have defaults, so we only need the key set to reject unknown keys.
APP_CUSTOMIZATION_KEYS = {
"customLabel", "hideLabel", "customIconPath", "iconTintColor",
"iconTintBlendMode", "iconTintIntensity", "iconTintBackgroundOnly",
"iconShape", "iconShapeExp", "iconShapeExpV2", "iconSizePercent",
"iconTextSizePercent", "labelFontId", "labelColor", "labelColorIntensity",
"customIconPackName", "hideSourceBadge", "detachedFromGrid", "detachedX",
"detachedY", "detachedScaleX", "detachedScaleY", "iconText",
"iconTextColor", "iconTextColorIntensity", "iconTextFontId",
"iconAsTextSizeSp", "useOriginalIcon", "folderPopupWidthPx",
"folderPopupHeightPx", "folderGridColumns", "folderGridRows",
}
ELEMENT_OF = {
"apps": "HomeScreenApp",
"dockApps": "DockApp",
"folders": "HomeFolder",
"dockFolders": "DockFolder",
}
# --- Folder names ------------------------------------------------------------
# All 27 Lawnchair folders have title = NULL. These names were derived by hand
# from each folder's actual contents; keyed by the folder's Launcher3 row id.
FOLDER_NAMES = {
2: "Media & Stores", 10: "Camera", 25: "Browsers",
29: "Messaging", 38: "System", 39: "Transit & Travel",
50: "Banking", 64: "Calculators", 67: "Shopping",
83: "Security", 86: "VPN & Remote", 100: "Recorders",
103: "Social", 108: "Photos", 110: "Files",
118: "Tools", 120: "Mail & Work", 124: "Utilities",
131: "Notes & Mail", 133: "Cloud & Shopping", 137: "System Info",
144: "VPN", 157: "Network", 158: "Mesh VPN",
165: "Notes & Tasks", 170: "Chat & Work", 192: "Travel",
}
class ConversionError(Exception):
pass
# --- Reading the Lawnchair backup -------------------------------------------
def package_of(intent):
"""Package name from a Launcher3 Intent URI, component= before package=."""
if not intent:
return None
m = re.search(r"component=([^;]+)", intent)
if m:
return m.group(1).split("/", 1)[0]
m = re.search(r"package=([^;]+)", intent)
return m.group(1) if m else None
def read_prefs_xml(text):
root = ElementTree.fromstring(text)
out = {}
for el in root:
name = el.get("name")
if el.tag == "int":
out[name] = int(el.get("value"))
elif el.tag == "boolean":
out[name] = el.get("value") == "true"
elif el.tag == "string":
out[name] = el.text or ""
return out
def read_backup(path):
with zipfile.ZipFile(path) as z:
names = set(z.namelist())
for required in ("launcher.db", "com.android.launcher3.prefs.xml"):
if required not in names:
raise ConversionError(f"{path.name} has no {required} entry")
tmp = Path(tempfile.mkdtemp(prefix="lc2l314-"))
db_path = tmp / "launcher.db"
db_path.write_bytes(z.read("launcher.db"))
prefs = read_prefs_xml(z.read("com.android.launcher3.prefs.xml").decode("utf-8"))
con = sqlite3.connect(f"file:{db_path.as_posix()}?mode=ro", uri=True)
con.row_factory = sqlite3.Row
rows = [dict(r) for r in con.execute("select * from favorites")]
con.close()
return rows, prefs
# --- Conversion --------------------------------------------------------------
def folder_id(lawnchair_id):
"""Stable, deterministic folder UUID (no randomness -- reruns are identical)."""
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"lawnchair-folder-{lawnchair_id}"))
def folder_name(lawnchair_id, mode):
if mode == "blank":
return ""
if mode == "folder":
return "Folder"
return FOLDER_NAMES.get(lawnchair_id, "Folder")
def convert(rows, prefs, folder_names_mode):
cols = prefs.get("pref_workspaceColumns")
grid_rows = prefs.get("pref_workspaceRows")
dock_slots = prefs.get("migration_src_hotseat_count")
if not (cols and grid_rows):
raise ConversionError("prefs xml has no pref_workspaceColumns/pref_workspaceRows")
by_id = {r["_id"]: r for r in rows}
desktop = [r for r in rows if r["container"] == CONTAINER_DESKTOP]
hotseat = [r for r in rows if r["container"] == CONTAINER_HOTSEAT]
pages = max((r["screen"] for r in desktop), default=0) + 1
if not dock_slots:
dock_slots = max((r["screen"] for r in hotseat), default=-1) + 1
dropped = [] # (where, title, why)
def members_of(fid):
kids = sorted((r for r in rows if r["container"] == fid),
key=lambda r: r["rank"])
out, seen = [], set()
for k in kids:
if k["itemType"] == ITEM_DEEP_SHORTCUT:
dropped.append((f"folder {fid}", k["title"],
"deep shortcut - not carriable by Launcher314's backup format"))
continue
if k["itemType"] != ITEM_APPLICATION:
dropped.append((f"folder {fid}", k["title"], f"itemType={k['itemType']}"))
continue
pkg = package_of(k["intent"])
if not pkg:
dropped.append((f"folder {fid}", k["title"], "no package in intent"))
continue
if pkg in seen:
# Launcher314 folders hold plain package names with no profile
# qualifier, so a work-profile copy collides with the personal one.
dropped.append((f"folder {fid}", k["title"],
f"duplicate package {pkg} (profileId={k['profileId']})"))
continue
seen.add(pkg)
out.append(pkg)
return out
def position_of(r):
return r["cellY"] * cols + r["cellX"]
apps, folders, dock_apps, dock_folders = [], [], [], []
for r in desktop:
pos, page = position_of(r), r["screen"]
if pos >= cols * grid_rows:
raise ConversionError(f"row {r['_id']} position {pos} exceeds grid {cols}x{grid_rows}")
if r["itemType"] == ITEM_FOLDER:
folders.append({
"id": folder_id(r["_id"]),
"name": folder_name(r["_id"], folder_names_mode),
"position": pos,
"page": page,
"appPackageNames": members_of(r["_id"]),
})
elif r["itemType"] == ITEM_APPLICATION:
entry = {"packageName": package_of(r["intent"]), "position": pos, "page": page}
if r["profileId"]:
entry["userSerial"] = r["profileId"]
apps.append(entry)
else:
dropped.append((f"page {page} cell ({r['cellX']},{r['cellY']})", r["title"],
"deep shortcut - not carriable by Launcher314's backup format"))
for r in hotseat:
slot = r["screen"]
if slot >= dock_slots:
raise ConversionError(f"dock slot {slot} exceeds dock_columns {dock_slots}")
if r["itemType"] == ITEM_FOLDER:
dock_folders.append({
"id": folder_id(r["_id"]),
"name": folder_name(r["_id"], folder_names_mode),
"position": slot,
"appPackageNames": members_of(r["_id"]),
"page": 0,
})
elif r["itemType"] == ITEM_APPLICATION:
entry = {"packageName": package_of(r["intent"]), "position": slot, "page": 0}
if r["profileId"]:
entry["userSerial"] = r["profileId"]
dock_apps.append(entry)
else:
dropped.append((f"dock slot {slot}", r["title"], "unsupported item type"))
home = {"apps": apps, "dockApps": dock_apps,
"folders": folders, "dockFolders": dock_folders}
# Lawnchair keeps custom app labels in pref_appNameMap, keyed by
# "pkg/component#userId". Launcher314 keys customizations by bare package.
labels = {}
raw = prefs.get("pref_appNameMap")
if raw:
for key, label in json.loads(raw).items():
pkg = key.split("/", 1)[0]
labels[pkg] = label
geometry = {"cols": cols, "rows": grid_rows, "pages": pages, "dock_slots": dock_slots}
return home, labels, geometry, dropped, by_id
# --- Strict validation -------------------------------------------------------
def validate_obj(kind, obj, path, errors):
spec = SCHEMA[kind]
for key in obj:
if key not in spec:
errors.append(f"{path}: unknown key {key!r} for {kind} "
f"-- kotlinx Json would reject the whole file")
for key, (types, default) in spec.items():
if key not in obj:
if default is REQUIRED:
errors.append(f"{path}: missing required {kind}.{key}")
continue
value = obj[key]
if isinstance(value, bool) or not isinstance(value, types):
errors.append(f"{path}.{key}: expected {[t.__name__ for t in types]}, "
f"got {type(value).__name__} ({value!r})")
def validate_home(home):
errors = []
validate_obj("HomeScreenData", home, "home_screen_data", errors)
for field, kind in ELEMENT_OF.items():
for i, item in enumerate(home.get(field, [])):
validate_obj(kind, item, f"home_screen_data.{field}[{i}]", errors)
for j, pkg in enumerate(item.get("appPackageNames", [])):
if not isinstance(pkg, str):
errors.append(f"home_screen_data.{field}[{i}].appPackageNames[{j}]: not a string")
return errors
def validate_customizations(customizations):
errors = []
for pkg, cust in customizations.items():
for key in cust:
if key not in APP_CUSTOMIZATION_KEYS:
errors.append(f"app_customizations[{pkg!r}]: unknown key {key!r}")
return errors
# --- Preview -----------------------------------------------------------------
def cell_label(text, width=13):
text = text or ""
if len(text) > width:
text = text[: width - 1] + "…"
return text.center(width)
def render_page(home, page, cols, rows, titles):
"""Replay Launcher314's buildGridCellsForPage: folders first, apps only
into cells that are still Empty (LauncherScreen.kt:1246-1278)."""
cells = [None] * (cols * rows)
for f in home["folders"]:
if f["page"] == page and f["position"] < len(cells) and cells[f["position"]] is None:
cells[f["position"]] = "[" + (f["name"] or "folder") + "]"
for a in home["apps"]:
if a["page"] == page and a["position"] < len(cells) and cells[a["position"]] is None:
cells[a["position"]] = app_label(a["packageName"], titles)
return cells
def app_label(pkg, titles):
return titles.get(pkg) or pkg.rsplit(".", 1)[-1]
def preview(home, geometry, titles, dropped):
cols, rows = geometry["cols"], geometry["rows"]
for page in range(geometry["pages"]):
print(f"\n HOME PAGE {page} ({cols} x {rows}, as Launcher314 will build it)")
print(" " + "-" * (cols * 14))
cells = render_page(home, page, cols, rows, titles)
for r in range(rows):
row = [cell_label(cells[r * cols + c] or ".") for c in range(cols)]
print(" |" + "|".join(row) + "|")
print(f"\n DOCK ({geometry['dock_slots']} slots)")
slots = [None] * geometry["dock_slots"]
for f in home["dockFolders"]:
slots[f["position"]] = "[" + (f["name"] or "folder") + "]"
for a in home["dockApps"]:
if slots[a["position"]] is None:
slots[a["position"]] = app_label(a["packageName"], titles)
print(" |" + "|".join(cell_label(s or ".") for s in slots) + "|")
print(f"\n FOLDERS ({len(home['folders'])} home + {len(home['dockFolders'])} dock)")
for f in home["folders"] + home["dockFolders"]:
members = ", ".join(app_label(p, titles) for p in f["appPackageNames"])
print(f" {f['name']:<18} {len(f['appPackageNames']):>2} {members}")
if dropped:
print(f"\n DROPPED ({len(dropped)}) -- re-add these by hand")
for where, title, why in dropped:
print(f" {where:<24} {str(title):<28} {why}")
# --- Emit --------------------------------------------------------------------
def build_document(home, labels, geometry, base):
home_prefs = {
"home_grid_columns": ("i", geometry["cols"]),
"home_grid_rows": ("i", geometry["rows"]),
"home_dock_columns": ("i", geometry["dock_slots"]),
"home_dock_enabled": ("b", True),
"home_dock_pages": ("i", 1),
}
launcher_prefs = {"launcher_total_pages": ("i", geometry["pages"])}
if base is not None:
root = json.loads(json.dumps(base)) # copy
root.setdefault("app", "Launcher314")
root.setdefault("backupVersion", 1)
root.setdefault("prefs", {})
root.setdefault("files", {})
else:
root = {"app": "Launcher314", "backupVersion": 1, "prefs": {}, "files": {}}
for pref_file, entries in (("home_screen_settings", home_prefs),
("launcher_prefs", launcher_prefs)):
target = root["prefs"].setdefault(pref_file, {})
for key, (tag, value) in entries.items():
target[key] = {"t": tag, "v": value}
# Merge custom labels into whatever customizations the base already has, so
# existing icon/shape/tint overrides survive.
existing = {}
raw = root["files"].get("app_customizations.json")
if raw:
existing = json.loads(raw).get("customizations", {})
for pkg, label in labels.items():
existing.setdefault(pkg, {})["customLabel"] = label
errors = validate_home(home) + validate_customizations(existing)
if errors:
raise ConversionError(
"refusing to write -- Launcher314 would silently discard this file "
"and show a blank home screen:\n " + "\n ".join(errors))
root["files"]["home_screen_data.json"] = json.dumps(home, separators=(",", ":"))
root["files"]["app_customizations.json"] = json.dumps(
{"customizations": existing}, separators=(",", ":"))
return root
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--backup", required=True, type=Path)
ap.add_argument("--base", type=Path,
help="a backup exported from Launcher314; strongly recommended")
ap.add_argument("--out", type=Path)
ap.add_argument("--folder-names", choices=("auto", "blank", "folder"), default="auto")
ap.add_argument("--dry-run", action="store_true",
help="validate and preview without writing")
args = ap.parse_args()
if not args.dry_run and not args.out:
ap.error("--out is required unless --dry-run")
rows, prefs = read_backup(args.backup)
home, labels, geometry, dropped, by_id = convert(rows, prefs, args.folder_names)
titles = {}
for r in rows:
pkg = package_of(r["intent"])
if pkg and r["title"]:
titles.setdefault(pkg, r["title"])
base = None
if args.base:
base = json.loads(args.base.read_text(encoding="utf-8"))
if base.get("app") != "Launcher314":
raise ConversionError(f"{args.base} is not a Launcher314 backup")
document = build_document(home, labels, geometry, base)
print(f" grid {geometry['cols']}x{geometry['rows']}, {geometry['pages']} pages, "
f"{geometry['dock_slots']} dock slots")
print(f" {len(home['apps'])} home apps, {len(home['folders'])} home folders, "
f"{len(home['dockApps'])} dock apps, {len(home['dockFolders'])} dock folders, "
f"{len(labels)} custom labels")
preview(home, geometry, titles, dropped)
thin = [f for f in home["folders"] + home["dockFolders"] if len(f["appPackageNames"]) < 2]
for f in thin:
print(f"\n warning: folder {f['name']!r} has {len(f['appPackageNames'])} app(s)")
if base is None:
print("\n note: no --base given. Restoring resets home_screen_settings and "
"launcher_prefs to just the keys above. Harmless on a fresh Launcher314 "
"install; pass --base if you have already tuned icon size, fonts or the dock.")
if args.dry_run:
print("\n dry run -- validated, nothing written")
return
args.out.write_text(json.dumps(document, indent=2), encoding="utf-8")
print(f"\n wrote {args.out}")
if __name__ == "__main__":
try:
main()
except ConversionError as e:
print(f"error: {e}", file=sys.stderr)
sys.exit(1)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment