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
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""A small adb wrapper.
|
|
2
|
+
|
|
3
|
+
stdin is always closed. `adb shell` reads stdin, so inside a shell `while read` loop it would eat the
|
|
4
|
+
rest of the list and only the first item would be processed.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DeviceUnavailable(RuntimeError):
|
|
14
|
+
"""adb could not reach the phone. Raised instead of returning empty output, which would read as "nothing installed"."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Adb:
|
|
18
|
+
def __init__(self, serial: str | None = None, binary: str | None = None):
|
|
19
|
+
self.serial = serial
|
|
20
|
+
self.binary = binary or os.environ.get("IPHONE2ANDROID_ADB") or shutil.which("adb") or "adb"
|
|
21
|
+
|
|
22
|
+
def run(self, *args: str, timeout: float = 90) -> subprocess.CompletedProcess:
|
|
23
|
+
cmd = [self.binary] + (["-s", self.serial] if self.serial else []) + list(args)
|
|
24
|
+
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL)
|
|
25
|
+
|
|
26
|
+
def shell(self, cmd: str, timeout: float = 90) -> str:
|
|
27
|
+
try:
|
|
28
|
+
r = self.run("shell", cmd, timeout=timeout)
|
|
29
|
+
except subprocess.TimeoutExpired as e:
|
|
30
|
+
raise DeviceUnavailable(f"adb shell timed out after {timeout}s") from e
|
|
31
|
+
# adb's own failures start with "adb:" or "error:". A failing command on the phone does not.
|
|
32
|
+
if r.returncode != 0 and r.stderr.lstrip().startswith(("adb:", "error:")):
|
|
33
|
+
raise DeviceUnavailable(r.stderr.strip()[:200])
|
|
34
|
+
return r.stdout
|
|
35
|
+
|
|
36
|
+
def installed(self, package: str) -> bool:
|
|
37
|
+
return f"package:{package}\n" in self.shell(f"pm list packages {package}") + "\n"
|
|
38
|
+
|
|
39
|
+
def screen_size(self) -> tuple[int, int]:
|
|
40
|
+
"""The size touches are measured in: the override size when one is set, else the physical size."""
|
|
41
|
+
out = self.shell("wm size").strip()
|
|
42
|
+
if "x" not in out:
|
|
43
|
+
raise DeviceUnavailable(f"cannot read the screen size: {out[:80]!r}")
|
|
44
|
+
w, h = out.splitlines()[-1].split(":")[-1].strip().split("x")
|
|
45
|
+
return int(w), int(h)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Build a home screen from a layout file, and put each page in order.
|
|
2
|
+
|
|
3
|
+
{"pages": [["Camera", "Photos", {"folder": "Social", "apps": ["WhatsApp", "Telegram"]}]],
|
|
4
|
+
"dock": ["Phone", "Messages", "Chrome"]}
|
|
5
|
+
|
|
6
|
+
Each name is what to type in the app drawer search. Building never assumes where a drop landed: it
|
|
7
|
+
reads the screen again after every drop and uses the real position. Progress is saved after every
|
|
8
|
+
step, so an interrupted build continues where it stopped.
|
|
9
|
+
|
|
10
|
+
Order is fixed afterwards by a selection sort that makes one swap and then reads the screen again.
|
|
11
|
+
Several swaps from one reading do not converge, because every move shifts the grid. A swap that
|
|
12
|
+
creates a folder by accident stops the sort.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _label(entry) -> str:
|
|
21
|
+
return f"Folder:{entry['folder']}" if isinstance(entry, dict) else entry
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Builder:
|
|
25
|
+
def __init__(self, launcher, state_file: Path | None = None, log=print):
|
|
26
|
+
self.l = launcher
|
|
27
|
+
self.state_file = state_file
|
|
28
|
+
self.log = log
|
|
29
|
+
self.done: list[str] = []
|
|
30
|
+
if state_file and state_file.exists():
|
|
31
|
+
self.done = json.loads(state_file.read_text()).get("done", [])
|
|
32
|
+
|
|
33
|
+
def _mark(self, key: str) -> None:
|
|
34
|
+
self.done.append(key)
|
|
35
|
+
if self.state_file:
|
|
36
|
+
self.state_file.write_text(json.dumps({"done": self.done}))
|
|
37
|
+
|
|
38
|
+
def add(self, name: str, page: int, onto: tuple[int, int] | None = None):
|
|
39
|
+
"""Drag an app from the drawer to the page (onto a position to merge). Returns where it landed."""
|
|
40
|
+
hit = self.l.drawer_search(name)
|
|
41
|
+
if not hit:
|
|
42
|
+
self.log(f" not in the drawer: {name}")
|
|
43
|
+
return None
|
|
44
|
+
tx, ty = onto if onto else self.l.at(0.5, 0.71)
|
|
45
|
+
self.l.drag(hit.x, hit.y, tx, ty, 1800)
|
|
46
|
+
landed = [n for n in self.l.icons(page) if n.label == hit.label]
|
|
47
|
+
return landed[0] if landed else None
|
|
48
|
+
|
|
49
|
+
def build_page(self, page: int, entries: list) -> None:
|
|
50
|
+
for e in entries:
|
|
51
|
+
key = f"p{page}:{_label(e)}"
|
|
52
|
+
if key in self.done:
|
|
53
|
+
continue
|
|
54
|
+
if isinstance(e, str):
|
|
55
|
+
r = self.add(e, page)
|
|
56
|
+
self.log(f" {e}: {'placed' if r else 'FAILED'}")
|
|
57
|
+
else:
|
|
58
|
+
self._build_folder(page, e["folder"], e["apps"])
|
|
59
|
+
self._mark(key)
|
|
60
|
+
|
|
61
|
+
def _build_folder(self, page: int, name: str, apps: list[str]) -> None:
|
|
62
|
+
if not apps:
|
|
63
|
+
return
|
|
64
|
+
before = {n.label for n in self.l.icons(page) if n.folder}
|
|
65
|
+
first = self.add(apps[0], page)
|
|
66
|
+
if not first:
|
|
67
|
+
self.log(f" folder {name}: cannot place {apps[0]}")
|
|
68
|
+
return
|
|
69
|
+
if len(apps) == 1:
|
|
70
|
+
self.log(f" folder {name} has one app, placed it loose")
|
|
71
|
+
return
|
|
72
|
+
self.add(apps[1], page, onto=(first.x, first.y))
|
|
73
|
+
new = [n for n in self.l.icons(page) if n.folder and n.label not in before]
|
|
74
|
+
if not new:
|
|
75
|
+
self.log(f" folder {name}: no folder formed")
|
|
76
|
+
return
|
|
77
|
+
# Identify the new folder by being new, never by its auto-name (two can share one).
|
|
78
|
+
self.l.tap(new[0].x, new[0].y)
|
|
79
|
+
self.l.rename_open_folder(name)
|
|
80
|
+
for app in apps[2:]:
|
|
81
|
+
target = [n for n in self.l.icons(page) if n.label == f"Folder:{name}"]
|
|
82
|
+
if not target:
|
|
83
|
+
self.log(f" folder {name}: lost it after renaming")
|
|
84
|
+
return
|
|
85
|
+
self.add(app, page, onto=(target[0].x, target[0].y))
|
|
86
|
+
self.log(f" folder {name}: {len(apps)} apps")
|
|
87
|
+
|
|
88
|
+
def build(self, layout: dict) -> None:
|
|
89
|
+
for page, entries in enumerate(layout.get("pages", [])):
|
|
90
|
+
self.log(f"page {page + 1}")
|
|
91
|
+
self.build_page(page, entries)
|
|
92
|
+
|
|
93
|
+
def order(self, page: int, wanted: list[str]) -> bool:
|
|
94
|
+
"""Selection sort with one swap per reading. Returns False when it had to stop."""
|
|
95
|
+
for i, label in enumerate(wanted):
|
|
96
|
+
items = self.l.icons(page)
|
|
97
|
+
names = [n.label for n in items]
|
|
98
|
+
if i >= len(items) or names[i] == label:
|
|
99
|
+
continue
|
|
100
|
+
if label not in names:
|
|
101
|
+
self.log(f" {label} is not on page {page + 1}")
|
|
102
|
+
continue
|
|
103
|
+
folders = {n.label for n in items if n.folder}
|
|
104
|
+
j = names.index(label)
|
|
105
|
+
self.l.drag(items[j].x, items[j].y, items[i].x, items[i].y, 2000)
|
|
106
|
+
after = {n.label for n in self.l.icons(page) if n.folder}
|
|
107
|
+
if after - folders:
|
|
108
|
+
self.log(f" stopped: the swap created a folder {sorted(after - folders)}")
|
|
109
|
+
return False
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
def order_all(self, layout: dict) -> bool:
|
|
113
|
+
ok = True
|
|
114
|
+
for page, entries in enumerate(layout.get("pages", [])):
|
|
115
|
+
ok = self.order(page, [_label(e) for e in entries]) and ok
|
|
116
|
+
return ok
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Remove preinstalled apps for the current user, without root.
|
|
2
|
+
|
|
3
|
+
`pm uninstall --user 0` keeps the app on the system partition, so it can come back with
|
|
4
|
+
`cmd package install-existing <package>`. Some system parts refuse and are disabled instead.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .adb import Adb
|
|
9
|
+
|
|
10
|
+
# Never removed, whatever the list says.
|
|
11
|
+
PROTECTED = {
|
|
12
|
+
"com.google.android.euicc", # eSIM
|
|
13
|
+
"com.android.vending", # Play Store
|
|
14
|
+
"com.google.android.gms", # Play services
|
|
15
|
+
"com.android.phone",
|
|
16
|
+
"com.android.settings",
|
|
17
|
+
"com.android.systemui",
|
|
18
|
+
"com.google.android.dialer",
|
|
19
|
+
"com.android.dialer",
|
|
20
|
+
"com.google.android.apps.messaging",
|
|
21
|
+
"com.android.mms",
|
|
22
|
+
"com.android.launcher",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def remove(adb: Adb, packages: list[str], keep: set[str] = frozenset(), dry_run: bool = False) -> dict[str, str]:
|
|
27
|
+
out: dict[str, str] = {}
|
|
28
|
+
for pkg in packages:
|
|
29
|
+
if pkg in PROTECTED or pkg in keep:
|
|
30
|
+
out[pkg] = "kept"
|
|
31
|
+
continue
|
|
32
|
+
if not adb.installed(pkg):
|
|
33
|
+
out[pkg] = "absent"
|
|
34
|
+
continue
|
|
35
|
+
if dry_run:
|
|
36
|
+
out[pkg] = "would remove"
|
|
37
|
+
continue
|
|
38
|
+
r = adb.shell(f"pm uninstall --user 0 {pkg}")
|
|
39
|
+
if "Success" in r:
|
|
40
|
+
out[pkg] = "removed"
|
|
41
|
+
continue
|
|
42
|
+
r2 = adb.shell(f"pm disable-user --user 0 {pkg}")
|
|
43
|
+
out[pkg] = "disabled" if "disabled" in r2.lower() else f"failed: {r.strip()[:80]}"
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def restore(adb: Adb, package: str) -> bool:
|
|
48
|
+
return "installed" in adb.shell(f"cmd package install-existing {package}").lower()
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Drive a stock home screen through its UI, which is the only way on locked-down launchers.
|
|
2
|
+
|
|
3
|
+
On ColorOS (OPPO, OnePlus) the launcher database, its content provider and shortcut pinning are all
|
|
4
|
+
closed to adb without root, so icons are moved the way a person moves them. Positions scale with
|
|
5
|
+
the screen size. Tested on ColorOS 16 (Android 16). Other launchers need their own tuning.
|
|
6
|
+
|
|
7
|
+
Rules learned the hard way:
|
|
8
|
+
- Re-read the screen before every drag. The grid moves after each change.
|
|
9
|
+
- Dropping an icon onto another makes a folder. Dropping onto a folder adds to it.
|
|
10
|
+
- A folder is renamed by tapping its title, selecting all, typing and pressing BACK (not ENTER).
|
|
11
|
+
- Icons cannot be dragged out of a folder. Long-press inside the open folder and choose Remove.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
from . import ui
|
|
18
|
+
from .adb import Adb
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Launcher:
|
|
22
|
+
def __init__(self, adb: Adb, pause: float = 1.0):
|
|
23
|
+
self.adb = adb
|
|
24
|
+
self.pause = pause
|
|
25
|
+
self.w, self.h = adb.screen_size()
|
|
26
|
+
|
|
27
|
+
def _sleep(self, s: float) -> None:
|
|
28
|
+
time.sleep(s * self.pause)
|
|
29
|
+
|
|
30
|
+
# positions as fractions of the screen, measured on a 1264x2780 screen
|
|
31
|
+
def at(self, fx: float, fy: float) -> tuple[int, int]:
|
|
32
|
+
return int(self.w * fx), int(self.h * fy)
|
|
33
|
+
|
|
34
|
+
def dock_top(self) -> int:
|
|
35
|
+
return int(self.h * 0.845)
|
|
36
|
+
|
|
37
|
+
def in_dock(self, n: ui.Node) -> bool:
|
|
38
|
+
return n.y > self.dock_top()
|
|
39
|
+
|
|
40
|
+
def dump(self) -> list[ui.Node]:
|
|
41
|
+
return ui.dump(self.adb)
|
|
42
|
+
|
|
43
|
+
def home(self) -> None:
|
|
44
|
+
for _ in range(2):
|
|
45
|
+
self.adb.shell("input keyevent KEYCODE_HOME")
|
|
46
|
+
self._sleep(1.6)
|
|
47
|
+
|
|
48
|
+
def page_next(self) -> None:
|
|
49
|
+
(x1, y), (x2, _) = self.at(0.87, 0.58), self.at(0.16, 0.58)
|
|
50
|
+
# 250 ms. A slower swipe is read as a drag and does not change page.
|
|
51
|
+
self.adb.shell(f"input swipe {x1} {y} {x2} {y} 250")
|
|
52
|
+
self._sleep(1.8)
|
|
53
|
+
|
|
54
|
+
def goto(self, page: int) -> None:
|
|
55
|
+
self.home() # HOME is page 0; swiping left from it opens the feed, not a page
|
|
56
|
+
for _ in range(page):
|
|
57
|
+
self.page_next()
|
|
58
|
+
|
|
59
|
+
def drag(self, x1: int, y1: int, x2: int, y2: int, ms: int = 600) -> None:
|
|
60
|
+
# About 600 ms merges reliably. A long hover opens the folder under the finger instead.
|
|
61
|
+
self.adb.shell(f"input draganddrop {x1} {y1} {x2} {y2} {ms}")
|
|
62
|
+
self._sleep(2.5)
|
|
63
|
+
|
|
64
|
+
def tap(self, x: int, y: int) -> None:
|
|
65
|
+
self.adb.shell(f"input tap {x} {y}")
|
|
66
|
+
self._sleep(1.8)
|
|
67
|
+
|
|
68
|
+
def icons(self, page: int) -> list[ui.Node]:
|
|
69
|
+
"""Icons and folders on a page, in reading order, without the dock and the label nodes of folders."""
|
|
70
|
+
self.goto(page)
|
|
71
|
+
nodes = self.dump()
|
|
72
|
+
folder_names = {n.label for n in nodes if n.folder}
|
|
73
|
+
items = [n for n in nodes if n.clickable and n.cls in ("TextView", "FrameLayout")
|
|
74
|
+
and not self.in_dock(n) and f"Folder:{n.label}" not in folder_names]
|
|
75
|
+
return sorted(items, key=lambda n: (n.y, n.x))
|
|
76
|
+
|
|
77
|
+
def drawer_search(self, term: str) -> ui.Node | None:
|
|
78
|
+
"""Open the app drawer, search, and return the first result whose name contains the first word."""
|
|
79
|
+
self.home()
|
|
80
|
+
(x, y1), (_, y2) = self.at(0.5, 0.83), self.at(0.5, 0.32)
|
|
81
|
+
self.adb.shell(f"input swipe {x} {y1} {x} {y2} 300")
|
|
82
|
+
self._sleep(2.5)
|
|
83
|
+
nodes = self.dump()
|
|
84
|
+
field = [n for n in nodes if n.cls == "EditText"] or [n for n in nodes if n.label.lower().startswith("search")]
|
|
85
|
+
if not field:
|
|
86
|
+
return None
|
|
87
|
+
self.tap(field[0].x, field[0].y)
|
|
88
|
+
self.adb.shell("input keycombination 113 29") # ctrl+a
|
|
89
|
+
self.adb.shell("input text " + term.replace(" ", "%s"))
|
|
90
|
+
self._sleep(2.2)
|
|
91
|
+
first = term.split()[0].lower()
|
|
92
|
+
for n in self.dump():
|
|
93
|
+
if n.cls == "TextView" and n.y < self.dock_top() and first in n.label.lower():
|
|
94
|
+
return n
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
def rename_open_folder(self, name: str) -> bool:
|
|
98
|
+
field = [n for n in self.dump() if n.cls == "EditText"]
|
|
99
|
+
if not field:
|
|
100
|
+
return False
|
|
101
|
+
self.tap(field[0].x, field[0].y)
|
|
102
|
+
self.adb.shell("input keycombination 113 29")
|
|
103
|
+
self.adb.shell("input text " + name.replace(" ", "%s"))
|
|
104
|
+
self._sleep(1.2)
|
|
105
|
+
self.adb.shell("input keyevent KEYCODE_BACK") # ENTER does not commit the name, BACK does
|
|
106
|
+
self._sleep(2.0)
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
def long_press(self, x: int, y: int, seconds: float = 1.4) -> None:
|
|
110
|
+
# One shell, so the finger stays down for the whole gesture.
|
|
111
|
+
self.adb.shell(f"input motionevent DOWN {x} {y}; sleep {seconds}; input motionevent UP {x} {y}")
|
|
112
|
+
self._sleep(1.5)
|
|
113
|
+
|
|
114
|
+
def set_autofill(self, on: bool) -> str:
|
|
115
|
+
"""Toggle Home screen settings > Icon autofill. Build with it off, then turn it on once to pack the grid."""
|
|
116
|
+
self.home()
|
|
117
|
+
self.long_press(*self.at(0.5, 0.54))
|
|
118
|
+
settings = [n for n in self.dump() if n.label == "Home screen settings" and n.clickable]
|
|
119
|
+
if not settings:
|
|
120
|
+
self.home()
|
|
121
|
+
return "not found"
|
|
122
|
+
self.tap(settings[0].x, settings[0].y)
|
|
123
|
+
nodes = self.dump()
|
|
124
|
+
row = [n for n in nodes if n.label == "Icon autofill"]
|
|
125
|
+
switch = [n for n in nodes if n.cls == "Switch" and row and abs(n.y - row[0].y) < 60]
|
|
126
|
+
state = "not found"
|
|
127
|
+
if switch:
|
|
128
|
+
current = switch[0].label.lower() == "on"
|
|
129
|
+
if current != on:
|
|
130
|
+
self.tap(switch[0].x, switch[0].y)
|
|
131
|
+
state = "on" if on else "off"
|
|
132
|
+
self.adb.shell("input keyevent KEYCODE_BACK")
|
|
133
|
+
self.home()
|
|
134
|
+
return state
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Play Store checks and installs.
|
|
2
|
+
|
|
3
|
+
Guessed package names are often wrong (a bank's Android package rarely matches its iOS bundle id),
|
|
4
|
+
so every mapping is checked against the live Play Store page before anything is installed.
|
|
5
|
+
Installs are free apps only, by opening the store page and pressing Install, like a person would.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
|
|
13
|
+
from .adb import Adb
|
|
14
|
+
from .ui import find_button
|
|
15
|
+
|
|
16
|
+
PLAY_URL = "https://play.google.com/store/apps/details?id={package}&hl=en"
|
|
17
|
+
INSTALL_LABELS = {"Install", "Εγκατάσταση", "Instalar", "Installer", "Installieren", "Installa"}
|
|
18
|
+
DONE_LABELS = {"Open", "Play", "Uninstall", "Update", "Άνοιγμα", "Abrir", "Ouvrir", "Öffnen", "Apri"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def listed(package: str, url: str = PLAY_URL, timeout: float = 15.0) -> bool | None:
|
|
22
|
+
"""True when the Play Store has a page for the package, False when it does not, None on network errors."""
|
|
23
|
+
req = urllib.request.Request(url.format(package=package), headers={"user-agent": "Mozilla/5.0"})
|
|
24
|
+
try:
|
|
25
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
26
|
+
return r.status == 200
|
|
27
|
+
except urllib.error.HTTPError as e:
|
|
28
|
+
return False if e.code == 404 else None
|
|
29
|
+
except (urllib.error.URLError, TimeoutError):
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def install(adb: Adb, package: str, log=print, wait_s: float = 240, poll_s: float = 6) -> str:
|
|
34
|
+
"""Install one free app through the Play Store app. Returns installed, present, no-button or timeout."""
|
|
35
|
+
if adb.installed(package):
|
|
36
|
+
return "present"
|
|
37
|
+
# market:// can open an OEM store chooser, so the Play Store is named explicitly.
|
|
38
|
+
adb.run("shell", "am", "start", "-a", "android.intent.action.VIEW",
|
|
39
|
+
"-d", f"https://play.google.com/store/apps/details?id={package}", "-p", "com.android.vending")
|
|
40
|
+
button = None
|
|
41
|
+
for _ in range(10): # the store page renders slowly under load
|
|
42
|
+
time.sleep(2.5)
|
|
43
|
+
button = find_button(adb, INSTALL_LABELS | DONE_LABELS)
|
|
44
|
+
if button:
|
|
45
|
+
break
|
|
46
|
+
if not button:
|
|
47
|
+
adb.shell("input keyevent KEYCODE_BACK")
|
|
48
|
+
return "no-button"
|
|
49
|
+
label, (x, y) = button
|
|
50
|
+
if label in DONE_LABELS:
|
|
51
|
+
adb.shell("input keyevent KEYCODE_BACK")
|
|
52
|
+
return "present"
|
|
53
|
+
adb.shell(f"input tap {x} {y}")
|
|
54
|
+
deadline = time.time() + wait_s
|
|
55
|
+
while time.time() < deadline:
|
|
56
|
+
time.sleep(poll_s)
|
|
57
|
+
if adb.installed(package):
|
|
58
|
+
adb.shell("input keyevent KEYCODE_BACK")
|
|
59
|
+
return "installed"
|
|
60
|
+
adb.shell("input keyevent KEYCODE_BACK")
|
|
61
|
+
return "timeout"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Read the phone's screen through uiautomator."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from .adb import Adb
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class Node:
|
|
13
|
+
label: str
|
|
14
|
+
x: int
|
|
15
|
+
y: int
|
|
16
|
+
cls: str
|
|
17
|
+
clickable: bool
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def folder(self) -> bool:
|
|
21
|
+
return self.label.startswith("Folder:")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _center(bounds: str) -> tuple[int, int]:
|
|
25
|
+
m = [int(v) for v in re.findall(r"-?\d+", bounds)]
|
|
26
|
+
return (m[0] + m[2]) // 2, (m[1] + m[3]) // 2
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse(xml: str) -> list[Node]:
|
|
30
|
+
try:
|
|
31
|
+
root = ET.fromstring(xml)
|
|
32
|
+
except ET.ParseError:
|
|
33
|
+
return []
|
|
34
|
+
out = []
|
|
35
|
+
for n in root.iter("node"):
|
|
36
|
+
label = (n.get("content-desc") or "").strip() or (n.get("text") or "").strip()
|
|
37
|
+
bounds = n.get("bounds", "")
|
|
38
|
+
if not label or not bounds:
|
|
39
|
+
continue
|
|
40
|
+
x, y = _center(bounds)
|
|
41
|
+
out.append(Node(label, x, y, n.get("class", "").split(".")[-1], n.get("clickable") == "true"))
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def dump(adb: Adb) -> list[Node]:
|
|
46
|
+
adb.shell("uiautomator dump /sdcard/window.xml")
|
|
47
|
+
return parse(adb.shell("cat /sdcard/window.xml"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def find_button(adb: Adb, labels: set[str]):
|
|
51
|
+
for n in dump(adb):
|
|
52
|
+
if n.label in labels:
|
|
53
|
+
return n.label, (n.x, n.y)
|
|
54
|
+
return None
|
iphone2android/backup.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Open an encrypted iPhone backup made by Finder or iTunes, and copy files out of it.
|
|
2
|
+
|
|
3
|
+
Backups live in ~/Library/Application Support/MobileSync/Backup/<UDID>/ on macOS and in
|
|
4
|
+
%APPDATA%\\Apple Computer\\MobileSync\\Backup on Windows. Info.plist and Manifest.plist can be read
|
|
5
|
+
without the password. Everything else needs the backup password.
|
|
6
|
+
|
|
7
|
+
A file is named by its domain and its relative path. The same relative path can exist in several
|
|
8
|
+
domains, so files are looked up in the decrypted manifest by both, and the manifest row is handed
|
|
9
|
+
to the decryptor (it has no domain parameter).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import plistlib
|
|
15
|
+
import shutil
|
|
16
|
+
import sqlite3
|
|
17
|
+
from collections import Counter, defaultdict
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
DEFAULT_ROOT = Path.home() / "Library/Application Support/MobileSync/Backup"
|
|
22
|
+
|
|
23
|
+
# The files worth pulling, by what they are and where each can go on Android.
|
|
24
|
+
KNOWN = [
|
|
25
|
+
("HomeDomain", "Library/SMS/sms.db", "sms.db", "Messages", "SMS Backup & Restore XML"),
|
|
26
|
+
("HomeDomain", "Library/CallHistoryDB/CallHistory.storedata", "CallHistory.storedata", "Call history", "SMS Backup & Restore XML"),
|
|
27
|
+
("HomeDomain", "Library/AddressBook/AddressBook.sqlitedb", "AddressBook.sqlitedb", "Contacts", "vCard"),
|
|
28
|
+
("HomeDomain", "Library/Calendar/Calendar.sqlitedb", "Calendar.sqlitedb", "Calendars", "ICS"),
|
|
29
|
+
("HomeDomain", "Library/Safari/Bookmarks.db", "Safari_Bookmarks.db", "Safari bookmarks", "bookmarks HTML"),
|
|
30
|
+
("AppDomainGroup-group.com.apple.notes", "NoteStore.sqlite", "NoteStore.sqlite", "Apple Notes", "read with an Apple Notes parser"),
|
|
31
|
+
("AppDomainGroup-group.net.whatsapp.WhatsApp.shared", "ChatStorage.sqlite", "ChatStorage.sqlite", "WhatsApp chats", "a WhatsApp iOS-to-Android migrator"),
|
|
32
|
+
("HomeDomain", "Library/SpringBoard/IconState.plist", "IconState.plist", "Home screen layout", "iphone2android layout"),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def backups(root: Path = DEFAULT_ROOT) -> list[dict]:
|
|
37
|
+
"""Every backup under root, with what its Info.plist says (no password needed)."""
|
|
38
|
+
out = []
|
|
39
|
+
if not root.is_dir():
|
|
40
|
+
return out
|
|
41
|
+
for d in sorted(root.iterdir()):
|
|
42
|
+
info = d / "Info.plist"
|
|
43
|
+
if not info.is_file():
|
|
44
|
+
continue
|
|
45
|
+
with open(info, "rb") as f:
|
|
46
|
+
p = plistlib.load(f)
|
|
47
|
+
out.append({
|
|
48
|
+
"udid": d.name,
|
|
49
|
+
"name": p.get("Device Name", ""),
|
|
50
|
+
"model": p.get("Product Type", ""),
|
|
51
|
+
"ios": p.get("Product Version", ""),
|
|
52
|
+
"date": str(p.get("Last Backup Date", "")),
|
|
53
|
+
"apps": len(p.get("Installed Applications", []) or []),
|
|
54
|
+
})
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def installed_apps(udid: str, root: Path = DEFAULT_ROOT) -> list[str]:
|
|
59
|
+
"""Bundle ids of the apps installed when the backup was made (no password needed)."""
|
|
60
|
+
with open(root / udid / "Info.plist", "rb") as f:
|
|
61
|
+
return list(plistlib.load(f).get("Installed Applications", []) or [])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class Backup:
|
|
66
|
+
udid: str
|
|
67
|
+
impl: object # the iOSbackup object
|
|
68
|
+
manifest: Path # durable copy of the decrypted Manifest.db
|
|
69
|
+
|
|
70
|
+
def _row(self, domain: str, relative_path: str) -> dict | None:
|
|
71
|
+
con = sqlite3.connect(self.manifest)
|
|
72
|
+
con.row_factory = sqlite3.Row
|
|
73
|
+
try:
|
|
74
|
+
r = con.execute("SELECT * FROM Files WHERE domain=? AND relativePath=?", (domain, relative_path)).fetchone()
|
|
75
|
+
finally:
|
|
76
|
+
con.close()
|
|
77
|
+
return dict(r) if r else None
|
|
78
|
+
|
|
79
|
+
def rows(self, sql_where: str, params: tuple = ()) -> list[dict]:
|
|
80
|
+
con = sqlite3.connect(self.manifest)
|
|
81
|
+
con.row_factory = sqlite3.Row
|
|
82
|
+
try:
|
|
83
|
+
return [dict(r) for r in con.execute(f"SELECT * FROM Files WHERE {sql_where}", params)]
|
|
84
|
+
finally:
|
|
85
|
+
con.close()
|
|
86
|
+
|
|
87
|
+
def copy_entry(self, row: dict, target_folder: Path, target_name: str) -> Path:
|
|
88
|
+
entry = dict(row)
|
|
89
|
+
entry["manifest"] = entry.pop("file") # the library wants the blob under this key
|
|
90
|
+
target_folder.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
self.impl.getFileDecryptedCopy(manifestEntry=entry, targetName=target_name, targetFolder=str(target_folder))
|
|
92
|
+
return target_folder / target_name
|
|
93
|
+
|
|
94
|
+
def copy(self, domain: str, relative_path: str, target_folder: Path, target_name: str) -> Path | None:
|
|
95
|
+
row = self._row(domain, relative_path)
|
|
96
|
+
if not row:
|
|
97
|
+
return None
|
|
98
|
+
return self.copy_entry(row, target_folder, target_name)
|
|
99
|
+
|
|
100
|
+
def inventory(self) -> dict:
|
|
101
|
+
"""Files and bytes per domain, read from the manifest's own size field."""
|
|
102
|
+
from iOSbackup import iOSbackup
|
|
103
|
+
|
|
104
|
+
files, size = Counter(), defaultdict(int)
|
|
105
|
+
for r in self.rows("flags=1"):
|
|
106
|
+
files[r["domain"]] += 1
|
|
107
|
+
try:
|
|
108
|
+
size[r["domain"]] += int(iOSbackup.getFileInfo(r["file"]).get("size") or 0)
|
|
109
|
+
except Exception: # noqa: BLE001 - an unreadable blob only loses its size
|
|
110
|
+
pass
|
|
111
|
+
return {d: {"files": n, "bytes": size[d]} for d, n in files.most_common()}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def open_backup(udid: str, password: str, workdir: Path, root: Path = DEFAULT_ROOT) -> Backup:
|
|
115
|
+
from iOSbackup import iOSbackup
|
|
116
|
+
|
|
117
|
+
impl = iOSbackup(udid=udid, cleartextpassword=password, backuproot=str(root))
|
|
118
|
+
workdir.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
# The decrypted manifest lives in a temp folder that is cleaned up, so keep a copy.
|
|
120
|
+
durable = workdir / "Manifest.decrypted.db"
|
|
121
|
+
shutil.copyfile(impl.manifestDB, durable)
|
|
122
|
+
return Backup(udid=udid, impl=impl, manifest=durable)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def password_from_env_or_prompt() -> str:
|
|
126
|
+
pw = os.environ.get("IPHONE_BACKUP_PASSWORD")
|
|
127
|
+
if pw:
|
|
128
|
+
return pw
|
|
129
|
+
import getpass
|
|
130
|
+
|
|
131
|
+
return getpass.getpass("Backup password: ")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def extract_known(b: Backup, out: Path) -> list[tuple[str, str, Path | None]]:
|
|
135
|
+
"""Copy every known database out. Returns (label, destination format, path or None)."""
|
|
136
|
+
got = []
|
|
137
|
+
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
|
|
142
|
+
got.append((label, dest, p))
|
|
143
|
+
return got
|