/** * Copyright 2026 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { Injectable, signal, computed, inject, DestroyRef, NgZone } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable, finalize, shareReplay, tap } from 'rxjs'; import { AdbServerConnectionResponse, AdbServerStatus, SystemReadinessReport, DeviceInfo, ProbeResult, EmulatorLaunchState, EmulatorLaunchStage } from '../core/models/system.model'; export function formatAdbRestartFeedback(result?: { success?: boolean; skipped?: boolean; message?: string; automatic_connections?: { success?: boolean; pending?: boolean }[]; }): string { if (!result?.success) return result?.message || '重启失败'; if (result.skipped) return '设备已刷新 ✓'; const connections = result.automatic_connections ?? []; const connected = connections.filter(item => item.success).length; const pending = connections.filter(item => item.pending).length; const failed = connections.filter(item => !item.success && !item.pending).length; return `ADB 已重启${connected ? `,已连接 ${connected} 台设备` : ''}${pending ? `,${pending} 台设备连接中,将自动重试` : ''}${failed ? `,${failed} 项连接失败,请查看诊断` : ' ✓'}`; } @Injectable({ providedIn: 'root' }) export class SystemService { private http = inject(HttpClient); // Core reactive signals public readinessReport = signal(null); public hasReadinessReport = computed(() => this.readinessReport() !== null); public isLoading = signal(false); public isRestartingAdb = signal(false); public adbServerStatus = signal(null); public isRemoteAdbServer = computed( () => this.adbServerStatus()?.endpoint.mode === 'remote' ); public launchingAvd = signal(null); public emulatorLaunchState = signal(null); public isEmulatorLaunching = computed(() => { const s = this.emulatorLaunchState()?.status; return s === 'starting' || s === 'waiting_for_adb' || s === 'booting'; }); public lastCheckedTime = signal(null); private emulatorPollTimer: any = null; // Probe lookups public pythonProbe = computed(() => this.probes().find(p => p.id === 'python_runtime') || null); public configProbe = computed(() => this.probes().find(p => p.id === 'system_config') || null); public toolchainProbe = computed(() => this.probes().find(p => p.id === 'toolchain') || null); public adbProbe = computed(() => this.probes().find(p => p.id === 'android_adb') || null); public llmProbe = computed(() => this.probes().find(p => p.id === 'openai_api_key' || p.id === 'llm_api_key') || null); public openaiProbe = computed(() => this.llmProbe()); public ocrProbe = computed(() => this.probes().find(p => p.id === 'vision_ocr_key' || p.id === 'ocr_api_key') || null); // Grouped readiness helpers for the 3-step onboarding flow public isEnvironmentReady = computed(() => { const py = this.pythonProbe(); const cfg = this.configProbe(); const adb = this.adbProbe(); const tc = this.toolchainProbe(); const pyOk = py?.status === 'pass'; const cfgOk = cfg?.status === 'pass'; const adbInstalled = adb?.metadata?.['installed'] ?? (adb?.status !== 'fail'); const tcOk = tc?.status === 'pass' || tc?.is_blocker === false; return pyOk && cfgOk && adbInstalled && tcOk; }); public isSkipCredentialsCheck = signal(false); public isCredentialsReady = computed(() => { if (this.isSkipCredentialsCheck()) { return true; } const llm = this.llmProbe(); return llm?.status === 'pass'; }); public isDeviceReady = computed(() => { const adb = this.adbProbe(); return adb?.status === 'pass'; }); // Step-level counting matching the 3-step onboarding guide public totalStepCount = computed(() => 3); public passedStepCount = computed(() => { let count = 0; if (this.isEnvironmentReady()) count++; if (this.isCredentialsReady()) count++; if (this.isDeviceReady()) count++; return count; }); // Computed helper signals public isReady = computed(() => { return this.isEnvironmentReady() && this.isCredentialsReady() && this.isDeviceReady(); }); public blockerCount = computed(() => this.totalStepCount()); public passedBlockerCount = computed(() => this.passedStepCount()); public probes = computed(() => this.readinessReport()?.probes ?? []); public activeDevice = computed(() => this.readinessReport()?.active_device ?? null); public osType = computed<'linux' | 'darwin' | 'windows'>(() => { const raw = this.readinessReport()?.os_type; if (raw === 'windows' || raw === 'win32') return 'windows'; if (raw === 'darwin' || raw === 'macos' || raw === 'mac') return 'darwin'; return 'linux'; }); public skipCredentialsCheck(): void { this.isSkipCredentialsCheck.set(true); } public setSkipCredentialsCheck(skip: boolean): void { this.isSkipCredentialsCheck.set(skip); } // Device list from metadata public connectedDevices = computed(() => { const meta = this.adbProbe()?.metadata; if (meta && Array.isArray(meta['devices'])) { return meta['devices'] as DeviceInfo[]; } return []; }); // Installed local AVD emulators from metadata public installedAvds = computed(() => { const meta = this.adbProbe()?.metadata; if (meta && Array.isArray(meta['installed_avds'])) { return meta['installed_avds'] as string[]; } return []; }); public emulatorPath = computed(() => { return (this.adbProbe()?.metadata?.['emulator_path'] as string) || null; }); public isEmulatorInPath = computed(() => { return (this.adbProbe()?.metadata?.['is_emulator_in_path'] as boolean) ?? true; }); private pollingTimer: any = null; private readinessRequest$: Observable | null = null; private zone = inject(NgZone); private lastAppliedReportJson: string | null = null; private onVisibilityChange = () => { if (typeof document !== 'undefined' && !document.hidden) { this.fetchReadiness(true).subscribe({ error: () => {} }); } }; constructor() { this.startAutoPolling(3000); if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', this.onVisibilityChange); } inject(DestroyRef).onDestroy(() => { this.stopAutoPolling(); this.stopEmulatorStatusPolling(); if (typeof document !== 'undefined') { document.removeEventListener('visibilitychange', this.onVisibilityChange); } }); } /** * Start periodic auto-polling for system readiness changes. The interval * runs outside the Angular zone (a tick alone must not trigger change * detection) and pauses while the tab is hidden; a visibilitychange listener * refreshes immediately when the tab becomes visible again. */ public startAutoPolling(intervalMs: number = 3000): void { if (this.pollingTimer) { clearInterval(this.pollingTimer); } this.zone.runOutsideAngular(() => { this.pollingTimer = setInterval(() => { if (typeof document !== 'undefined' && document.hidden) { return; } // Perform silent background check without disturbing UI loading state this.fetchReadiness(true).subscribe({ error: () => {} // Silent catch }); }, intervalMs); }); } /** * Stop auto-polling */ public stopAutoPolling(): void { if (this.pollingTimer) { clearInterval(this.pollingTimer); this.pollingTimer = null; } } /** * Fetch latest system readiness report from backend * @param silent If true, updates signals silently without triggering global isLoading spinner */ public fetchReadiness( silent: boolean = false, forceRefresh: boolean = false ): Observable { if (!silent) { this.isLoading.set(true); } // Share one request across initialization, focus events, polling, and manual // refreshes. This prevents a slow probe from creating an unbounded queue. if (this.readinessRequest$) { return this.readinessRequest$; } const request$ = this.http.get('/api/system/readiness', { params: forceRefresh ? { force: true } : {} }).pipe( tap({ next: (report) => { this.applyReadinessReport(report); }, error: (err) => { console.error('Failed to fetch system readiness:', err); } }), finalize(() => { if (this.readinessRequest$ === request$) { this.readinessRequest$ = null; } this.isLoading.set(false); }), shareReplay({ bufferSize: 1, refCount: false }) ); this.readinessRequest$ = request$; return request$; } private applyReadinessReport(report: SystemReadinessReport): void { const current = this.readinessReport(); if (current && report.timestamp < current.timestamp) { return; } this.lastCheckedTime.set(new Date(report.timestamp * 1000)); // A poll usually returns an identical report; setting a new object anyway // would cascade through every probe computed for no visible change. const serialized = JSON.stringify({ ...report, timestamp: 0 }); if (serialized === this.lastAppliedReportJson) { return; } this.lastAppliedReportJson = serialized; this.readinessReport.set(report); } /** * Fetch current emulator launch progress state snapshot */ public fetchEmulatorStatus(): Observable { return this.http.get('/api/system/emulator/status').pipe( tap({ next: (state) => { this.emulatorLaunchState.set(state); if (state.status === 'ready') { this.launchingAvd.set(null); this.stopEmulatorStatusPolling(); this.fetchReadiness().subscribe(); } else if (state.status === 'failed' || state.status === 'stopped' || state.status === 'idle') { this.launchingAvd.set(null); this.stopEmulatorStatusPolling(); } else { if (state.avd_name) { this.launchingAvd.set(state.avd_name); } } }, error: (err) => { console.error('Failed to fetch emulator status:', err); } }) ); } /** * Start 1s interval polling for emulator boot progression */ public startEmulatorStatusPolling(): void { if (this.emulatorPollTimer) { clearInterval(this.emulatorPollTimer); } this.emulatorPollTimer = setInterval(() => { this.fetchEmulatorStatus().subscribe({ error: () => {} }); }, 1000); } /** * Stop polling for emulator boot status */ public stopEmulatorStatusPolling(): void { if (this.emulatorPollTimer) { clearInterval(this.emulatorPollTimer); this.emulatorPollTimer = null; } } /** * Launch a local AVD emulator in the background and continuously track boot progress */ public launchEmulator(avdName: string): Observable { this.launchingAvd.set(avdName); this.emulatorLaunchState.set({ avd_name: avdName, status: 'starting', pid: null, serial: null, stage_message: "正在启动模拟器进程…", progress_percent: 15, started_at: Date.now() / 1000, elapsed_seconds: 0, error: null, logs: [`Initiating launch for AVD: ${avdName}...`], can_retry: true, }); return this.http.post('/api/system/emulator/launch', { avd_name: avdName }).pipe( tap({ next: (state) => { this.emulatorLaunchState.set(state); if (state.status === 'failed') { this.launchingAvd.set(null); } else { this.startEmulatorStatusPolling(); } }, error: (err) => { console.error('Failed to launch emulator:', err); this.launchingAvd.set(null); const errorMsg = err?.error?.detail || err?.message || "启动模拟器进程失败。"; this.emulatorLaunchState.set({ avd_name: avdName, status: 'failed', pid: null, serial: null, stage_message: "启动失败。", progress_percent: 0, started_at: null, elapsed_seconds: 0, error: errorMsg, logs: [errorMsg], can_retry: true, }); } }) ); } /** * Terminate running emulator */ public stopEmulator(): Observable { return this.http.post('/api/system/emulator/stop', {}).pipe( tap({ next: () => { this.launchingAvd.set(null); this.stopEmulatorStatusPolling(); this.fetchEmulatorStatus().subscribe(); this.fetchReadiness().subscribe(); } }) ); } /** * Clear and dismiss emulator launch tracker state */ public dismissEmulatorStatus(): Observable { return this.http.post('/api/system/emulator/dismiss', {}).pipe( tap({ next: () => { this.emulatorLaunchState.set(null); this.launchingAvd.set(null); this.stopEmulatorStatusPolling(); } }) ); } /** * Restart local ADB server and refresh readiness state */ public restartAdb(): Observable { this.isRestartingAdb.set(true); return this.http.post('/api/system/adb/restart', {}).pipe( tap({ next: (res) => { if (res?.report) { this.applyReadinessReport(res.report); } this.isRestartingAdb.set(false); }, error: (err) => { console.error('Failed to restart ADB server:', err); this.isRestartingAdb.set(false); } }) ); } /** * Connect to an Android device over Wi-Fi */ public connectWirelessAdb(host: string, port: number = 5555): Observable { return this.http.post('/api/system/adb/connect', { host, port }).pipe( tap({ next: (res) => { if (res?.report) { this.applyReadinessReport(res.report); } } }) ); } /** * Fetch the process-wide ADB server endpoint used by Artemis. */ public fetchAdbServerStatus(): Observable { return this.http.get('/api/system/adb/server').pipe( tap(status => this.adbServerStatus.set(status)) ); } /** * Test an ADB server endpoint without changing the active connection. */ public probeAdbServer( host: string, port: number ): Observable { return this.http.post( '/api/system/adb/server/probe', { host, port, persist: false } ); } /** * Validate and activate an ADB server endpoint. */ public connectAdbServer( host: string, port: number, persist: boolean = true ): Observable { return this.http.post( '/api/system/adb/server/connect', { host, port, persist } ).pipe( tap(response => { if (response.connection_result.success) { this.adbServerStatus.set({ endpoint: response.connection_result.endpoint }); if (response.report) { this.applyReadinessReport(response.report); } } }) ); } /** * Switch Artemis back to the standard local ADB server. */ public useLocalAdbServer(persist: boolean = true): Observable { return this.http.post( '/api/system/adb/server/local', {}, { params: { persist } } ).pipe( tap(response => { this.adbServerStatus.set({ endpoint: response.connection_result.endpoint }); if (response.report) { this.applyReadinessReport(response.report); } }) ); } /** * Select a specific connected device serial as active target */ public selectDevice(serial: string): Observable { this.isLoading.set(true); return this.http.post('/api/system/devices/select', { serial }).pipe( tap({ next: (res) => { if (res?.report) { this.applyReadinessReport(res.report); } this.isLoading.set(false); }, error: (err) => { console.error('Failed to select active device:', err); this.isLoading.set(false); } }) ); } public currentApiKey = computed(() => { return (this.llmProbe()?.metadata?.['current_key'] as string) || ''; }); public apiKeysMap = computed>(() => { return (this.llmProbe()?.metadata?.['api_keys'] as Record) || {}; }); public modelConfigEnv = signal(null); /** * Fetch current model configuration (artemis.jsonc) and environment (.env) status */ public fetchModelConfigEnv(): Observable { return this.http.get('/api/system/model-config-env').pipe( tap({ next: (data) => { this.modelConfigEnv.set(data); }, error: (err) => { console.error('Failed to fetch model config and env:', err); } }) ); } /** * Test and verify an API key against live provider endpoint without persisting */ public testApiKey(provider: string, apiKey: string, baseUrl?: string): Observable<{ valid: boolean; provider: string; message: string }> { return this.http.post<{ valid: boolean; provider: string; message: string }>('/api/system/credentials/test', { provider, api_key: apiKey, base_url: baseUrl }); } /** * Update, verify, and configure API key for an LLM provider or Vision OCR */ public updateApiKey(provider: string, apiKey: string, persistToEnv: boolean = true): Observable { return this.http.post('/api/system/credentials', { provider, api_key: apiKey, persist_to_env: persistToEnv }).pipe( tap({ next: (res) => { if (res?.report) { this.applyReadinessReport(res.report); } // Refresh model config & env after updating key this.fetchModelConfigEnv().subscribe(); }, error: (err) => { console.error(`Failed to update credentials for ${provider}:`, err); } }) ); } } export interface ModelConfigEnvResponse { config_path: string; config_filename: string; config_content: string; default_model: { provider?: string; model?: string; thinking_level?: string; fallback?: { provider?: string; model?: string; thinking_level?: string; }; }; presets: Record; env_path: string; env_filename: string; env_vars: Array<{ name: string; provider: string; is_set: boolean; preview: string | null; description: string; }>; }