|
#!/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), |
|
) |