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.
- iphone2android-0.3.0/PKG-INFO +115 -0
- iphone2android-0.3.0/README.md +96 -0
- {iphone2android-0.1.0 → iphone2android-0.3.0}/pyproject.toml +4 -1
- {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android/__init__.py +1 -1
- iphone2android-0.3.0/src/iphone2android/accounts.py +33 -0
- {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android/android/adb.py +27 -0
- iphone2android-0.3.0/src/iphone2android/android/build.py +209 -0
- iphone2android-0.3.0/src/iphone2android/android/launcher.py +351 -0
- iphone2android-0.3.0/src/iphone2android/android/profile.py +51 -0
- iphone2android-0.3.0/src/iphone2android/android/profiles/coloros-16.json +197 -0
- iphone2android-0.3.0/src/iphone2android/android/profiles/generic.json +158 -0
- {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android/android/ui.py +9 -6
- iphone2android-0.3.0/src/iphone2android/appdata.py +108 -0
- iphone2android-0.3.0/src/iphone2android/apps.py +146 -0
- iphone2android-0.3.0/src/iphone2android/audit.py +103 -0
- {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android/backup.py +14 -4
- iphone2android-0.3.0/src/iphone2android/cli.py +481 -0
- {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android/layout.py +17 -2
- {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android/media.py +10 -1
- iphone2android-0.3.0/src/iphone2android/skill/SKILL.md +247 -0
- iphone2android-0.3.0/src/iphone2android/wallpaper.py +88 -0
- iphone2android-0.3.0/src/iphone2android.egg-info/PKG-INFO +115 -0
- {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android.egg-info/SOURCES.txt +13 -1
- {iphone2android-0.1.0 → iphone2android-0.3.0}/tests/test_android.py +0 -104
- iphone2android-0.3.0/tests/test_builder.py +102 -0
- iphone2android-0.3.0/tests/test_launcher_driver.py +97 -0
- iphone2android-0.3.0/tests/test_v2.py +258 -0
- iphone2android-0.1.0/PKG-INFO +0 -127
- iphone2android-0.1.0/README.md +0 -108
- iphone2android-0.1.0/src/iphone2android/android/build.py +0 -116
- iphone2android-0.1.0/src/iphone2android/android/launcher.py +0 -134
- iphone2android-0.1.0/src/iphone2android/cli.py +0 -214
- iphone2android-0.1.0/src/iphone2android.egg-info/PKG-INFO +0 -127
- {iphone2android-0.1.0 → iphone2android-0.3.0}/LICENSE +0 -0
- {iphone2android-0.1.0 → iphone2android-0.3.0}/setup.cfg +0 -0
- {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android/__main__.py +0 -0
- {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android/android/__init__.py +0 -0
- {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android/android/debloat.py +0 -0
- {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android/android/play.py +0 -0
- {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android/convert.py +0 -0
- {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android.egg-info/dependency_links.txt +0 -0
- {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android.egg-info/entry_points.txt +0 -0
- {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android.egg-info/requires.txt +0 -0
- {iphone2android-0.1.0 → iphone2android-0.3.0}/src/iphone2android.egg-info/top_level.txt +0 -0
- {iphone2android-0.1.0 → iphone2android-0.3.0}/tests/test_backup_media.py +0 -0
- {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.
|
|
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"]
|
|
@@ -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
|