559 lines
20 KiB
Python
559 lines
20 KiB
Python
# -*- 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()
|