diff --git a/Assets/MagnetismHeroes/Editor/ProjectBootstrap.cs b/Assets/MagnetismHeroes/Editor/ProjectBootstrap.cs index ca15805..34ee5ee 100644 --- a/Assets/MagnetismHeroes/Editor/ProjectBootstrap.cs +++ b/Assets/MagnetismHeroes/Editor/ProjectBootstrap.cs @@ -67,6 +67,17 @@ namespace MagnetismHeroes.Editor Debug.Log("[Integration] Main connected to GameApp."); } + public static void ConfigureAndroid() + { + PlayerSettings.SetScriptingBackend(UnityEditor.Build.NamedBuildTarget.Android, ScriptingImplementation.IL2CPP); + PlayerSettings.Android.targetArchitectures = AndroidArchitecture.ARM64; + PlayerSettings.Android.buildApkPerCpuArchitecture = false; + PlayerSettings.defaultInterfaceOrientation = UIOrientation.LandscapeLeft; + PlayerSettings.Android.forceInternetPermission = false; + AssetDatabase.SaveAssets(); + Debug.Log("[AndroidConfiguration] ARM64 IL2CPP, landscape, no forced Internet permission."); + } + public static void BuildAndroid() { string apkPath = Environment.GetEnvironmentVariable("MAGNETISM_APK_PATH"); @@ -79,6 +90,8 @@ namespace MagnetismHeroes.Editor }); Debug.Log($"[AndroidBuild] {report.summary.result}: {report.summary.totalSize} bytes, {report.summary.totalErrors} errors."); if (report.summary.result != BuildResult.Succeeded) throw new InvalidOperationException("Android build did not succeed."); + File.WriteAllLines(apkPath + ".packed-assets.txt", report.packedAssets + .SelectMany(asset => asset.contents).Select(asset => asset.sourceAssetPath).Distinct().OrderBy(path => path)); } } } diff --git a/tools/android/inspect-apk.ps1 b/tools/android/inspect-apk.ps1 new file mode 100644 index 0000000..74d4aef --- /dev/null +++ b/tools/android/inspect-apk.ps1 @@ -0,0 +1,27 @@ +param([Parameter(Mandatory)][string]$ApkPath, [Parameter(Mandatory)][string]$EvidenceDir) +$ErrorActionPreference = 'Stop' +$sdk = if ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } else { Join-Path $env:LOCALAPPDATA 'Android/Sdk' } +$aapt = Get-ChildItem -LiteralPath (Join-Path $sdk 'build-tools') -Filter aapt.exe -Recurse | Sort-Object FullName -Descending | Select-Object -First 1 +if (!$aapt) { throw 'APK manifest 검사에 필요한 aapt를 찾지 못했습니다.' } +$badging = & $aapt.FullName dump badging $ApkPath +$nativeExit = $LASTEXITCODE +$badging | Set-Content -LiteralPath (Join-Path $EvidenceDir 'apk-badging.txt') -Encoding utf8 +if ($nativeExit -ne 0) { throw "aapt 종료 코드: $nativeExit" } +$zip = [IO.Compression.ZipFile]::OpenRead($ApkPath) +try { + $entries = @($zip.Entries | ForEach-Object FullName) + $abis = @($entries | Where-Object { $_ -match '^lib/([^/]+)/libil2cpp\.so$' } | ForEach-Object { ($_ -split '/')[1] }) + if ($abis.Count -eq 0) { throw 'APK에 IL2CPP 라이브러리가 없습니다.' } + foreach ($abi in $abis) { if ($entries -notcontains "lib/$abi/libunity.so") { throw "$abi Unity 런타임 누락" } } + if ($entries | Where-Object { $_ -match '(?i)(SourceAssets|conda|Qwen|safetensors)' }) { throw '제작 환경 파일이 APK에 포함되었습니다.' } + $entries | Set-Content -LiteralPath (Join-Path $EvidenceDir 'apk-entries.txt') -Encoding utf8 +} finally { $zip.Dispose() } +$package = [regex]::Match(($badging -join "`n"), "package: name='([^']+)'").Groups[1].Value +$activity = [regex]::Match(($badging -join "`n"), "launchable-activity: name='([^']+)'").Groups[1].Value +if ($package -ne 'com.magnetismheroes.game') { throw "예상하지 않은 패키지: $package" } +if (!$activity) { throw 'APK 시작 Activity를 찾지 못했습니다.' } +if (($badging -join "`n") -match "uses-permission: name='android.permission.INTERNET'") { throw '오프라인 앱에 INTERNET 권한이 있습니다.' } +[ordered]@{ apk=$ApkPath; apkSha256=(Get-FileHash -LiteralPath $ApkPath -Algorithm SHA256).Hash; package=$package; + abis=$abis; activity=$activity; aaptCommand=('"' + $aapt.FullName + '" dump badging "' + $ApkPath + '"'); aaptExit=$nativeExit; + sourceCommit=(& git rev-parse HEAD); sourceDirty=[bool](& git status --porcelain) } | + ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $EvidenceDir 'apk-metadata.json') -Encoding utf8 diff --git a/tools/android/observe.ps1 b/tools/android/observe.ps1 new file mode 100644 index 0000000..d344ebe --- /dev/null +++ b/tools/android/observe.ps1 @@ -0,0 +1,29 @@ +param( + [Parameter(Mandatory)][ValidatePattern('^[a-z0-9-]+$')][string]$Step, + [string[]]$AdbArguments = @(), [string]$Serial = 'emulator-5554', + [string]$EvidenceDir = 'artifacts/verification/FirstSlice', [int]$DelayMilliseconds = 800, + [switch]$Audio +) +$ErrorActionPreference = 'Stop' +$sdk = if ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } else { Join-Path $env:LOCALAPPDATA 'Android/Sdk' } +$adb = Join-Path $sdk 'platform-tools/adb.exe' +New-Item -ItemType Directory -Path $EvidenceDir -Force | Out-Null +$commands = [Collections.Generic.List[object]]::new() +function Invoke-Adb([string[]]$Arguments) { + $output = & $adb -s $Serial @Arguments 2>&1 + $code = $LASTEXITCODE + $commands.Add([ordered]@{ command=('"' + $adb + '" -s ' + $Serial + ' ' + ($Arguments -join ' ')); exitCode=$code; output=($output -join "`n") }) + if ($code -ne 0) { throw "ADB 실패: $($Arguments -join ' '), 종료 코드 $code" } + return $output +} +try { + if ($AdbArguments.Count) { Invoke-Adb $AdbArguments | Out-Null } + if ($Audio) { Invoke-Adb @('shell','dumpsys','media.audio_flinger') | Set-Content -LiteralPath (Join-Path $EvidenceDir "$Step-audio-flinger.txt") -Encoding utf8 } + Start-Sleep -Milliseconds $DelayMilliseconds + Invoke-Adb @('shell','screencap','-p',"/sdcard/magnetismheroes-$Step.png") | Out-Null + Invoke-Adb @('pull',"/sdcard/magnetismheroes-$Step.png",(Join-Path $EvidenceDir "$Step.png")) | Out-Null + Invoke-Adb @('shell','rm',"/sdcard/magnetismheroes-$Step.png") | Out-Null +} finally { + [ordered]@{ step=$Step; observedUtc=[DateTime]::UtcNow.ToString('o'); serial=$Serial; sourceCommit=(& git rev-parse HEAD); + commands=$commands.ToArray() } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $EvidenceDir "$Step-commands.json") -Encoding utf8 +} diff --git a/tools/android/smoke.ps1 b/tools/android/smoke.ps1 new file mode 100644 index 0000000..934c074 --- /dev/null +++ b/tools/android/smoke.ps1 @@ -0,0 +1,63 @@ +param([Parameter(Mandatory)][string]$ApkPath, [string]$Milestone='FirstSlice', [Parameter(Mandatory)][string]$EvidenceDir, [string]$Serial='emulator-5554') +$ErrorActionPreference = 'Stop' +$sdk = if ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } else { Join-Path $env:LOCALAPPDATA 'Android/Sdk' } +$adb = Join-Path $sdk 'platform-tools/adb.exe' +$commands = [Collections.Generic.List[object]]::new() +$checks = [Collections.Generic.List[object]]::new() +function Invoke-Adb([string[]]$Arguments) { + $output = & $adb -s $Serial @Arguments 2>&1 + $code = $LASTEXITCODE + $commands.Add([ordered]@{ command=('"' + $adb + '" -s ' + $Serial + ' ' + ($Arguments -join ' ')); exitCode=$code; output=($output -join "`n") }) + if ($code -ne 0) { throw "ADB 실패: $($Arguments -join ' '), 종료 코드 $code" } + return ($output -join "`n").Trim() +} +function Assert-Check([string]$Name,[bool]$Condition,[string]$Actual) { + $checks.Add([ordered]@{ criterion=$Name; passed=$Condition; actual=$Actual }) + if (!$Condition) { throw "검사 실패: $Name ($Actual)" } +} +$result = [ordered]@{ milestone=$Milestone; serial=$Serial; sourceCommit=(& git rev-parse HEAD); sourceDirty=[bool](& git status --porcelain); status='failed'; commands=@(); checks=@() } +try { + Assert-Check 'clean-source' (!$result.sourceDirty) $result.sourceCommit + & (Join-Path $PSScriptRoot 'inspect-apk.ps1') -ApkPath $ApkPath -EvidenceDir $EvidenceDir + $apk = Get-Content -LiteralPath (Join-Path $EvidenceDir 'apk-metadata.json') -Raw | ConvertFrom-Json + $result.apkSha256 = $apk.apkSha256 + $supported = (Invoke-Adb @('shell','getprop','ro.product.cpu.abilist')) -split ',' + $native = $supported[0] + $bridge = Invoke-Adb @('shell','getprop','ro.dalvik.vm.native.bridge') + $intersection = @($apk.abis | Where-Object { $supported -contains $_ }) + $result.device = [ordered]@{ supportedAbis=$supported; nativeArchitecture=$native; bridge=$bridge; apkSupportedIntersection=$intersection; + model=(Invoke-Adb @('shell','getprop','ro.product.model')); api=(Invoke-Adb @('shell','getprop','ro.build.version.sdk')); + features=(Invoke-Adb @('shell','pm','list','features')); graphics=(Invoke-Adb @('shell','dumpsys','SurfaceFlinger')) } + Assert-Check 'supported-abi-intersection' ($intersection.Count -gt 0) ($intersection -join ',') + if ($supported[0] -ne $intersection[0]) { Assert-Check 'arm-native-bridge' ($bridge -match '^lib.+\.so$') $bridge } + Invoke-Adb @('install','-r',$ApkPath) | Out-Null + Invoke-Adb @('shell','am','start','-W','-n',($apk.package + '/' + $apk.activity)) | Out-Null + Start-Sleep -Seconds 3 + $pidValue = Invoke-Adb @('shell','pidof',$apk.package) + Assert-Check 'running-process' ($pidValue -match '^\d+') $pidValue + Invoke-Adb @('logcat','-d','-s','Unity','AndroidRuntime') | Set-Content -LiteralPath (Join-Path $EvidenceDir 'android-runtime.log') -Encoding utf8 + & (Join-Path $PSScriptRoot 'observe.ps1') -Step 'smoke-home' -Serial $Serial -EvidenceDir $EvidenceDir + $manualPath = Join-Path $EvidenceDir 'android-observations.json' + Assert-Check 'observations-present' (Test-Path -LiteralPath $manualPath) $manualPath + $manual = Get-Content -LiteralPath $manualPath -Raw | ConvertFrom-Json + Assert-Check 'observations-match-apk' ($manual.apkSha256 -eq $apk.apkSha256 -and $manual.sourceCommit -eq $result.sourceCommit -and $manual.serial -eq $Serial) $manual.apkSha256 + $required = @('landscape-korean-assets','precollect-tap','direct-collection','camera-orbit','incomplete-restore','count-success-same-board','free-move','free-restore','offline-collection-count','offline-restore','guide-collect-playback','guide-count-playback','count01-playback') + foreach ($id in $required) { + $observations = @($manual.cases | Where-Object id -eq $id) + Assert-Check "observation-$id" ($observations.Count -eq 1 -and $observations[0].passed -eq $true -and $observations[0].actual -and $observations[0].procedure) (($observations | ForEach-Object actual) -join ' ') + foreach ($file in $observations[0].evidence) { + $path = Join-Path $EvidenceDir $file + Assert-Check "evidence-$id-$file" ((Test-Path -LiteralPath $path) -and (Get-Item -LiteralPath $path).Length -gt 0) $file + } + Assert-Check "has-evidence-$id" (@($observations[0].evidence).Count -gt 0) (@($observations[0].evidence) -join ',') + } + $result.manualObservations = $manual + $result.status='passed' +} catch { $result.error=$_.Exception.Message; Write-Host $result.error -ForegroundColor Red } +finally { + $result.commands=$commands.ToArray(); $result.checks=$checks.ToArray(); $result.finishedUtc=[DateTime]::UtcNow.ToString('o') + $result | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath (Join-Path $EvidenceDir 'android-device-evidence.json') -Encoding utf8 +} +if ($result.status -ne 'passed') { exit 1 } +Write-Host "Android 실제 기기 검사와 $($required.Count)개 관찰 검사 통과. 근거: $EvidenceDir" +exit 0 diff --git a/tools/verify.ps1 b/tools/verify.ps1 index 03974a5..9ca7416 100644 --- a/tools/verify.ps1 +++ b/tools/verify.ps1 @@ -45,6 +45,8 @@ try { if ($LASTEXITCODE -ne 0 -or !(Test-Path -LiteralPath $apkPath)) { throw 'Android build failed or produced no APK.' } $record.apk = $apkPath $record.apkSha256 = (Get-FileHash -LiteralPath $apkPath -Algorithm SHA256).Hash + & (Join-Path $PSScriptRoot 'android/inspect-apk.ps1') -ApkPath $apkPath -EvidenceDir $evidenceDir + $record.apkMetadata = Join-Path $evidenceDir 'apk-metadata.json' if ($Milestone -eq 'Final') { Copy-Item -LiteralPath $apkPath -Destination (Join-Path $projectRoot 'artifacts/android/MagnetismHeroes.apk') }