#Requires -Version 5.1 <# .SYNOPSIS NetDesk - Android build pipeline. Compiles the Rust FFI layer (netdesk-frb, a cdylib that statically links netdesk-core) into per-ABI .so libraries with cargo-ndk, drops them into the Flutter jniLibs tree, optionally provisions a release signing keystore, then produces the release APK, the per-ABI split APKs and the Play Store App Bundle (.aab). Developed by Akronlogic (https://akronlogic.com). .DESCRIPTION The Flutter UI (ui/) binds, via flutter_rust_bridge, to the compiled Rust library shipped as a shared object inside the APK/AAB under lib//libnetdesk_frb.so (the Dart loader looks for stem 'netdesk_frb'). This script is the single entry point that wires the Android toolchain: netdesk-frb --cargo-ndk--> ui/android/app/src/main/jniLibs//libnetdesk_frb.so Flutter --gradle-----> release .apk / split .apk / .aab (signed) THE ONE COMMAND (from the repo root): powershell -ExecutionPolicy Bypass -File packaging\build-android.ps1 First release-signed run (keystore does not exist yet): $env:NETDESK_KEYSTORE_PASSWORD = '' powershell -ExecutionPolicy Bypass -File packaging\build-android.ps1 -GenerateKeystore .NOTES Prerequisites (the script verifies / auto-provisions what it can): * Rust toolchain on PATH (cargo, rustup). * cargo-ndk (auto-installed with `cargo install cargo-ndk` if absent). * rustup Android std (auto-added if absent). * Android NDK (default C:\dev\android-ndk; override with -NdkHome or $env:ANDROID_NDK_HOME). REQUIRED for the Rust step. * Android SDK (default %LOCALAPPDATA%\Android\Sdk). * Flutter (default C:\dev\flutter\bin\flutter.bat). * A JDK for keytool / Gradle (Android Studio's JBR is auto-detected). SECURITY: no real keystore password is ever stored in a committed file. key.properties and *.keystore are git-ignored. -GenerateKeystore reads the password ONLY from -KeystorePassword or $env:NETDESK_KEYSTORE_PASSWORD. #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'KeystorePassword', Justification = 'keytool -storepass/-keypass require plaintext; sourced from an env var and never committed.')] [CmdletBinding()] param( # Root of the Android NDK (contains source.properties + toolchains/llvm). [string] $NdkHome = $(if ($env:ANDROID_NDK_HOME) { $env:ANDROID_NDK_HOME } else { 'C:\dev\android-ndk' }), # Android SDK root. [string] $SdkHome = $(if ($env:ANDROID_HOME) { $env:ANDROID_HOME } elseif ($env:ANDROID_SDK_ROOT) { $env:ANDROID_SDK_ROOT } else { Join-Path $env:LOCALAPPDATA 'Android\Sdk' }), # JDK home for keytool + Gradle. Auto-detected (Android Studio JBR) when empty. [string] $JavaHome = $env:JAVA_HOME, # ABIs to build. arm64-v8a is the primary shipping ABI. [string[]] $Abis = @('arm64-v8a', 'armeabi-v7a', 'x86_64'), # Android API level the .so links against. Must be <= the Flutter minSdk. [int] $MinSdk = 21, # Cargo package compiled into the Android .so (the flutter_rust_bridge layer). [string] $RustPackage = 'netdesk-frb', # The crate's [lib] name -> the .so is lib.so (what the Dart loader opens). [string] $LibName = 'netdesk_frb', # Which deliverables to produce. [ValidateSet('all', 'apk', 'split', 'aab', 'rust', 'none')] [string] $Target = 'all', # Skip the Rust/cargo-ndk step (reuse whatever .so are already in jniLibs). [switch] $SkipRust, # Create (if missing) the release keystore + key.properties before building. [switch] $GenerateKeystore, # Keystore / key password. NEVER hard-code this - pass it or set the env var. [string] $KeystorePassword = $env:NETDESK_KEYSTORE_PASSWORD, # Key alias inside the keystore. [string] $KeyAlias = 'netdesk', # Path to flutter.bat. [string] $FlutterBat = 'C:\dev\flutter\bin\flutter.bat' ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version 2.0 # --------------------------------------------------------------------------- # # Paths # --------------------------------------------------------------------------- # $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition $RepoRoot = Split-Path -Parent $ScriptDir $UiDir = Join-Path $RepoRoot 'ui' $AndroidDir = Join-Path $UiDir 'android' $JniLibs = Join-Path $AndroidDir 'app\src\main\jniLibs' $CorePkg = $RustPackage $SoName = "lib$LibName.so" # Flutter ABI name -> Rust target triple (for reporting / target checks). $AbiTriple = @{ 'arm64-v8a' = 'aarch64-linux-android' 'armeabi-v7a' = 'armv7-linux-androideabi' 'x86_64' = 'x86_64-linux-android' 'x86' = 'i686-linux-android' } # --------------------------------------------------------------------------- # # Logging # --------------------------------------------------------------------------- # function Write-Step { param([string]$m) Write-Host "`n==> $m" -ForegroundColor Cyan } function Write-Info { param([string]$m) Write-Host " $m" -ForegroundColor Gray } function Write-Ok { param([string]$m) Write-Host " [ok] $m" -ForegroundColor Green } function Write-Warn2{ param([string]$m) Write-Host " [warn] $m" -ForegroundColor Yellow } function Die { param([string]$m) Write-Host "`n[FATAL] $m`n" -ForegroundColor Red; exit 1 } function Invoke-Native { param([string]$Exe, [string[]]$ArgList, [string]$WorkDir) if ($WorkDir) { Push-Location $WorkDir } try { Write-Info "$Exe $($ArgList -join ' ')" & $Exe @ArgList if ($LASTEXITCODE -ne 0) { Die "'$Exe' exited with code $LASTEXITCODE" } } finally { if ($WorkDir) { Pop-Location } } } $banner = @' _ _ _ ____ _ _ _ _ _ | \ | | ___| |_| _ \ ___ ___| | _| | __ _ _ __ __| |_ __ ___ (_) __| | | \| |/ _ \ __| | | |/ _ \/ __| |/ / | / _` | '_ \ / _` | '__/ _ \| |/ _` | | |\ | __/ |_| |_| | __/\__ \ <| |___| (_| | | | | (_| | | | (_) | | (_| | |_| \_|\___|\__|____/ \___||___/_|\_\_____|\__,_|_| |_|\__,_|_| \___/|_|\__,_| '@ Write-Host $banner -ForegroundColor Blue Write-Host " NetDesk Android build pipeline - Akronlogic" -ForegroundColor DarkGray # --------------------------------------------------------------------------- # # Toolchain discovery / environment # --------------------------------------------------------------------------- # Write-Step 'Resolving toolchain' if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) { Die 'cargo not found on PATH. Install Rust (https://rustup.rs) and re-open the shell.' } Write-Ok "cargo: $((cargo --version) 2>&1)" # JAVA_HOME: auto-detect Android Studio's bundled JBR when not provided. if (-not $JavaHome -or -not (Test-Path (Join-Path $JavaHome 'bin'))) { $jbrCandidates = @( 'C:\Program Files\Android\Android Studio\jbr', 'C:\Program Files\Android\Android Studio\jre', (Join-Path ${env:ProgramFiles} 'Android\Android Studio\jbr') ) | Where-Object { $_ -and (Test-Path (Join-Path $_ 'bin\java.exe')) } if ($jbrCandidates) { $JavaHome = $jbrCandidates[0] } } if ($JavaHome -and (Test-Path (Join-Path $JavaHome 'bin\java.exe'))) { $env:JAVA_HOME = $JavaHome $env:PATH = (Join-Path $JavaHome 'bin') + ';' + $env:PATH Write-Ok "JAVA_HOME: $JavaHome" } else { Write-Warn2 'No JDK found (JAVA_HOME unset and no Android Studio JBR). Gradle/keytool may fail.' } # Android SDK. if (Test-Path $SdkHome) { $env:ANDROID_HOME = $SdkHome $env:ANDROID_SDK_ROOT = $SdkHome Write-Ok "ANDROID_HOME: $SdkHome" } else { Write-Warn2 "Android SDK not found at $SdkHome (Flutter build apk/aab needs it)." } $needRust = (-not $SkipRust) -and ($Target -in @('all', 'apk', 'split', 'aab', 'rust')) # --------------------------------------------------------------------------- # # Android NDK - detection (the script "detects it"; required only for Rust) # --------------------------------------------------------------------------- # function Test-Ndk { param([string]$Path) if (-not $Path) { return $false } if (-not (Test-Path (Join-Path $Path 'source.properties'))) { return $false } if (-not (Test-Path (Join-Path $Path 'toolchains\llvm\prebuilt'))) { return $false } return $true } $ndkOk = Test-Ndk $NdkHome if ($needRust) { Write-Step 'Locating Android NDK' if ($ndkOk) { $rev = (Select-String -Path (Join-Path $NdkHome 'source.properties') -Pattern 'Pkg.Revision\s*=\s*(.+)' ).Matches.Groups[1].Value.Trim() $env:ANDROID_NDK_HOME = $NdkHome $env:ANDROID_NDK_ROOT = $NdkHome Write-Ok "NDK $rev at $NdkHome" } else { Die @" Android NDK not found (or incomplete) at: $NdkHome Expected '$NdkHome\source.properties' and '$NdkHome\toolchains\llvm\prebuilt'. The NDK may still be downloading/extracting. When it is in place, re-run: powershell -ExecutionPolicy Bypass -File packaging\build-android.ps1 Or point at another location: -NdkHome / `$env:ANDROID_NDK_HOME Or skip the Rust step to build Flutter only: -SkipRust "@ } } # --------------------------------------------------------------------------- # # Rust -> per-ABI .so via cargo-ndk # --------------------------------------------------------------------------- # if ($needRust) { Write-Step 'Preparing Rust Android targets' # cargo-ndk (auto-install if missing). if (-not (Get-Command cargo-ndk -ErrorAction SilentlyContinue)) { Write-Warn2 'cargo-ndk not found - installing (cargo install cargo-ndk)...' Invoke-Native 'cargo' @('install', 'cargo-ndk') } Write-Ok "cargo-ndk: $((cargo ndk --version) 2>&1)" # rustup std for each requested ABI (auto-add if missing). $installed = (rustup target list --installed) 2>&1 foreach ($abi in $Abis) { $triple = $AbiTriple[$abi] if (-not $triple) { Die "Unknown ABI '$abi'. Valid: $($AbiTriple.Keys -join ', ')" } if ($installed -notcontains $triple) { Write-Warn2 "rustup target '$triple' missing - adding..." Invoke-Native 'rustup' @('target', 'add', $triple) } else { Write-Ok "rust target present: $triple" } } Write-Step "Building $CorePkg -> $SoName [$($Abis -join ', ')] (API $MinSdk)" New-Item -ItemType Directory -Force -Path $JniLibs | Out-Null # cargo-ndk writes // directly (that IS the "copy"). # cargo ndk -t ... -o --platform build --release --package --lib $ndkArgs = @() foreach ($abi in $Abis) { $ndkArgs += @('-t', $abi) } $ndkArgs += '-o'; $ndkArgs += $JniLibs $ndkArgs += '--platform'; $ndkArgs += "$MinSdk" $ndkArgs += 'build'; $ndkArgs += '--release' $ndkArgs += '--package'; $ndkArgs += $CorePkg $ndkArgs += '--lib' Invoke-Native 'cargo' (@('ndk') + $ndkArgs) $RepoRoot Write-Step 'Verifying .so artifacts' $allOk = $true foreach ($abi in $Abis) { $so = Join-Path $JniLibs "$abi\$SoName" if (Test-Path $so) { $kb = [math]::Round((Get-Item $so).Length / 1KB) Write-Ok "$abi/$SoName ($kb KB)" } else { Write-Warn2 "MISSING: $abi/$SoName" $allOk = $false } } if (-not $allOk) { Die 'One or more ABIs failed to produce a .so (see cargo output above).' } # ----------------------------------------------------------------------- # # Bundle the shared C++ STL beside the engine. The Rust cdylib links # against libc++_shared (pulled in by the openh264 C++ build), and Android # devices do NOT ship it — without this copy the app dies at startup with: # dlopen failed: library "libc++_shared.so" not found # (needed by .../lib//libnetdesk_frb.so) # NOTE: the sysroot folder for armeabi-v7a is 'arm-linux-androideabi' # (NOT the Rust triple 'armv7-linux-androideabi'). # ----------------------------------------------------------------------- # Write-Step 'Bundling libc++_shared.so per ABI' $SysrootLib = Join-Path $NdkHome 'toolchains\llvm\prebuilt\windows-x86_64\sysroot\usr\lib' $AbiSysDir = @{ 'arm64-v8a' = 'aarch64-linux-android' 'armeabi-v7a' = 'arm-linux-androideabi' 'x86_64' = 'x86_64-linux-android' } foreach ($abi in $Abis) { $src = Join-Path $SysrootLib "$($AbiSysDir[$abi])\libc++_shared.so" if (-not (Test-Path $src)) { Die "libc++_shared.so not found for ${abi}: $src" } Copy-Item $src (Join-Path $JniLibs "$abi\libc++_shared.so") -Force $kb = [math]::Round((Get-Item (Join-Path $JniLibs "$abi\libc++_shared.so")).Length / 1KB) Write-Ok "$abi/libc++_shared.so bundled ($kb KB)" } } else { Write-Step 'Skipping Rust step (-SkipRust or -Target none)' } if ($Target -eq 'rust' -or $Target -eq 'none') { Write-Step 'Done (Rust-only target requested).' exit 0 } # --------------------------------------------------------------------------- # # Release signing (optional keystore provisioning) # --------------------------------------------------------------------------- # $KeyPropsFile = Join-Path $AndroidDir 'key.properties' $KeystoreFile = Join-Path $AndroidDir 'app\netdesk-release.keystore' if ($GenerateKeystore) { Write-Step 'Provisioning release signing keystore' if (Test-Path $KeystoreFile) { Write-Ok "Keystore already exists: $KeystoreFile (not regenerating)" } else { if ([string]::IsNullOrWhiteSpace($KeystorePassword)) { Die @' -GenerateKeystore was requested but no password was supplied. Set it WITHOUT hard-coding it anywhere committed, e.g.: $env:NETDESK_KEYSTORE_PASSWORD = '' ... -GenerateKeystore or pass -KeystorePassword ''. '@ } $keytool = $null foreach ($c in @( (Join-Path $env:JAVA_HOME 'bin\keytool.exe'), 'C:\Program Files\Android\Android Studio\jbr\bin\keytool.exe')) { if ($c -and (Test-Path $c)) { $keytool = $c; break } } if (-not $keytool) { $gc = Get-Command keytool -ErrorAction SilentlyContinue if ($gc) { $keytool = $gc.Source } } if (-not $keytool) { Die 'keytool not found (install a JDK or Android Studio).' } New-Item -ItemType Directory -Force -Path (Split-Path -Parent $KeystoreFile) | Out-Null Invoke-Native $keytool @( '-genkeypair', '-v', '-keystore', $KeystoreFile, '-alias', $KeyAlias, '-keyalg', 'RSA', '-keysize', '4096', '-validity', '10000', '-storepass', $KeystorePassword, '-keypass', $KeystorePassword, '-dname', 'CN=NetDesk, O=Akronlogic, OU=Release, C=DE' ) Write-Ok "Created keystore: $KeystoreFile" } # Write key.properties (git-ignored; holds the local password). @" # NetDesk release signing - LOCAL ONLY, never commit (git-ignored). # Regenerate the keystore with: build-android.ps1 -GenerateKeystore storePassword=$KeystorePassword keyPassword=$KeystorePassword keyAlias=$KeyAlias storeFile=$($KeystoreFile -replace '\\','/') "@ | Set-Content -Encoding ASCII -Path $KeyPropsFile Write-Ok "Wrote $KeyPropsFile" } if (-not (Test-Path $KeyPropsFile)) { Write-Warn2 'No key.properties found - release artifacts will be DEBUG-signed.' Write-Warn2 'Run with -GenerateKeystore (and a password) to produce upload-signed builds.' } # --------------------------------------------------------------------------- # # Flutter build # --------------------------------------------------------------------------- # if (-not (Test-Path $FlutterBat)) { Die "flutter.bat not found at $FlutterBat (use -FlutterBat)." } if (-not (Test-Path $AndroidDir)) { Die "Flutter android/ project missing at $AndroidDir. Run: flutter create --platforms=android . (in ui/)" } Write-Step 'flutter pub get' Invoke-Native $FlutterBat @('pub', 'get') $UiDir function Invoke-FlutterBuild { param([string[]]$ArgList, [string]$Label) Write-Step "flutter build $Label" Invoke-Native $FlutterBat (@('build') + $ArgList) $UiDir Write-Ok "$Label done" } switch ($Target) { 'apk' { Invoke-FlutterBuild @('apk', '--release') 'apk (fat)' } 'split' { Invoke-FlutterBuild @('apk', '--release', '--split-per-abi') 'apk (split-per-abi)' } 'aab' { Invoke-FlutterBuild @('appbundle', '--release') 'appbundle (.aab)' } 'all' { Invoke-FlutterBuild @('apk', '--release') 'apk (fat)' Invoke-FlutterBuild @('apk', '--release', '--split-per-abi') 'apk (split-per-abi)' Invoke-FlutterBuild @('appbundle', '--release') 'appbundle (.aab)' } } # --------------------------------------------------------------------------- # # Report artifacts # --------------------------------------------------------------------------- # Write-Step 'Artifacts' $outApk = Join-Path $UiDir 'build\app\outputs\flutter-apk' $outAab = Join-Path $UiDir 'build\app\outputs\bundle\release' foreach ($dir in @($outApk, $outAab)) { if (Test-Path $dir) { Get-ChildItem $dir -Filter '*.ap*' -ErrorAction SilentlyContinue | ForEach-Object { $mb = [math]::Round($_.Length / 1MB, 2) Write-Host (" {0,-34} {1,8} MB" -f $_.Name, $mb) -ForegroundColor Green Write-Host (" {0}" -f $_.FullName) -ForegroundColor DarkGray } } } Write-Host "`n==> NetDesk Android build complete.`n" -ForegroundColor Cyan