idevice 0.1.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.
- idevice-0.1.0/.cursor/rules/AGENTS.md +55 -0
- idevice-0.1.0/.github/workflows/workflow.yml +49 -0
- idevice-0.1.0/.gitignore +25 -0
- idevice-0.1.0/.python-version +1 -0
- idevice-0.1.0/AGENTS.md +13 -0
- idevice-0.1.0/PKG-INFO +17 -0
- idevice-0.1.0/README.md +5 -0
- idevice-0.1.0/examples/README.md +34 -0
- idevice-0.1.0/examples/android_device_install.py +131 -0
- idevice-0.1.0/idevice/__init__.py +5 -0
- idevice-0.1.0/idevice/device/__init__.py +29 -0
- idevice-0.1.0/idevice/device/android/__init__.py +5 -0
- idevice-0.1.0/idevice/device/android/device.py +217 -0
- idevice-0.1.0/idevice/device/base/__init__.py +20 -0
- idevice-0.1.0/idevice/device/base/device.py +151 -0
- idevice-0.1.0/idevice/device/base/errors.py +32 -0
- idevice-0.1.0/idevice/device/base/runner.py +83 -0
- idevice-0.1.0/idevice/device/cache.py +52 -0
- idevice-0.1.0/idevice/device/config.py +26 -0
- idevice-0.1.0/idevice/device/factory.py +37 -0
- idevice-0.1.0/idevice/device/ios/__init__.py +6 -0
- idevice-0.1.0/idevice/device/ios/device.py +202 -0
- idevice-0.1.0/idevice/device/windows/__init__.py +5 -0
- idevice-0.1.0/idevice/device/windows/device.py +155 -0
- idevice-0.1.0/idevice/uiauto/__init__.py +15 -0
- idevice-0.1.0/idevice/uiauto/android/__init__.py +5 -0
- idevice-0.1.0/idevice/uiauto/android/automation.py +100 -0
- idevice-0.1.0/idevice/uiauto/android/dialogs.py +44 -0
- idevice-0.1.0/idevice/uiauto/android/hierarchy.py +87 -0
- idevice-0.1.0/idevice/uiauto/base/__init__.py +6 -0
- idevice-0.1.0/idevice/uiauto/base/automation.py +43 -0
- idevice-0.1.0/idevice/uiauto/base/errors.py +9 -0
- idevice-0.1.0/idevice/uiauto/config.py +15 -0
- idevice-0.1.0/idevice/uiauto/factory.py +27 -0
- idevice-0.1.0/pyproject.toml +38 -0
- idevice-0.1.0/tests/test_config.py +27 -0
- idevice-0.1.0/tests/test_factory.py +23 -0
- idevice-0.1.0/uv.lock +351 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Cursor rules for Python development with projects guide integration."
|
|
3
|
+
globs: **/*
|
|
4
|
+
alwaysApply: true
|
|
5
|
+
---
|
|
6
|
+
You are an AI assistant specialized in Python development. Your approach emphasizes:
|
|
7
|
+
|
|
8
|
+
1. Clear project structure with monorepo.
|
|
9
|
+
3. Configuration management using environment variables.
|
|
10
|
+
4. Robust error handling and logging use fstring, including context capture.
|
|
11
|
+
5. Comprehensive testing with pytest.
|
|
12
|
+
6. Detailed documentation using docstrings and README files.
|
|
13
|
+
7. Dependency management via https://docs.astral.sh/uv and virtual environments.
|
|
14
|
+
8. Code style consistency using Ruff.
|
|
15
|
+
9. CI/CD implementation with GitHub Actions or GitLab CI.
|
|
16
|
+
10. AI-friendly coding practices:
|
|
17
|
+
- Descriptive variable and function names
|
|
18
|
+
- Type hints
|
|
19
|
+
- Detailed comments for complex logic
|
|
20
|
+
- Rich error context for debugging
|
|
21
|
+
|
|
22
|
+
You provide code snippets and explanations tailored to these principles, optimizing for clarity and AI-assisted development.
|
|
23
|
+
|
|
24
|
+
## Project Structure
|
|
25
|
+
- Use src-layout with `src/your_package_name/`
|
|
26
|
+
- Place tests in `tests/` directory parallel to `src/`
|
|
27
|
+
- Keep configuration in `config/` or as environment variables
|
|
28
|
+
- Store requirements in `pyproject.toml`
|
|
29
|
+
- Place static files in `static/` directory
|
|
30
|
+
|
|
31
|
+
## Code Style
|
|
32
|
+
- Follow Black code formatting
|
|
33
|
+
- Use isort for import sorting
|
|
34
|
+
- Follow PEP 8 naming conventions:
|
|
35
|
+
- snake_case for functions and variables
|
|
36
|
+
- PascalCase for classes
|
|
37
|
+
- UPPER_CASE for constants
|
|
38
|
+
- Maximum line length of 88 characters (Black default)
|
|
39
|
+
- Use absolute imports over relative imports
|
|
40
|
+
|
|
41
|
+
## Type Hints
|
|
42
|
+
- Use type hints for all function parameters and returns
|
|
43
|
+
- Import types from `typing` module
|
|
44
|
+
- Use `Optional[Type]` instead of `Type | None`
|
|
45
|
+
- Use `TypeVar` for generic types
|
|
46
|
+
- Define custom types in `types.py`
|
|
47
|
+
- Use `Protocol` for duck typing
|
|
48
|
+
|
|
49
|
+
## Documentation
|
|
50
|
+
- Use Google-style docstrings
|
|
51
|
+
- Document all public APIs
|
|
52
|
+
- Keep README.md updated
|
|
53
|
+
- Use proper inline comments
|
|
54
|
+
- Generate API documentation
|
|
55
|
+
- Document environment setup
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
tags: ["v*"]
|
|
7
|
+
pull_request:
|
|
8
|
+
workflow_dispatch:
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
test:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- name: Install uv
|
|
17
|
+
uses: astral-sh/setup-uv@v6
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.14"
|
|
20
|
+
enable-cache: true
|
|
21
|
+
|
|
22
|
+
- name: Install dependencies
|
|
23
|
+
run: uv sync --extra dev --frozen
|
|
24
|
+
|
|
25
|
+
- name: Lint
|
|
26
|
+
run: uv run ruff check .
|
|
27
|
+
|
|
28
|
+
- name: Test
|
|
29
|
+
run: uv run pytest --import-mode=importlib
|
|
30
|
+
|
|
31
|
+
publish:
|
|
32
|
+
needs: test
|
|
33
|
+
runs-on: ubuntu-latest
|
|
34
|
+
environment: pypi
|
|
35
|
+
permissions:
|
|
36
|
+
id-token: write
|
|
37
|
+
steps:
|
|
38
|
+
- uses: actions/checkout@v4
|
|
39
|
+
|
|
40
|
+
- name: Install uv
|
|
41
|
+
uses: astral-sh/setup-uv@v6
|
|
42
|
+
with:
|
|
43
|
+
python-version: "3.14"
|
|
44
|
+
|
|
45
|
+
- name: Build
|
|
46
|
+
run: uv build
|
|
47
|
+
|
|
48
|
+
- name: Publish to PyPI
|
|
49
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
idevice-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Python-generated files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[oc]
|
|
4
|
+
build/
|
|
5
|
+
dist/
|
|
6
|
+
wheels/
|
|
7
|
+
*.egg-info
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
.venv
|
|
11
|
+
|
|
12
|
+
# IDEs
|
|
13
|
+
.vscode/
|
|
14
|
+
.idea/
|
|
15
|
+
|
|
16
|
+
# OS generated files
|
|
17
|
+
.DS_Store
|
|
18
|
+
Thumbs.db
|
|
19
|
+
ehthumbs.db
|
|
20
|
+
Desktop.ini
|
|
21
|
+
Icon?
|
|
22
|
+
$RECYCLE.BIN/
|
|
23
|
+
$SystemVolumeInformation/
|
|
24
|
+
$Windows.~WS/
|
|
25
|
+
$Windows.~WS/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.14
|
idevice-0.1.0/AGENTS.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Agents
|
|
2
|
+
|
|
3
|
+
This repository uses VS Code Copilot agents and customization files to assist with development tasks.
|
|
4
|
+
|
|
5
|
+
## Purpose
|
|
6
|
+
|
|
7
|
+
- Document available agents and how to use them in this workspace.
|
|
8
|
+
- Provide a reference for contributors.
|
|
9
|
+
|
|
10
|
+
## Notes
|
|
11
|
+
|
|
12
|
+
- The workspace contains a `.cursor/rules/AGENTS.md` file for agent behavior rules.
|
|
13
|
+
- Add any high-level agent usage or customization details here as needed.
|
idevice-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: idevice
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cross-platform device automation for end-to-end test workflows
|
|
5
|
+
Requires-Python: >=3.14
|
|
6
|
+
Requires-Dist: uiautomator2>=3.5.2
|
|
7
|
+
Provides-Extra: dev
|
|
8
|
+
Requires-Dist: pytest-mock>=3.14; extra == 'dev'
|
|
9
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
10
|
+
Requires-Dist: ruff>=0.9; extra == 'dev'
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# idevice
|
|
14
|
+
|
|
15
|
+
Cross-platform device automation for end-to-end test workflows: install and manage apps on physical devices, and drive UI interactions through a small, platform-agnostic API.
|
|
16
|
+
|
|
17
|
+
**Status:** iOS is implemented (go-ios for package management, WebDriverAgent for UI). Android, Windows, macOS, and HarmonyOS are planned.
|
idevice-0.1.0/README.md
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# idevice
|
|
2
|
+
|
|
3
|
+
Cross-platform device automation for end-to-end test workflows: install and manage apps on physical devices, and drive UI interactions through a small, platform-agnostic API.
|
|
4
|
+
|
|
5
|
+
**Status:** iOS is implemented (go-ios for package management, WebDriverAgent for UI). Android, Windows, macOS, and HarmonyOS are planned.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Examples
|
|
2
|
+
|
|
3
|
+
## Android: install an APK
|
|
4
|
+
|
|
5
|
+
Install a package on a connected device and verify it with `pm list packages`:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv run python examples/android_device_install.py \
|
|
9
|
+
--serial "$(adb devices | awk 'NR>2 && $2=="device" {print $1; exit}')" \
|
|
10
|
+
--apk tests/apk/app.apk \
|
|
11
|
+
--package com.Unity.TrashDash
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Optional: dismiss OEM post-install dialogs after install:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
uv run python examples/android_device_install.py \
|
|
18
|
+
--serial e8b2b043 \
|
|
19
|
+
--apk tests/apk/app.apk \
|
|
20
|
+
--package com.Unity.TrashDash \
|
|
21
|
+
--dismiss-dialogs
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Minimal Python usage:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from idevice.device.android.device import AndroidDevice
|
|
30
|
+
|
|
31
|
+
device = AndroidDevice("e8b2b043")
|
|
32
|
+
device.install(Path("tests/apk/app.apk"), app_id="com.Unity.TrashDash")
|
|
33
|
+
assert device.is_installed("com.Unity.TrashDash")
|
|
34
|
+
```
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Install an APK on a connected Android device using :class:`AndroidDevice`.
|
|
3
|
+
|
|
4
|
+
Prerequisites:
|
|
5
|
+
- ``adb`` on PATH (or set ``IDEVICE_ADB_BINARY``)
|
|
6
|
+
- Device visible in ``adb devices`` with state ``device``
|
|
7
|
+
- ``uiautomator2`` (installed with idevice)
|
|
8
|
+
|
|
9
|
+
Example:
|
|
10
|
+
uv run python examples/android_device_install.py \\
|
|
11
|
+
--serial e8b2b043 \\
|
|
12
|
+
--apk tests/apk/app.apk \\
|
|
13
|
+
--package com.Unity.TrashDash
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import logging
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from idevice.device.android.device import AndroidDevice, AndroidDeviceError
|
|
25
|
+
from idevice.device.config import adb_binary
|
|
26
|
+
from idevice.uiauto.android.automation import AndroidUIAuto
|
|
27
|
+
|
|
28
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
29
|
+
DEFAULT_APK = REPO_ROOT / "tests" / "apk" / "app.apk"
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _resolve_serial(explicit: str | None) -> str:
|
|
35
|
+
if explicit:
|
|
36
|
+
return explicit
|
|
37
|
+
try:
|
|
38
|
+
completed = subprocess.run(
|
|
39
|
+
[adb_binary(), "devices"],
|
|
40
|
+
capture_output=True,
|
|
41
|
+
text=True,
|
|
42
|
+
timeout=30,
|
|
43
|
+
check=True,
|
|
44
|
+
)
|
|
45
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
46
|
+
raise SystemExit(f"Failed to list devices: {exc}") from exc
|
|
47
|
+
|
|
48
|
+
for line in completed.stdout.splitlines():
|
|
49
|
+
parts = line.split()
|
|
50
|
+
if len(parts) >= 2 and parts[1] == "device":
|
|
51
|
+
return parts[0]
|
|
52
|
+
raise SystemExit("No connected device found. Pass --serial or connect a device via adb.")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main(argv: list[str] | None = None) -> int:
|
|
56
|
+
parser = argparse.ArgumentParser(
|
|
57
|
+
description="Install an APK on an Android device via idevice.",
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--serial",
|
|
61
|
+
help="adb device serial (default: first device in `adb devices`)",
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--apk",
|
|
65
|
+
type=Path,
|
|
66
|
+
default=DEFAULT_APK,
|
|
67
|
+
help=f"Path to the APK (default: {DEFAULT_APK.relative_to(REPO_ROOT)})",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--package",
|
|
71
|
+
required=True,
|
|
72
|
+
help="Android package name (applicationId), e.g. com.example.app",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--dismiss-dialogs",
|
|
76
|
+
action="store_true",
|
|
77
|
+
help="Dismiss OEM post-install dialogs after install",
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"-v",
|
|
81
|
+
"--verbose",
|
|
82
|
+
action="store_true",
|
|
83
|
+
help="Enable debug logging",
|
|
84
|
+
)
|
|
85
|
+
args = parser.parse_args(argv)
|
|
86
|
+
|
|
87
|
+
logging.basicConfig(
|
|
88
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
89
|
+
format="%(levelname)s %(message)s",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
serial = _resolve_serial(args.serial)
|
|
93
|
+
apk_path = args.apk.resolve()
|
|
94
|
+
if not apk_path.is_file():
|
|
95
|
+
logger.error("APK not found: %s", apk_path)
|
|
96
|
+
return 1
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
device = AndroidDevice(serial)
|
|
100
|
+
except AndroidDeviceError as exc:
|
|
101
|
+
logger.error("%s", exc)
|
|
102
|
+
return 1
|
|
103
|
+
|
|
104
|
+
logger.info("Installing %s on %s", apk_path.name, serial)
|
|
105
|
+
try:
|
|
106
|
+
device.install(apk_path, app_id=args.package)
|
|
107
|
+
except (AndroidDeviceError, FileNotFoundError) as exc:
|
|
108
|
+
logger.error("Install failed: %s", exc)
|
|
109
|
+
return 1
|
|
110
|
+
|
|
111
|
+
if not device.is_installed(args.package):
|
|
112
|
+
logger.error("Package %s not reported as installed", args.package)
|
|
113
|
+
return 1
|
|
114
|
+
|
|
115
|
+
pkg_file = device.get_installed_pkg_name(args.package)
|
|
116
|
+
logger.info(
|
|
117
|
+
"Installed %s (recorded package file: %s)",
|
|
118
|
+
args.package,
|
|
119
|
+
pkg_file or apk_path.name,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
if args.dismiss_dialogs:
|
|
123
|
+
uiauto = AndroidUIAuto(serial)
|
|
124
|
+
dismissed = uiauto.dismiss_post_install_dialogs()
|
|
125
|
+
logger.info("Dismissed %s post-install dialog(s)", dismissed)
|
|
126
|
+
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
sys.exit(main())
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Public API for ``DeviceBase`` and platform-specific device implementations."""
|
|
2
|
+
|
|
3
|
+
from idevice.device.android.device import AndroidDevice
|
|
4
|
+
from idevice.device.base.device import DeviceBase
|
|
5
|
+
from idevice.device.base.errors import (
|
|
6
|
+
AppNotInstalledError,
|
|
7
|
+
CommandExecutionError,
|
|
8
|
+
DeviceError,
|
|
9
|
+
DeviceNotFoundError,
|
|
10
|
+
)
|
|
11
|
+
from idevice.device.base.runner import CommandResult, SubprocessRunner
|
|
12
|
+
from idevice.device.factory import Platform, create_device
|
|
13
|
+
from idevice.device.ios.device import IOSDevice
|
|
14
|
+
from idevice.device.windows.device import WindowsDevice
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"AndroidDevice",
|
|
18
|
+
"AppNotInstalledError",
|
|
19
|
+
"CommandExecutionError",
|
|
20
|
+
"CommandResult",
|
|
21
|
+
"DeviceError",
|
|
22
|
+
"DeviceNotFoundError",
|
|
23
|
+
"DeviceBase",
|
|
24
|
+
"IOSDevice",
|
|
25
|
+
"Platform",
|
|
26
|
+
"SubprocessRunner",
|
|
27
|
+
"WindowsDevice",
|
|
28
|
+
"create_device",
|
|
29
|
+
]
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Android ``DeviceBase`` implementation via adb."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from idevice.device.base.device import DeviceBase
|
|
13
|
+
from idevice.device.base.errors import AppNotInstalledError
|
|
14
|
+
from idevice.device.base.runner import SubprocessRunner
|
|
15
|
+
from idevice.device.config import adb_binary
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class InstallResult:
|
|
22
|
+
ok: bool
|
|
23
|
+
returncode: int
|
|
24
|
+
stdout: str
|
|
25
|
+
stderr: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AndroidDeviceError(RuntimeError):
|
|
29
|
+
"""Raised when an Android device operation fails."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AndroidDevice(DeviceBase):
|
|
33
|
+
"""``DeviceBase`` implementation for Android using adb."""
|
|
34
|
+
|
|
35
|
+
DEFAULT_BINARY = "adb"
|
|
36
|
+
|
|
37
|
+
def __init__(self, device_id: str) -> None:
|
|
38
|
+
super().__init__(device_id)
|
|
39
|
+
self._binary = self.DEFAULT_BINARY
|
|
40
|
+
self._runner = SubprocessRunner()
|
|
41
|
+
if shutil.which(self._binary) is None:
|
|
42
|
+
logger.error(f"[AndroidDevice] `{self._binary}` CLI not found on PATH")
|
|
43
|
+
raise AndroidDeviceError(
|
|
44
|
+
f"`{self._binary}` CLI not found on PATH. "
|
|
45
|
+
"Install adb: https://developer.android.com/studio/releases/platform-tools"
|
|
46
|
+
)
|
|
47
|
+
self._installed_pkg_names: dict[str, str] = {}
|
|
48
|
+
|
|
49
|
+
def _base_command(self) -> list[str]:
|
|
50
|
+
return [adb_binary(), "-s", self.device_id]
|
|
51
|
+
|
|
52
|
+
def install(self, package_path: Path, app_id: str | None = None) -> None:
|
|
53
|
+
"""Install an APK on the bound device via uiautomator2.
|
|
54
|
+
|
|
55
|
+
Example::
|
|
56
|
+
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
|
|
59
|
+
from idevice.device.android.device import AndroidDevice
|
|
60
|
+
|
|
61
|
+
device = AndroidDevice("e8b2b043")
|
|
62
|
+
apk = Path("tests/apk/app.apk")
|
|
63
|
+
device.install(apk, app_id="com.example.app")
|
|
64
|
+
assert device.is_installed("com.example.app")
|
|
65
|
+
"""
|
|
66
|
+
logger.info(f"[AndroidDevice] Installing package on {self.device_id}: {package_path}")
|
|
67
|
+
if not package_path.exists():
|
|
68
|
+
raise FileNotFoundError(f"Package not found: {package_path}")
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
cmd = self._base_command()
|
|
72
|
+
cmd.extend(["install", "-r", str(package_path)])
|
|
73
|
+
result = self._install_with_uiautomator2(cmd, device_id=self.device_id)
|
|
74
|
+
if not result.ok:
|
|
75
|
+
raise AndroidDeviceError(f"Package install failed on {self.device_id}: {result.stderr}")
|
|
76
|
+
except Exception as exc:
|
|
77
|
+
raise AndroidDeviceError(f"Package install failed on {self.device_id}: {exc}") from exc
|
|
78
|
+
|
|
79
|
+
if app_id:
|
|
80
|
+
self._installed_pkg_names[app_id] = package_path.name
|
|
81
|
+
logger.debug(f"[AndroidDevice] Cached package name for app_id={app_id}")
|
|
82
|
+
|
|
83
|
+
def uninstall(self, app_id: str) -> None:
|
|
84
|
+
logger.info(f"[AndroidDevice] Uninstalling {app_id} on {self.device_id}")
|
|
85
|
+
command = self._base_command()
|
|
86
|
+
command.extend(["uninstall", app_id])
|
|
87
|
+
self._runner.run(command)
|
|
88
|
+
self._installed_pkg_names.pop(app_id, None)
|
|
89
|
+
|
|
90
|
+
def is_installed(self, app_id: str) -> bool:
|
|
91
|
+
command = self._base_command()
|
|
92
|
+
command.extend(["shell", "pm", "list", "packages", app_id])
|
|
93
|
+
result = self._runner.run(command)
|
|
94
|
+
prefix = f"package:{app_id}"
|
|
95
|
+
installed = any(line.strip() == prefix for line in result.stdout.splitlines())
|
|
96
|
+
logger.debug(f"App {app_id} installed on Android device {self.device_id}: {installed}")
|
|
97
|
+
return installed
|
|
98
|
+
|
|
99
|
+
def launch_app(self, app_id: str) -> None:
|
|
100
|
+
if not app_id:
|
|
101
|
+
raise ValueError("app_id is required and must be a non-empty string")
|
|
102
|
+
if not self.is_installed(app_id):
|
|
103
|
+
raise AppNotInstalledError(f"App not installed: {app_id}")
|
|
104
|
+
logger.info(f"[AndroidDevice] Launching {app_id} on {self.device_id}")
|
|
105
|
+
command = self._base_command()
|
|
106
|
+
command.extend(
|
|
107
|
+
[
|
|
108
|
+
"shell",
|
|
109
|
+
"monkey",
|
|
110
|
+
"-p",
|
|
111
|
+
app_id,
|
|
112
|
+
"-c",
|
|
113
|
+
"android.intent.category.LAUNCHER",
|
|
114
|
+
"1",
|
|
115
|
+
]
|
|
116
|
+
)
|
|
117
|
+
self._runner.run(command)
|
|
118
|
+
|
|
119
|
+
def stop_app(self, app_id: str) -> None:
|
|
120
|
+
if not app_id:
|
|
121
|
+
raise ValueError("app_id is required and must be a non-empty string")
|
|
122
|
+
logger.info(f"Stopping app on Android device {self.device_id}: {app_id}")
|
|
123
|
+
command = self._base_command()
|
|
124
|
+
command.extend(["shell", "am", "force-stop", app_id])
|
|
125
|
+
self._runner.run(command)
|
|
126
|
+
|
|
127
|
+
def get_installed_pkg_name(self, app_id: str) -> str | None:
|
|
128
|
+
if not self.is_installed(app_id):
|
|
129
|
+
return None
|
|
130
|
+
return self._installed_pkg_names.get(app_id)
|
|
131
|
+
|
|
132
|
+
def host_is_running(self) -> bool:
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
def push(
|
|
136
|
+
self,
|
|
137
|
+
local: Path | str,
|
|
138
|
+
remote: str,
|
|
139
|
+
*,
|
|
140
|
+
app_id: str | None = None,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Push a local file or directory to the device via ``adb push``."""
|
|
143
|
+
del app_id
|
|
144
|
+
if not remote:
|
|
145
|
+
raise ValueError("remote is required and must be a non-empty string")
|
|
146
|
+
local_path = Path(local)
|
|
147
|
+
if not local_path.exists():
|
|
148
|
+
raise FileNotFoundError(f"Local path not found: {local_path}")
|
|
149
|
+
logger.info(f"[AndroidDevice] Pushing {local_path} to {self.device_id}:{remote}")
|
|
150
|
+
command = self._base_command()
|
|
151
|
+
command.extend(["push", str(local_path), remote])
|
|
152
|
+
self._runner.run(command)
|
|
153
|
+
|
|
154
|
+
def pull(
|
|
155
|
+
self,
|
|
156
|
+
remote: str,
|
|
157
|
+
local: Path | str,
|
|
158
|
+
*,
|
|
159
|
+
app_id: str | None = None,
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Pull a remote file or directory from the device via ``adb pull``."""
|
|
162
|
+
del app_id
|
|
163
|
+
if not remote:
|
|
164
|
+
raise ValueError("remote is required and must be a non-empty string")
|
|
165
|
+
local_path = Path(local)
|
|
166
|
+
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
logger.info(f"[AndroidDevice] Pulling {self.device_id}:{remote} to {local_path}")
|
|
168
|
+
command = self._base_command()
|
|
169
|
+
command.extend(["pull", remote, str(local_path)])
|
|
170
|
+
self._runner.run(command)
|
|
171
|
+
|
|
172
|
+
def _install_with_uiautomator2(self, cmd: list[str], *, device_id: str | None) -> InstallResult:
|
|
173
|
+
"""Use uiautomator2 WatchContext (builtin + extra) while adb install runs."""
|
|
174
|
+
import uiautomator2 as u2
|
|
175
|
+
|
|
176
|
+
logger.info(f"install with uiautomator2: {cmd}")
|
|
177
|
+
d = u2.connect(device_id) if device_id else u2.connect()
|
|
178
|
+
# autostart=False so we can register rules before the background thread runs.
|
|
179
|
+
with d.watch_context(builtin=True, autostart=False) as ctx:
|
|
180
|
+
# Builtin rules cover common install prompts (继续安装, ALLOW, Agree, …).
|
|
181
|
+
ctx.when("仍要安装").click()
|
|
182
|
+
ctx.when("Install").click()
|
|
183
|
+
ctx.start()
|
|
184
|
+
p = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
|
|
185
|
+
|
|
186
|
+
if p.returncode == 0:
|
|
187
|
+
self._dismiss_post_install_popups(d)
|
|
188
|
+
|
|
189
|
+
out = (p.stdout or "").strip()
|
|
190
|
+
err = (p.stderr or "").strip()
|
|
191
|
+
ok = p.returncode == 0
|
|
192
|
+
return InstallResult(ok, p.returncode, out, err)
|
|
193
|
+
|
|
194
|
+
def _dismiss_post_install_popups(self, d) -> None:
|
|
195
|
+
"""Dismiss OEM dialogs after adb install; watcher already stopped."""
|
|
196
|
+
stable = float(os.environ.get("GAUTO_APK_POST_INSTALL_STABLE_SEC", "2"))
|
|
197
|
+
timeout_sec = float(os.environ.get("GAUTO_APK_POST_INSTALL_TIMEOUT_SEC", "30"))
|
|
198
|
+
with d.watch_context(builtin=True, autostart=False) as ctx:
|
|
199
|
+
# Prefer dismiss over launching the app when both exist.
|
|
200
|
+
ctx.when("完成").click()
|
|
201
|
+
ctx.when("完成安装").click()
|
|
202
|
+
ctx.when("知道了").click()
|
|
203
|
+
ctx.when("我知道了").click()
|
|
204
|
+
ctx.when("以后再说").click()
|
|
205
|
+
ctx.when("稍后").click()
|
|
206
|
+
ctx.when("暂不").click()
|
|
207
|
+
ctx.when("跳过").click()
|
|
208
|
+
ctx.when("关闭").click()
|
|
209
|
+
ctx.when("Done").click()
|
|
210
|
+
ctx.when("OPEN").click()
|
|
211
|
+
ctx.when("打开").click()
|
|
212
|
+
ctx.when("立即打开").click()
|
|
213
|
+
ctx.start()
|
|
214
|
+
try:
|
|
215
|
+
ctx.wait_stable(seconds=stable, timeout=timeout_sec)
|
|
216
|
+
except TimeoutError:
|
|
217
|
+
logger.debug(f"post-install popups did not stabilize within {timeout_sec}s")
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""``DeviceBase`` and shared utilities for device control."""
|
|
2
|
+
|
|
3
|
+
from idevice.device.base.device import DeviceBase
|
|
4
|
+
from idevice.device.base.errors import (
|
|
5
|
+
AppNotInstalledError,
|
|
6
|
+
CommandExecutionError,
|
|
7
|
+
DeviceError,
|
|
8
|
+
DeviceNotFoundError,
|
|
9
|
+
)
|
|
10
|
+
from idevice.device.base.runner import CommandResult, SubprocessRunner
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AppNotInstalledError",
|
|
14
|
+
"CommandExecutionError",
|
|
15
|
+
"CommandResult",
|
|
16
|
+
"DeviceError",
|
|
17
|
+
"DeviceNotFoundError",
|
|
18
|
+
"DeviceBase",
|
|
19
|
+
"SubprocessRunner",
|
|
20
|
+
]
|