autool/utils_android/download_app/download_controller.py
2026-06-17 19:44:18 +08:00

559 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- encoding=utf8 -*-
"""
Download Controller
Unified entry point for downloading/installing apps from various sources.
"""
import logging
import sys
import os
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)).rsplit('\\', 3)[0])
from config_loader import load_config as load_autool_config
from utils_android.device_config import build_airtest_android_uri, ensure_android_wireless_connected, get_android_device_serial
from google_play_downloader import GooglePlayDownloader
from apkpure_downloader import ApkPureDownloader
from local_file_importer import LocalFileImporter
import apks_export_import
from result_codes import DownloadError
from country_codes import normalize_country_codes
logging.getLogger("airtest").setLevel(logging.ERROR)
logging.getLogger("adb").setLevel(logging.ERROR)
logging.getLogger("airtest.core.android.adb").setLevel(logging.ERROR)
logger = logging.getLogger(__name__)
logger.setLevel(logging.INFO)
_downloaders = {}
_config_cache = None
SUPPORTED_SOURCES = ('google_play', 'local')
DEFAULT_SOURCES = ['google_play', 'local']
GOOGLE_PLAY_COUNTRY_CONTINUE_ERRORS = {
DownloadError.REGION_RESTRICTED,
DownloadError.APP_NOT_FOUND,
DownloadError.INCOMPATIBLE,
}
LOCAL_FALLBACK_ELIGIBLE_ERRORS = GOOGLE_PLAY_COUNTRY_CONTINUE_ERRORS
LOCAL_SOURCE_UNAVAILABLE_ERRORS = {
"folder not found",
"base.apk not found and xapk missing",
"no apk files",
"invalid xapk",
"xapk missing apk files",
}
def _load_autool_config():
global _config_cache
if _config_cache is not None:
return _config_cache
_config_cache = load_autool_config()
return _config_cache
DOWNLOAD_ERROR_MAP = {
'app not available': DownloadError.REGION_RESTRICTED,
'account banned': DownloadError.ACCOUNT_BANNED,
'app not found': DownloadError.APP_NOT_FOUND,
'app incompatible': DownloadError.INCOMPATIBLE,
'install overtime': DownloadError.DOWNLOAD_TIMEOUT,
'jump timeout': DownloadError.DOWNLOAD_TIMEOUT,
'timeout': DownloadError.DOWNLOAD_TIMEOUT,
'jump failed': DownloadError.NETWORK_ERROR,
'app page load failed': DownloadError.NETWORK_ERROR,
'click download button failed': DownloadError.INSTALL_FAILED,
'install failed': DownloadError.INSTALL_FAILED,
'download failed': DownloadError.DOWNLOAD_TIMEOUT,
'install not verified': DownloadError.INSTALL_FAILED,
'network error': DownloadError.NETWORK_ERROR,
'source unavailable': DownloadError.SOURCE_UNAVAILABLE,
'folder not found': DownloadError.SOURCE_UNAVAILABLE,
'base.apk not found and xapk missing': DownloadError.SOURCE_UNAVAILABLE,
'no apk files': DownloadError.SOURCE_UNAVAILABLE,
'invalid xapk': DownloadError.SOURCE_UNAVAILABLE,
'xapk missing apk files': DownloadError.SOURCE_UNAVAILABLE,
}
def _parse_download_error(message: str, source: str = None) -> DownloadError:
"""将错误消息字符串转换为 DownloadError"""
message_lower = str(message or '').strip().lower()
exact_match = DOWNLOAD_ERROR_MAP.get(message_lower)
if exact_match is not None:
return exact_match
if str(source or '').strip().lower() == 'local' and message_lower in LOCAL_SOURCE_UNAVAILABLE_ERRORS:
return DownloadError.SOURCE_UNAVAILABLE
if 'region' in message_lower or 'not available in your country' in message_lower or 'country restricted' in message_lower:
return DownloadError.REGION_RESTRICTED
if 'banned' in message_lower or 'authentication' in message_lower:
return DownloadError.ACCOUNT_BANNED
if 'not found' in message_lower:
return DownloadError.APP_NOT_FOUND
if 'incompatible' in message_lower or 'other devices' in message_lower or '不兼容' in message_lower or '不支持' in message_lower:
return DownloadError.INCOMPATIBLE
if 'timeout' in message_lower or 'overtime' in message_lower:
return DownloadError.DOWNLOAD_TIMEOUT
if 'network' in message_lower or 'connection' in message_lower:
return DownloadError.NETWORK_ERROR
if 'install' in message_lower:
return DownloadError.INSTALL_FAILED
return DownloadError.OTHER
def _run_state_to_error(run_state: str):
if run_state == 'not_installed':
return DownloadError.INSTALL_FAILED, 'install not verified'
if run_state == 'unknown':
return DownloadError.OTHER, 'app state check returned unknown'
return DownloadError.OTHER, f'unexpected run state ({run_state})'
def _build_google_play_error_key(country):
return f"google_play:{country}"
def _normalize_available_sources(available_sources):
if isinstance(available_sources, str):
requested_sources = [available_sources]
else:
requested_sources = available_sources or DEFAULT_SOURCES
normalized = []
seen = set()
for source in requested_sources:
source_name = str(source or '').strip()
if not source_name or source_name not in SUPPORTED_SOURCES or source_name in seen:
continue
seen.add(source_name)
normalized.append(source_name)
if not normalized:
normalized = list(DEFAULT_SOURCES)
return normalized
def _build_errors_json(errors):
errors_json = {}
for item in errors:
entry = {
'code': item['code'],
'message': item['message'],
}
if item.get('run') is not None:
entry['run'] = item['run']
errors_json[item['source']] = entry
return errors_json
def _build_failed_result(package_name, primary_error, errors, attempted_countries=None):
result = {
'package_name': package_name,
'source': primary_error['source'],
'state': 'failed',
'run': primary_error.get('run', 'unknown'),
'error_code': primary_error['code'],
'details': primary_error['message'],
'errors': _build_errors_json(errors),
}
if attempted_countries:
result['attempted_countries'] = attempted_countries
return result
def _can_fallback_to_local(country_errors):
return bool(country_errors) and all(
item['code'] in LOCAL_FALLBACK_ELIGIBLE_ERRORS for item in country_errors
)
def _is_country_locked_terminal(errors):
if not errors:
return False
google_play_codes = []
local_codes = []
for item in errors:
if not isinstance(item, dict):
return False
source_name = str(item.get('source') or '').strip().lower()
code = item.get('code')
if source_name.startswith('google_play:'):
google_play_codes.append(code)
elif source_name == 'local':
local_codes.append(code)
if not google_play_codes or not local_codes:
return False
if any(code not in GOOGLE_PLAY_COUNTRY_CONTINUE_ERRORS for code in google_play_codes):
return False
return True
def _pick_terminal_download_primary_error(errors):
priority_by_code = {
DownloadError.APP_NOT_FOUND: 1,
DownloadError.REGION_RESTRICTED: 2,
DownloadError.INCOMPATIBLE: 3,
}
best_item = None
best_priority = -1
for item in errors or []:
source_name = str(item.get('source') or '').strip().lower()
code = item.get('code')
if not source_name.startswith('google_play:') or code not in GOOGLE_PLAY_COUNTRY_CONTINUE_ERRORS:
continue
priority = priority_by_code.get(code, 0)
if priority > best_priority:
best_item = item
best_priority = priority
if best_item is not None:
return best_item
return (errors or [])[-1] if errors else {
'source': 'unknown',
'code': DownloadError.ALL_SOURCES_FAILED,
'message': 'All sources failed',
'run': 'unknown',
}
def _get_downloader(platform):
"""
Factory method to get or create downloader instances.
"""
if platform not in _downloaders:
if platform == 'google_play':
_downloaders[platform] = GooglePlayDownloader()
elif platform == 'apkpure':
_downloaders[platform] = ApkPureDownloader()
elif platform == 'local':
_downloaders[platform] = LocalFileImporter()
return _downloaders.get(platform)
import time
from airtest.core.api import *
# 导入 ADB Helper 和异常
from adb_helper import ADBHelper
from DroidBot.exceptions import ADBException
# 延迟初始化的 ADB Helper 实例
_adb_helper = None
def _get_adb_helper():
"""获取 ADB Helper 实例(延迟初始化)
避免在模块导入时就尝试连接设备,这样可以让主进程有机会
先恢复模拟器,再执行 ADB 相关操作。
"""
global _adb_helper
if _adb_helper is None:
_adb_helper = ADBHelper()
return _adb_helper
def check_app_state(package_name):
"""
Check if the app runs normally or crashes.
Returns: 'normal', 'crash', 'not_installed', or 'unknown'
"""
app_state = 'unknown'
try:
logger.info(f"Checking app state for {package_name}...")
config = _load_autool_config()
serial = get_android_device_serial(config)
ensure_android_wireless_connected(config, serial)
auto_setup(
__file__,
logdir=False,
devices=[build_airtest_android_uri(config, serial=serial)],
)
# 首先检查应用是否已安装
# 使用 ADBHelper延迟初始化ADBException 会自动向上传递
output = _get_adb_helper().shell(f"pm path {package_name}")
package_paths = [
line.strip()
for line in str(output or "").splitlines()
if line.strip().startswith("package:")
]
if not package_paths:
logger.info(f"App {package_name} is not installed")
return 'not_installed'
# 应用已安装,尝试启动
start_app(package_name)
wait_time = 10
# Wait for app launch
for _ in range(wait_time):
time.sleep(1)
output = _get_adb_helper().shell(f"pidof {package_name}")
if output.strip():
app_state = 'normal'
break
if app_state != 'normal':
app_state = 'crash'
logger.info(f"App state check result: {package_name} - {app_state}")
time.sleep(2)
stop_app(package_name)
except Exception as e:
logger.error(f"Error checking app state: {package_name} - {e}")
import traceback
logger.error(traceback.format_exc())
raise
return app_state
def download_app_chain(package_name, **kwargs):
"""
Smart download chain: iterates through available sources.
Attempts to download and install. If install succeeds, treat it as success directly.
Args:
package_name (str): The package name of the app
**kwargs: Additional arguments:
- app_name (str): Name of the app
- country (str): Country code for Google Play
- local_path (str): Custom path for local import
- target_account (str): Target Google account email for Google Play
- available_sources (list): List of sources to try in order, e.g. ['google_play', 'local']
Returns:
tuple: (success (bool), result (dict))
result format:
{
'package_name': str,
'source': str, # 'local'/'google_play'/'pre-installed'
'state': str, # 'success'/'failed'
'run': str, # 'normal'/'crash'/'not_installed'/'unknown'
'error_code': int, # DownloadError 枚举值
'details': str, # 失败原因或成功说明
'crashed_source': str # 闪退的下载源(仅当 run='crash' 时)
}
"""
available_sources = _normalize_available_sources(kwargs.get('available_sources', DEFAULT_SOURCES))
country_codes = normalize_country_codes(kwargs.get('country_codes') or kwargs.get('country'))
platforms = available_sources
errors = []
all_attempted_countries = []
logger.info(f"Checking if {package_name} is already installed...")
run_state = check_app_state(package_name)
if run_state == 'normal':
return True, {
'package_name': package_name,
'source': 'pre-installed',
'state': 'success',
'run': 'normal',
'error_code': None,
'details': 'App already installed and runs normally'
}
for platform in platforms:
logger.info(f"--- Attempting download from: {platform} ---")
if platform == 'google_play':
attempted_countries = []
country_errors = []
for country in country_codes:
attempted_countries.append(country)
all_attempted_countries = list(attempted_countries)
attempt_kwargs = dict(kwargs)
attempt_kwargs['country'] = country
logger.info(f"--- Attempting Google Play country: {country} ---")
success, message = download_app(platform, package_name, **attempt_kwargs)
if not success:
error_code = _parse_download_error(message, platform)
country_errors.append({
'source': _build_google_play_error_key(country),
'code': error_code,
'message': message,
})
if error_code in GOOGLE_PLAY_COUNTRY_CONTINUE_ERRORS:
continue
break
logger.info(f"Download/Install successful from {platform} ({country}). Skipping run-state verification.")
local_path = kwargs.get('local_path')
if local_path is None:
local_path = str(_load_autool_config()["LOCAL_APK_PATH"]).strip()
logger.info(f"Exporting APK to {local_path}")
try:
apks_export_import.export_multi_apk(package_name, local_path)
except Exception as e:
logger.error(f"Error exporting APK: {e}")
return True, {
'package_name': package_name,
'source': platform,
'country': country,
'state': 'success',
'run': 'unknown',
'error_code': None,
'details': f'Installed from {platform} ({country})'
}
if country_errors and _can_fallback_to_local(country_errors):
errors.extend(country_errors)
if 'local' in platforms:
continue
return False, _build_failed_result(
package_name,
country_errors[-1],
errors,
attempted_countries=attempted_countries,
)
if country_errors:
errors.extend(country_errors)
primary_error = next(
(
item for item in reversed(country_errors)
if item['code'] not in LOCAL_FALLBACK_ELIGIBLE_ERRORS
),
country_errors[-1],
)
return False, _build_failed_result(
package_name,
primary_error,
errors,
attempted_countries=attempted_countries,
)
continue
success, message = download_app(platform, package_name, **kwargs)
if not success:
error_code = _parse_download_error(message, platform)
errors.append({
'source': platform,
'code': error_code,
'message': message,
})
continue
logger.info(f"Download/Install successful from {platform}. Skipping run-state verification.")
if platform != 'local':
local_path = kwargs.get('local_path')
if local_path is None:
local_path = str(_load_autool_config()["LOCAL_APK_PATH"]).strip()
logger.info(f"Exporting APK to {local_path}")
try:
apks_export_import.export_multi_apk(package_name, local_path)
except Exception as e:
logger.error(f"Error exporting APK: {e}")
return True, {
'package_name': package_name,
'source': platform,
'state': 'success',
'run': 'unknown',
'error_code': None,
'details': f'Installed from {platform}'
}
logger.error("All download sources failed or app crashed on all versions.")
if _is_country_locked_terminal(errors):
return False, _build_failed_result(
package_name,
_pick_terminal_download_primary_error(errors),
errors,
attempted_countries=all_attempted_countries,
)
last_error = errors[-1] if errors else {
'source': 'unknown',
'code': DownloadError.ALL_SOURCES_FAILED,
'message': 'All sources failed',
'run': 'unknown',
}
return False, {
'package_name': package_name,
'source': last_error['source'],
'state': 'failed',
'run': last_error.get('run', 'unknown'),
'error_code': last_error['code'],
'details': last_error['message'],
'errors': _build_errors_json(errors)
}
def download_app(platform, package_name, **kwargs):
"""
Unified interface for downloading apps.
Args:
platform (str): 'google_play' or 'local'
package_name (str): The package name of the app (e.g., com.example.app)
**kwargs: Additional arguments depending on platform:
- app_name (str): Name of the app (useful for search in APKPure)
- country (str): Country code (useful for Google Play)
- local_path (str): Custom path for local import
- target_account (str): Target Google account email for Google Play
Returns:
tuple: (success (bool), message (str))
"""
logger.info(f"Requesting download: {package_name} from {platform}")
try:
downloader = _get_downloader(platform)
if downloader:
return downloader.start(package_name, **kwargs)
else:
msg = f"Unknown platform: {platform}"
logger.error(msg)
return False, msg
except ADBException:
raise
except Exception as e:
logger.error(f"Error during download process: {e}")
return False, str(e)
def stop_services():
"""Stop all downloader services/connections"""
logger.info("Stopping all downloader services...")
for platform, downloader in _downloaders.items():
try:
if hasattr(downloader, 'stop'):
downloader.stop()
except Exception as e:
logger.error(f"Error stopping service for {platform}: {e}")
# Optional: Clear the instances if you want to force re-initialization next time
# _downloaders.clear()
def reset_runtime_state():
"""清空下载链缓存对象,避免模拟器重启后复用旧连接。"""
global _adb_helper
stop_services()
_downloaders.clear()
_adb_helper = None
apks_export_import.reset_runtime_state()
if __name__ == "__main__":
# Example usage for testing download chain
# This will try Google Play -> Local
package_name = 'com.aakenya.testapp'
# Optional args
config = _load_autool_config()
kwargs = {
'country': 'us',
'app_name': 'IKEA',
'local_path': str(config["LOCAL_APK_PATH"]).strip(),
}
logger.info(f"Starting download chain test for {package_name}")
success, result = download_app_chain(package_name, **kwargs)
print(f"\nFinal Chain Result: {success}")
print(f"Details: {result}")
stop_services()