Skip to content

Instantly share code, notes, and snippets.

@paracycle
Last active March 7, 2026 17:19
Show Gist options
  • Select an option

  • Save paracycle/2ec039c2e8f887b63a870c8b639ca27c to your computer and use it in GitHub Desktop.

Select an option

Save paracycle/2ec039c2e8f887b63a870c8b639ca27c to your computer and use it in GitHub Desktop.
Convert a Nova Launcher backup to Lawnchair format

nova-lawnchair

Convert a Nova Launcher backup to a Lawnchair backup.

What gets transferred

  • Home screen layout (all pages)
  • Dock (hotseat) icons and their positions
  • Folders and their contents
  • Widgets (provider/size/position — widget IDs are reset and must be re-bound on device)
  • Deep shortcuts (pinned contacts, app shortcuts, etc.)
  • Grid dimensions (columns, rows, dock slot count)

What does not get transferred

  • Appearance settings (icon pack, colors, fonts, etc.)
  • App drawer settings
  • Gestures
  • Wallpaper

How to create the backups

You need one backup from each launcher before running the converter.

Nova Launcher backup

  1. Open Nova Launcher → long-press home → Nova SettingsBackup & import settingsBackup.
  2. Save the .novabackup file somewhere accessible (e.g. your Downloads folder or a computer).

Lawnchair backup

The converter uses a Lawnchair backup only as a template for schema and preference defaults — the layout inside it is discarded. Any backup will do, including a fresh one from a clean Lawnchair install.

  1. Install Lawnchair and set it as your launcher (you do not need to set anything up).
  2. Long-press home → Home settingsBackupCreate backup.
  3. Save the .lawnchairbackup file alongside the Nova backup.

Once you have both files, proceed to Usage below.

Requirements

Python 3.10 or later (no third-party dependencies).

Usage

python3 nova2lawnchair.py <nova_backup> <lawnchair_backup> [-o output]
Argument Description
nova_backup Path to your .novabackup file
lawnchair_backup Path to any existing .lawnchairbackup — used only for the DB schema and Lawnchair preference defaults
-o output Output filename (default: converted.lawnchairbackup)

Example

python3 nova2lawnchair.py 2026-02-21.novabackup existing.lawnchairbackup -o my_layout.lawnchairbackup

How to restore

  1. Copy my_layout.lawnchairbackup to your Android device.
  2. Open Lawnchair → long-press home → Home settingsBackupRestore.
  3. Select the converted file.
  4. After restoring, any widgets will show as empty — open each widget's resize handle and confirm to re-bind it.

Notes

  • Nova stores hotseat position in cellX; Lawnchair uses screen. The converter handles this remapping automatically.
  • Nova-specific intent extras (extendedLaunchFlags) are stripped so Launcher3 can parse the intents correctly.
  • Widget appWidgetId values are always reset to -1 because they are device-local and cannot be transferred between launchers.
#!/usr/bin/env python3
"""
Convert a Nova Launcher backup (.novabackup) to a Lawnchair backup (.lawnchairbackup).
Home screen layout (icons, folders, widgets, shortcuts) and grid dimensions are
transferred. Other appearance settings are left at Lawnchair defaults.
"""
import argparse
import shutil
import sqlite3
import struct
import tempfile
import time
import xml.etree.ElementTree as ET
import zipfile
from pathlib import Path
# ---------------------------------------------------------------------------
# Nova container constants
# ---------------------------------------------------------------------------
NOVA_CONTAINER_DESKTOP = -100
NOVA_CONTAINER_HOTSEAT = -101
# Containers <= -200 are Nova app-drawer groups (not home screen) — skip them.
# Nova itemType constants (same as Lawnchair / AOSP Launcher3)
ITEM_TYPE_APPLICATION = 0
ITEM_TYPE_SHORTCUT = 1 # legacy, treat same as app
ITEM_TYPE_FOLDER = 2
ITEM_TYPE_APPWIDGET = 4
ITEM_TYPE_DEEP_SHORTCUT = 6
# ---------------------------------------------------------------------------
# Lawnchair container constants (same as AOSP Launcher3)
# ---------------------------------------------------------------------------
LC_CONTAINER_DESKTOP = -100
LC_CONTAINER_HOTSEAT = -101
# ---------------------------------------------------------------------------
# Minimal protobuf helpers (no external deps)
# ---------------------------------------------------------------------------
def _varint(value: int) -> bytes:
"""Encode a non-negative integer as a protobuf varint."""
buf = []
while True:
bits = value & 0x7F
value >>= 7
if value:
buf.append(bits | 0x80)
else:
buf.append(bits)
break
return bytes(buf)
def _field(field_number: int, wire_type: int, payload: bytes) -> bytes:
tag = (field_number << 3) | wire_type
return _varint(tag) + payload
def _varint_field(field_number: int, value: int) -> bytes:
return _field(field_number, 0, _varint(value))
def _bool_field(field_number: int, value: bool) -> bytes:
return _varint_field(field_number, 1 if value else 0)
def _len_field(field_number: int, payload: bytes) -> bytes:
return _field(field_number, 2, _varint(len(payload)) + payload)
def _fixed64(value: int) -> bytes:
return struct.pack('<Q', value)
def build_info_pb(lawnchair_version: int = 0,
backup_version: int = 1,
contents: int = 1, # bit0 = layout+settings
grid_cols: int = 5,
grid_rows: int = 8,
hotseat_count: int = 5,
device_type: int = 0) -> bytes:
"""
Build a minimal BackupInfo protobuf message.
message BackupInfo {
int32 lawnchair_version = 1;
int32 backup_version = 2;
Timestamp created_at = 3;
int32 contents = 4;
int32 preview_width = 5;
int32 preview_height = 6;
bool preview_dark_text = 8;
GridState grid_state = 7;
}
message GridState {
string grid_size = 1; // e.g. "5,8"
int32 hotseat_count = 2;
int32 device_type = 3;
}
message Timestamp { int64 seconds=1; int32 nanos=2; }
"""
now_sec = int(time.time())
# Timestamp (message, field 3)
ts_payload = _varint_field(1, now_sec) # seconds
# nanos omitted (default 0)
ts_msg = _len_field(3, ts_payload)
# GridState (message, field 7)
grid_size_str = f"{grid_cols},{grid_rows}".encode()
gs_payload = _len_field(1, grid_size_str) # grid_size string
gs_payload += _varint_field(2, hotseat_count) # hotseat_count
gs_payload += _varint_field(3, device_type) # device_type
gs_msg = _len_field(7, gs_payload)
msg = _varint_field(1, lawnchair_version)
msg += _varint_field(2, backup_version)
msg += ts_msg
msg += _varint_field(4, contents)
msg += _varint_field(5, 0) # preview_width (no screenshot)
msg += _varint_field(6, 0) # preview_height
msg += gs_msg
msg += _bool_field(8, False) # preview_dark_text
return msg
# ---------------------------------------------------------------------------
# Intent cleanup
# ---------------------------------------------------------------------------
def read_nova_grid(nova_xml: Path) -> tuple[int, int, int]:
"""
Parse nova.xml and return (workspace_cols, workspace_rows, hotseat_cols).
Nova stores the true grid dimensions in the `desktop_grid` string as
"ROWSxCOLS" (e.g. "8x5"). The separate `desktop_grid_rows` int is
unreliable — it appears to exclude the dock row and doesn't match the
actual layout data.
"""
tree = ET.parse(nova_xml)
root = tree.getroot()
# Nova XML uses `value` attribute for int/bool, text content for string elements.
vals = {el.get("name"): (el.get("value") if el.get("value") is not None else el.text)
for el in root}
# Primary source: "desktop_grid" string, format "ROWSxCOLS".
# May have trailing text (e.g. "8x5 subgrid") — extract leading digits only.
import re as _re
m = _re.match(r"(\d+)x(\d+)", vals.get("desktop_grid", ""))
if m:
rows = int(m.group(1))
cols = int(m.group(2))
else:
# Fallback to separate int keys
cols = int(vals.get("desktop_grid_cols", 5))
rows = int(vals.get("desktop_grid_rows", 5))
hotseat = int(vals.get("dock_grid_cols", 5))
return cols, rows, hotseat
def patch_lawnchair_xml(xml_path: Path, cols: int, rows: int, hotseat: int) -> None:
"""
Update grid dimension keys in Lawnchair's SharedPreferences XML:
pref_workspaceColumns, pref_workspaceRows, pref_hotseatColumns
Also updates the migration_src_* keys so Lawnchair doesn't try to
rescale the layout on first boot.
"""
tree = ET.parse(xml_path)
root = tree.getroot()
updates = {
"pref_workspaceColumns": str(cols),
"pref_workspaceRows": str(rows),
"pref_hotseatColumns": str(hotseat),
"migration_src_workspace_size": f"{cols},{rows}",
"migration_src_hotseat_count": str(hotseat),
}
# Update existing elements; add missing ones.
existing = {el.get("name"): el for el in root}
for key, value in updates.items():
is_string = "workspace_size" in key
if key in existing:
el = existing[key]
if is_string:
el.text = value
else:
el.set("value", value)
else:
tag = "string" if is_string else "int"
el = ET.SubElement(root, tag)
el.set("name", key)
if is_string:
el.text = value
else:
el.set("value", value)
tree.write(xml_path, encoding="utf-8", xml_declaration=True)
def clean_intent(intent: str | None) -> str | None:
"""
Strip Nova-specific extras from intent URIs so Launcher3 can parse them.
Nova adds 'extendedLaunchFlags' which Launcher3 doesn't understand and
can cause parse errors.
"""
if not intent:
return intent
import re
intent = re.sub(r'extendedLaunchFlags=0x[0-9a-fA-F]+;', '', intent)
return intent
# ---------------------------------------------------------------------------
# Main conversion
# ---------------------------------------------------------------------------
def convert(nova_backup: Path, lawnchair_backup: Path, output: Path) -> None:
with tempfile.TemporaryDirectory() as tmp:
tmp = Path(tmp)
# -- Extract both backups ------------------------------------------
nova_dir = tmp / "nova"
lc_dir = tmp / "lc"
nova_dir.mkdir(); lc_dir.mkdir()
with zipfile.ZipFile(nova_backup) as z:
z.extractall(nova_dir)
with zipfile.ZipFile(lawnchair_backup) as z:
z.extractall(lc_dir)
# -- Copy Lawnchair DB as starting point ---------------------------
lc_db_path = tmp / "launcher_converted.db"
shutil.copy2(lc_dir / "launcher.db", lc_db_path)
# -- Open databases ------------------------------------------------
nova_con = sqlite3.connect(nova_dir / "nova.db")
nova_con.row_factory = sqlite3.Row
lc_con = sqlite3.connect(lc_db_path)
nova_cur = nova_con.cursor()
lc_cur = lc_con.cursor()
# -- Wipe existing layout from Lawnchair DB -----------------------
lc_cur.execute("DELETE FROM favorites")
# -- Collect Nova home-screen items --------------------------------
# Step 1: desktop and hotseat items only (container = -100 or -101).
nova_cur.execute("""
SELECT _id, title, intent, container, screen,
cellX, cellY, spanX, spanY,
itemType, appWidgetId, appWidgetProvider,
icon, profileId, rank, options, restored
FROM favorites
WHERE container IN (-100, -101)
""")
nova_rows = list(nova_cur.fetchall())
# Step 2: collect folder IDs from the rows above, then fetch their contents.
folder_ids = {r["_id"] for r in nova_rows if r["itemType"] == ITEM_TYPE_FOLDER}
if folder_ids:
placeholders = ",".join("?" * len(folder_ids))
nova_cur.execute(f"""
SELECT _id, title, intent, container, screen,
cellX, cellY, spanX, spanY,
itemType, appWidgetId, appWidgetProvider,
icon, profileId, rank, options, restored
FROM favorites
WHERE container IN ({placeholders})
""", list(folder_ids))
nova_rows += nova_cur.fetchall()
# -- Read grid dimensions from Nova XML ----------------------------
grid_cols, grid_rows, hotseat_cols = read_nova_grid(nova_dir / "nova.xml")
print(f"Nova grid: {grid_cols} cols × {grid_rows} rows, hotseat: {hotseat_cols}")
# -- Build ID remapping (Nova _id → new sequential _id) -----------
# Nova IDs can be large/arbitrary; we just preserve them as-is since
# folder contents reference parent IDs via container field.
# We only need to ensure no collision with existing Lawnchair rows
# (we wiped them above) and that folder self-references stay intact.
# -- Insert rows ---------------------------------------------------
insert_sql = """
INSERT INTO favorites
(_id, title, intent, container, screen,
cellX, cellY, spanX, spanY,
itemType, appWidgetId, appWidgetProvider,
icon, profileId, rank, options, restored,
modified, appWidgetSource)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
"""
skipped = 0
inserted = 0
for r in nova_rows:
item_type = r["itemType"]
# Skip unknown/non-actionable items
if item_type == -1:
skipped += 1
continue
# Map container
container = r["container"]
# profileId: Nova uses -1 for personal profile; Lawnchair uses 0
profile_id = 0 if r["profileId"] in (-1, None) else r["profileId"]
# For widgets: reset appWidgetId to -1 (they need re-binding on device)
app_widget_id = -1 # always reset — widget IDs are device-local
# Clean intent
intent = clean_intent(r["intent"])
# Hotseat position: Nova stores slot in cellX (screen is always 0).
# Lawnchair stores slot in `screen` (and mirrors it in cellX).
if container == LC_CONTAINER_HOTSEAT:
screen = int(r["cellX"])
cell_x = int(r["cellX"])
cell_y = 0
else:
screen = r["screen"]
cell_x = int(r["cellX"])
cell_y = int(r["cellY"])
lc_cur.execute(insert_sql, (
r["_id"],
r["title"],
intent,
container,
screen,
cell_x,
cell_y,
int(r["spanX"]),
int(r["spanY"]),
item_type,
app_widget_id,
r["appWidgetProvider"],
r["icon"], # keep custom icon blob if present
profile_id,
r["rank"],
r["options"],
0, # restored=0 so Launcher3 re-validates
int(time.time() * 1000), # modified
-1, # appWidgetSource
))
inserted += 1
lc_con.commit()
nova_con.close()
lc_con.close()
print(f"Inserted {inserted} items, skipped {skipped}")
# -- Patch Lawnchair XML prefs with Nova grid dimensions -----------
lc_xml_src = lc_dir / "com.android.launcher3.prefs.xml"
lc_xml_dst = tmp / "com.android.launcher3.prefs.xml"
shutil.copy2(lc_xml_src, lc_xml_dst)
patch_lawnchair_xml(lc_xml_dst, grid_cols, grid_rows, hotseat_cols)
print(f"Patched XML prefs: {grid_cols}×{grid_rows} grid, {hotseat_cols} hotseat slots")
# -- Build info.pb -------------------------------------------------
info_pb = build_info_pb(
contents=1, # layout only (bit 0)
grid_cols=grid_cols,
grid_rows=grid_rows,
hotseat_count=hotseat_cols,
)
# -- Assemble output zip -------------------------------------------
lc_xml_name = "com.android.launcher3.prefs.xml"
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as out:
out.writestr("info.pb", info_pb)
out.write(lc_db_path, "launcher.db")
out.write(lc_xml_dst, lc_xml_name)
print(f"Written: {output}")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Convert a Nova Launcher backup to Lawnchair format (layout only)."
)
parser.add_argument("nova_backup", help="Path to .novabackup file")
parser.add_argument("lawnchair_backup", help="Path to existing .lawnchairbackup (for reference DB schema)")
parser.add_argument("-o", "--output", help="Output .lawnchairbackup path",
default="converted.lawnchairbackup")
args = parser.parse_args()
convert(
Path(args.nova_backup),
Path(args.lawnchair_backup),
Path(args.output),
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment