feat: integrate N7Cockpit remote mode control
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigPath = '',
|
||||
[ValidateSet('debug', 'release')]
|
||||
[string]$Variant = 'debug',
|
||||
[switch]$AllowHardware
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
if (-not $ConfigPath) {
|
||||
$ConfigPath = Join-Path $projectRoot 'config\migration.psd1'
|
||||
}
|
||||
if (-not [System.IO.Path]::IsPathRooted($ConfigPath)) {
|
||||
$ConfigPath = Join-Path $projectRoot $ConfigPath
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $ConfigPath)) {
|
||||
throw "Migration config does not exist: $ConfigPath"
|
||||
}
|
||||
|
||||
$config = Import-PowerShellDataFile -LiteralPath $ConfigPath
|
||||
$required = @(
|
||||
'ServerAUrl',
|
||||
'AndroidServerAUrl',
|
||||
'VehicleId',
|
||||
'HardwareClientId',
|
||||
'ExecutionTarget',
|
||||
'TokenFile'
|
||||
)
|
||||
foreach ($key in $required) {
|
||||
if (-not $config.ContainsKey($key) -or -not ([string]$config[$key]).Trim()) {
|
||||
throw "Migration config is missing: $key"
|
||||
}
|
||||
}
|
||||
|
||||
$serverAUrl = ([string]$config.ServerAUrl).TrimEnd('/')
|
||||
$androidServerAUrl = ([string]$config.AndroidServerAUrl).TrimEnd('/')
|
||||
$vehicleId = ([string]$config.VehicleId).Trim()
|
||||
$hardwareClientId = ([string]$config.HardwareClientId).Trim()
|
||||
$executionTarget = ([string]$config.ExecutionTarget).Trim().ToLowerInvariant()
|
||||
if ($executionTarget -notin @('dry_run', 'simulated_hardware', 'hardware')) {
|
||||
throw "Unsupported ExecutionTarget: $executionTarget"
|
||||
}
|
||||
if ($executionTarget -eq 'hardware' -and -not $AllowHardware) {
|
||||
throw 'Safety interlock: hardware builds require the explicit -AllowHardware switch.'
|
||||
}
|
||||
|
||||
$tokenPath = [string]$config.TokenFile
|
||||
if (-not [System.IO.Path]::IsPathRooted($tokenPath)) {
|
||||
$tokenPath = Join-Path $projectRoot $tokenPath
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $tokenPath)) {
|
||||
throw "Device token file does not exist: $tokenPath"
|
||||
}
|
||||
$deviceToken = (Get-Content -LiteralPath $tokenPath -Raw).Trim()
|
||||
if ($deviceToken.Length -lt 32) {
|
||||
throw 'The device token must contain at least 32 characters.'
|
||||
}
|
||||
|
||||
$javaHomeCandidates = @(@(
|
||||
[string]$config.JavaHome,
|
||||
$env:JAVA_HOME,
|
||||
'C:\Program Files\Android\Android Studio\jbr'
|
||||
) | Where-Object { $_ -and (Test-Path -LiteralPath $_) })
|
||||
if (-not $javaHomeCandidates) {
|
||||
throw 'JDK 17 was not found. Set JavaHome in config or JAVA_HOME.'
|
||||
}
|
||||
$javaHome = $javaHomeCandidates[0]
|
||||
$javaExe = Join-Path $javaHome 'bin\java.exe'
|
||||
if (-not (Test-Path -LiteralPath $javaExe)) {
|
||||
throw "java.exe does not exist under JavaHome: $javaHome"
|
||||
}
|
||||
$savedErrorAction = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$javaVersion = (& $javaExe -version 2>&1 | Out-String)
|
||||
$ErrorActionPreference = $savedErrorAction
|
||||
$javaMajor = if ($javaVersion -match 'version "(?<major>\d+)') {
|
||||
[int]$Matches.major
|
||||
} else {
|
||||
0
|
||||
}
|
||||
if ($javaMajor -lt 17) {
|
||||
throw "JDK 17 or newer is required. Detected: $($javaVersion.Trim())"
|
||||
}
|
||||
|
||||
$sdkCandidates = @(@(
|
||||
[string]$config.AndroidSdk,
|
||||
$env:ANDROID_SDK_ROOT,
|
||||
$env:ANDROID_HOME,
|
||||
(Join-Path $env:LOCALAPPDATA 'Android\Sdk')
|
||||
) | Where-Object { $_ -and (Test-Path -LiteralPath $_) })
|
||||
if (-not $sdkCandidates) {
|
||||
throw 'Android SDK was not found. Set AndroidSdk, ANDROID_SDK_ROOT, or ANDROID_HOME.'
|
||||
}
|
||||
$androidSdk = $sdkCandidates[0]
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $androidSdk 'platforms\android-35'))) {
|
||||
throw "Android SDK platform 35 is missing under: $androidSdk"
|
||||
}
|
||||
|
||||
$headers = @{ Authorization = "Bearer $deviceToken" }
|
||||
$catalogUri = "$serverAUrl/api/v1/vehicles/$vehicleId/modes"
|
||||
try {
|
||||
$catalog = Invoke-RestMethod -Uri $catalogUri -Headers $headers -TimeoutSec 10
|
||||
} catch {
|
||||
throw "Server A preflight failed at $catalogUri : $($_.Exception.Message)"
|
||||
}
|
||||
$presetCount = @($catalog.modes | Where-Object { $_.origin -eq 'preset' }).Count
|
||||
if ($presetCount -lt 1) {
|
||||
throw 'Server A returned no repository preset modes.'
|
||||
}
|
||||
|
||||
$oldJavaHome = $env:JAVA_HOME
|
||||
$oldAndroidSdkRoot = $env:ANDROID_SDK_ROOT
|
||||
$oldAndroidHome = $env:ANDROID_HOME
|
||||
$oldBaseUrl = $env:COGNITIVE_AGENT_BASE_URL
|
||||
$oldDeviceToken = $env:COGNITIVE_AGENT_DEVICE_API_TOKEN
|
||||
try {
|
||||
$env:JAVA_HOME = $javaHome
|
||||
$env:ANDROID_SDK_ROOT = $androidSdk
|
||||
$env:ANDROID_HOME = $androidSdk
|
||||
$env:COGNITIVE_AGENT_BASE_URL = $androidServerAUrl
|
||||
$env:COGNITIVE_AGENT_DEVICE_API_TOKEN = $deviceToken
|
||||
|
||||
$gradle = ([string]$config.GradleExecutable).Trim()
|
||||
if (-not $gradle) { $gradle = Join-Path $projectRoot 'gradlew.bat' }
|
||||
if (-not [System.IO.Path]::IsPathRooted($gradle)) {
|
||||
$gradle = Join-Path $projectRoot $gradle
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $gradle)) {
|
||||
throw "Gradle executable does not exist: $gradle"
|
||||
}
|
||||
$variantTask = if ($Variant -eq 'release') { 'assembleRelease' } else { 'assembleDebug' }
|
||||
$testTask = if ($Variant -eq 'release') { $null } else { 'testDebugUnitTest' }
|
||||
$arguments = @(
|
||||
'--no-daemon',
|
||||
"-PorchestratorVehicleId=$vehicleId",
|
||||
"-PorchestratorHardwareClientId=$hardwareClientId",
|
||||
"-PorchestratorExecutionTarget=$executionTarget"
|
||||
)
|
||||
if ($testTask) { $arguments += $testTask }
|
||||
$arguments += $variantTask
|
||||
Push-Location $projectRoot
|
||||
try {
|
||||
& $gradle @arguments
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Gradle build failed.' }
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
} finally {
|
||||
$env:JAVA_HOME = $oldJavaHome
|
||||
$env:ANDROID_SDK_ROOT = $oldAndroidSdkRoot
|
||||
$env:ANDROID_HOME = $oldAndroidHome
|
||||
$env:COGNITIVE_AGENT_BASE_URL = $oldBaseUrl
|
||||
$env:COGNITIVE_AGENT_DEVICE_API_TOKEN = $oldDeviceToken
|
||||
$deviceToken = $null
|
||||
}
|
||||
|
||||
$sourceApk = Join-Path $projectRoot "app\build\outputs\apk\$Variant\app-$Variant.apk"
|
||||
if (-not (Test-Path -LiteralPath $sourceApk)) {
|
||||
throw "Built APK was not found: $sourceApk"
|
||||
}
|
||||
$distDirectory = Join-Path $projectRoot 'dist'
|
||||
New-Item -ItemType Directory -Force -Path $distDirectory | Out-Null
|
||||
$destinationApk = Join-Path $distDirectory "N7Cockpit-remote-$Variant.apk"
|
||||
Copy-Item -LiteralPath $sourceApk -Destination $destinationApk -Force
|
||||
$apkHash = (Get-FileHash -LiteralPath $destinationApk -Algorithm SHA256).Hash
|
||||
$buildInfo = [ordered]@{
|
||||
generated_at = (Get-Date).ToUniversalTime().ToString('o')
|
||||
variant = $Variant
|
||||
package = if ($Variant -eq 'debug') {
|
||||
'com.seeker.n7cockpit.remote'
|
||||
} else {
|
||||
'com.seeker.n7cockpit'
|
||||
}
|
||||
server_a_url_from_server_b = $serverAUrl
|
||||
android_server_a_url = $androidServerAUrl
|
||||
vehicle_id = $vehicleId
|
||||
hardware_client_id = $hardwareClientId
|
||||
execution_target = $executionTarget
|
||||
preset_count = $presetCount
|
||||
apk_sha256 = $apkHash
|
||||
}
|
||||
$buildInfo | ConvertTo-Json | Set-Content -LiteralPath (
|
||||
Join-Path $distDirectory 'build-info.json'
|
||||
) -Encoding UTF8
|
||||
|
||||
Write-Host ''
|
||||
Write-Host 'BUILD PASSED'
|
||||
Write-Host "APK: $destinationApk"
|
||||
Write-Host "SHA256: $apkHash"
|
||||
Write-Host "Execution target: $executionTarget"
|
||||
Write-Host 'The token is embedded in the APK. Keep the APK private and rotate the token if leaked.'
|
||||
@@ -0,0 +1,31 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigPath = ''
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
if (-not $ConfigPath) { $ConfigPath = Join-Path $projectRoot 'config\migration.psd1' }
|
||||
if (-not [System.IO.Path]::IsPathRooted($ConfigPath)) {
|
||||
$ConfigPath = Join-Path $projectRoot $ConfigPath
|
||||
}
|
||||
$config = Import-PowerShellDataFile -LiteralPath $ConfigPath
|
||||
$tokenPath = [string]$config.TokenFile
|
||||
if (-not [System.IO.Path]::IsPathRooted($tokenPath)) {
|
||||
$tokenPath = Join-Path $projectRoot $tokenPath
|
||||
}
|
||||
$token = (Get-Content -LiteralPath $tokenPath -Raw).Trim()
|
||||
$baseUrl = ([string]$config.ServerAUrl).TrimEnd('/')
|
||||
$vehicleId = ([string]$config.VehicleId).Trim()
|
||||
$headers = @{ Authorization = "Bearer $token" }
|
||||
$catalog = Invoke-RestMethod -Uri "$baseUrl/api/v1/vehicles/$vehicleId/modes" `
|
||||
-Headers $headers -TimeoutSec 10
|
||||
$events = Invoke-RestMethod -Uri "$baseUrl/api/v1/vehicles/$vehicleId/events?after=0" `
|
||||
-Headers $headers -TimeoutSec 10
|
||||
[pscustomobject]@{
|
||||
Passed = $true
|
||||
ServerA = $baseUrl
|
||||
VehicleId = $catalog.vehicle_id
|
||||
PresetModes = @($catalog.modes | Where-Object { $_.origin -eq 'preset' }).Count
|
||||
EventSequence = $events.next_sequence
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$OutputDirectory = ''
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
if (-not $OutputDirectory) {
|
||||
$OutputDirectory = Join-Path $projectRoot 'dist'
|
||||
}
|
||||
if (-not [System.IO.Path]::IsPathRooted($OutputDirectory)) {
|
||||
$OutputDirectory = Join-Path $projectRoot $OutputDirectory
|
||||
}
|
||||
$outputRoot = [System.IO.Path]::GetFullPath($OutputDirectory)
|
||||
$projectFull = [System.IO.Path]::GetFullPath($projectRoot)
|
||||
if (-not $outputRoot.StartsWith($projectFull, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw 'OutputDirectory must stay inside the N7Cockpit project.'
|
||||
}
|
||||
|
||||
$version = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$stagingRoot = Join-Path $outputRoot "portable-staging-$version"
|
||||
$packageRoot = Join-Path $stagingRoot 'N7Cockpit'
|
||||
$zipPath = Join-Path $outputRoot "N7Cockpit-portable-$version.zip"
|
||||
New-Item -ItemType Directory -Force -Path $packageRoot | Out-Null
|
||||
|
||||
$rootFiles = @(
|
||||
'.gitignore',
|
||||
'build.gradle',
|
||||
'gradle.properties',
|
||||
'gradlew',
|
||||
'gradlew.bat',
|
||||
'README.md',
|
||||
'MIGRATION_GUIDE.md',
|
||||
'settings.gradle'
|
||||
)
|
||||
foreach ($relative in $rootFiles) {
|
||||
Copy-Item -LiteralPath (Join-Path $projectRoot $relative) `
|
||||
-Destination (Join-Path $packageRoot $relative)
|
||||
}
|
||||
|
||||
$directoryPairs = @(
|
||||
@('gradle', 'gradle'),
|
||||
@('app\src', 'app\src')
|
||||
)
|
||||
foreach ($pair in $directoryPairs) {
|
||||
$destination = Join-Path $packageRoot $pair[1]
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $destination) | Out-Null
|
||||
Copy-Item -LiteralPath (Join-Path $projectRoot $pair[0]) `
|
||||
-Destination $destination -Recurse
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $packageRoot 'app') | Out-Null
|
||||
Copy-Item -LiteralPath (Join-Path $projectRoot 'app\build.gradle') `
|
||||
-Destination (Join-Path $packageRoot 'app\build.gradle')
|
||||
Copy-Item -LiteralPath (Join-Path $projectRoot 'app\proguard-rules.pro') `
|
||||
-Destination (Join-Path $packageRoot 'app\proguard-rules.pro')
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $packageRoot 'scripts') | Out-Null
|
||||
$portableScripts = @(
|
||||
'initialize-migration.ps1',
|
||||
'verify-migration-environment.ps1',
|
||||
'check-server-a.ps1',
|
||||
'test-server-a-dry-run.ps1',
|
||||
'build-migrated-apk.ps1',
|
||||
'install-migrated-apk.ps1',
|
||||
'create-portable-package.ps1'
|
||||
)
|
||||
foreach ($script in $portableScripts) {
|
||||
Copy-Item -LiteralPath (Join-Path $projectRoot "scripts\$script") `
|
||||
-Destination (Join-Path $packageRoot "scripts\$script")
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $packageRoot 'config') | Out-Null
|
||||
Copy-Item -LiteralPath (Join-Path $projectRoot 'config\migration.example.psd1') `
|
||||
-Destination (Join-Path $packageRoot 'config\migration.example.psd1')
|
||||
|
||||
$forbiddenNames = @(
|
||||
'migration.psd1',
|
||||
'device-token.txt',
|
||||
'local.properties'
|
||||
)
|
||||
$forbidden = Get-ChildItem -Recurse -Force -File -LiteralPath $packageRoot |
|
||||
Where-Object { $_.Name -in $forbiddenNames -or $_.Extension -in @('.jks', '.p12') }
|
||||
if ($forbidden) {
|
||||
throw "Portable package contains forbidden files: $($forbidden.FullName -join ', ')"
|
||||
}
|
||||
|
||||
$files = Get-ChildItem -Recurse -Force -File -LiteralPath $packageRoot |
|
||||
Sort-Object FullName
|
||||
$manifest = [ordered]@{
|
||||
package = 'N7Cockpit portable source package'
|
||||
generated_at = (Get-Date).ToUniversalTime().ToString('o')
|
||||
contains_credentials = $false
|
||||
default_execution_target = 'dry_run'
|
||||
file_count = $files.Count
|
||||
files = @($files | ForEach-Object {
|
||||
[ordered]@{
|
||||
path = $_.FullName.Substring($packageRoot.Length + 1).Replace('\', '/')
|
||||
size = $_.Length
|
||||
sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash
|
||||
}
|
||||
})
|
||||
}
|
||||
$manifest | ConvertTo-Json -Depth 5 | Set-Content `
|
||||
-LiteralPath (Join-Path $packageRoot 'PACKAGE-MANIFEST.json') -Encoding UTF8
|
||||
|
||||
Compress-Archive -LiteralPath $packageRoot -DestinationPath $zipPath `
|
||||
-CompressionLevel Optimal
|
||||
$zipHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash
|
||||
$checksumPath = "$zipPath.sha256"
|
||||
"$zipHash $([System.IO.Path]::GetFileName($zipPath))" | Set-Content `
|
||||
-LiteralPath $checksumPath -Encoding ASCII
|
||||
|
||||
$resolvedStaging = [System.IO.Path]::GetFullPath($stagingRoot)
|
||||
if (-not $resolvedStaging.StartsWith($outputRoot, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw 'Refusing to remove staging directory outside output root.'
|
||||
}
|
||||
Remove-Item -LiteralPath $resolvedStaging -Recurse -Force
|
||||
|
||||
[pscustomobject]@{
|
||||
Passed = $true
|
||||
Zip = $zipPath
|
||||
Sha256 = $zipHash
|
||||
ChecksumFile = $checksumPath
|
||||
CredentialFilesIncluded = $false
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
[CmdletBinding()]
|
||||
param()
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
$configDirectory = Join-Path $projectRoot 'config'
|
||||
$secretDirectory = Join-Path $projectRoot 'secrets'
|
||||
$example = Join-Path $configDirectory 'migration.example.psd1'
|
||||
$config = Join-Path $configDirectory 'migration.psd1'
|
||||
$token = Join-Path $secretDirectory 'device-token.txt'
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $configDirectory, $secretDirectory | Out-Null
|
||||
if (-not (Test-Path -LiteralPath $config)) {
|
||||
Copy-Item -LiteralPath $example -Destination $config
|
||||
Write-Host "Created: $config"
|
||||
} else {
|
||||
Write-Host "Kept existing: $config"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $token)) {
|
||||
New-Item -ItemType File -Path $token | Out-Null
|
||||
Write-Host "Created empty token file: $token"
|
||||
} else {
|
||||
Write-Host "Kept existing: $token"
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host 'Next steps:'
|
||||
Write-Host '1. Edit config\migration.psd1.'
|
||||
Write-Host '2. Paste the server A device token into secrets\device-token.txt.'
|
||||
Write-Host '3. Run scripts\build-migrated-apk.ps1.'
|
||||
@@ -0,0 +1,56 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$AdbPath = '',
|
||||
[string]$ApkPath = '',
|
||||
[string]$Serial = '',
|
||||
[ValidateSet('debug', 'release')]
|
||||
[string]$Variant = 'debug'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
if (-not $AdbPath) {
|
||||
$sdkRoots = @(
|
||||
$env:ANDROID_SDK_ROOT,
|
||||
$env:ANDROID_HOME,
|
||||
(Join-Path $env:LOCALAPPDATA 'Android\Sdk')
|
||||
) | Where-Object { $_ }
|
||||
foreach ($sdkRoot in $sdkRoots) {
|
||||
$candidate = Join-Path $sdkRoot 'platform-tools\adb.exe'
|
||||
if (Test-Path -LiteralPath $candidate) { $AdbPath = $candidate; break }
|
||||
}
|
||||
}
|
||||
if (-not $AdbPath -or -not (Test-Path -LiteralPath $AdbPath)) {
|
||||
throw 'adb.exe was not found. Set -AdbPath or ANDROID_SDK_ROOT.'
|
||||
}
|
||||
if (-not $ApkPath) {
|
||||
$ApkPath = Join-Path $projectRoot "dist\N7Cockpit-remote-$Variant.apk"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $ApkPath)) {
|
||||
throw "APK does not exist: $ApkPath"
|
||||
}
|
||||
|
||||
$adbArgs = @()
|
||||
if ($Serial) { $adbArgs = @('-s', $Serial) }
|
||||
$online = @(& $AdbPath devices) | Where-Object { $_ -match "\tdevice$" }
|
||||
if (-not $Serial -and $online.Count -ne 1) {
|
||||
throw "Exactly one online Android device is required; found $($online.Count)."
|
||||
}
|
||||
& $AdbPath @adbArgs install -r $ApkPath
|
||||
if ($LASTEXITCODE -ne 0) { throw 'APK installation failed.' }
|
||||
|
||||
$package = if ($Variant -eq 'debug') {
|
||||
'com.seeker.n7cockpit.remote'
|
||||
} else {
|
||||
'com.seeker.n7cockpit'
|
||||
}
|
||||
$component = "$package/com.seeker.n7cockpit.MainActivity"
|
||||
& $AdbPath @adbArgs shell am force-stop $package | Out-Null
|
||||
$launch = & $AdbPath @adbArgs shell am start -W -n $component 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or ($launch -join "`n") -notmatch 'Status:\s+ok') {
|
||||
throw "APK installed but launch failed:`n$($launch -join "`n")"
|
||||
}
|
||||
|
||||
Write-Host 'INSTALL PASSED'
|
||||
Write-Host "Package: $package"
|
||||
Write-Host "Component: $component"
|
||||
@@ -0,0 +1,57 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$AdbPath = 'D:\Android\Sdk\platform-tools\adb.exe',
|
||||
[string]$PackageName = 'com.seeker.n7cockpit.remote',
|
||||
[string]$ActivityName = 'com.seeker.n7cockpit.MainActivity',
|
||||
[int]$TimeoutSeconds = 15
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $AdbPath)) {
|
||||
throw "adb does not exist: $AdbPath"
|
||||
}
|
||||
|
||||
$deviceLines = @(& $AdbPath devices) | Where-Object { $_ -match "\tdevice$" }
|
||||
if ($deviceLines.Count -ne 1) {
|
||||
throw "Exactly one online Android device is required; found $($deviceLines.Count)."
|
||||
}
|
||||
|
||||
$installed = (& $AdbPath shell pm list packages $PackageName | Out-String).Trim()
|
||||
if ($installed -notmatch [regex]::Escape($PackageName)) {
|
||||
throw "The remote debug app is not installed: $PackageName"
|
||||
}
|
||||
|
||||
$component = "$PackageName/$ActivityName"
|
||||
& $AdbPath shell am force-stop $PackageName | Out-Null
|
||||
$launchOutput = & $AdbPath shell am start -W -n $component 2>&1
|
||||
if ($LASTEXITCODE -ne 0 -or ($launchOutput -join "`n") -notmatch 'Status:\s+ok') {
|
||||
throw "Failed to launch app:`n$($launchOutput -join "`n")"
|
||||
}
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
$resumed = ''
|
||||
$pidValue = ''
|
||||
do {
|
||||
Start-Sleep -Milliseconds 500
|
||||
$pidValue = (& $AdbPath shell pidof $PackageName 2>$null | Out-String).Trim()
|
||||
$resumed = (& $AdbPath shell dumpsys activity activities |
|
||||
Select-String 'topResumedActivity' |
|
||||
Select-Object -First 1).Line
|
||||
} while ((-not $pidValue -or $resumed -notmatch [regex]::Escape($component)) -and
|
||||
(Get-Date) -lt $deadline)
|
||||
|
||||
if (-not $pidValue) {
|
||||
throw 'The app process was not found after launch.'
|
||||
}
|
||||
if ($resumed -notmatch [regex]::Escape($component)) {
|
||||
throw "The app process exists but its Activity is not foreground: $resumed"
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
Device = (([string]$deviceLines) -split "\t")[0]
|
||||
Package = $PackageName
|
||||
Component = $component
|
||||
Pid = $pidValue
|
||||
Foreground = $true
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$AdbPath = 'D:\Android\Sdk\platform-tools\adb.exe',
|
||||
[string]$ServerBaseUrl = 'http://127.0.0.1:8000',
|
||||
[string]$TokenFile = "$env:LOCALAPPDATA\Temp\n7-device-token.txt",
|
||||
[string]$PackageName = 'com.seeker.n7cockpit.remote',
|
||||
[string]$ModeId = 'baby_nap',
|
||||
[int]$TimeoutSeconds = 40,
|
||||
[string]$ArtifactDirectory = ''
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
if (-not $ArtifactDirectory) {
|
||||
$ArtifactDirectory = Join-Path $projectRoot 'artifacts\remote-link'
|
||||
}
|
||||
$buildConfig = Join-Path $projectRoot 'app\build\generated\source\buildConfig\debug\com\seeker\n7cockpit\BuildConfig.java'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $TokenFile)) {
|
||||
throw "The device token file does not exist: $TokenFile"
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $buildConfig)) {
|
||||
throw 'Debug BuildConfig was not found. Build the remote debug APK first.'
|
||||
}
|
||||
|
||||
$configText = Get-Content -LiteralPath $buildConfig -Raw -Encoding UTF8
|
||||
if ($configText -notmatch 'ORCHESTRATOR_EXECUTION_TARGET\s*=\s*"dry_run"') {
|
||||
throw 'Safety interlock: the APK execution target is not dry_run.'
|
||||
}
|
||||
if ($configText -notmatch 'ORCHESTRATOR_BASE_URL\s*=\s*"http://10\.0\.2\.2:8000"') {
|
||||
throw 'This test only permits an emulator APK targeting 10.0.2.2:8000.'
|
||||
}
|
||||
|
||||
$token = (Get-Content -LiteralPath $TokenFile -Raw).Trim()
|
||||
if (-not $token) {
|
||||
throw 'The device token is empty.'
|
||||
}
|
||||
$headers = @{ Authorization = "Bearer $token" }
|
||||
|
||||
$catalog = Invoke-RestMethod -Uri "$ServerBaseUrl/api/v1/vehicles/denza_n7/modes" `
|
||||
-Headers $headers -TimeoutSec 5
|
||||
$mode = @($catalog.modes | Where-Object { $_.id -eq $ModeId -and $_.origin -eq 'preset' })
|
||||
if ($mode.Count -ne 1) {
|
||||
throw "The preset is not unique in the server catalog: $ModeId"
|
||||
}
|
||||
$expectedModeName = [string]$mode[0].name
|
||||
|
||||
& (Join-Path $PSScriptRoot 'start-remote-app.ps1') -AdbPath $AdbPath `
|
||||
-PackageName $PackageName | Out-Null
|
||||
Start-Sleep -Seconds 1
|
||||
|
||||
# Standard 2560x1600 emulator: mode navigation, then the first preset button.
|
||||
& $AdbPath shell input tap 94 406
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $ArtifactDirectory | Out-Null
|
||||
& $AdbPath shell uiautomator dump /sdcard/n7-before-mode.xml | Out-Null
|
||||
& $AdbPath pull /sdcard/n7-before-mode.xml (Join-Path $ArtifactDirectory 'before-mode.xml') | Out-Null
|
||||
$beforeUi = Get-Content -LiteralPath (Join-Path $ArtifactDirectory 'before-mode.xml') `
|
||||
-Raw -Encoding UTF8
|
||||
if (-not $beforeUi.Contains('http://10.0.2.2:8000')) {
|
||||
throw 'The Android mode page did not render its server connection.'
|
||||
}
|
||||
if (-not $beforeUi.Contains($expectedModeName)) {
|
||||
throw "The Android mode page did not render the preset: $ModeId"
|
||||
}
|
||||
|
||||
$baseline = Invoke-RestMethod `
|
||||
-Uri "$ServerBaseUrl/api/v1/vehicles/denza_n7/events?after=0" `
|
||||
-Headers $headers -TimeoutSec 5
|
||||
$sequence = [long]$baseline.next_sequence
|
||||
|
||||
& $AdbPath shell input tap 1469 444
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
$observed = New-Object System.Collections.Generic.List[object]
|
||||
$terminal = $null
|
||||
do {
|
||||
Start-Sleep -Seconds 1
|
||||
$batch = Invoke-RestMethod `
|
||||
-Uri "$ServerBaseUrl/api/v1/vehicles/denza_n7/events?after=$sequence" `
|
||||
-Headers $headers -TimeoutSec 5
|
||||
foreach ($event in @($batch.events)) {
|
||||
$execution = $event.execution
|
||||
if (-not $execution -and $event.data) {
|
||||
$execution = $event.data.execution
|
||||
}
|
||||
if (-not $execution -or $execution.mode_id -ne $ModeId -or
|
||||
$execution.source -ne 'android') {
|
||||
continue
|
||||
}
|
||||
$observed.Add([pscustomobject]@{
|
||||
sequence = [long]$event.sequence
|
||||
execution_id = $execution.execution_id
|
||||
status = $execution.status
|
||||
target = $execution.execution_target
|
||||
})
|
||||
if ($execution.status -in @('succeeded', 'failed', 'cancelled')) {
|
||||
$terminal = $execution
|
||||
}
|
||||
}
|
||||
$sequence = [long]$batch.next_sequence
|
||||
} while (-not $terminal -and (Get-Date) -lt $deadline)
|
||||
|
||||
if (-not $terminal) {
|
||||
throw "No terminal mode event was received within $TimeoutSeconds seconds."
|
||||
}
|
||||
if ($terminal.execution_target -ne 'dry_run') {
|
||||
throw "Safety failure: server execution target was $($terminal.execution_target)."
|
||||
}
|
||||
if ($terminal.status -ne 'succeeded') {
|
||||
throw "Mode execution failed: $($terminal.status) $($terminal.error)"
|
||||
}
|
||||
|
||||
$statusPath = Join-Path $ArtifactDirectory 'after-execution.xml'
|
||||
$screenPath = Join-Path $ArtifactDirectory 'after-execution.png'
|
||||
Start-Sleep -Seconds 2
|
||||
& $AdbPath shell uiautomator dump /sdcard/n7-after-execution.xml | Out-Null
|
||||
& $AdbPath pull /sdcard/n7-after-execution.xml $statusPath | Out-Null
|
||||
& $AdbPath shell screencap -p /sdcard/n7-after-execution.png | Out-Null
|
||||
& $AdbPath pull /sdcard/n7-after-execution.png $screenPath | Out-Null
|
||||
$afterUi = Get-Content -LiteralPath $statusPath -Raw -Encoding UTF8
|
||||
|
||||
if (-not $afterUi.Contains($expectedModeName) -or -not $afterUi.Contains('100%')) {
|
||||
throw 'The server task succeeded, but Android did not render terminal progress.'
|
||||
}
|
||||
if (-not $afterUi.Contains($terminal.execution_id)) {
|
||||
throw 'The Android execution_id does not match the server execution_id.'
|
||||
}
|
||||
if (-not $afterUi.Contains('N7Cockpit')) {
|
||||
throw 'Android did not render the expected source identity.'
|
||||
}
|
||||
|
||||
$statuses = @($observed | Sort-Object sequence | Select-Object -ExpandProperty status)
|
||||
foreach ($required in @('queued', 'executing', 'succeeded')) {
|
||||
if ($required -notin $statuses) {
|
||||
throw "The event stream is missing state: $required"
|
||||
}
|
||||
}
|
||||
|
||||
[pscustomobject]@{
|
||||
Passed = $true
|
||||
SafetyTarget = $terminal.execution_target
|
||||
CanExecuted = $false
|
||||
ModeId = $terminal.mode_id
|
||||
ExecutionId = $terminal.execution_id
|
||||
EventStates = ($statuses -join ' -> ')
|
||||
AndroidStateSynchronized = $true
|
||||
Screenshot = $screenPath
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigPath = '',
|
||||
[string]$ModeId = 'baby_nap',
|
||||
[int]$TimeoutSeconds = 30
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
if (-not $ConfigPath) { $ConfigPath = Join-Path $projectRoot 'config\migration.psd1' }
|
||||
if (-not [System.IO.Path]::IsPathRooted($ConfigPath)) {
|
||||
$ConfigPath = Join-Path $projectRoot $ConfigPath
|
||||
}
|
||||
$config = Import-PowerShellDataFile -LiteralPath $ConfigPath
|
||||
if (([string]$config.ExecutionTarget).Trim().ToLowerInvariant() -ne 'dry_run') {
|
||||
throw 'Safety interlock: this smoke test requires ExecutionTarget=dry_run.'
|
||||
}
|
||||
$tokenPath = [string]$config.TokenFile
|
||||
if (-not [System.IO.Path]::IsPathRooted($tokenPath)) {
|
||||
$tokenPath = Join-Path $projectRoot $tokenPath
|
||||
}
|
||||
$token = (Get-Content -LiteralPath $tokenPath -Raw).Trim()
|
||||
$baseUrl = ([string]$config.ServerAUrl).TrimEnd('/')
|
||||
$vehicleId = ([string]$config.VehicleId).Trim()
|
||||
$headers = @{
|
||||
Authorization = "Bearer $token"
|
||||
'Content-Type' = 'application/json; charset=utf-8'
|
||||
'Idempotency-Key' = "server-b-smoke-$([guid]::NewGuid().ToString('N'))"
|
||||
}
|
||||
$payload = @{
|
||||
mode_id = $ModeId
|
||||
execution_target = 'dry_run'
|
||||
source = 'android'
|
||||
source_device_id = 'n7-server-b-smoke'
|
||||
} | ConvertTo-Json
|
||||
$accepted = Invoke-RestMethod `
|
||||
-Uri "$baseUrl/api/v1/vehicles/$vehicleId/mode-executions" `
|
||||
-Method Post -Headers $headers -Body $payload -TimeoutSec 10
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
|
||||
$terminal = $accepted
|
||||
do {
|
||||
if ($terminal.status -in @(
|
||||
'succeeded', 'partially_succeeded', 'failed', 'cancelled', 'timed_out'
|
||||
)) { break }
|
||||
Start-Sleep -Milliseconds 500
|
||||
$terminal = Invoke-RestMethod `
|
||||
-Uri "$baseUrl/api/v1/mode-executions/$($accepted.execution_id)" `
|
||||
-Headers @{ Authorization = "Bearer $token" } -TimeoutSec 10
|
||||
} while ((Get-Date) -lt $deadline)
|
||||
$token = $null
|
||||
if ($terminal.execution_target -ne 'dry_run') {
|
||||
throw "Safety failure: server used $($terminal.execution_target)."
|
||||
}
|
||||
if ($terminal.status -ne 'succeeded') {
|
||||
throw "Dry-run smoke test failed: $($terminal.status) $($terminal.message)"
|
||||
}
|
||||
[pscustomobject]@{
|
||||
Passed = $true
|
||||
CanExecuted = $false
|
||||
ExecutionTarget = $terminal.execution_target
|
||||
ExecutionId = $terminal.execution_id
|
||||
ModeId = $terminal.mode_id
|
||||
Status = $terminal.status
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigPath = ''
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
|
||||
if (-not $ConfigPath) { $ConfigPath = Join-Path $projectRoot 'config\migration.psd1' }
|
||||
if (-not [System.IO.Path]::IsPathRooted($ConfigPath)) {
|
||||
$ConfigPath = Join-Path $projectRoot $ConfigPath
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $ConfigPath)) {
|
||||
throw 'Run scripts\initialize-migration.ps1 and edit config\migration.psd1 first.'
|
||||
}
|
||||
$config = Import-PowerShellDataFile -LiteralPath $ConfigPath
|
||||
|
||||
$javaCandidates = @(
|
||||
[string]$config.JavaHome,
|
||||
$env:JAVA_HOME,
|
||||
'C:\Program Files\Android\Android Studio\jbr'
|
||||
) | Where-Object { $_ -and (Test-Path -LiteralPath $_) }
|
||||
$javaHome = $javaCandidates | Select-Object -First 1
|
||||
$javaOk = $false
|
||||
$javaSummary = 'not found'
|
||||
if ($javaHome) {
|
||||
$javaExe = Join-Path $javaHome 'bin\java.exe'
|
||||
if (Test-Path -LiteralPath $javaExe) {
|
||||
$savedErrorAction = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$javaOutput = (& $javaExe -version 2>&1 | Out-String)
|
||||
$ErrorActionPreference = $savedErrorAction
|
||||
$javaSummary = ($javaOutput -split "`r?`n" | Select-Object -First 1).Trim()
|
||||
$javaMajor = if ($javaOutput -match 'version "(?<major>\d+)') {
|
||||
[int]$Matches.major
|
||||
} else {
|
||||
0
|
||||
}
|
||||
$javaOk = $javaMajor -ge 17
|
||||
}
|
||||
}
|
||||
|
||||
$sdkCandidates = @(
|
||||
[string]$config.AndroidSdk,
|
||||
$env:ANDROID_SDK_ROOT,
|
||||
$env:ANDROID_HOME,
|
||||
(Join-Path $env:LOCALAPPDATA 'Android\Sdk')
|
||||
) | Where-Object { $_ -and (Test-Path -LiteralPath $_) }
|
||||
$androidSdk = $sdkCandidates | Select-Object -First 1
|
||||
$platform35 = $androidSdk -and (
|
||||
Test-Path -LiteralPath (Join-Path $androidSdk 'platforms\android-35')
|
||||
)
|
||||
$adb = if ($androidSdk) { Join-Path $androidSdk 'platform-tools\adb.exe' } else { '' }
|
||||
$adbOk = $adb -and (Test-Path -LiteralPath $adb)
|
||||
|
||||
$tokenPath = [string]$config.TokenFile
|
||||
if (-not [System.IO.Path]::IsPathRooted($tokenPath)) {
|
||||
$tokenPath = Join-Path $projectRoot $tokenPath
|
||||
}
|
||||
$tokenOk = (Test-Path -LiteralPath $tokenPath) -and (
|
||||
(Get-Content -LiteralPath $tokenPath -Raw).Trim().Length -ge 32
|
||||
)
|
||||
|
||||
$serverOk = $false
|
||||
$presetCount = 0
|
||||
if ($tokenOk) {
|
||||
try {
|
||||
$token = (Get-Content -LiteralPath $tokenPath -Raw).Trim()
|
||||
$baseUrl = ([string]$config.ServerAUrl).TrimEnd('/')
|
||||
$catalog = Invoke-RestMethod `
|
||||
-Uri "$baseUrl/api/v1/vehicles/$($config.VehicleId)/modes" `
|
||||
-Headers @{ Authorization = "Bearer $token" } -TimeoutSec 10
|
||||
$presetCount = @($catalog.modes | Where-Object { $_.origin -eq 'preset' }).Count
|
||||
$serverOk = $presetCount -gt 0
|
||||
} catch {
|
||||
$serverOk = $false
|
||||
} finally {
|
||||
$token = $null
|
||||
}
|
||||
}
|
||||
|
||||
$result = [pscustomobject]@{
|
||||
Passed = [bool]($javaOk -and $platform35 -and $adbOk -and $tokenOk -and $serverOk)
|
||||
Java17OrNewer = $javaOk
|
||||
Java = $javaSummary
|
||||
AndroidSdk = $androidSdk
|
||||
AndroidPlatform35 = [bool]$platform35
|
||||
Adb = [bool]$adbOk
|
||||
DeviceToken = [bool]$tokenOk
|
||||
ServerA = [bool]$serverOk
|
||||
PresetModes = $presetCount
|
||||
}
|
||||
$result
|
||||
if (-not $result.Passed) { exit 2 }
|
||||
Reference in New Issue
Block a user