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/__init__.py +3 -0
- iphone2android/__main__.py +3 -0
- iphone2android/android/__init__.py +0 -0
- iphone2android/android/adb.py +45 -0
- iphone2android/android/build.py +116 -0
- iphone2android/android/debloat.py +48 -0
- iphone2android/android/launcher.py +134 -0
- iphone2android/android/play.py +61 -0
- iphone2android/android/ui.py +54 -0
- iphone2android/backup.py +143 -0
- iphone2android/cli.py +214 -0
- iphone2android/convert.py +208 -0
- iphone2android/layout.py +73 -0
- iphone2android/media.py +67 -0
- iphone2android-0.1.0.dist-info/METADATA +127 -0
- iphone2android-0.1.0.dist-info/RECORD +20 -0
- iphone2android-0.1.0.dist-info/WHEEL +5 -0
- iphone2android-0.1.0.dist-info/entry_points.txt +2 -0
- iphone2android-0.1.0.dist-info/licenses/LICENSE +21 -0
- iphone2android-0.1.0.dist-info/top_level.txt +1 -0
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
|
iphone2android/layout.py
ADDED
|
@@ -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"))
|
iphone2android/media.py
ADDED
|
@@ -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}
|