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.
Files changed (38) hide show
  1. iphone2android-0.2.0/PKG-INFO +101 -0
  2. iphone2android-0.2.0/README.md +82 -0
  3. {iphone2android-0.1.0 → iphone2android-0.2.0}/pyproject.toml +4 -1
  4. {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/__init__.py +1 -1
  5. {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/android/adb.py +8 -0
  6. {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/android/build.py +14 -1
  7. iphone2android-0.2.0/src/iphone2android/appdata.py +106 -0
  8. iphone2android-0.2.0/src/iphone2android/apps.py +146 -0
  9. iphone2android-0.2.0/src/iphone2android/audit.py +91 -0
  10. {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/backup.py +13 -4
  11. iphone2android-0.2.0/src/iphone2android/cli.py +356 -0
  12. {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/layout.py +17 -2
  13. iphone2android-0.2.0/src/iphone2android/skill/SKILL.md +144 -0
  14. iphone2android-0.2.0/src/iphone2android/wallpaper.py +88 -0
  15. iphone2android-0.2.0/src/iphone2android.egg-info/PKG-INFO +101 -0
  16. {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android.egg-info/SOURCES.txt +7 -1
  17. iphone2android-0.2.0/tests/test_v2.py +256 -0
  18. iphone2android-0.1.0/PKG-INFO +0 -127
  19. iphone2android-0.1.0/README.md +0 -108
  20. iphone2android-0.1.0/src/iphone2android/cli.py +0 -214
  21. iphone2android-0.1.0/src/iphone2android.egg-info/PKG-INFO +0 -127
  22. {iphone2android-0.1.0 → iphone2android-0.2.0}/LICENSE +0 -0
  23. {iphone2android-0.1.0 → iphone2android-0.2.0}/setup.cfg +0 -0
  24. {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/__main__.py +0 -0
  25. {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/android/__init__.py +0 -0
  26. {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/android/debloat.py +0 -0
  27. {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/android/launcher.py +0 -0
  28. {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/android/play.py +0 -0
  29. {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/android/ui.py +0 -0
  30. {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/convert.py +0 -0
  31. {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android/media.py +0 -0
  32. {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android.egg-info/dependency_links.txt +0 -0
  33. {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android.egg-info/entry_points.txt +0 -0
  34. {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android.egg-info/requires.txt +0 -0
  35. {iphone2android-0.1.0 → iphone2android-0.2.0}/src/iphone2android.egg-info/top_level.txt +0 -0
  36. {iphone2android-0.1.0 → iphone2android-0.2.0}/tests/test_android.py +0 -0
  37. {iphone2android-0.1.0 → iphone2android-0.2.0}/tests/test_backup_media.py +0 -0
  38. {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.1.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"]
@@ -1,3 +1,3 @@
1
1
  """Move from an iPhone to an Android phone: messages, contacts, photos, apps and the home screen."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -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
- try:
139
- p = b.copy(domain, rel, out, name)
140
- except Exception: # noqa: BLE001 - a file that fails to decrypt is reported as missing
141
- p = None
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