iphone2android 0.1.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 (28) hide show
  1. iphone2android-0.1.0/LICENSE +21 -0
  2. iphone2android-0.1.0/PKG-INFO +127 -0
  3. iphone2android-0.1.0/README.md +108 -0
  4. iphone2android-0.1.0/pyproject.toml +27 -0
  5. iphone2android-0.1.0/setup.cfg +4 -0
  6. iphone2android-0.1.0/src/iphone2android/__init__.py +3 -0
  7. iphone2android-0.1.0/src/iphone2android/__main__.py +3 -0
  8. iphone2android-0.1.0/src/iphone2android/android/__init__.py +0 -0
  9. iphone2android-0.1.0/src/iphone2android/android/adb.py +45 -0
  10. iphone2android-0.1.0/src/iphone2android/android/build.py +116 -0
  11. iphone2android-0.1.0/src/iphone2android/android/debloat.py +48 -0
  12. iphone2android-0.1.0/src/iphone2android/android/launcher.py +134 -0
  13. iphone2android-0.1.0/src/iphone2android/android/play.py +61 -0
  14. iphone2android-0.1.0/src/iphone2android/android/ui.py +54 -0
  15. iphone2android-0.1.0/src/iphone2android/backup.py +143 -0
  16. iphone2android-0.1.0/src/iphone2android/cli.py +214 -0
  17. iphone2android-0.1.0/src/iphone2android/convert.py +208 -0
  18. iphone2android-0.1.0/src/iphone2android/layout.py +73 -0
  19. iphone2android-0.1.0/src/iphone2android/media.py +67 -0
  20. iphone2android-0.1.0/src/iphone2android.egg-info/PKG-INFO +127 -0
  21. iphone2android-0.1.0/src/iphone2android.egg-info/SOURCES.txt +26 -0
  22. iphone2android-0.1.0/src/iphone2android.egg-info/dependency_links.txt +1 -0
  23. iphone2android-0.1.0/src/iphone2android.egg-info/entry_points.txt +2 -0
  24. iphone2android-0.1.0/src/iphone2android.egg-info/requires.txt +5 -0
  25. iphone2android-0.1.0/src/iphone2android.egg-info/top_level.txt +1 -0
  26. iphone2android-0.1.0/tests/test_android.py +190 -0
  27. iphone2android-0.1.0/tests/test_backup_media.py +68 -0
  28. iphone2android-0.1.0/tests/test_convert_layout.py +155 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Konstantinos Georgiou
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.4
2
+ Name: iphone2android
3
+ Version: 0.1.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 moves your data from an iPhone to an Android phone using an encrypted iPhone backup on your computer and `adb` to the new phone. It converts messages, call history, contacts, calendars and bookmarks into formats Android and Google import, copies photos and the media Google Photos does not keep, installs your apps, and rebuilds your iPhone home screen on the Android launcher.
23
+
24
+ ## Before you wipe the iPhone
25
+
26
+ Some things cannot be recovered from any backup, so move them while the iPhone still works.
27
+
28
+ - Authenticator apps. Export the accounts from the iPhone app (Google Authenticator has Transfer accounts) and scan them on Android.
29
+ - Passwords in the iCloud Keychain. A backup cannot decrypt them, because every item is also sealed with a key that never leaves the iPhone. Use a password manager or a Mac signed in to the same Apple ID to export them.
30
+ - Tickets and passes bound to the device (transit tickets, ski passes). Move them through the provider's account.
31
+ - Footage that exists only on the phone in apps such as action-camera apps.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install iphone2android
37
+ ```
38
+
39
+ You also need `adb` (`brew install android-platform-tools` on macOS) with USB debugging on in the phone's developer options.
40
+
41
+ ## 1. Make an encrypted backup
42
+
43
+ In Finder (or iTunes on Windows) select the iPhone, choose "Back up all of the data on your iPhone to this Mac", tick "Encrypt local backup" and back up. Only an encrypted backup contains messages, call history and app data.
44
+
45
+ ```bash
46
+ iphone2android backups # list the backups on this computer
47
+ iphone2android apps # the apps that were installed (no password needed)
48
+ ```
49
+
50
+ On Windows, pass `--backup-root "%APPDATA%\Apple Computer\MobileSync\Backup"`.
51
+
52
+ ## 2. Extract and convert
53
+
54
+ ```bash
55
+ iphone2android extract --inventory # asks for the backup password (or set IPHONE_BACKUP_PASSWORD)
56
+ iphone2android convert --out android-import
57
+ ```
58
+
59
+ | File | Import it with |
60
+ |---|---|
61
+ | `sms_backup.xml`, `calls_backup.xml` | the SMS Backup & Restore app on the phone (Restore, then choose the file) |
62
+ | `contacts.vcf` | Google Contacts (Import) |
63
+ | `calendar.ics` | Google Calendar (Settings, Import & export) |
64
+ | `safari_bookmarks.html` | Chrome on a computer (Bookmarks, Import), then sync |
65
+
66
+ Apple sometimes saves the message database in the middle of a write, so the backup copy is shorter than its own header says and SQLite refuses to open it. `convert` corrects the header in a copy and reads what is there. If that is not enough and your `sqlite3` has the `.recover` command, it uses that instead.
67
+
68
+ WhatsApp chats (`ChatStorage.sqlite`) need a dedicated iOS to Android WhatsApp migrator, and Apple Notes (`NoteStore.sqlite`) need an Apple Notes parser. `extract` copies both out for those tools.
69
+
70
+ ## 3. Photos and media
71
+
72
+ ```bash
73
+ iphone2android media camera-roll message-attachments photo-edits
74
+ ```
75
+
76
+ Files are decrypted in batches, pushed to the phone and deleted, so the computer does not need room for the whole camera roll. If your photos are already in Google Photos, skip `camera-roll`. The other two are never in Google Photos (iMessage attachments, and the edited versions of photos).
77
+
78
+ ## 4. Apps
79
+
80
+ Write a mapping from iPhone bundle ids to Android packages, using `iphone2android apps` for the list. `label` is the app's name in the Android app drawer.
81
+
82
+ ```json
83
+ {"net.whatsapp.WhatsApp": {"package": "com.whatsapp", "label": "WhatsApp"}}
84
+ ```
85
+
86
+ Check every package before installing. Guessed package names are often wrong, because the Android package of a bank or a local app rarely matches its iOS bundle id.
87
+
88
+ ```bash
89
+ iphone2android verify mapping.json
90
+ iphone2android install --mapping mapping.json
91
+ ```
92
+
93
+ `install` opens each app's page in the Play Store app and presses Install, so it works for free apps only. Each app is checked as installed before the next one starts.
94
+
95
+ To remove preinstalled apps you do not want, list them in a file and run `iphone2android debloat list.txt --keep-mapping mapping.json --dry-run`, then again without `--dry-run`. Apps are removed for the current user only, and `adb shell cmd package install-existing <package>` brings one back.
96
+
97
+ ## 5. Home screen
98
+
99
+ ```bash
100
+ iphone2android extract # also copies IconState.plist
101
+ iphone2android layout extracted/IconState.plist --mapping mapping.json > layout.json
102
+ iphone2android build layout.json --state build.json --autofill
103
+ ```
104
+
105
+ `layout.json` lists pages of apps and folders by their drawer name, which you can edit before building. The builder adds each app from the app drawer search, reads the screen again after every drop, and saves its progress so an interrupted build continues where it stopped. Then it puts each page in order one swap at a time, and finally turns the launcher's icon autofill on to close the gaps. Do not touch the phone while it runs.
106
+
107
+ Many launchers do not let adb change the layout directly (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. It is tested on ColorOS 16 (OPPO Find X9 Pro). Other launchers name their menus differently and may need changes in `android/launcher.py`.
108
+
109
+ ## Limits
110
+
111
+ - Only an encrypted backup has messages and app data.
112
+ - Keychain passwords cannot be extracted (see above).
113
+ - Paid apps are not installed.
114
+ - The home screen builder is tested on one launcher.
115
+
116
+ ## Development
117
+
118
+ ```bash
119
+ python -m venv .venv && .venv/bin/pip install -e '.[test]'
120
+ .venv/bin/pytest
121
+ ```
122
+
123
+ The tests use synthetic databases, a fake decryptor, a fake `adb` and a simulated home screen, so they need no iPhone and no Android phone.
124
+
125
+ ## License
126
+
127
+ MIT. Backup decryption uses [iOSbackup](https://github.com/avibrazil/iOSbackup) (LGPL).
@@ -0,0 +1,108 @@
1
+ # iphone2android
2
+
3
+ iphone2android moves your data from an iPhone to an Android phone using an encrypted iPhone backup on your computer and `adb` to the new phone. It converts messages, call history, contacts, calendars and bookmarks into formats Android and Google import, copies photos and the media Google Photos does not keep, installs your apps, and rebuilds your iPhone home screen on the Android launcher.
4
+
5
+ ## Before you wipe the iPhone
6
+
7
+ Some things cannot be recovered from any backup, so move them while the iPhone still works.
8
+
9
+ - Authenticator apps. Export the accounts from the iPhone app (Google Authenticator has Transfer accounts) and scan them on Android.
10
+ - Passwords in the iCloud Keychain. A backup cannot decrypt them, because every item is also sealed with a key that never leaves the iPhone. Use a password manager or a Mac signed in to the same Apple ID to export them.
11
+ - Tickets and passes bound to the device (transit tickets, ski passes). Move them through the provider's account.
12
+ - Footage that exists only on the phone in apps such as action-camera apps.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install iphone2android
18
+ ```
19
+
20
+ You also need `adb` (`brew install android-platform-tools` on macOS) with USB debugging on in the phone's developer options.
21
+
22
+ ## 1. Make an encrypted backup
23
+
24
+ In Finder (or iTunes on Windows) select the iPhone, choose "Back up all of the data on your iPhone to this Mac", tick "Encrypt local backup" and back up. Only an encrypted backup contains messages, call history and app data.
25
+
26
+ ```bash
27
+ iphone2android backups # list the backups on this computer
28
+ iphone2android apps # the apps that were installed (no password needed)
29
+ ```
30
+
31
+ On Windows, pass `--backup-root "%APPDATA%\Apple Computer\MobileSync\Backup"`.
32
+
33
+ ## 2. Extract and convert
34
+
35
+ ```bash
36
+ iphone2android extract --inventory # asks for the backup password (or set IPHONE_BACKUP_PASSWORD)
37
+ iphone2android convert --out android-import
38
+ ```
39
+
40
+ | File | Import it with |
41
+ |---|---|
42
+ | `sms_backup.xml`, `calls_backup.xml` | the SMS Backup & Restore app on the phone (Restore, then choose the file) |
43
+ | `contacts.vcf` | Google Contacts (Import) |
44
+ | `calendar.ics` | Google Calendar (Settings, Import & export) |
45
+ | `safari_bookmarks.html` | Chrome on a computer (Bookmarks, Import), then sync |
46
+
47
+ Apple sometimes saves the message database in the middle of a write, so the backup copy is shorter than its own header says and SQLite refuses to open it. `convert` corrects the header in a copy and reads what is there. If that is not enough and your `sqlite3` has the `.recover` command, it uses that instead.
48
+
49
+ WhatsApp chats (`ChatStorage.sqlite`) need a dedicated iOS to Android WhatsApp migrator, and Apple Notes (`NoteStore.sqlite`) need an Apple Notes parser. `extract` copies both out for those tools.
50
+
51
+ ## 3. Photos and media
52
+
53
+ ```bash
54
+ iphone2android media camera-roll message-attachments photo-edits
55
+ ```
56
+
57
+ Files are decrypted in batches, pushed to the phone and deleted, so the computer does not need room for the whole camera roll. If your photos are already in Google Photos, skip `camera-roll`. The other two are never in Google Photos (iMessage attachments, and the edited versions of photos).
58
+
59
+ ## 4. Apps
60
+
61
+ Write a mapping from iPhone bundle ids to Android packages, using `iphone2android apps` for the list. `label` is the app's name in the Android app drawer.
62
+
63
+ ```json
64
+ {"net.whatsapp.WhatsApp": {"package": "com.whatsapp", "label": "WhatsApp"}}
65
+ ```
66
+
67
+ Check every package before installing. Guessed package names are often wrong, because the Android package of a bank or a local app rarely matches its iOS bundle id.
68
+
69
+ ```bash
70
+ iphone2android verify mapping.json
71
+ iphone2android install --mapping mapping.json
72
+ ```
73
+
74
+ `install` opens each app's page in the Play Store app and presses Install, so it works for free apps only. Each app is checked as installed before the next one starts.
75
+
76
+ To remove preinstalled apps you do not want, list them in a file and run `iphone2android debloat list.txt --keep-mapping mapping.json --dry-run`, then again without `--dry-run`. Apps are removed for the current user only, and `adb shell cmd package install-existing <package>` brings one back.
77
+
78
+ ## 5. Home screen
79
+
80
+ ```bash
81
+ iphone2android extract # also copies IconState.plist
82
+ iphone2android layout extracted/IconState.plist --mapping mapping.json > layout.json
83
+ iphone2android build layout.json --state build.json --autofill
84
+ ```
85
+
86
+ `layout.json` lists pages of apps and folders by their drawer name, which you can edit before building. The builder adds each app from the app drawer search, reads the screen again after every drop, and saves its progress so an interrupted build continues where it stopped. Then it puts each page in order one swap at a time, and finally turns the launcher's icon autofill on to close the gaps. Do not touch the phone while it runs.
87
+
88
+ Many launchers do not let adb change the layout directly (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. It is tested on ColorOS 16 (OPPO Find X9 Pro). Other launchers name their menus differently and may need changes in `android/launcher.py`.
89
+
90
+ ## Limits
91
+
92
+ - Only an encrypted backup has messages and app data.
93
+ - Keychain passwords cannot be extracted (see above).
94
+ - Paid apps are not installed.
95
+ - The home screen builder is tested on one launcher.
96
+
97
+ ## Development
98
+
99
+ ```bash
100
+ python -m venv .venv && .venv/bin/pip install -e '.[test]'
101
+ .venv/bin/pytest
102
+ ```
103
+
104
+ The tests use synthetic databases, a fake decryptor, a fake `adb` and a simulated home screen, so they need no iPhone and no Android phone.
105
+
106
+ ## License
107
+
108
+ MIT. Backup decryption uses [iOSbackup](https://github.com/avibrazil/iOSbackup) (LGPL).
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "iphone2android"
7
+ version = "0.1.0"
8
+ description = "Move from an iPhone to an Android phone: messages, contacts, photos, apps and the home screen, from an encrypted iPhone backup"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{ name = "Konstantinos Georgiou" }]
13
+ dependencies = ["iOSbackup>=0.9.9", "pycryptodome>=3.18"]
14
+ keywords = ["iphone", "ios", "android", "migration", "backup", "adb", "sms", "launcher"]
15
+ classifiers = ["Programming Language :: Python :: 3", "Topic :: Utilities"]
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/drkostas/iphone2android"
19
+
20
+ [project.scripts]
21
+ iphone2android = "iphone2android.cli:main"
22
+
23
+ [project.optional-dependencies]
24
+ test = ["pytest>=8"]
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Move from an iPhone to an Android phone: messages, contacts, photos, apps and the home screen."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,45 @@
1
+ """A small adb wrapper.
2
+
3
+ stdin is always closed. `adb shell` reads stdin, so inside a shell `while read` loop it would eat the
4
+ rest of the list and only the first item would be processed.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import shutil
10
+ import subprocess
11
+
12
+
13
+ class DeviceUnavailable(RuntimeError):
14
+ """adb could not reach the phone. Raised instead of returning empty output, which would read as "nothing installed"."""
15
+
16
+
17
+ class Adb:
18
+ def __init__(self, serial: str | None = None, binary: str | None = None):
19
+ self.serial = serial
20
+ self.binary = binary or os.environ.get("IPHONE2ANDROID_ADB") or shutil.which("adb") or "adb"
21
+
22
+ def run(self, *args: str, timeout: float = 90) -> subprocess.CompletedProcess:
23
+ cmd = [self.binary] + (["-s", self.serial] if self.serial else []) + list(args)
24
+ return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL)
25
+
26
+ def shell(self, cmd: str, timeout: float = 90) -> str:
27
+ try:
28
+ r = self.run("shell", cmd, timeout=timeout)
29
+ except subprocess.TimeoutExpired as e:
30
+ raise DeviceUnavailable(f"adb shell timed out after {timeout}s") from e
31
+ # adb's own failures start with "adb:" or "error:". A failing command on the phone does not.
32
+ if r.returncode != 0 and r.stderr.lstrip().startswith(("adb:", "error:")):
33
+ raise DeviceUnavailable(r.stderr.strip()[:200])
34
+ return r.stdout
35
+
36
+ def installed(self, package: str) -> bool:
37
+ return f"package:{package}\n" in self.shell(f"pm list packages {package}") + "\n"
38
+
39
+ def screen_size(self) -> tuple[int, int]:
40
+ """The size touches are measured in: the override size when one is set, else the physical size."""
41
+ out = self.shell("wm size").strip()
42
+ if "x" not in out:
43
+ raise DeviceUnavailable(f"cannot read the screen size: {out[:80]!r}")
44
+ w, h = out.splitlines()[-1].split(":")[-1].strip().split("x")
45
+ return int(w), int(h)
@@ -0,0 +1,116 @@
1
+ """Build a home screen from a layout file, and put each page in order.
2
+
3
+ {"pages": [["Camera", "Photos", {"folder": "Social", "apps": ["WhatsApp", "Telegram"]}]],
4
+ "dock": ["Phone", "Messages", "Chrome"]}
5
+
6
+ Each name is what to type in the app drawer search. Building never assumes where a drop landed: it
7
+ reads the screen again after every drop and uses the real position. Progress is saved after every
8
+ step, so an interrupted build continues where it stopped.
9
+
10
+ Order is fixed afterwards by a selection sort that makes one swap and then reads the screen again.
11
+ Several swaps from one reading do not converge, because every move shifts the grid. A swap that
12
+ creates a folder by accident stops the sort.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from pathlib import Path
18
+
19
+
20
+ def _label(entry) -> str:
21
+ return f"Folder:{entry['folder']}" if isinstance(entry, dict) else entry
22
+
23
+
24
+ class Builder:
25
+ def __init__(self, launcher, state_file: Path | None = None, log=print):
26
+ self.l = launcher
27
+ self.state_file = state_file
28
+ self.log = log
29
+ self.done: list[str] = []
30
+ if state_file and state_file.exists():
31
+ self.done = json.loads(state_file.read_text()).get("done", [])
32
+
33
+ def _mark(self, key: str) -> None:
34
+ self.done.append(key)
35
+ if self.state_file:
36
+ self.state_file.write_text(json.dumps({"done": self.done}))
37
+
38
+ def add(self, name: str, page: int, onto: tuple[int, int] | None = None):
39
+ """Drag an app from the drawer to the page (onto a position to merge). Returns where it landed."""
40
+ hit = self.l.drawer_search(name)
41
+ if not hit:
42
+ self.log(f" not in the drawer: {name}")
43
+ return None
44
+ tx, ty = onto if onto else self.l.at(0.5, 0.71)
45
+ self.l.drag(hit.x, hit.y, tx, ty, 1800)
46
+ landed = [n for n in self.l.icons(page) if n.label == hit.label]
47
+ return landed[0] if landed else None
48
+
49
+ def build_page(self, page: int, entries: list) -> None:
50
+ for e in entries:
51
+ key = f"p{page}:{_label(e)}"
52
+ if key in self.done:
53
+ continue
54
+ if isinstance(e, str):
55
+ r = self.add(e, page)
56
+ self.log(f" {e}: {'placed' if r else 'FAILED'}")
57
+ else:
58
+ self._build_folder(page, e["folder"], e["apps"])
59
+ self._mark(key)
60
+
61
+ def _build_folder(self, page: int, name: str, apps: list[str]) -> None:
62
+ if not apps:
63
+ return
64
+ before = {n.label for n in self.l.icons(page) if n.folder}
65
+ first = self.add(apps[0], page)
66
+ if not first:
67
+ self.log(f" folder {name}: cannot place {apps[0]}")
68
+ return
69
+ if len(apps) == 1:
70
+ self.log(f" folder {name} has one app, placed it loose")
71
+ return
72
+ self.add(apps[1], page, onto=(first.x, first.y))
73
+ new = [n for n in self.l.icons(page) if n.folder and n.label not in before]
74
+ if not new:
75
+ self.log(f" folder {name}: no folder formed")
76
+ return
77
+ # Identify the new folder by being new, never by its auto-name (two can share one).
78
+ self.l.tap(new[0].x, new[0].y)
79
+ self.l.rename_open_folder(name)
80
+ for app in apps[2:]:
81
+ target = [n for n in self.l.icons(page) if n.label == f"Folder:{name}"]
82
+ if not target:
83
+ self.log(f" folder {name}: lost it after renaming")
84
+ return
85
+ self.add(app, page, onto=(target[0].x, target[0].y))
86
+ self.log(f" folder {name}: {len(apps)} apps")
87
+
88
+ def build(self, layout: dict) -> None:
89
+ for page, entries in enumerate(layout.get("pages", [])):
90
+ self.log(f"page {page + 1}")
91
+ self.build_page(page, entries)
92
+
93
+ def order(self, page: int, wanted: list[str]) -> bool:
94
+ """Selection sort with one swap per reading. Returns False when it had to stop."""
95
+ for i, label in enumerate(wanted):
96
+ items = self.l.icons(page)
97
+ names = [n.label for n in items]
98
+ if i >= len(items) or names[i] == label:
99
+ continue
100
+ if label not in names:
101
+ self.log(f" {label} is not on page {page + 1}")
102
+ continue
103
+ folders = {n.label for n in items if n.folder}
104
+ j = names.index(label)
105
+ self.l.drag(items[j].x, items[j].y, items[i].x, items[i].y, 2000)
106
+ after = {n.label for n in self.l.icons(page) if n.folder}
107
+ if after - folders:
108
+ self.log(f" stopped: the swap created a folder {sorted(after - folders)}")
109
+ return False
110
+ return True
111
+
112
+ def order_all(self, layout: dict) -> bool:
113
+ ok = True
114
+ for page, entries in enumerate(layout.get("pages", [])):
115
+ ok = self.order(page, [_label(e) for e in entries]) and ok
116
+ return ok
@@ -0,0 +1,48 @@
1
+ """Remove preinstalled apps for the current user, without root.
2
+
3
+ `pm uninstall --user 0` keeps the app on the system partition, so it can come back with
4
+ `cmd package install-existing <package>`. Some system parts refuse and are disabled instead.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from .adb import Adb
9
+
10
+ # Never removed, whatever the list says.
11
+ PROTECTED = {
12
+ "com.google.android.euicc", # eSIM
13
+ "com.android.vending", # Play Store
14
+ "com.google.android.gms", # Play services
15
+ "com.android.phone",
16
+ "com.android.settings",
17
+ "com.android.systemui",
18
+ "com.google.android.dialer",
19
+ "com.android.dialer",
20
+ "com.google.android.apps.messaging",
21
+ "com.android.mms",
22
+ "com.android.launcher",
23
+ }
24
+
25
+
26
+ def remove(adb: Adb, packages: list[str], keep: set[str] = frozenset(), dry_run: bool = False) -> dict[str, str]:
27
+ out: dict[str, str] = {}
28
+ for pkg in packages:
29
+ if pkg in PROTECTED or pkg in keep:
30
+ out[pkg] = "kept"
31
+ continue
32
+ if not adb.installed(pkg):
33
+ out[pkg] = "absent"
34
+ continue
35
+ if dry_run:
36
+ out[pkg] = "would remove"
37
+ continue
38
+ r = adb.shell(f"pm uninstall --user 0 {pkg}")
39
+ if "Success" in r:
40
+ out[pkg] = "removed"
41
+ continue
42
+ r2 = adb.shell(f"pm disable-user --user 0 {pkg}")
43
+ out[pkg] = "disabled" if "disabled" in r2.lower() else f"failed: {r.strip()[:80]}"
44
+ return out
45
+
46
+
47
+ def restore(adb: Adb, package: str) -> bool:
48
+ return "installed" in adb.shell(f"cmd package install-existing {package}").lower()
@@ -0,0 +1,134 @@
1
+ """Drive a stock home screen through its UI, which is the only way on locked-down launchers.
2
+
3
+ On ColorOS (OPPO, OnePlus) the launcher database, its content provider and shortcut pinning are all
4
+ closed to adb without root, so icons are moved the way a person moves them. Positions scale with
5
+ the screen size. Tested on ColorOS 16 (Android 16). Other launchers need their own tuning.
6
+
7
+ Rules learned the hard way:
8
+ - Re-read the screen before every drag. The grid moves after each change.
9
+ - Dropping an icon onto another makes a folder. Dropping onto a folder adds to it.
10
+ - A folder is renamed by tapping its title, selecting all, typing and pressing BACK (not ENTER).
11
+ - Icons cannot be dragged out of a folder. Long-press inside the open folder and choose Remove.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import time
16
+
17
+ from . import ui
18
+ from .adb import Adb
19
+
20
+
21
+ class Launcher:
22
+ def __init__(self, adb: Adb, pause: float = 1.0):
23
+ self.adb = adb
24
+ self.pause = pause
25
+ self.w, self.h = adb.screen_size()
26
+
27
+ def _sleep(self, s: float) -> None:
28
+ time.sleep(s * self.pause)
29
+
30
+ # positions as fractions of the screen, measured on a 1264x2780 screen
31
+ def at(self, fx: float, fy: float) -> tuple[int, int]:
32
+ return int(self.w * fx), int(self.h * fy)
33
+
34
+ def dock_top(self) -> int:
35
+ return int(self.h * 0.845)
36
+
37
+ def in_dock(self, n: ui.Node) -> bool:
38
+ return n.y > self.dock_top()
39
+
40
+ def dump(self) -> list[ui.Node]:
41
+ return ui.dump(self.adb)
42
+
43
+ def home(self) -> None:
44
+ for _ in range(2):
45
+ self.adb.shell("input keyevent KEYCODE_HOME")
46
+ self._sleep(1.6)
47
+
48
+ def page_next(self) -> None:
49
+ (x1, y), (x2, _) = self.at(0.87, 0.58), self.at(0.16, 0.58)
50
+ # 250 ms. A slower swipe is read as a drag and does not change page.
51
+ self.adb.shell(f"input swipe {x1} {y} {x2} {y} 250")
52
+ self._sleep(1.8)
53
+
54
+ def goto(self, page: int) -> None:
55
+ self.home() # HOME is page 0; swiping left from it opens the feed, not a page
56
+ for _ in range(page):
57
+ self.page_next()
58
+
59
+ def drag(self, x1: int, y1: int, x2: int, y2: int, ms: int = 600) -> None:
60
+ # About 600 ms merges reliably. A long hover opens the folder under the finger instead.
61
+ self.adb.shell(f"input draganddrop {x1} {y1} {x2} {y2} {ms}")
62
+ self._sleep(2.5)
63
+
64
+ def tap(self, x: int, y: int) -> None:
65
+ self.adb.shell(f"input tap {x} {y}")
66
+ self._sleep(1.8)
67
+
68
+ def icons(self, page: int) -> list[ui.Node]:
69
+ """Icons and folders on a page, in reading order, without the dock and the label nodes of folders."""
70
+ self.goto(page)
71
+ nodes = self.dump()
72
+ folder_names = {n.label for n in nodes if n.folder}
73
+ items = [n for n in nodes if n.clickable and n.cls in ("TextView", "FrameLayout")
74
+ and not self.in_dock(n) and f"Folder:{n.label}" not in folder_names]
75
+ return sorted(items, key=lambda n: (n.y, n.x))
76
+
77
+ def drawer_search(self, term: str) -> ui.Node | None:
78
+ """Open the app drawer, search, and return the first result whose name contains the first word."""
79
+ self.home()
80
+ (x, y1), (_, y2) = self.at(0.5, 0.83), self.at(0.5, 0.32)
81
+ self.adb.shell(f"input swipe {x} {y1} {x} {y2} 300")
82
+ self._sleep(2.5)
83
+ nodes = self.dump()
84
+ field = [n for n in nodes if n.cls == "EditText"] or [n for n in nodes if n.label.lower().startswith("search")]
85
+ if not field:
86
+ return None
87
+ self.tap(field[0].x, field[0].y)
88
+ self.adb.shell("input keycombination 113 29") # ctrl+a
89
+ self.adb.shell("input text " + term.replace(" ", "%s"))
90
+ self._sleep(2.2)
91
+ first = term.split()[0].lower()
92
+ for n in self.dump():
93
+ if n.cls == "TextView" and n.y < self.dock_top() and first in n.label.lower():
94
+ return n
95
+ return None
96
+
97
+ def rename_open_folder(self, name: str) -> bool:
98
+ field = [n for n in self.dump() if n.cls == "EditText"]
99
+ if not field:
100
+ return False
101
+ self.tap(field[0].x, field[0].y)
102
+ self.adb.shell("input keycombination 113 29")
103
+ self.adb.shell("input text " + name.replace(" ", "%s"))
104
+ self._sleep(1.2)
105
+ self.adb.shell("input keyevent KEYCODE_BACK") # ENTER does not commit the name, BACK does
106
+ self._sleep(2.0)
107
+ return True
108
+
109
+ def long_press(self, x: int, y: int, seconds: float = 1.4) -> None:
110
+ # One shell, so the finger stays down for the whole gesture.
111
+ self.adb.shell(f"input motionevent DOWN {x} {y}; sleep {seconds}; input motionevent UP {x} {y}")
112
+ self._sleep(1.5)
113
+
114
+ def set_autofill(self, on: bool) -> str:
115
+ """Toggle Home screen settings > Icon autofill. Build with it off, then turn it on once to pack the grid."""
116
+ self.home()
117
+ self.long_press(*self.at(0.5, 0.54))
118
+ settings = [n for n in self.dump() if n.label == "Home screen settings" and n.clickable]
119
+ if not settings:
120
+ self.home()
121
+ return "not found"
122
+ self.tap(settings[0].x, settings[0].y)
123
+ nodes = self.dump()
124
+ row = [n for n in nodes if n.label == "Icon autofill"]
125
+ switch = [n for n in nodes if n.cls == "Switch" and row and abs(n.y - row[0].y) < 60]
126
+ state = "not found"
127
+ if switch:
128
+ current = switch[0].label.lower() == "on"
129
+ if current != on:
130
+ self.tap(switch[0].x, switch[0].y)
131
+ state = "on" if on else "off"
132
+ self.adb.shell("input keyevent KEYCODE_BACK")
133
+ self.home()
134
+ return state