iphone2android 0.1.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. iphone2android-0.3.0/PKG-INFO +115 -0
  2. iphone2android-0.3.0/README.md +96 -0
  3. {iphone2android-0.1.0 → iphone2android-0.3.0}/pyproject.toml +4 -1
  4. {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android/__init__.py +1 -1
  5. iphone2android-0.3.0/src/iphone2android/accounts.py +33 -0
  6. {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android/android/adb.py +27 -0
  7. iphone2android-0.3.0/src/iphone2android/android/build.py +209 -0
  8. iphone2android-0.3.0/src/iphone2android/android/launcher.py +351 -0
  9. iphone2android-0.3.0/src/iphone2android/android/profile.py +51 -0
  10. iphone2android-0.3.0/src/iphone2android/android/profiles/coloros-16.json +197 -0
  11. iphone2android-0.3.0/src/iphone2android/android/profiles/generic.json +158 -0
  12. {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android/android/ui.py +9 -6
  13. iphone2android-0.3.0/src/iphone2android/appdata.py +108 -0
  14. iphone2android-0.3.0/src/iphone2android/apps.py +146 -0
  15. iphone2android-0.3.0/src/iphone2android/audit.py +103 -0
  16. {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android/backup.py +14 -4
  17. iphone2android-0.3.0/src/iphone2android/cli.py +481 -0
  18. {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android/layout.py +17 -2
  19. {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android/media.py +10 -1
  20. iphone2android-0.3.0/src/iphone2android/skill/SKILL.md +247 -0
  21. iphone2android-0.3.0/src/iphone2android/wallpaper.py +88 -0
  22. iphone2android-0.3.0/src/iphone2android.egg-info/PKG-INFO +115 -0
  23. {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android.egg-info/SOURCES.txt +13 -1
  24. {iphone2android-0.1.0 → iphone2android-0.3.0}/tests/test_android.py +0 -104
  25. iphone2android-0.3.0/tests/test_builder.py +102 -0
  26. iphone2android-0.3.0/tests/test_launcher_driver.py +97 -0
  27. iphone2android-0.3.0/tests/test_v2.py +258 -0
  28. iphone2android-0.1.0/PKG-INFO +0 -127
  29. iphone2android-0.1.0/README.md +0 -108
  30. iphone2android-0.1.0/src/iphone2android/android/build.py +0 -116
  31. iphone2android-0.1.0/src/iphone2android/android/launcher.py +0 -134
  32. iphone2android-0.1.0/src/iphone2android/cli.py +0 -214
  33. iphone2android-0.1.0/src/iphone2android.egg-info/PKG-INFO +0 -127
  34. {iphone2android-0.1.0 → iphone2android-0.3.0}/LICENSE +0 -0
  35. {iphone2android-0.1.0 → iphone2android-0.3.0}/setup.cfg +0 -0
  36. {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android/__main__.py +0 -0
  37. {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android/android/__init__.py +0 -0
  38. {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android/android/debloat.py +0 -0
  39. {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android/android/play.py +0 -0
  40. {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android/convert.py +0 -0
  41. {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android.egg-info/dependency_links.txt +0 -0
  42. {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android.egg-info/entry_points.txt +0 -0
  43. {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android.egg-info/requires.txt +0 -0
  44. {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android.egg-info/top_level.txt +0 -0
  45. {iphone2android-0.1.0 → iphone2android-0.3.0}/tests/test_backup_media.py +0 -0
  46. {iphone2android-0.1.0 → iphone2android-0.3.0}/tests/test_convert_layout.py +0 -0
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: iphone2android
3
+ Version: 0.3.0
4
+ Summary: Move from an iPhone to an Android phone: messages, contacts, photos, apps and the home screen, from an encrypted iPhone backup
5
+ Author: Konstantinos Georgiou
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/drkostas/iphone2android
8
+ Keywords: iphone,ios,android,migration,backup,adb,sms,launcher
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Topic :: Utilities
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: iOSbackup>=0.9.9
15
+ Requires-Dist: pycryptodome>=3.18
16
+ Provides-Extra: test
17
+ Requires-Dist: pytest>=8; extra == "test"
18
+ Dynamic: license-file
19
+
20
+ # iphone2android
21
+
22
+ iphone2android is a toolkit for moving from an iPhone to an Android phone without leaving anything behind. It is built for Claude Code to run the move, and every command also works by hand.
23
+
24
+ The official transfer (the setup wizard's "Copy apps and data", Samsung Smart Switch, OPPO Clone Phone) moves a lot, but it misses apps without an obvious match, app data, iMessage attachments, photo edits, widgets and the home screen itself. iphone2android starts from there. It reads an encrypted iPhone backup on your computer, finds what the official transfer missed, and fills the gaps on the phone over `adb`, checking each step.
25
+
26
+ What it covers:
27
+
28
+ - Finding the Android version of every iPhone app (Apple's app lookup, then the Play Store search) and installing the free ones
29
+ - Messages, call history, contacts, calendars and Safari bookmarks, converted for Android and Google
30
+ - Photos, iMessage attachments and photo edits, copied to the phone
31
+ - The data iPhone apps keep locally, found, copied, and the account names in their settings
32
+ - The home screen with its folders, order and widgets, and the iPhone wallpaper
33
+ - An audit of what the iPhone had against what the phone has, before and after
34
+
35
+ ## With Claude Code
36
+
37
+ ```bash
38
+ pip install iphone2android
39
+ iphone2android skill # installs the skill into ~/.claude/skills/iphone2android
40
+ ```
41
+
42
+ Then ask Claude to move you from your iPhone. The skill walks the whole move in order, starting with what has to happen before the iPhone is wiped, and Claude runs each command with `--json`, reads the result, and checks the phone with screenshots. You only do what needs your hands or your consent (the backup password, accepting terms, choosing a default SMS app).
43
+
44
+ ## By hand
45
+
46
+ You need `adb` (`brew install android-platform-tools` on macOS) with USB debugging on in the phone's developer options, and an encrypted backup (Finder, select the iPhone, tick "Encrypt local backup", Back Up Now).
47
+
48
+ ```bash
49
+ iphone2android backups # the backups on this computer
50
+ iphone2android extract --inventory # asks for the backup password
51
+ iphone2android audit --photos # what the official transfer missed
52
+
53
+ iphone2android suggest # mapping.json, and mapping.draft.json for apps to review
54
+ iphone2android find-app "App name" # search the Play Store by hand
55
+ iphone2android verify mapping.json
56
+ iphone2android install --mapping mapping.json
57
+
58
+ iphone2android convert --out android-import
59
+ iphone2android media message-attachments photo-edits
60
+ iphone2android appdata survey
61
+
62
+ iphone2android accounts # what to sign in to again
63
+
64
+ iphone2android launcher probe # which launcher, and which profile matches
65
+ iphone2android layout extracted/IconState.plist --mapping mapping.json > layout.json
66
+ iphone2android wallpaper extract
67
+ iphone2android build layout.json --state build.json --autofill
68
+ iphone2android check layout.json # every page and folder against the layout
69
+ iphone2android snapshot # the whole home screen as JSON
70
+ iphone2android wallpaper set wallpaper/<image>
71
+ ```
72
+
73
+ | File from `convert` | Import it with |
74
+ |---|---|
75
+ | `sms_backup.xml`, `calls_backup.xml` | the SMS Backup & Restore app |
76
+ | `contacts.vcf` | Google Contacts (Import) |
77
+ | `calendar.ics` | Google Calendar on the web (Settings, Import & export) |
78
+ | `safari_bookmarks.html` | Chrome on a computer (Bookmarks, Import) |
79
+
80
+ `iphone2android screenshot` and `iphone2android screen` show what is on the phone, and `iphone2android debloat` removes preinstalled apps you do not want (reversibly).
81
+
82
+ ## What cannot move
83
+
84
+ - Passwords in the iCloud Keychain. Every item is sealed with a key that never leaves the iPhone, so no backup can open them. Export them with a password manager first.
85
+ - Authenticator codes. Transfer them from the authenticator app while the iPhone still works.
86
+ - Most app data. Android apps cannot read their iPhone version's files, so app data is copied out for an app's own import, a dedicated migrator (WhatsApp chats need one) or safekeeping. Apps that keep their data in an account only need signing in.
87
+ - Paid apps are not installed automatically.
88
+
89
+ ## How the home screen is rebuilt
90
+
91
+ Many launchers do not let `adb` change the layout (on ColorOS the launcher database and shortcut pinning are closed without root), so the builder moves icons through the screen the way a person does. That only works with the right timings and gestures for each launcher, so they live in launcher profiles (`android/profiles/*.json`) as data, with the evidence for each. The ColorOS 16 profile was measured during a real migration on an OPPO Find X9 Pro. A few of the findings it holds:
92
+
93
+ - A swipe changes page at 250 ms and is read as a drag at 400 ms.
94
+ - A long press only opens the menu when the press, the wait and the release are sent in one adb shell.
95
+ - A drop from the app drawer always lands on the first page, so everything is made there and carried to its page by holding at the screen edge, 0.9 seconds per page.
96
+ - A slow hover merges an icon into a folder, while a 2 second drag onto an icon swaps them.
97
+ - A folder dropped onto a folder merges the two, and new folders can get the same automatic name, so folders are found by position.
98
+ - Icons cannot be dragged out of a folder, only removed inside it.
99
+
100
+ The builder follows these, reads the screen again after every step, saves its progress, checks every folder by opening it, and orders each page one swap at a time. For a launcher without a profile, the skill has a calibration procedure, and `iphone2android launcher save-profile` stores the result. Profiles for other launchers are very welcome.
101
+
102
+ Wallpapers come from the backup as images (iOS 16 and later) or Apple's `.cpbitmap` format (older versions), converted to PNG. `wallpaper set` copies the image to the phone and opens its "Set as" screen. Widgets are listed for placing through the launcher's widget picker.
103
+
104
+ ## Development
105
+
106
+ ```bash
107
+ python -m venv .venv && .venv/bin/pip install -e '.[test]'
108
+ .venv/bin/pytest
109
+ ```
110
+
111
+ The tests use synthetic databases, a fake decryptor, fake web pages for the app stores, a fake `adb` and a simulated home screen, so they need no iPhone and no Android phone.
112
+
113
+ ## License
114
+
115
+ MIT. Backup decryption uses [iOSbackup](https://github.com/avibrazil/iOSbackup) (LGPL). The cpbitmap layout follows [cpbitmap-to-png](https://github.com/hthetiot/cpbitmap-to-png) (MIT).
@@ -0,0 +1,96 @@
1
+ # iphone2android
2
+
3
+ iphone2android is a toolkit for moving from an iPhone to an Android phone without leaving anything behind. It is built for Claude Code to run the move, and every command also works by hand.
4
+
5
+ The official transfer (the setup wizard's "Copy apps and data", Samsung Smart Switch, OPPO Clone Phone) moves a lot, but it misses apps without an obvious match, app data, iMessage attachments, photo edits, widgets and the home screen itself. iphone2android starts from there. It reads an encrypted iPhone backup on your computer, finds what the official transfer missed, and fills the gaps on the phone over `adb`, checking each step.
6
+
7
+ What it covers:
8
+
9
+ - Finding the Android version of every iPhone app (Apple's app lookup, then the Play Store search) and installing the free ones
10
+ - Messages, call history, contacts, calendars and Safari bookmarks, converted for Android and Google
11
+ - Photos, iMessage attachments and photo edits, copied to the phone
12
+ - The data iPhone apps keep locally, found, copied, and the account names in their settings
13
+ - The home screen with its folders, order and widgets, and the iPhone wallpaper
14
+ - An audit of what the iPhone had against what the phone has, before and after
15
+
16
+ ## With Claude Code
17
+
18
+ ```bash
19
+ pip install iphone2android
20
+ iphone2android skill # installs the skill into ~/.claude/skills/iphone2android
21
+ ```
22
+
23
+ Then ask Claude to move you from your iPhone. The skill walks the whole move in order, starting with what has to happen before the iPhone is wiped, and Claude runs each command with `--json`, reads the result, and checks the phone with screenshots. You only do what needs your hands or your consent (the backup password, accepting terms, choosing a default SMS app).
24
+
25
+ ## By hand
26
+
27
+ You need `adb` (`brew install android-platform-tools` on macOS) with USB debugging on in the phone's developer options, and an encrypted backup (Finder, select the iPhone, tick "Encrypt local backup", Back Up Now).
28
+
29
+ ```bash
30
+ iphone2android backups # the backups on this computer
31
+ iphone2android extract --inventory # asks for the backup password
32
+ iphone2android audit --photos # what the official transfer missed
33
+
34
+ iphone2android suggest # mapping.json, and mapping.draft.json for apps to review
35
+ iphone2android find-app "App name" # search the Play Store by hand
36
+ iphone2android verify mapping.json
37
+ iphone2android install --mapping mapping.json
38
+
39
+ iphone2android convert --out android-import
40
+ iphone2android media message-attachments photo-edits
41
+ iphone2android appdata survey
42
+
43
+ iphone2android accounts # what to sign in to again
44
+
45
+ iphone2android launcher probe # which launcher, and which profile matches
46
+ iphone2android layout extracted/IconState.plist --mapping mapping.json > layout.json
47
+ iphone2android wallpaper extract
48
+ iphone2android build layout.json --state build.json --autofill
49
+ iphone2android check layout.json # every page and folder against the layout
50
+ iphone2android snapshot # the whole home screen as JSON
51
+ iphone2android wallpaper set wallpaper/<image>
52
+ ```
53
+
54
+ | File from `convert` | Import it with |
55
+ |---|---|
56
+ | `sms_backup.xml`, `calls_backup.xml` | the SMS Backup & Restore app |
57
+ | `contacts.vcf` | Google Contacts (Import) |
58
+ | `calendar.ics` | Google Calendar on the web (Settings, Import & export) |
59
+ | `safari_bookmarks.html` | Chrome on a computer (Bookmarks, Import) |
60
+
61
+ `iphone2android screenshot` and `iphone2android screen` show what is on the phone, and `iphone2android debloat` removes preinstalled apps you do not want (reversibly).
62
+
63
+ ## What cannot move
64
+
65
+ - Passwords in the iCloud Keychain. Every item is sealed with a key that never leaves the iPhone, so no backup can open them. Export them with a password manager first.
66
+ - Authenticator codes. Transfer them from the authenticator app while the iPhone still works.
67
+ - Most app data. Android apps cannot read their iPhone version's files, so app data is copied out for an app's own import, a dedicated migrator (WhatsApp chats need one) or safekeeping. Apps that keep their data in an account only need signing in.
68
+ - Paid apps are not installed automatically.
69
+
70
+ ## How the home screen is rebuilt
71
+
72
+ Many launchers do not let `adb` change the layout (on ColorOS the launcher database and shortcut pinning are closed without root), so the builder moves icons through the screen the way a person does. That only works with the right timings and gestures for each launcher, so they live in launcher profiles (`android/profiles/*.json`) as data, with the evidence for each. The ColorOS 16 profile was measured during a real migration on an OPPO Find X9 Pro. A few of the findings it holds:
73
+
74
+ - A swipe changes page at 250 ms and is read as a drag at 400 ms.
75
+ - A long press only opens the menu when the press, the wait and the release are sent in one adb shell.
76
+ - A drop from the app drawer always lands on the first page, so everything is made there and carried to its page by holding at the screen edge, 0.9 seconds per page.
77
+ - A slow hover merges an icon into a folder, while a 2 second drag onto an icon swaps them.
78
+ - A folder dropped onto a folder merges the two, and new folders can get the same automatic name, so folders are found by position.
79
+ - Icons cannot be dragged out of a folder, only removed inside it.
80
+
81
+ The builder follows these, reads the screen again after every step, saves its progress, checks every folder by opening it, and orders each page one swap at a time. For a launcher without a profile, the skill has a calibration procedure, and `iphone2android launcher save-profile` stores the result. Profiles for other launchers are very welcome.
82
+
83
+ Wallpapers come from the backup as images (iOS 16 and later) or Apple's `.cpbitmap` format (older versions), converted to PNG. `wallpaper set` copies the image to the phone and opens its "Set as" screen. Widgets are listed for placing through the launcher's widget picker.
84
+
85
+ ## Development
86
+
87
+ ```bash
88
+ python -m venv .venv && .venv/bin/pip install -e '.[test]'
89
+ .venv/bin/pytest
90
+ ```
91
+
92
+ The tests use synthetic databases, a fake decryptor, fake web pages for the app stores, a fake `adb` and a simulated home screen, so they need no iPhone and no Android phone.
93
+
94
+ ## License
95
+
96
+ MIT. Backup decryption uses [iOSbackup](https://github.com/avibrazil/iOSbackup) (LGPL). The cpbitmap layout follows [cpbitmap-to-png](https://github.com/hthetiot/cpbitmap-to-png) (MIT).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "iphone2android"
7
- version = "0.1.0"
7
+ version = "0.3.0"
8
8
  description = "Move from an iPhone to an Android phone: messages, contacts, photos, apps and the home screen, from an encrypted iPhone backup"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -25,3 +25,6 @@ test = ["pytest>=8"]
25
25
 
26
26
  [tool.setuptools.packages.find]
27
27
  where = ["src"]
28
+
29
+ [tool.setuptools.package-data]
30
+ iphone2android = ["skill/SKILL.md", "android/profiles/*.json"]
@@ -1,3 +1,3 @@
1
1
  """Move from an iPhone to an Android phone: messages, contacts, photos, apps and the home screen."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.3.0"
@@ -0,0 +1,33 @@
1
+ """The accounts the iPhone was signed in to (Google, Exchange, iCloud and others), from Accounts3.sqlite.
2
+
3
+ This is the reliable list of accounts to add on Android. Searching app data for email addresses
4
+ instead finds other people's addresses and typos. The output holds your addresses, so keep it local.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import sqlite3
9
+ from pathlib import Path
10
+
11
+
12
+ def read(db: Path) -> list[dict]:
13
+ con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
14
+ try:
15
+ rows = con.execute(
16
+ """SELECT a.ZUSERNAME, a.ZACCOUNTDESCRIPTION, t.ZACCOUNTTYPEDESCRIPTION, t.ZIDENTIFIER
17
+ FROM ZACCOUNT a LEFT JOIN ZACCOUNTTYPE t ON a.ZACCOUNTTYPE = t.Z_PK"""
18
+ ).fetchall()
19
+ finally:
20
+ con.close()
21
+ out = []
22
+ for user, desc, type_desc, ident in rows:
23
+ if not user:
24
+ continue
25
+ out.append({"username": user, "description": desc, "type": type_desc, "type_id": ident,
26
+ "google": bool(ident and "google" in ident.lower()) or str(user).lower().endswith(("@gmail.com", "@googlemail.com"))})
27
+ seen, unique = set(), []
28
+ for a in out:
29
+ key = (a["username"].lower(), a["type_id"])
30
+ if key not in seen:
31
+ seen.add(key)
32
+ unique.append(a)
33
+ return unique
@@ -10,6 +10,25 @@ import shutil
10
10
  import subprocess
11
11
 
12
12
 
13
+ def png_is_black(data: bytes) -> bool:
14
+ """True when a PNG holds only black pixels (what a locked or sleeping screen captures as)."""
15
+ import struct
16
+ import zlib
17
+
18
+ pos, idat = 8, b""
19
+ while pos + 8 <= len(data):
20
+ n = struct.unpack(">I", data[pos:pos + 4])[0]
21
+ if data[pos + 4:pos + 8] == b"IDAT":
22
+ idat += data[pos + 8:pos + 8 + n]
23
+ pos += 12 + n
24
+ try:
25
+ raw = zlib.decompress(idat)
26
+ except zlib.error:
27
+ return False
28
+ # Black rows are filter bytes (0 to 4), zero colour bytes and 255 alpha bytes, nothing else.
29
+ return bool(raw) and set(raw) <= {0, 1, 2, 3, 4, 255}
30
+
31
+
13
32
  class DeviceUnavailable(RuntimeError):
14
33
  """adb could not reach the phone. Raised instead of returning empty output, which would read as "nothing installed"."""
15
34
 
@@ -23,6 +42,14 @@ class Adb:
23
42
  cmd = [self.binary] + (["-s", self.serial] if self.serial else []) + list(args)
24
43
  return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL)
25
44
 
45
+ def screenshot(self, timeout: float = 30) -> bytes:
46
+ """The screen as PNG bytes (binary output, so not through run's text decoding)."""
47
+ cmd = [self.binary] + (["-s", self.serial] if self.serial else []) + ["exec-out", "screencap", "-p"]
48
+ r = subprocess.run(cmd, capture_output=True, timeout=timeout, stdin=subprocess.DEVNULL)
49
+ if r.returncode != 0 or not r.stdout.startswith(b"\x89PNG"):
50
+ raise DeviceUnavailable((r.stderr or b"").decode("utf-8", "replace").strip()[:200] or "no screenshot returned")
51
+ return r.stdout
52
+
26
53
  def shell(self, cmd: str, timeout: float = 90) -> str:
27
54
  try:
28
55
  r = self.run("shell", cmd, timeout=timeout)
@@ -0,0 +1,209 @@
1
+ """Build a home screen from a layout file, the way the launcher actually behaves.
2
+
3
+ {"pages": [["Camera", {"folder": "Social", "apps": ["WhatsApp", "Telegram"]},
4
+ {"widget": ["Spotify"], "size": "medium"}]],
5
+ "dock": ["Phone", "Messages", "Chrome"]}
6
+
7
+ Each name is the app's name in the drawer. What the builder relies on (ColorOS 16, see the profile):
8
+
9
+ - A drop from the drawer lands on the FIRST page's first free cell, whatever page is showing. So
10
+ everything is made on the first page and then carried to its page with an edge hold, one hold
11
+ per page, and dropped on an EMPTY cell (a folder dropped on a folder merges them).
12
+ - Dropping a drawer result onto an icon makes a folder, and onto a folder adds to it. The new folder
13
+ is found by its position next to the seed icon, never by its name (auto-names collide).
14
+ - A folder's contents are checked by opening it with a fresh navigation. Extras are removed from
15
+ inside the folder. Missing apps are added by dropping from the drawer onto the folder.
16
+ - Order is fixed with Icon autofill off by a selection sort that makes one swap and then reads the
17
+ screen again. A swap that creates a folder stops the sort.
18
+
19
+ Progress is saved after every step, so an interrupted build continues where it stopped. Widgets are
20
+ listed for placing through the launcher's widget picker.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from pathlib import Path
26
+
27
+
28
+ def entry_label(entry, prefix: str = "Folder:") -> str:
29
+ if isinstance(entry, dict) and "widget" in entry:
30
+ return f"Widget:{'/'.join(entry['widget'])}"
31
+ return f"{prefix}{entry['folder']}" if isinstance(entry, dict) else entry
32
+
33
+
34
+ def is_widget(entry) -> bool:
35
+ return isinstance(entry, dict) and "widget" in entry
36
+
37
+
38
+ class Builder:
39
+ def __init__(self, launcher, state_file: Path | None = None, log=print, retries: int = 2):
40
+ self.l = launcher
41
+ self.state_file = state_file
42
+ self.log = log
43
+ self.retries = retries
44
+ self.done: list[str] = []
45
+ self.todo: list[dict] = []
46
+ self.problems: list[str] = []
47
+ if state_file and state_file.exists():
48
+ self.done = json.loads(state_file.read_text()).get("done", [])
49
+
50
+ @property
51
+ def prefix(self) -> str:
52
+ return self.l.p["labels"]["folder_prefix"] if hasattr(self.l, "p") else "Folder:"
53
+
54
+ def _mark(self, key: str) -> None:
55
+ self.done.append(key)
56
+ if self.state_file:
57
+ self.state_file.write_text(json.dumps({"done": self.done}))
58
+
59
+ def _find(self, page: int, label: str):
60
+ hits = [i for i in self.l.page(page) if i.label == label]
61
+ return hits[0] if hits else None
62
+
63
+ # ---------------------------------------------------------------- placing
64
+
65
+ def place_app(self, name: str) -> object | None:
66
+ """Drop an app from the drawer onto the first page. Returns where it landed."""
67
+ before = {(i.label, i.cell) for i in self.l.page(0)}
68
+ hit = self.l.drawer_find(name, want=name)
69
+ if not hit:
70
+ self.problems.append(f"not in the drawer: {name}")
71
+ return None
72
+ self.l.drop_from_drawer(hit)
73
+ new = [i for i in self.l.page(0) if (i.label, i.cell) not in before and i.label == hit.label]
74
+ if not new:
75
+ self.problems.append(f"{name}: no new icon on the first page after the drop (page full?)")
76
+ return None
77
+ return new[0]
78
+
79
+ def carry(self, item, to_page: int) -> bool:
80
+ """Move an icon or folder from the first page to another page, onto an empty cell."""
81
+ if to_page == 0:
82
+ return True
83
+ target = self.l.page(to_page) if to_page < len(self.l.pages()) else []
84
+ free = self.l.free_cells(target)
85
+ if target and not free:
86
+ self.problems.append(f"page {to_page + 1} is full, cannot carry {item.label}")
87
+ return False
88
+ for attempt in range(self.retries + 1):
89
+ src = self._find(0, item.label)
90
+ if not src:
91
+ break
92
+ self.l.move_to_page(src, to_page, free[0] if free else None)
93
+ if self._find(to_page, item.label):
94
+ return True
95
+ self.log(f" carry {item.label} to page {to_page + 1}: retry {attempt + 1}")
96
+ self.problems.append(f"could not carry {item.label} to page {to_page + 1}")
97
+ return False
98
+
99
+ def build_folder(self, name: str, apps: list[str]) -> object | None:
100
+ """Make a folder on the first page from apps in the drawer, name it, and check its contents."""
101
+ label = f"{self.prefix}{name}"
102
+ existing = self._find(0, label)
103
+ if not existing:
104
+ seed = self.place_app(apps[0])
105
+ if not seed or len(apps) == 1:
106
+ return seed
107
+ before = {(i.label, i.cell) for i in self.l.page(0) if i.kind == "folder"}
108
+ hit = self.l.drawer_find(apps[1], want=apps[1])
109
+ if not hit:
110
+ self.problems.append(f"not in the drawer: {apps[1]}")
111
+ return seed
112
+ self.l.drop_from_drawer(hit, onto=(seed.x, seed.y))
113
+ made = [i for i in self.l.page(0) if i.kind == "folder" and (i.label, i.cell) not in before]
114
+ if not made:
115
+ self.problems.append(f"folder {name}: dropping {apps[1]} onto {apps[0]} made no folder")
116
+ return None
117
+ folder = min(made, key=lambda f: abs(f.x - seed.x) + abs(f.y - seed.y))
118
+ self.l.tap(folder.x, folder.y)
119
+ self.l.rename_open_folder(name)
120
+ self.l.home()
121
+ self.fill_folder(0, name, apps)
122
+ return self._find(0, label)
123
+
124
+ def fill_folder(self, page: int, name: str, apps: list[str]) -> None:
125
+ label = f"{self.prefix}{name}"
126
+ for attempt in range(self.retries + 1):
127
+ inside = self.l.open_folder(page, label)
128
+ if inside is None:
129
+ self.problems.append(f"folder {name} is not on page {page + 1}")
130
+ return
131
+ missing = [a for a in apps if not any(a.lower() == x.lower() for x in inside)]
132
+ extra = [x for x in inside if not any(a.lower() == x.lower() for a in apps)]
133
+ for x in extra:
134
+ self.log(f" {name}: removing {x}, which does not belong")
135
+ self.l.remove_from_folder(page, label, x)
136
+ if not missing:
137
+ return
138
+ for a in missing:
139
+ folder = self._find(page, label)
140
+ hit = self.l.drawer_find(a, want=a)
141
+ if not hit or not folder:
142
+ continue
143
+ if page != 0:
144
+ self.problems.append(f"folder {name}: add {a} on the first page first")
145
+ continue
146
+ self.l.drop_from_drawer(hit, onto=(folder.x, folder.y))
147
+ inside = self.l.open_folder(page, label) or []
148
+ still = [a for a in apps if not any(a.lower() == x.lower() for x in inside)]
149
+ if still:
150
+ self.problems.append(f"folder {name}: still missing {', '.join(still)}")
151
+
152
+ # ---------------------------------------------------------------- the whole layout
153
+
154
+ def build(self, layout: dict) -> None:
155
+ # Build back to front, so carrying an item never passes a page that is already finished.
156
+ for page in reversed(range(len(layout.get("pages", [])))):
157
+ for e in layout["pages"][page]:
158
+ key = f"p{page}:{entry_label(e, self.prefix)}"
159
+ if key in self.done:
160
+ continue
161
+ if is_widget(e):
162
+ self.todo.append({"page": page + 1, "widget": e["widget"], "size": e.get("size")})
163
+ self.log(f" widget {'/'.join(e['widget'])} ({e.get('size')}): place through the widget picker")
164
+ self._mark(key)
165
+ continue
166
+ self.log(f"page {page + 1}: {entry_label(e, self.prefix)}")
167
+ item = self.build_folder(e["folder"], e["apps"]) if isinstance(e, dict) else self.place_app(e)
168
+ if item and self.carry(item, page):
169
+ self._mark(key)
170
+
171
+ def order(self, page: int, wanted: list[str]) -> bool:
172
+ """Selection sort with one swap per reading. Returns False when it had to stop."""
173
+ for i, label in enumerate(wanted):
174
+ items = [x for x in self.l.page(page) if x.kind != "widget"]
175
+ names = [x.label for x in items]
176
+ if i >= len(items) or names[i] == label:
177
+ continue
178
+ if label not in names:
179
+ self.problems.append(f"{label} is not on page {page + 1}")
180
+ continue
181
+ folders = {x.label for x in items if x.kind == "folder"}
182
+ self.l.swap(items[names.index(label)], items[i])
183
+ after = {x.label for x in self.l.page(page) if x.kind == "folder"}
184
+ if after - folders:
185
+ self.problems.append(f"page {page + 1}: a swap created a folder {sorted(after - folders)}, stopped")
186
+ return False
187
+ return True
188
+
189
+ def order_all(self, layout: dict) -> bool:
190
+ ok = True
191
+ for page, entries in enumerate(layout.get("pages", [])):
192
+ ok = self.order(page, [entry_label(e, self.prefix) for e in entries if not is_widget(e)]) and ok
193
+ return ok
194
+
195
+ def check(self, layout: dict) -> list[str]:
196
+ """Compare every page and folder with the layout. Returns the differences (empty when it matches)."""
197
+ diffs = []
198
+ pages = self.l.pages()
199
+ for p, entries in enumerate(layout.get("pages", [])):
200
+ have = [i.label for i in pages[p] if i.kind != "widget"] if p < len(pages) else []
201
+ want = [entry_label(e, self.prefix) for e in entries if not is_widget(e)]
202
+ if have != want:
203
+ diffs.append(f"page {p + 1}: have {have}, want {want}")
204
+ for e in entries:
205
+ if isinstance(e, dict) and "folder" in e:
206
+ inside = self.l.open_folder(p, entry_label(e, self.prefix)) or []
207
+ if sorted(x.lower() for x in inside) != sorted(a.lower() for a in e["apps"]):
208
+ diffs.append(f"folder {e['folder']}: have {inside}, want {e['apps']}")
209
+ return diffs