param( [Parameter(Mandatory=$true)][string]$ProjectPath, [ValidateSet('debug','release')][string]$Mode='debug', [string]$ApiUrl='http://127.0.0.1:9996/api', [string]$FlutterPath='', [string]$OutputPath='', [string]$BuildName='', [string]$BuildNumber='', [ValidateRange(2,8)][int]$GradleHeapGB=4, [switch]$RunTests, [switch]$CheckOnly, [switch]$InstallOnly, [string]$ApkPath='' ) $ErrorActionPreference='Stop' [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) $OutputEncoding = [Console]::OutputEncoding $buildLock=$null $transcribing=$false $previousGradleOpts=$env:GRADLE_OPTS function Invoke-Checked([string]$Executable, [string[]]$Arguments) { $ErrorActionPreference='Continue' & $Executable @Arguments 2>&1 | ForEach-Object { Write-Output "$_" } if ($LASTEXITCODE -ne 0) { throw "Command failed (exit $LASTEXITCODE): $Executable" } } function Read-Property([string]$Name) { $propertyFile=Join-Path $ProjectPath 'android/local.properties' if (Test-Path -LiteralPath $propertyFile) { $line=Get-Content -LiteralPath $propertyFile | Where-Object { $_.StartsWith($Name+'=') } | Select-Object -First 1 if ($line) { return $line.Substring($Name.Length+1).Replace('\\','\').Replace('\:',':') } } return '' } try { $ProjectPath=(Resolve-Path -LiteralPath $ProjectPath).Path if (!(Test-Path -LiteralPath (Join-Path $ProjectPath 'pubspec.yaml'))) { throw 'Select the Flutter project folder containing pubspec.yaml.' } Set-Location -LiteralPath $ProjectPath if (!$OutputPath) { $OutputPath=Join-Path $ProjectPath 'packages' } $OutputPath=[IO.Path]::GetFullPath($OutputPath) $null=New-Item -ItemType Directory -Force -Path $OutputPath $jobPath=Join-Path $OutputPath ((Get-Date -Format 'yyyyMMdd-HHmmss-fff')+'-'+$Mode) $null=New-Item -ItemType Directory -Path $jobPath Start-Transcript -LiteralPath (Join-Path $jobPath 'build.log') -Force | Out-Null $transcribing=$true Write-Output "PACKAGER_OUTPUT=$jobPath" $lockDirectory=Join-Path $ProjectPath '.dart_tool' $null=New-Item -ItemType Directory -Force -Path $lockDirectory $buildLock=[IO.File]::Open((Join-Path $lockDirectory 'yd-packager.lock'),[IO.FileMode]::OpenOrCreate,[IO.FileAccess]::ReadWrite,[IO.FileShare]::None) $sdkPath=Read-Property 'sdk.dir' if (!$sdkPath) { $sdkPath=$env:ANDROID_HOME } if (!$sdkPath) { $sdkPath=$env:ANDROID_SDK_ROOT } $adb=if ($sdkPath) { Join-Path $sdkPath 'platform-tools/adb.exe' } else { '' } if (!$adb -or !(Test-Path -LiteralPath $adb)) { $command=Get-Command adb.exe -ErrorAction SilentlyContinue; if ($command) { $adb=$command.Source } } if ($InstallOnly) { if (!$adb -or !(Test-Path -LiteralPath $adb)) { throw 'ADB not found. Install Android SDK platform-tools.' } if (!(Test-Path -LiteralPath $ApkPath) -or [IO.Path]::GetExtension($ApkPath) -ne '.apk') { throw 'A valid APK file is required.' } $deviceLines=& $adb devices $devices=@($deviceLines | Where-Object { $_ -match '^\S+\s+device$' } | ForEach-Object { ($_ -split '\s+')[0] }) if ($devices.Count -ne 1) { throw 'Connect exactly one authorized Android device. Check USB debugging authorization.' } Invoke-Checked $adb @('-s',$devices[0],'install','-r',$ApkPath) Write-Output 'PACKAGER_INSTALL_OK' exit 0 } [Uri]$endpoint=$null if (![Uri]::TryCreate($ApiUrl,[UriKind]::Absolute,[ref]$endpoint) -or $endpoint.Scheme -notin @('http','https') -or !$endpoint.Host -or $endpoint.UserInfo -or $endpoint.Query -or $endpoint.Fragment) { throw 'API URL must be an absolute HTTP/HTTPS address, without credentials, query or fragment.' } $ApiUrl=$ApiUrl.TrimEnd('/') if ($endpoint.AbsolutePath -eq '/') { $ApiUrl += '/api' Write-Output "API root URL normalized to: $ApiUrl" } if ($Mode -eq 'release' -and $endpoint.Scheme -ne 'https') { throw 'Release requires HTTPS. Use Debug for HTTP testing.' } if ($BuildName -and $BuildName -notmatch '^\d+\.\d+\.\d+(?:[.-][A-Za-z0-9.-]+)?$') { throw 'Invalid version name. Example: 1.0.0' } if ($BuildNumber -and ($BuildNumber -notmatch '^[1-9]\d{0,8}$')) { throw 'Version code must be a positive integer (max 9 digits).' } if (!$FlutterPath) { $flutterSdk=Read-Property 'flutter.sdk' if ($flutterSdk) { $FlutterPath=Join-Path $flutterSdk 'bin/flutter.bat' } } if (!$FlutterPath) { $command=Get-Command flutter.bat -ErrorAction SilentlyContinue; if ($command) { $FlutterPath=$command.Source } } if (!$FlutterPath -or !(Test-Path -LiteralPath $FlutterPath)) { throw 'Flutter was not found. Select flutter.bat in the tool.' } if (!$sdkPath -or !(Test-Path -LiteralPath $sdkPath)) { throw 'Android SDK not found. Configure android/local.properties or ANDROID_HOME.' } Write-Output "Project: $ProjectPath" Write-Output "Flutter: $FlutterPath" Write-Output "Android SDK: $sdkPath" Write-Output "API: $ApiUrl" Write-Output 'NOTE: Existing in-app API settings override the compiled default URL.' if ($Mode -eq 'release') { Write-Output 'WARNING: Current project uses a debug signing key for Release. Not for production publishing.' } Invoke-Checked $FlutterPath @('--version') if ($CheckOnly) { Write-Output 'PACKAGER_CHECK_OK'; exit 0 } if ($RunTests) { Invoke-Checked $FlutterPath @('analyze') Invoke-Checked $FlutterPath @('test') } # User-level gradle.properties overrides project properties. An explicit # JVM system property wins for this child process, without changing globals. $heapOption='-Dorg.gradle.jvmargs="-Xmx'+$GradleHeapGB+'g -XX:MaxMetaspaceSize=1g -XX:ReservedCodeCacheSize=256m -Dfile.encoding=UTF-8"' $env:GRADLE_OPTS=($previousGradleOpts+' '+$heapOption).Trim() Write-Output "Gradle build heap: ${GradleHeapGB}GB; metaspace: 1GB (process-local override)." # A fresh Gradle daemon avoids accumulated heap pressure on this workstation. $gradle=Join-Path $ProjectPath 'android/gradlew.bat' if (Test-Path -LiteralPath $gradle) { Push-Location (Join-Path $ProjectPath 'android') try { Invoke-Checked $gradle @('--stop') } finally { Pop-Location } } $arguments=@('build','apk',('--'+$Mode),('--dart-define=API_BASE_URL='+$ApiUrl)) if ($BuildName) { $arguments+=('--build-name='+$BuildName) } if ($BuildNumber) { $arguments+=('--build-number='+$BuildNumber) } Invoke-Checked $FlutterPath $arguments $sourceApk=Join-Path $ProjectPath ('build/app/outputs/flutter-apk/app-'+$Mode+'.apk') if (!(Test-Path -LiteralPath $sourceApk)) { throw 'Build completed without the expected APK.' } $targetApk=Join-Path $jobPath ('YD-Pattern-Scoring-'+$Mode+'.apk') Copy-Item -LiteralPath $sourceApk -Destination $targetApk $hash=(Get-FileHash -LiteralPath $targetApk -Algorithm SHA256).Hash [ordered]@{ builtAt=(Get-Date).ToString('o'); mode=$Mode; apiBaseUrl=$ApiUrl; gradleHeapGB=$GradleHeapGB; versionName=$BuildName; versionCode=$BuildNumber; apk=$targetApk; sha256=$hash; signing='Project Gradle signing configuration (currently debug key)' } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $jobPath 'build-info.json') -Encoding UTF8 Write-Output "SHA256=$hash" Write-Output "PACKAGER_APK=$targetApk" exit 0 } catch { Write-Output ('PACKAGER_ERROR='+$_.Exception.Message) exit 1 } finally { $env:GRADLE_OPTS=$previousGradleOpts if ($buildLock) { $buildLock.Dispose() } if ($transcribing) { Stop-Transcript | Out-Null } }