iphone2android 0.1.0__tar.gz → 0.2.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.2.0/PKG-INFO +101 -0
- iphone2android-0.2.0/README.md +82 -0
- {iphone2android-0.1.0 → iphone2android-0.2.0}/pyproject.toml +4 -1
- {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/__init__.py +1 -1
- {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/android/adb.py +8 -0
- {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/android/build.py +14 -1
- iphone2android-0.2.0/src/iphone2android/appdata.py +106 -0
- iphone2android-0.2.0/src/iphone2android/apps.py +146 -0
- iphone2android-0.2.0/src/iphone2android/audit.py +91 -0
- {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/backup.py +13 -4
- iphone2android-0.2.0/src/iphone2android/cli.py +356 -0
- {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/layout.py +17 -2
- iphone2android-0.2.0/src/iphone2android/skill/SKILL.md +144 -0
- iphone2android-0.2.0/src/iphone2android/wallpaper.py +88 -0
- iphone2android-0.2.0/src/iphone2android.egg-info/PKG-INFO +101 -0
- {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android.egg-info/SOURCES.txt +7 -1
- iphone2android-0.2.0/tests/test_v2.py +256 -0
- iphone2android-0.1.0/PKG-INFO +0 -127
- iphone2android-0.1.0/README.md +0 -108
- 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.2.0}/LICENSE +0 -0
- {iphone2android-0.1.0 → iphone2android-0.2.0}/setup.cfg +0 -0
- {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/__main__.py +0 -0
- {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/android/__init__.py +0 -0
- {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/android/debloat.py +0 -0
- {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/android/launcher.py +0 -0
- {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/android/play.py +0 -0
- {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/android/ui.py +0 -0
- {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/convert.py +0 -0
- {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/media.py +0 -0
- {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android.egg-info/dependency_links.txt +0 -0
- {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android.egg-info/entry_points.txt +0 -0
- {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android.egg-info/requires.txt +0 -0
- {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android.egg-info/top_level.txt +0 -0
- {iphone2android-0.1.0 → iphone2android-0.2.0}/tests/test_android.py +0 -0
- {iphone2android-0.1.0 → iphone2android-0.2.0}/tests/test_backup_media.py +0 -0
- {iphone2android-0.1.0 → iphone2android-0.2.0}/tests/test_convert_layout.py +0 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: iphone2android
|
|
3
|
+
Version: 0.2.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 layout extracted/IconState.plist --mapping mapping.json > layout.json
|
|
63
|
+
iphone2android wallpaper extract
|
|
64
|
+
iphone2android build layout.json --state build.json --autofill
|
|
65
|
+
iphone2android wallpaper set wallpaper/<image>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
| File from `convert` | Import it with |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `sms_backup.xml`, `calls_backup.xml` | the SMS Backup & Restore app |
|
|
71
|
+
| `contacts.vcf` | Google Contacts (Import) |
|
|
72
|
+
| `calendar.ics` | Google Calendar on the web (Settings, Import & export) |
|
|
73
|
+
| `safari_bookmarks.html` | Chrome on a computer (Bookmarks, Import) |
|
|
74
|
+
|
|
75
|
+
`iphone2android screenshot` and `iphone2android screen` show what is on the phone, and `iphone2android debloat` removes preinstalled apps you do not want (reversibly).
|
|
76
|
+
|
|
77
|
+
## What cannot move
|
|
78
|
+
|
|
79
|
+
- 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.
|
|
80
|
+
- Authenticator codes. Transfer them from the authenticator app while the iPhone still works.
|
|
81
|
+
- 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.
|
|
82
|
+
- Paid apps are not installed automatically.
|
|
83
|
+
|
|
84
|
+
## How the home screen is rebuilt
|
|
85
|
+
|
|
86
|
+
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. It adds each app from the drawer search, reads the screen again after every drop, saves its progress so an interrupted build continues, puts each page in order one swap at a time, and turns on icon autofill at the end to close the gaps. Widgets are listed for placing through the launcher's widget picker. The builder is tested on ColorOS 16 (OPPO Find X9 Pro). Other launchers name their menus differently and may need changes in `android/launcher.py`.
|
|
87
|
+
|
|
88
|
+
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.
|
|
89
|
+
|
|
90
|
+
## Development
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
python -m venv .venv && .venv/bin/pip install -e '.[test]'
|
|
94
|
+
.venv/bin/pytest
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
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.
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
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,82 @@
|
|
|
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 layout extracted/IconState.plist --mapping mapping.json > layout.json
|
|
44
|
+
iphone2android wallpaper extract
|
|
45
|
+
iphone2android build layout.json --state build.json --autofill
|
|
46
|
+
iphone2android wallpaper set wallpaper/<image>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
| File from `convert` | Import it with |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `sms_backup.xml`, `calls_backup.xml` | the SMS Backup & Restore app |
|
|
52
|
+
| `contacts.vcf` | Google Contacts (Import) |
|
|
53
|
+
| `calendar.ics` | Google Calendar on the web (Settings, Import & export) |
|
|
54
|
+
| `safari_bookmarks.html` | Chrome on a computer (Bookmarks, Import) |
|
|
55
|
+
|
|
56
|
+
`iphone2android screenshot` and `iphone2android screen` show what is on the phone, and `iphone2android debloat` removes preinstalled apps you do not want (reversibly).
|
|
57
|
+
|
|
58
|
+
## What cannot move
|
|
59
|
+
|
|
60
|
+
- 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.
|
|
61
|
+
- Authenticator codes. Transfer them from the authenticator app while the iPhone still works.
|
|
62
|
+
- 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.
|
|
63
|
+
- Paid apps are not installed automatically.
|
|
64
|
+
|
|
65
|
+
## How the home screen is rebuilt
|
|
66
|
+
|
|
67
|
+
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. It adds each app from the drawer search, reads the screen again after every drop, saves its progress so an interrupted build continues, puts each page in order one swap at a time, and turns on icon autofill at the end to close the gaps. Widgets are listed for placing through the launcher's widget picker. The builder is tested on ColorOS 16 (OPPO Find X9 Pro). Other launchers name their menus differently and may need changes in `android/launcher.py`.
|
|
68
|
+
|
|
69
|
+
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.
|
|
70
|
+
|
|
71
|
+
## Development
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
python -m venv .venv && .venv/bin/pip install -e '.[test]'
|
|
75
|
+
.venv/bin/pytest
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
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.
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
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.2.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"]
|
|
@@ -23,6 +23,14 @@ class Adb:
|
|
|
23
23
|
cmd = [self.binary] + (["-s", self.serial] if self.serial else []) + list(args)
|
|
24
24
|
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL)
|
|
25
25
|
|
|
26
|
+
def screenshot(self, timeout: float = 30) -> bytes:
|
|
27
|
+
"""The screen as PNG bytes (binary output, so not through run's text decoding)."""
|
|
28
|
+
cmd = [self.binary] + (["-s", self.serial] if self.serial else []) + ["exec-out", "screencap", "-p"]
|
|
29
|
+
r = subprocess.run(cmd, capture_output=True, timeout=timeout, stdin=subprocess.DEVNULL)
|
|
30
|
+
if r.returncode != 0 or not r.stdout.startswith(b"\x89PNG"):
|
|
31
|
+
raise DeviceUnavailable((r.stderr or b"").decode("utf-8", "replace").strip()[:200] or "no screenshot returned")
|
|
32
|
+
return r.stdout
|
|
33
|
+
|
|
26
34
|
def shell(self, cmd: str, timeout: float = 90) -> str:
|
|
27
35
|
try:
|
|
28
36
|
r = self.run("shell", cmd, timeout=timeout)
|
|
@@ -18,15 +18,22 @@ from pathlib import Path
|
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def _label(entry) -> str:
|
|
21
|
+
if isinstance(entry, dict) and "widget" in entry:
|
|
22
|
+
return f"Widget:{'/'.join(entry['widget'])}"
|
|
21
23
|
return f"Folder:{entry['folder']}" if isinstance(entry, dict) else entry
|
|
22
24
|
|
|
23
25
|
|
|
26
|
+
def _is_widget(entry) -> bool:
|
|
27
|
+
return isinstance(entry, dict) and "widget" in entry
|
|
28
|
+
|
|
29
|
+
|
|
24
30
|
class Builder:
|
|
25
31
|
def __init__(self, launcher, state_file: Path | None = None, log=print):
|
|
26
32
|
self.l = launcher
|
|
27
33
|
self.state_file = state_file
|
|
28
34
|
self.log = log
|
|
29
35
|
self.done: list[str] = []
|
|
36
|
+
self.todo: list[dict] = []
|
|
30
37
|
if state_file and state_file.exists():
|
|
31
38
|
self.done = json.loads(state_file.read_text()).get("done", [])
|
|
32
39
|
|
|
@@ -51,6 +58,12 @@ class Builder:
|
|
|
51
58
|
key = f"p{page}:{_label(e)}"
|
|
52
59
|
if key in self.done:
|
|
53
60
|
continue
|
|
61
|
+
if _is_widget(e):
|
|
62
|
+
# Widget pickers differ between launchers, so a widget is placed through the screen
|
|
63
|
+
# (see the skill), and the builder records it as still to do.
|
|
64
|
+
self.todo.append({"page": page + 1, "widget": e["widget"], "size": e.get("size")})
|
|
65
|
+
self.log(f" widget {'/'.join(e['widget'])} ({e.get('size')}): to place by hand")
|
|
66
|
+
continue
|
|
54
67
|
if isinstance(e, str):
|
|
55
68
|
r = self.add(e, page)
|
|
56
69
|
self.log(f" {e}: {'placed' if r else 'FAILED'}")
|
|
@@ -112,5 +125,5 @@ class Builder:
|
|
|
112
125
|
def order_all(self, layout: dict) -> bool:
|
|
113
126
|
ok = True
|
|
114
127
|
for page, entries in enumerate(layout.get("pages", [])):
|
|
115
|
-
ok = self.order(page, [_label(e) for e in entries]) and ok
|
|
128
|
+
ok = self.order(page, [_label(e) for e in entries if not _is_widget(e)]) and ok
|
|
116
129
|
return ok
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""The data iPhone apps keep on the phone itself: databases, documents and settings.
|
|
2
|
+
|
|
3
|
+
An app's data is in AppDomain-<bundle id> and AppDomainGroup-group.<bundle id> in the backup. The
|
|
4
|
+
Android version of an app cannot read it directly (different formats, and Android keeps each app's
|
|
5
|
+
data private without root), so this finds which apps hold real data and copies it out, for an app
|
|
6
|
+
that has its own import, for a dedicated migrator (WhatsApp), or just to keep.
|
|
7
|
+
|
|
8
|
+
Settings files often contain account names and email addresses. `settings_summary` reads those keys
|
|
9
|
+
to help set each app up again, skips anything that looks like a secret, and is meant to stay on your
|
|
10
|
+
computer.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import plistlib
|
|
15
|
+
import re
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
SKIP = ("Caches/", "/Cache", "tmp/", "Crashlytics", "Logs/", "GoogleAnalytics", "Firebase",
|
|
20
|
+
"com.crashlytics", "AppCenter", ".log", ".ttf", ".car", ".nib")
|
|
21
|
+
DB_EXT = (".sqlite", ".db", ".sqlite3", ".realm")
|
|
22
|
+
SECRET = ("password", "passwd", "secret", "token", "refresh", "private", "pin", "cvv", "apikey", "api_key", "cookie", "session")
|
|
23
|
+
INTEREST = ("account", "user", "email", "login", "server", "host", "device", "pair", "region", "locale", "profile")
|
|
24
|
+
EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _domains(bundle: str) -> tuple[str, str]:
|
|
28
|
+
return f"AppDomain-{bundle}", f"AppDomainGroup-group.{bundle}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _size(row: dict) -> int:
|
|
32
|
+
from iOSbackup import iOSbackup
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
return int(iOSbackup.getFileInfo(row["file"]).get("size") or 0)
|
|
36
|
+
except Exception: # noqa: BLE001
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def survey(backup, bundles: list[str], size=_size) -> list[dict]:
|
|
41
|
+
"""Which apps hold real local data, biggest first."""
|
|
42
|
+
out = []
|
|
43
|
+
for b in bundles:
|
|
44
|
+
rows = backup.rows("flags=1 AND (domain=? OR domain=?)", _domains(b))
|
|
45
|
+
dbs, doc_files, doc_bytes = [], 0, 0
|
|
46
|
+
for r in rows:
|
|
47
|
+
rel = r["relativePath"] or ""
|
|
48
|
+
if any(s in rel for s in SKIP):
|
|
49
|
+
continue
|
|
50
|
+
n = size(r)
|
|
51
|
+
if rel.lower().endswith(DB_EXT) and n > 20_000:
|
|
52
|
+
dbs.append({"path": rel, "bytes": n})
|
|
53
|
+
if rel.startswith("Documents/") and n > 0:
|
|
54
|
+
doc_files += 1
|
|
55
|
+
doc_bytes += n
|
|
56
|
+
if dbs or doc_bytes > 200_000:
|
|
57
|
+
out.append({"bundle": b, "databases": sorted(dbs, key=lambda d: -d["bytes"])[:5],
|
|
58
|
+
"documents": doc_files, "document_bytes": doc_bytes,
|
|
59
|
+
"total_bytes": sum(d["bytes"] for d in dbs) + doc_bytes})
|
|
60
|
+
return sorted(out, key=lambda x: -x["total_bytes"])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def extract(backup, bundle: str, out: Path) -> dict:
|
|
64
|
+
"""Copy one app's whole container, keeping its folder structure."""
|
|
65
|
+
base = out / bundle
|
|
66
|
+
copied, failed = 0, 0
|
|
67
|
+
for r in backup.rows("flags=1 AND (domain=? OR domain=?)", _domains(bundle)):
|
|
68
|
+
rel = r["relativePath"] or "root"
|
|
69
|
+
folder = base / Path(rel).parent
|
|
70
|
+
try:
|
|
71
|
+
backup.copy_entry(r, folder, Path(rel).name or "root")
|
|
72
|
+
copied += 1
|
|
73
|
+
except Exception: # noqa: BLE001
|
|
74
|
+
failed += 1
|
|
75
|
+
return {"bundle": bundle, "folder": str(base), "files": copied, "failed": failed}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def push(adb, folder: Path, dest_root: str = "/sdcard/iPhoneMigration/AppData") -> bool:
|
|
79
|
+
adb.run("shell", "mkdir", "-p", dest_root)
|
|
80
|
+
return adb.run("push", str(folder), dest_root + "/", timeout=3600).returncode == 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def settings_summary(backup, bundle: str, limit: int = 40) -> dict:
|
|
84
|
+
"""Account names, emails and non-secret settings from an app's plist files."""
|
|
85
|
+
emails: set[str] = set()
|
|
86
|
+
keys: dict[str, str] = {}
|
|
87
|
+
rows = [r for r in backup.rows("flags=1 AND (domain=? OR domain=?)", _domains(bundle))
|
|
88
|
+
if (r["relativePath"] or "").endswith(".plist")][:limit]
|
|
89
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
90
|
+
for r in rows:
|
|
91
|
+
try:
|
|
92
|
+
p = backup.copy_entry(r, Path(tmp), "t.plist")
|
|
93
|
+
d = plistlib.loads(p.read_bytes())
|
|
94
|
+
except Exception: # noqa: BLE001
|
|
95
|
+
continue
|
|
96
|
+
if not isinstance(d, dict):
|
|
97
|
+
continue
|
|
98
|
+
for k, v in d.items():
|
|
99
|
+
kl = str(k).lower()
|
|
100
|
+
if any(s in kl for s in SECRET) or not isinstance(v, (str, int, float)):
|
|
101
|
+
continue
|
|
102
|
+
s = str(v)
|
|
103
|
+
emails.update(EMAIL.findall(s))
|
|
104
|
+
if any(t in kl for t in INTEREST) and len(s) < 90:
|
|
105
|
+
keys[str(k)] = s
|
|
106
|
+
return {"bundle": bundle, "emails": sorted(emails)[:10], "settings": dict(list(keys.items())[:20])}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Find the Android app for each iPhone app.
|
|
2
|
+
|
|
3
|
+
The iPhone side gives bundle ids (com.spotify.client). Apple's public lookup turns a bundle id into
|
|
4
|
+
the app's name and developer. The Play Store's own search page then gives candidate packages for that
|
|
5
|
+
name. A candidate is picked automatically only when its Play Store title matches the iPhone name;
|
|
6
|
+
everything else is left for a person (or Claude) to choose from the candidates.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
import urllib.parse
|
|
14
|
+
import urllib.request
|
|
15
|
+
|
|
16
|
+
UA = {
|
|
17
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36",
|
|
18
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
19
|
+
}
|
|
20
|
+
ITUNES_LOOKUP = "https://itunes.apple.com/lookup?bundleId={bundle}&country={country}"
|
|
21
|
+
PLAY_SEARCH = "https://play.google.com/store/search?{query}"
|
|
22
|
+
PLAY_DETAILS = "https://play.google.com/store/apps/details?id={package}&hl=en"
|
|
23
|
+
|
|
24
|
+
# Apple's own apps are not in the App Store lookup, and most have a Google or system equivalent.
|
|
25
|
+
APPLE_BUILTINS = {
|
|
26
|
+
"com.apple.mobilephone": ("Phone", "com.google.android.dialer"),
|
|
27
|
+
"com.apple.MobileSMS": ("Messages", "com.google.android.apps.messaging"),
|
|
28
|
+
"com.apple.mobilesafari": ("Safari", "com.android.chrome"),
|
|
29
|
+
"com.apple.mobilemail": ("Mail", "com.google.android.gm"),
|
|
30
|
+
"com.apple.mobilecal": ("Calendar", "com.google.android.calendar"),
|
|
31
|
+
"com.apple.camera": ("Camera", None),
|
|
32
|
+
"com.apple.mobileslideshow": ("Photos", "com.google.android.apps.photos"),
|
|
33
|
+
"com.apple.Maps": ("Maps", "com.google.android.apps.maps"),
|
|
34
|
+
"com.apple.MobileAddressBook": ("Contacts", "com.google.android.contacts"),
|
|
35
|
+
"com.apple.mobilenotes": ("Notes", "com.google.android.keep"),
|
|
36
|
+
"com.apple.reminders": ("Reminders", "com.google.android.apps.tasks"),
|
|
37
|
+
"com.apple.Preferences": ("Settings", None),
|
|
38
|
+
"com.apple.AppStore": ("App Store", "com.android.vending"),
|
|
39
|
+
"com.apple.weather": ("Weather", None),
|
|
40
|
+
"com.apple.mobiletimer": ("Clock", "com.google.android.deskclock"),
|
|
41
|
+
"com.apple.calculator": ("Calculator", "com.google.android.calculator"),
|
|
42
|
+
"com.apple.Music": ("Music", None),
|
|
43
|
+
"com.apple.Health": ("Health", "com.google.android.apps.healthdata"),
|
|
44
|
+
"com.apple.DocumentsApp": ("Files", "com.google.android.apps.nbu.files"),
|
|
45
|
+
"com.apple.findmy": ("Find My", "com.google.android.apps.adm"),
|
|
46
|
+
"com.apple.Passbook": ("Wallet", "com.google.android.apps.walletnfcrel"),
|
|
47
|
+
"com.apple.VoiceMemos": ("Voice Memos", None),
|
|
48
|
+
"com.apple.compass": ("Compass", None),
|
|
49
|
+
"com.apple.tv": ("TV", None),
|
|
50
|
+
"com.apple.podcasts": ("Podcasts", None),
|
|
51
|
+
"com.apple.shortcuts": ("Shortcuts", None),
|
|
52
|
+
"com.apple.Translate": ("Translate", "com.google.android.apps.translate"),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _get(url: str, timeout: float = 20.0, tries: int = 3) -> str | None:
|
|
57
|
+
for attempt in range(tries):
|
|
58
|
+
try:
|
|
59
|
+
with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=timeout) as r:
|
|
60
|
+
return r.read().decode("utf-8", "replace")
|
|
61
|
+
except urllib.error.HTTPError as e:
|
|
62
|
+
if e.code == 404:
|
|
63
|
+
return None
|
|
64
|
+
except (urllib.error.URLError, TimeoutError):
|
|
65
|
+
pass
|
|
66
|
+
time.sleep(2 + attempt * 3)
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def ios_info(bundle: str, country: str = "us", lookup_url: str = ITUNES_LOOKUP) -> dict | None:
|
|
71
|
+
"""Name, developer and store link for a bundle id, from Apple's public lookup."""
|
|
72
|
+
if bundle in APPLE_BUILTINS:
|
|
73
|
+
name, _ = APPLE_BUILTINS[bundle]
|
|
74
|
+
return {"name": name, "developer": "Apple", "builtin": True}
|
|
75
|
+
body = _get(lookup_url.format(bundle=urllib.parse.quote(bundle), country=country))
|
|
76
|
+
if not body:
|
|
77
|
+
return None
|
|
78
|
+
results = (json.loads(body) or {}).get("results") or []
|
|
79
|
+
if not results:
|
|
80
|
+
return None
|
|
81
|
+
r = results[0]
|
|
82
|
+
return {"name": r.get("trackName"), "developer": r.get("sellerName") or r.get("artistName"),
|
|
83
|
+
"genre": r.get("primaryGenreName"), "builtin": False}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def play_search(query: str, country: str = "us", search_url: str = PLAY_SEARCH, limit: int = 6) -> list[str]:
|
|
87
|
+
"""Package ids the Play Store search page lists for a query, in order."""
|
|
88
|
+
body = _get(search_url.format(query=urllib.parse.urlencode({"q": query, "c": "apps", "gl": country.upper(), "hl": "en"})))
|
|
89
|
+
if not body:
|
|
90
|
+
return []
|
|
91
|
+
out: list[str] = []
|
|
92
|
+
for m in re.finditer(r"/store/apps/details\?id=([A-Za-z0-9_.]+)", body):
|
|
93
|
+
if m.group(1) not in out:
|
|
94
|
+
out.append(m.group(1))
|
|
95
|
+
return out[:limit]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def play_title(package: str, details_url: str = PLAY_DETAILS) -> str | None:
|
|
99
|
+
body = _get(details_url.format(package=package))
|
|
100
|
+
if not body:
|
|
101
|
+
return None
|
|
102
|
+
m = re.search(r"<title[^>]*>(.*?)</title>", body, re.S)
|
|
103
|
+
if not m:
|
|
104
|
+
return None
|
|
105
|
+
return re.sub(r"\s*-\s*Apps on Google Play\s*$", "", m.group(1).strip())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _norm(s: str) -> str:
|
|
109
|
+
s = (s or "").lower()
|
|
110
|
+
s = re.split(r"[:\-–|(]", s)[0] # "Spotify: Music and Podcasts" -> "spotify"
|
|
111
|
+
return re.sub(r"[^a-z0-9]+", "", s)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def suggest(bundle: str, country: str = "us", **urls) -> dict:
|
|
115
|
+
"""One mapping suggestion: the iPhone app, the candidates, and a pick when the titles agree."""
|
|
116
|
+
out = {"bundle": bundle, "name": None, "developer": None, "candidates": [], "package": None, "label": None, "confident": False}
|
|
117
|
+
if bundle in APPLE_BUILTINS:
|
|
118
|
+
name, pkg = APPLE_BUILTINS[bundle]
|
|
119
|
+
out.update(name=name, developer="Apple", package=pkg, label=name, confident=True)
|
|
120
|
+
return out
|
|
121
|
+
info = ios_info(bundle, country, urls.get("lookup_url", ITUNES_LOOKUP))
|
|
122
|
+
if not info or not info.get("name"):
|
|
123
|
+
return out
|
|
124
|
+
out.update(name=info["name"], developer=info.get("developer"))
|
|
125
|
+
want = _norm(info["name"])
|
|
126
|
+
for pkg in play_search(info["name"], country, urls.get("search_url", PLAY_SEARCH)):
|
|
127
|
+
title = play_title(pkg, urls.get("details_url", PLAY_DETAILS))
|
|
128
|
+
out["candidates"].append({"package": pkg, "title": title})
|
|
129
|
+
if title and not out["package"] and _norm(title) == want:
|
|
130
|
+
out.update(package=pkg, label=title.split(":")[0].strip(), confident=True)
|
|
131
|
+
return out
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def suggest_all(bundles: list[str], country: str = "us", log=print, **urls) -> dict:
|
|
135
|
+
"""A draft mapping for every bundle id. Entries without a confident pick keep their candidates."""
|
|
136
|
+
draft = {}
|
|
137
|
+
for i, b in enumerate(bundles, 1):
|
|
138
|
+
s = suggest(b, country, **urls)
|
|
139
|
+
draft[b] = s
|
|
140
|
+
log(f"[{i}/{len(bundles)}] {b}: " + (f"{s['package']} ({s['label']})" if s["confident"] else f"{len(s['candidates'])} candidates, pick one"))
|
|
141
|
+
return draft
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def to_mapping(draft: dict) -> dict:
|
|
145
|
+
"""The confident part of a draft, in the mapping format the other commands read."""
|
|
146
|
+
return {b: {"package": s["package"], "label": s["label"]} for b, s in draft.items() if s.get("confident") and s.get("label")}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""What is on the iPhone (from the backup) and what reached the Android phone.
|
|
2
|
+
|
|
3
|
+
Run it after the official transfer to see what that missed, and again at the end to check the
|
|
4
|
+
migration. Every number on the phone side comes from the phone itself over adb. A count the phone
|
|
5
|
+
refuses to give (some vendors block the content providers to adb) is reported as null, not as 0.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sqlite3
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .android.adb import Adb, DeviceUnavailable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _count(db: Path, sql: str) -> int | None:
|
|
17
|
+
if not db.exists():
|
|
18
|
+
return None
|
|
19
|
+
try:
|
|
20
|
+
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
21
|
+
n = con.execute(sql).fetchone()[0]
|
|
22
|
+
con.close()
|
|
23
|
+
return n
|
|
24
|
+
except sqlite3.DatabaseError:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def iphone_counts(extracted: Path) -> dict:
|
|
29
|
+
sms = extracted / "sms_recovered.db"
|
|
30
|
+
if not sms.exists():
|
|
31
|
+
sms = extracted / "sms.db"
|
|
32
|
+
return {
|
|
33
|
+
"messages": _count(sms, "SELECT count(*) FROM message WHERE text IS NOT NULL AND text != ''"),
|
|
34
|
+
"calls": _count(extracted / "CallHistory.storedata", "SELECT count(*) FROM ZCALLRECORD"),
|
|
35
|
+
"contacts": _count(extracted / "AddressBook.sqlitedb", "SELECT count(*) FROM ABPerson"),
|
|
36
|
+
"calendar_events": _count(extracted / "Calendar.sqlitedb", "SELECT count(*) FROM CalendarItem"),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _provider_count(adb: Adb, uri: str) -> int | None:
|
|
41
|
+
out = adb.shell(f"content query --uri {uri} --projection _id 2>&1 | grep -c '^Row:'")
|
|
42
|
+
if "Permission Denial" in out or "SecurityException" in out:
|
|
43
|
+
return None
|
|
44
|
+
try:
|
|
45
|
+
return int(out.strip().splitlines()[-1])
|
|
46
|
+
except (ValueError, IndexError):
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def phone_counts(adb: Adb) -> dict:
|
|
51
|
+
return {
|
|
52
|
+
"messages": _provider_count(adb, "content://sms"),
|
|
53
|
+
"calls": _provider_count(adb, "content://call_log/calls"),
|
|
54
|
+
"contacts": _provider_count(adb, "content://com.android.contacts/contacts"),
|
|
55
|
+
"calendar_events": _provider_count(adb, "content://com.android.calendar/events"),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def phone_media_names(adb: Adb, roots=("/sdcard/DCIM", "/sdcard/Pictures", "/sdcard/Movies")) -> set[str]:
|
|
60
|
+
names: set[str] = set()
|
|
61
|
+
for root in roots:
|
|
62
|
+
out = adb.shell(f"find {root} -type f 2>/dev/null")
|
|
63
|
+
names.update(os.path.basename(line.strip()) for line in out.splitlines() if line.strip())
|
|
64
|
+
return names
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def camera_roll_names(backup) -> list[str]:
|
|
68
|
+
from .media import media_rows
|
|
69
|
+
|
|
70
|
+
return sorted({os.path.basename(r["relativePath"]) for r in media_rows(backup, "CameraRollDomain", "Media/DCIM/")})
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def run(adb: Adb, extracted: Path, mapping: dict | None = None, backup=None) -> dict:
|
|
74
|
+
report: dict = {"iphone": iphone_counts(extracted)}
|
|
75
|
+
try:
|
|
76
|
+
report["phone"] = phone_counts(adb)
|
|
77
|
+
packages = {m["package"] for m in (mapping or {}).values() if m.get("package")}
|
|
78
|
+
if packages:
|
|
79
|
+
installed = {line.split(":", 1)[1].strip() for line in adb.shell("pm list packages").splitlines() if ":" in line}
|
|
80
|
+
report["apps"] = {"mapped": len(packages), "missing": sorted(packages - installed)}
|
|
81
|
+
if backup is not None:
|
|
82
|
+
want = camera_roll_names(backup)
|
|
83
|
+
have = phone_media_names(adb)
|
|
84
|
+
missing = [n for n in want if n not in have]
|
|
85
|
+
report["photos"] = {"camera_roll": len(want), "missing_by_name": len(missing), "examples": missing[:20]}
|
|
86
|
+
except DeviceUnavailable as e:
|
|
87
|
+
report["phone"] = {"error": str(e)}
|
|
88
|
+
report["gaps"] = {k: (report["iphone"][k] or 0) - (report["phone"].get(k) or 0)
|
|
89
|
+
for k in report["iphone"]
|
|
90
|
+
if isinstance(report.get("phone"), dict) and report["phone"].get(k) is not None and report["iphone"][k] is not None}
|
|
91
|
+
return report
|
|
@@ -32,6 +32,11 @@ KNOWN = [
|
|
|
32
32
|
("HomeDomain", "Library/SpringBoard/IconState.plist", "IconState.plist", "Home screen layout", "iphone2android layout"),
|
|
33
33
|
]
|
|
34
34
|
|
|
35
|
+
# Files that have lived in more than one place across iOS versions: (domain, other path, name).
|
|
36
|
+
ALTERNATES = {
|
|
37
|
+
"IconState.plist": [("HomeDomain", "Library/Preferences/IconState.plist")],
|
|
38
|
+
}
|
|
39
|
+
|
|
35
40
|
|
|
36
41
|
def backups(root: Path = DEFAULT_ROOT) -> list[dict]:
|
|
37
42
|
"""Every backup under root, with what its Info.plist says (no password needed)."""
|
|
@@ -135,9 +140,13 @@ def extract_known(b: Backup, out: Path) -> list[tuple[str, str, Path | None]]:
|
|
|
135
140
|
"""Copy every known database out. Returns (label, destination format, path or None)."""
|
|
136
141
|
got = []
|
|
137
142
|
for domain, rel, name, label, dest in KNOWN:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
143
|
+
p = None
|
|
144
|
+
for d, r in [(domain, rel)] + ALTERNATES.get(name, []):
|
|
145
|
+
try:
|
|
146
|
+
p = b.copy(d, r, out, name)
|
|
147
|
+
except Exception: # noqa: BLE001 - a file that fails to decrypt is reported as missing
|
|
148
|
+
p = None
|
|
149
|
+
if p:
|
|
150
|
+
break
|
|
142
151
|
got.append((label, dest, p))
|
|
143
152
|
return got
|