iphone2android 0.1.0__py3-none-any.whl

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/cli.py ADDED
@@ -0,0 +1,214 @@
1
+ """iphone2android command line."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from . import __version__, backup, convert, layout, media
10
+ from .android import debloat, play
11
+ from .android.adb import Adb
12
+
13
+
14
+ def _open(args) -> backup.Backup:
15
+ udid = args.udid or _only_backup(args)
16
+ return backup.open_backup(udid, backup.password_from_env_or_prompt(), Path(args.work), _root(args))
17
+
18
+
19
+ def _root(args) -> Path:
20
+ return Path(args.backup_root) if args.backup_root else backup.DEFAULT_ROOT
21
+
22
+
23
+ def _only_backup(args) -> str:
24
+ found = backup.backups(_root(args))
25
+ if len(found) != 1:
26
+ sys.exit(f"found {len(found)} backups; pass --udid (see `iphone2android backups`)")
27
+ return found[0]["udid"]
28
+
29
+
30
+ def cmd_backups(args) -> int:
31
+ for b in backup.backups(_root(args)):
32
+ print(f"{b['udid']} {b['name']} ({b['model']}, iOS {b['ios']}) {b['apps']} apps {b['date']}")
33
+ return 0
34
+
35
+
36
+ def cmd_apps(args) -> int:
37
+ for a in sorted(backup.installed_apps(args.udid or _only_backup(args), _root(args))):
38
+ print(a)
39
+ return 0
40
+
41
+
42
+ def cmd_extract(args) -> int:
43
+ b = _open(args)
44
+ out = Path(args.work)
45
+ for label, dest, path in backup.extract_known(b, out):
46
+ print(f"{'ok' if path else '--'} {label:20s} {('-> ' + dest) if path else 'not in this backup'}")
47
+ if args.inventory:
48
+ (out / "inventory.json").write_text(json.dumps(b.inventory(), indent=1))
49
+ print(f"inventory written to {out / 'inventory.json'}")
50
+ return 0
51
+
52
+
53
+ def cmd_convert(args) -> int:
54
+ for name, n, err in convert.convert_all(Path(args.work), Path(args.out)):
55
+ print(f"{name:10s} {n if n is not None else 'FAILED ' + err}")
56
+ return 0
57
+
58
+
59
+ def cmd_layout(args) -> int:
60
+ ios = layout.read_iconstate(Path(args.iconstate))
61
+ if not args.mapping:
62
+ print(json.dumps(ios, indent=1, ensure_ascii=False))
63
+ return 0
64
+ android, missing = layout.to_android(ios, layout.load_json(Path(args.mapping)))
65
+ print(json.dumps(android, indent=1, ensure_ascii=False))
66
+ if missing:
67
+ print(f"no Android mapping for {len(missing)} apps: " + ", ".join(missing), file=sys.stderr)
68
+ return 0
69
+
70
+
71
+ def cmd_media(args) -> int:
72
+ b = _open(args)
73
+ adb = Adb(args.serial)
74
+ for name in args.sets:
75
+ domain, prefix, dest = media.SETS[name]
76
+ print(f"{name} -> {dest}")
77
+ r = media.push_set(b, adb, domain, prefix, dest)
78
+ print(f" done: {r['copied']}/{r['files']} files, {len(r['failed'])} failed")
79
+ return 0
80
+
81
+
82
+ def cmd_verify(args) -> int:
83
+ mapping = layout.load_json(Path(args.mapping))
84
+ bad = 0
85
+ for bundle, m in sorted(mapping.items()):
86
+ pkg = m.get("package")
87
+ if not pkg:
88
+ continue
89
+ ok = play.listed(pkg)
90
+ bad += ok is not True
91
+ print(f"{'ok ' if ok else 'MISSING' if ok is False else 'ERROR'} {pkg:45s} {bundle}")
92
+ return 1 if bad else 0
93
+
94
+
95
+ def cmd_install(args) -> int:
96
+ adb = Adb(args.serial)
97
+ packages = layout.packages_in(layout.load_json(Path(args.mapping))) if args.mapping else args.packages
98
+ failed = 0
99
+ for i, pkg in enumerate(packages, 1):
100
+ r = play.install(adb, pkg)
101
+ failed += r in ("no-button", "timeout")
102
+ print(f"[{i}/{len(packages)}] {pkg}: {r}", flush=True)
103
+ return 1 if failed else 0
104
+
105
+
106
+ def cmd_debloat(args) -> int:
107
+ adb = Adb(args.serial)
108
+ packages = [p.strip() for p in Path(args.list).read_text().splitlines() if p.strip() and not p.startswith("#")]
109
+ keep = set(layout.packages_in(layout.load_json(Path(args.keep_mapping)))) if args.keep_mapping else set()
110
+ for pkg, r in debloat.remove(adb, packages, keep, dry_run=args.dry_run).items():
111
+ print(f"{r:14s} {pkg}")
112
+ return 0
113
+
114
+
115
+ def _builder(args):
116
+ from .android.build import Builder
117
+ from .android.launcher import Launcher
118
+
119
+ return Builder(Launcher(Adb(args.serial)), Path(args.state) if args.state else None)
120
+
121
+
122
+ def cmd_build(args) -> int:
123
+ lay = layout.load_json(Path(args.layout))
124
+ b = _builder(args)
125
+ b.l.set_autofill(False)
126
+ b.build(lay)
127
+ ok = b.order_all(lay)
128
+ if args.autofill:
129
+ b.l.set_autofill(True)
130
+ return 0 if ok else 1
131
+
132
+
133
+ def cmd_order(args) -> int:
134
+ return 0 if _builder(args).order_all(layout.load_json(Path(args.layout))) else 1
135
+
136
+
137
+ def cmd_autofill(args) -> int:
138
+ from .android.launcher import Launcher
139
+
140
+ print(Launcher(Adb(args.serial)).set_autofill(args.state == "on"))
141
+ return 0
142
+
143
+
144
+ def main(argv: list[str] | None = None) -> int:
145
+ p = argparse.ArgumentParser(prog="iphone2android", description="Move from an iPhone to an Android phone.")
146
+ p.add_argument("--version", action="version", version=f"iphone2android {__version__}")
147
+ p.add_argument("--backup-root", help="folder that holds the backups (default: the Finder location on macOS)")
148
+ p.add_argument("--udid", help="which backup (default: the only one)")
149
+ p.add_argument("--work", default="extracted", help="where decrypted files go (default: ./extracted)")
150
+ p.add_argument("--serial", help="adb serial of the phone, when more than one is connected")
151
+ sub = p.add_subparsers(dest="cmd", required=True)
152
+
153
+ sub.add_parser("backups", help="list the backups on this computer").set_defaults(fn=cmd_backups)
154
+ sub.add_parser("apps", help="bundle ids of the apps in a backup (no password needed)").set_defaults(fn=cmd_apps)
155
+
156
+ s = sub.add_parser("extract", help="decrypt the backup and copy out the useful databases")
157
+ s.add_argument("--inventory", action="store_true", help="also write files and bytes per domain")
158
+ s.set_defaults(fn=cmd_extract)
159
+
160
+ s = sub.add_parser("convert", help="turn the extracted databases into Android and Google formats")
161
+ s.add_argument("--out", default="android-import")
162
+ s.set_defaults(fn=cmd_convert)
163
+
164
+ s = sub.add_parser("layout", help="read IconState.plist; with --mapping, write an Android layout")
165
+ s.add_argument("iconstate")
166
+ s.add_argument("--mapping")
167
+ s.set_defaults(fn=cmd_layout)
168
+
169
+ s = sub.add_parser("media", help="copy media from the backup to the phone over adb")
170
+ s.add_argument("sets", nargs="+", choices=sorted(media.SETS))
171
+ s.set_defaults(fn=cmd_media)
172
+
173
+ s = sub.add_parser("verify", help="check every package in a mapping file exists on the Play Store")
174
+ s.add_argument("mapping")
175
+ s.set_defaults(fn=cmd_verify)
176
+
177
+ s = sub.add_parser("install", help="install free apps through the Play Store app")
178
+ s.add_argument("packages", nargs="*")
179
+ s.add_argument("--mapping", help="install every package in a mapping file")
180
+ s.set_defaults(fn=cmd_install)
181
+
182
+ s = sub.add_parser("debloat", help="remove preinstalled apps listed in a file (reversible)")
183
+ s.add_argument("list")
184
+ s.add_argument("--keep-mapping", help="never remove a package that appears in this mapping file")
185
+ s.add_argument("--dry-run", action="store_true")
186
+ s.set_defaults(fn=cmd_debloat)
187
+
188
+ for name, fn, text in (("build", cmd_build, "build the home screen from a layout file"),
189
+ ("order", cmd_order, "put each page of the home screen in the layout's order")):
190
+ s = sub.add_parser(name, help=text)
191
+ s.add_argument("layout")
192
+ s.add_argument("--state", help="progress file, so an interrupted build continues")
193
+ if name == "build":
194
+ s.add_argument("--autofill", action="store_true", help="turn Icon autofill on at the end to pack the grid")
195
+ s.set_defaults(fn=fn)
196
+
197
+ s = sub.add_parser("autofill", help="turn the launcher's Icon autofill on or off")
198
+ s.add_argument("state", choices=["on", "off"])
199
+ s.set_defaults(fn=cmd_autofill)
200
+
201
+ args = p.parse_args(argv)
202
+ from .android.adb import DeviceUnavailable
203
+
204
+ try:
205
+ return args.fn(args)
206
+ except DeviceUnavailable as e:
207
+ print(f"the phone did not answer adb: {e}", file=sys.stderr)
208
+ return 3
209
+ except KeyboardInterrupt:
210
+ return 130
211
+
212
+
213
+ if __name__ == "__main__":
214
+ sys.exit(main())
@@ -0,0 +1,208 @@
1
+ """Turn the extracted iPhone databases into files Android and Google can import.
2
+
3
+ sms.db -> sms_backup.xml (SMS Backup & Restore)
4
+ CallHistory.storedata -> calls_backup.xml (SMS Backup & Restore)
5
+ AddressBook.sqlitedb -> contacts.vcf (Google Contacts)
6
+ Calendar.sqlitedb -> calendar.ics (Google Calendar)
7
+ Safari_Bookmarks.db -> safari_bookmarks.html (any browser's bookmark import)
8
+
9
+ Apple stores dates as seconds since 2001-01-01. Newer sms.db files use nanoseconds.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import datetime
14
+ import html
15
+ import shutil
16
+ import sqlite3
17
+ import subprocess
18
+ from pathlib import Path
19
+
20
+ APPLE_EPOCH = 978307200 # 2001-01-01 in unix seconds
21
+
22
+
23
+ def _open(path: Path) -> sqlite3.Connection:
24
+ c = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
25
+ c.text_factory = lambda b: b.decode("utf-8", "replace") if isinstance(b, bytes) else b
26
+ return c
27
+
28
+
29
+ def apple_ms(value) -> int:
30
+ if not value:
31
+ return 0
32
+ v = int(value)
33
+ if v > 10**12: # nanoseconds on newer iOS
34
+ v //= 1_000_000_000
35
+ return (v + APPLE_EPOCH) * 1000
36
+
37
+
38
+ def fix_page_count(src: Path, dst: Path) -> bool:
39
+ """Copy a database whose header claims more pages than the file holds, with the count corrected.
40
+
41
+ This is the usual damage in an iPhone backup: Apple snapshots sms.db in the middle of a write, the
42
+ file is shorter than its header says, and SQLite calls it malformed. The pages that are there are
43
+ readable once the header agrees with the file. Returns True when the copy opens cleanly.
44
+ """
45
+ data = bytearray(src.read_bytes())
46
+ if len(data) < 100 or not data.startswith(b"SQLite format 3\x00"):
47
+ return False
48
+ page_size = int.from_bytes(data[16:18], "big")
49
+ page_size = 65536 if page_size == 1 else page_size
50
+ if not page_size or len(data) % page_size:
51
+ return False
52
+ data[28:32] = (len(data) // page_size).to_bytes(4, "big")
53
+ data[92:96] = data[24:28] # mark the corrected count as valid for the current change counter
54
+ dst.write_bytes(bytes(data))
55
+ try:
56
+ con = sqlite3.connect(dst)
57
+ ok = con.execute("PRAGMA integrity_check").fetchone()[0] == "ok"
58
+ con.close()
59
+ return ok
60
+ except sqlite3.DatabaseError:
61
+ return False
62
+
63
+
64
+ def recover_sms(src: Path, dst: Path) -> bool:
65
+ """Rebuild a truncated sms.db with sqlite3's .recover.
66
+
67
+ Apple sometimes snapshots sms.db in the middle of a write, so the backup holds fewer pages than
68
+ the file's header declares and SQLite refuses to open it. .recover reads what is there.
69
+ """
70
+ if not shutil.which("sqlite3"):
71
+ return False
72
+ dump = subprocess.run(["sqlite3", str(src), ".recover"], capture_output=True, text=True)
73
+ if dump.returncode != 0 or not dump.stdout:
74
+ return False
75
+ if dst.exists():
76
+ dst.unlink()
77
+ load = subprocess.run(["sqlite3", str(dst)], input=dump.stdout, capture_output=True, text=True)
78
+ return load.returncode == 0 and dst.exists()
79
+
80
+
81
+ def sms(src_dir: Path, out_dir: Path) -> int:
82
+ src = src_dir / "sms_recovered.db"
83
+ if not src.exists():
84
+ src = src_dir / "sms.db"
85
+ try:
86
+ _open(src).execute("PRAGMA integrity_check").fetchone()
87
+ except sqlite3.DatabaseError:
88
+ fixed = src_dir / "sms_recovered.db"
89
+ if not (fix_page_count(src, fixed) or recover_sms(src, fixed)):
90
+ raise RuntimeError("sms.db is damaged and could not be repaired (a sqlite3 with the .recover command may manage it)")
91
+ src = fixed
92
+ rows = _open(src).execute(
93
+ """SELECT m.text, m.date, m.is_from_me, h.id FROM message m
94
+ LEFT JOIN handle h ON m.handle_id = h.ROWID
95
+ WHERE m.text IS NOT NULL AND m.text != ''"""
96
+ ).fetchall()
97
+ rows = [r for r in rows if r[3]]
98
+ with open(out_dir / "sms_backup.xml", "w", encoding="utf-8") as f:
99
+ f.write(f"<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>\n<smses count=\"{len(rows)}\">\n")
100
+ for text, date, from_me, addr in rows:
101
+ f.write(
102
+ f' <sms protocol="0" address="{html.escape(str(addr), True)}" date="{apple_ms(date)}" '
103
+ f'type="{2 if from_me else 1}" subject="null" body="{html.escape(str(text), True)}" '
104
+ 'toa="null" sc_toa="null" service_center="null" read="1" status="-1" locked="0" />\n'
105
+ )
106
+ f.write("</smses>\n")
107
+ return len(rows)
108
+
109
+
110
+ def calls(src_dir: Path, out_dir: Path) -> int:
111
+ rows = _open(src_dir / "CallHistory.storedata").execute(
112
+ "SELECT ZADDRESS, ZDATE, ZDURATION, ZORIGINATED, ZANSWERED FROM ZCALLRECORD WHERE ZADDRESS IS NOT NULL"
113
+ ).fetchall()
114
+ with open(out_dir / "calls_backup.xml", "w", encoding="utf-8") as f:
115
+ f.write(f"<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>\n<calls count=\"{len(rows)}\">\n")
116
+ for addr, date, dur, outgoing, answered in rows:
117
+ kind = 2 if outgoing else (1 if answered else 3) # 1 incoming, 2 outgoing, 3 missed
118
+ f.write(
119
+ f' <call number="{html.escape(str(addr), True)}" duration="{int(dur or 0)}" '
120
+ f'date="{int(((date or 0) + APPLE_EPOCH) * 1000)}" type="{kind}" presentation="1" />\n'
121
+ )
122
+ f.write("</calls>\n")
123
+ return len(rows)
124
+
125
+
126
+ def contacts(src_dir: Path, out_dir: Path) -> int:
127
+ c = _open(src_dir / "AddressBook.sqlitedb")
128
+ n = 0
129
+ with open(out_dir / "contacts.vcf", "w", encoding="utf-8") as f:
130
+ for pid, first, last, org in c.execute("SELECT ROWID, First, Last, Organization FROM ABPerson").fetchall():
131
+ first, last, org = first or "", last or "", org or ""
132
+ # property 3 is a phone number and 4 an email address
133
+ values = [v[0] for v in c.execute(
134
+ "SELECT value FROM ABMultiValue WHERE record_id=? AND property IN (3, 4)", (pid,)
135
+ ).fetchall() if v[0]]
136
+ if not (first or last or org or values):
137
+ continue
138
+ f.write("BEGIN:VCARD\nVERSION:3.0\n")
139
+ f.write(f"N:{last};{first};;;\nFN:{(first + ' ' + last).strip() or org}\n")
140
+ if org:
141
+ f.write(f"ORG:{org}\n")
142
+ for v in values:
143
+ f.write(f"EMAIL;TYPE=INTERNET:{v}\n" if "@" in str(v) else f"TEL;TYPE=CELL:{v}\n")
144
+ f.write("END:VCARD\n")
145
+ n += 1
146
+ return n
147
+
148
+
149
+ def _ics_time(value) -> str | None:
150
+ try:
151
+ return datetime.datetime.fromtimestamp(float(value) + APPLE_EPOCH, datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
152
+ except (TypeError, ValueError, OverflowError, OSError):
153
+ return None
154
+
155
+
156
+ def _ics_text(s) -> str:
157
+ return str(s).replace("\\", "\\\\").replace(";", r"\;").replace(",", r"\,").replace("\n", r"\n")
158
+
159
+
160
+ def calendar(src_dir: Path, out_dir: Path) -> int:
161
+ c = _open(src_dir / "Calendar.sqlitedb")
162
+ cols = {r[1] for r in c.execute("PRAGMA table_info(CalendarItem)")}
163
+ sel = ["ROWID", "summary", "start_date", "end_date"] + (["description"] if "description" in cols else [])
164
+ n = 0
165
+ with open(out_dir / "calendar.ics", "w", encoding="utf-8") as f:
166
+ f.write("BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//iphone2android//EN\nCALSCALE:GREGORIAN\n")
167
+ for r in c.execute(f"SELECT {', '.join(sel)} FROM CalendarItem WHERE summary IS NOT NULL").fetchall():
168
+ d = dict(zip(sel, r))
169
+ start, end = _ics_time(d.get("start_date")), _ics_time(d.get("end_date"))
170
+ if not start:
171
+ continue
172
+ f.write(f"BEGIN:VEVENT\nUID:ios-{d['ROWID']}@iphone2android\nDTSTAMP:{start}\nDTSTART:{start}\n")
173
+ if end:
174
+ f.write(f"DTEND:{end}\n")
175
+ f.write(f"SUMMARY:{_ics_text(d.get('summary') or '')}\n")
176
+ if d.get("description"):
177
+ f.write(f"DESCRIPTION:{_ics_text(d['description'])}\n")
178
+ f.write("END:VEVENT\n")
179
+ n += 1
180
+ f.write("END:VCALENDAR\n")
181
+ return n
182
+
183
+
184
+ def bookmarks(src_dir: Path, out_dir: Path) -> int:
185
+ rows = _open(src_dir / "Safari_Bookmarks.db").execute(
186
+ "SELECT title, url FROM bookmarks WHERE url IS NOT NULL AND url != ''"
187
+ ).fetchall()
188
+ with open(out_dir / "safari_bookmarks.html", "w", encoding="utf-8") as f:
189
+ f.write('<!DOCTYPE NETSCAPE-Bookmark-file-1>\n<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">\n'
190
+ "<TITLE>Bookmarks</TITLE>\n<H1>Bookmarks</H1>\n<DL><p>\n")
191
+ for title, url in rows:
192
+ f.write(f' <DT><A HREF="{html.escape(str(url), True)}">{html.escape(str(title or url))}</A>\n')
193
+ f.write("</DL><p>\n")
194
+ return len(rows)
195
+
196
+
197
+ ALL = (sms, calls, contacts, calendar, bookmarks)
198
+
199
+
200
+ def convert_all(src_dir: Path, out_dir: Path) -> list[tuple[str, int | None, str]]:
201
+ out_dir.mkdir(parents=True, exist_ok=True)
202
+ results = []
203
+ for fn in ALL:
204
+ try:
205
+ results.append((fn.__name__, fn(src_dir, out_dir), ""))
206
+ except Exception as e: # noqa: BLE001 - one missing database must not stop the others
207
+ results.append((fn.__name__, None, f"{type(e).__name__}: {e}"))
208
+ return results
@@ -0,0 +1,73 @@
1
+ """The iPhone home screen, read from SpringBoard/IconState.plist, and turned into an Android layout.
2
+
3
+ An iPhone layout lists bundle ids (com.spotify.client). The Android launcher is driven through its
4
+ app drawer search, so the Android layout lists the name to search for. A mapping file joins the two:
5
+
6
+ {"com.spotify.client": {"package": "com.spotify.music", "label": "Spotify"}}
7
+
8
+ `label` is what to type in the drawer search and how the icon is named on the home screen. Apps
9
+ with no Android equivalent are left out of the target layout and listed separately.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import plistlib
15
+ from pathlib import Path
16
+
17
+
18
+ def _bundle(item) -> str | None:
19
+ if isinstance(item, str):
20
+ return item
21
+ if isinstance(item, dict) and "displayName" not in item:
22
+ return item.get("bundleIdentifier") or item.get("displayIdentifier")
23
+ return None
24
+
25
+
26
+ def _entry(item):
27
+ """A loose app (its bundle id) or a folder {"folder": name, "apps": [...]}; None for widgets."""
28
+ if isinstance(item, dict) and "displayName" in item:
29
+ apps = [b for page in item.get("iconLists", []) for x in page if (b := _bundle(x))]
30
+ return {"folder": item["displayName"], "apps": apps}
31
+ return _bundle(item)
32
+
33
+
34
+ def read_iconstate(path: Path) -> dict:
35
+ with open(path, "rb") as f:
36
+ d = plistlib.load(f)
37
+ return {
38
+ "dock": [e for x in d.get("buttonBar", []) if (e := _entry(x))],
39
+ "pages": [[e for x in page if (e := _entry(x))] for page in d.get("iconLists", [])],
40
+ }
41
+
42
+
43
+ def to_android(ios_layout: dict, mapping: dict) -> tuple[dict, list[str]]:
44
+ """Map an iPhone layout to an Android one. Returns (layout, bundle ids with no mapping)."""
45
+ missing: list[str] = []
46
+
47
+ def label(bundle: str) -> str | None:
48
+ m = mapping.get(bundle)
49
+ if not m or not m.get("label"):
50
+ missing.append(bundle)
51
+ return None
52
+ return m["label"]
53
+
54
+ def entry(e):
55
+ if isinstance(e, dict):
56
+ apps = [a for b in e["apps"] if (a := label(b))]
57
+ return {"folder": e["folder"], "apps": apps} if apps else None
58
+ return label(e)
59
+
60
+ out = {
61
+ "dock": [x for e in ios_layout.get("dock", []) if (x := entry(e)) and isinstance(x, str)],
62
+ "pages": [[x for e in page if (x := entry(e))] for page in ios_layout.get("pages", [])],
63
+ }
64
+ out["pages"] = [p for p in out["pages"] if p]
65
+ return out, sorted(set(missing))
66
+
67
+
68
+ def packages_in(mapping: dict) -> list[str]:
69
+ return sorted({m["package"] for m in mapping.values() if m.get("package")})
70
+
71
+
72
+ def load_json(path: Path) -> dict:
73
+ return json.loads(Path(path).read_text(encoding="utf-8"))
@@ -0,0 +1,67 @@
1
+ """Copy photos and other media out of the backup and onto the phone, in batches.
2
+
3
+ Each batch is decrypted to a temp folder, pushed with adb and deleted, so the computer never needs
4
+ room for the whole camera roll.
5
+
6
+ The camera roll is often already in Google Photos. These are not, and are easy to lose:
7
+ iMessage attachments, photo edits (Mutations) and media kept inside apps.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import shutil
13
+ import tempfile
14
+ from pathlib import Path
15
+
16
+ from .android.adb import Adb
17
+ from .backup import Backup
18
+
19
+ MEDIA_EXT = {".jpg", ".jpeg", ".heic", ".heif", ".png", ".mov", ".mp4", ".dng", ".cr2", ".gif",
20
+ ".m4v", ".webp", ".m4a", ".wav", ".aac", ".caf", ".pdf"}
21
+
22
+ # name -> (domain, path prefix, destination on the phone)
23
+ SETS = {
24
+ "camera-roll": ("CameraRollDomain", "Media/DCIM/", "/sdcard/DCIM/iPhone"),
25
+ "message-attachments": ("MediaDomain", "Library/SMS/Attachments/", "/sdcard/Pictures/iPhone_Messages"),
26
+ "photo-edits": ("CameraRollDomain", "Media/PhotoData/Mutations/", "/sdcard/Pictures/iPhone_PhotoEdits"),
27
+ }
28
+
29
+
30
+ def app_set(domain: str, dest: str) -> tuple[str, str, str]:
31
+ """Media kept inside one app, for example AppDomainGroup-group.net.whatsapp.WhatsApp.shared."""
32
+ return (domain, "", dest)
33
+
34
+
35
+ def media_rows(b: Backup, domain: str, prefix: str) -> list[dict]:
36
+ rows = b.rows("flags=1 AND domain=? AND relativePath LIKE ?", (domain, prefix + "%"))
37
+ return [r for r in rows if os.path.splitext(r["relativePath"] or "")[1].lower() in MEDIA_EXT]
38
+
39
+
40
+ def push_set(b: Backup, adb: Adb, domain: str, prefix: str, dest: str, batch: int = 60, log=print) -> dict:
41
+ rows = media_rows(b, domain, prefix)
42
+ adb.run("shell", "mkdir", "-p", dest)
43
+ done = pushed_bytes = 0
44
+ failed: list[str] = []
45
+ for i in range(0, len(rows), batch):
46
+ stage = Path(tempfile.mkdtemp(prefix="iphone2android-"))
47
+ try:
48
+ used: set[str] = set()
49
+ for r in rows[i:i + batch]:
50
+ name = os.path.basename(r["relativePath"])
51
+ if name in used: # the same name from two folders
52
+ base, ext = os.path.splitext(name)
53
+ name = f"{base}_{r['fileID'][:6]}{ext}"
54
+ used.add(name)
55
+ try:
56
+ p = b.copy_entry(r, stage, name)
57
+ done += 1
58
+ pushed_bytes += p.stat().st_size
59
+ except Exception as e: # noqa: BLE001
60
+ failed.append(f"{r['relativePath']}\t{type(e).__name__}")
61
+ p = adb.run("push", f"{stage}/.", dest + "/", timeout=3600)
62
+ if p.returncode != 0:
63
+ failed.append(f"batch {i // batch}\t{p.stderr.strip()[:80]}")
64
+ finally:
65
+ shutil.rmtree(stage, ignore_errors=True)
66
+ log(f" {done}/{len(rows)} files, {pushed_bytes / 1e9:.2f} GB, {len(failed)} failed")
67
+ return {"files": len(rows), "copied": done, "bytes": pushed_bytes, "failed": failed}