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.
- iphone2android-0.1.0/LICENSE +21 -0
- iphone2android-0.1.0/PKG-INFO +127 -0
- iphone2android-0.1.0/README.md +108 -0
- iphone2android-0.1.0/pyproject.toml +27 -0
- iphone2android-0.1.0/setup.cfg +4 -0
- iphone2android-0.1.0/src/iphone2android/__init__.py +3 -0
- iphone2android-0.1.0/src/iphone2android/__main__.py +3 -0
- iphone2android-0.1.0/src/iphone2android/android/__init__.py +0 -0
- iphone2android-0.1.0/src/iphone2android/android/adb.py +45 -0
- iphone2android-0.1.0/src/iphone2android/android/build.py +116 -0
- iphone2android-0.1.0/src/iphone2android/android/debloat.py +48 -0
- iphone2android-0.1.0/src/iphone2android/android/launcher.py +134 -0
- iphone2android-0.1.0/src/iphone2android/android/play.py +61 -0
- iphone2android-0.1.0/src/iphone2android/android/ui.py +54 -0
- iphone2android-0.1.0/src/iphone2android/backup.py +143 -0
- iphone2android-0.1.0/src/iphone2android/cli.py +214 -0
- iphone2android-0.1.0/src/iphone2android/convert.py +208 -0
- iphone2android-0.1.0/src/iphone2android/layout.py +73 -0
- iphone2android-0.1.0/src/iphone2android/media.py +67 -0
- iphone2android-0.1.0/src/iphone2android.egg-info/PKG-INFO +127 -0
- iphone2android-0.1.0/src/iphone2android.egg-info/SOURCES.txt +26 -0
- iphone2android-0.1.0/src/iphone2android.egg-info/dependency_links.txt +1 -0
- iphone2android-0.1.0/src/iphone2android.egg-info/entry_points.txt +2 -0
- iphone2android-0.1.0/src/iphone2android.egg-info/requires.txt +5 -0
- iphone2android-0.1.0/src/iphone2android.egg-info/top_level.txt +1 -0
- iphone2android-0.1.0/tests/test_android.py +190 -0
- iphone2android-0.1.0/tests/test_backup_media.py +68 -0
- 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"]
|
|
File without changes
|
|
@@ -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
|