From f9a703e059c7ae67a91f6577e4d21e29ff70b20e Mon Sep 17 00:00:00 2001 From: Ben James Date: Sun, 22 Feb 2026 21:49:22 +0000 Subject: [PATCH 1/5] feat(android): port iOS rsync optimisations to Android build pipeline - Add shared getExcludedPaths() with comprehensive exclude list matching iOS - Add loadVendorExportIgnorePatterns() for .gitattributes export-ignore support - Fix vendor glob pattern from vendor/*/vendor to vendor/*/*/vendor - Replace platform-specific match block with unified getExcludedPaths() call - Stream ZIP iterator directly instead of materialising via iterator_to_array() - Remove redundant addDirectoryToZip() excludes now handled by rsync --- src/Traits/PlatformFileOperations.php | 3 - src/Traits/PreparesBuild.php | 153 +++++++++++++++++++++++--- 2 files changed, 136 insertions(+), 20 deletions(-) diff --git a/src/Traits/PlatformFileOperations.php b/src/Traits/PlatformFileOperations.php index df32e781..d1e65ee0 100644 --- a/src/Traits/PlatformFileOperations.php +++ b/src/Traits/PlatformFileOperations.php @@ -32,9 +32,6 @@ protected function platformOptimizedCopy(string $source, string $destination, ar } else { // Use rsync on Unix-like systems if (! empty($excludedDirs)) { - // Add specific exclusions for nested vendor directories that cause rsync cycles - $excludedDirs[] = 'vendor/*/vendor'; - $excludedDirs[] = 'vendor/nativephp/mobile/vendor'; $excludeFlags = implode(' ', array_map(fn ($d) => "--exclude='{$d}'", $excludedDirs)); $cmd = "rsync -aL {$excludeFlags} \"{$source}/\" \"{$destination}/\""; } else { diff --git a/src/Traits/PreparesBuild.php b/src/Traits/PreparesBuild.php index 46f56b51..ec7c873c 100644 --- a/src/Traits/PreparesBuild.php +++ b/src/Traits/PreparesBuild.php @@ -225,12 +225,14 @@ protected function prepareLaravelBundle(bool $excludeDevDependencies = true): vo unlink($destinationZip); } - $excludedDirs = match (PHP_OS_FAMILY) { - 'Windows' => array_merge(config('nativephp.cleanup_exclude_files'), ['.git', 'node_modules', 'nativephp', 'vendor/nativephp/mobile/resources']), - 'Linux' => array_merge(config('nativephp.cleanup_exclude_files'), ['.git', 'node_modules', 'nativephp/ios', 'nativephp/android']), - 'Darwin' => array_merge(config('nativephp.cleanup_exclude_files'), ['.git', 'node_modules', 'nativephp/ios', 'nativephp/android']), - default => config('nativephp.cleanup_exclude_files'), - }; + $excludedDirs = $this->getExcludedPaths(); + + // Respect export-ignore from vendor package .gitattributes files + foreach ($this->loadVendorExportIgnorePatterns($source) as $prefix => $patterns) { + foreach ($patterns as $pattern) { + $excludedDirs[] = $prefix.ltrim($pattern, '/'); + } + } $this->logToFile(' Excluded directories: '.implode(', ', $excludedDirs)); @@ -325,6 +327,129 @@ protected function prepareLaravelBundle(bool $excludeDevDependencies = true): vo } } + /** + * Paths to exclude from the app bundle. + * + * Patterns without a leading / match at any depth (e.g. inside vendor packages). + * Patterns with a leading / are anchored to the project root. + * Used by rsync during the initial copy step. + */ + protected function getExcludedPaths(): array + { + // Any depth (project root + inside vendor packages) + $excludes = [ + '.git', + '.github', + 'node_modules', + 'tests', + '.DS_Store', + '.gitignore', + '.gitattributes', + '.gitkeep', + '.editorconfig', + + // Non-runtime files inside vendor packages + '*.md', + 'LICENSE*', + 'docs', + '*.yml', + '*.yaml', + '*.neon', + '*.neon.dist', + + // Vendor-specific + 'vendor/nativephp/mobile/resources', + 'vendor/*/*/vendor', + 'vendor/livewire/livewire/src/Features/SupportFileUploads/browser_test_image_big.jpg', + ]; + + // Platform-specific project-level excludes + if (PHP_OS_FAMILY === 'Windows') { + $excludes[] = '/nativephp'; + } else { + $excludes[] = '/nativephp/ios'; + $excludes[] = '/nativephp/android'; + } + + // Project-level directories + $excludes = array_merge($excludes, [ + '/output', + '/build', + '/dist', + '/artifacts', + '/storage/logs', + '/storage/framework', + '/public/storage', + ]); + + // Project-level files + $excludes = array_merge($excludes, [ + '/*.js', + '/*.md', + '/*.lock', + '/*.xml', + '/.env.example', + '/artisan', + ]); + + // User-configured exclusions + $excludes = array_merge($excludes, config('nativephp.cleanup_exclude_files', [])); + + return $excludes; + } + + /** + * Load export-ignore patterns from .gitattributes in vendor packages. + * + * @return array + */ + protected function loadVendorExportIgnorePatterns(string $source): array + { + $patterns = []; + $vendorPath = $source.'/vendor/'; + + if (! is_dir($vendorPath)) { + return $patterns; + } + + foreach (new \DirectoryIterator($vendorPath) as $namespace) { + if ($namespace->isDot() || ! $namespace->isDir()) { + continue; + } + + foreach (new \DirectoryIterator($namespace->getPathname()) as $package) { + if ($package->isDot() || ! $package->isDir()) { + continue; + } + + $gitattributes = $package->getPathname().'/.gitattributes'; + if (! file_exists($gitattributes)) { + continue; + } + + $ignores = []; + foreach (file($gitattributes, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { + $line = trim($line); + if ($line === '' || $line[0] === '#' || ! str_contains($line, 'export-ignore')) { + continue; + } + + $path = trim(preg_split('/\s+/', $line, 2)[0] ?? ''); + if ($path !== '') { + $ignores[] = ltrim($path, '/'); + } + } + + if (! empty($ignores)) { + $prefix = 'vendor/'.$namespace->getFilename().'/'.$package->getFilename().'/'; + $patterns[$prefix] = $ignores; + } + } + } + + return $patterns; + } + /** * Create ZIP bundle with cross-platform support */ @@ -384,12 +509,12 @@ protected function addDirectoryToZip(\ZipArchive $zip, string $source, string $p { $source = rtrim(str_replace('\\', '/', $source), '/').'/'; - $files = iterator_to_array(new \RecursiveIteratorIterator( + $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::LEAVES_ONLY - )); + ); - foreach ($files as $file) { + foreach ($iterator as $file) { $filePath = str_replace('\\', '/', $file->getRealPath()); $relativePath = ltrim(str_replace('\\', '/', substr($filePath, strlen($source))), '/'); @@ -412,20 +537,14 @@ protected function addDirectoryToZip(\ZipArchive $zip, string $source, string $p } } - // Always exclude these directories + // Safety net for files that may be created between rsync and zip if ($shouldExclude || - Str::startsWith($relativePath, 'vendor/nativephp/mobile/resources') || - Str::startsWith($relativePath, 'vendor/nativephp/mobile/vendor') || - Str::startsWith($relativePath, 'vendor/endroid') || - Str::startsWith($relativePath, '.idea') || - Str::startsWith($relativePath, 'output') || Str::startsWith($relativePath, 'storage/framework/views/') || Str::startsWith($relativePath, 'storage/framework/cache/') || Str::startsWith($relativePath, 'storage/framework/sessions/') || Str::startsWith($relativePath, 'storage/app/native-build') || Str::startsWith($relativePath, 'bootstrap/cache/') || - Str::startsWith($relativePath, 'nativephp') || - Str::startsWith($relativePath, 'public/storage') || + Str::startsWith($relativePath, '.idea') || Str::endsWith($relativePath, '.jks') || Str::endsWith($relativePath, '.zip')) { continue; From 7146b1f1ca1f8131de3f662286ec086c8a88ff94 Mon Sep 17 00:00:00 2001 From: Ben James Date: Mon, 23 Feb 2026 20:08:29 +0000 Subject: [PATCH 2/5] refactor(android): replace duplicated build logic with shared BundleFileManager Remove getExcludedPaths(), loadVendorExportIgnorePatterns(), and platformOptimizedCopy() that duplicated the extracted BundleExclusions and BundleFileManager classes. Android bundle prep now uses the same copy/cleanup pipeline as iOS. --- src/Support/BundleExclusions.php | 8 + src/Traits/PlatformFileOperations.php | 35 ---- src/Traits/PreparesBuild.php | 188 ++------------------ tests/Feature/AndroidBundleCopyTest.php | 225 ++++++++++++++++++++++++ 4 files changed, 247 insertions(+), 209 deletions(-) create mode 100644 tests/Feature/AndroidBundleCopyTest.php diff --git a/src/Support/BundleExclusions.php b/src/Support/BundleExclusions.php index 0f3fa12e..3bdcff92 100644 --- a/src/Support/BundleExclusions.php +++ b/src/Support/BundleExclusions.php @@ -61,6 +61,14 @@ class BundleExclusions '*.neon.dist', ]; + /** Directories the Android runtime expects to exist (added as empty dirs in zip). */ + public const ANDROID_REQUIRED_DIRS = [ + 'bootstrap/cache', + 'storage/framework/cache', + 'storage/framework/sessions', + 'storage/framework/views', + ]; + /** Specific vendor paths to exclude. */ public const VENDOR_PATHS = [ 'vendor/nativephp/mobile/resources', diff --git a/src/Traits/PlatformFileOperations.php b/src/Traits/PlatformFileOperations.php index d1e65ee0..efc6127c 100644 --- a/src/Traits/PlatformFileOperations.php +++ b/src/Traits/PlatformFileOperations.php @@ -6,41 +6,6 @@ trait PlatformFileOperations { - /** - * Platform-optimized file copy operation - */ - protected function platformOptimizedCopy(string $source, string $destination, array $excludedDirs = []): void - { - if (PHP_OS_FAMILY === 'Windows') { - // Use robocopy on Windows - if (! empty($excludedDirs)) { - $excludeArgs = ''; - foreach ($excludedDirs as $dir) { - $excludeArgs .= " /XD \"{$source}\\{$dir}\""; - } - $cmd = "robocopy \"{$source}\" \"{$destination}\" /MIR /NFL /NDL /NJH /NJS /NP /R:0 /W:0{$excludeArgs}"; - } else { - $cmd = "xcopy \"{$source}\\*\" \"{$destination}\\\" /E /I /Y /Q"; - } - - exec($cmd, $output, $result); - - // Robocopy returns 0-7 as success codes - if ($result >= 8 && strpos($cmd, 'robocopy') !== false) { - $this->components->warn("robocopy failed with exit code $result"); - } - } else { - // Use rsync on Unix-like systems - if (! empty($excludedDirs)) { - $excludeFlags = implode(' ', array_map(fn ($d) => "--exclude='{$d}'", $excludedDirs)); - $cmd = "rsync -aL {$excludeFlags} \"{$source}/\" \"{$destination}/\""; - } else { - $cmd = "cp -a \"{$source}/.\" \"{$destination}/\""; - } - exec($cmd); - } - } - /** * Platform-optimized directory removal */ diff --git a/src/Traits/PreparesBuild.php b/src/Traits/PreparesBuild.php index ec7c873c..4ee7b956 100644 --- a/src/Traits/PreparesBuild.php +++ b/src/Traits/PreparesBuild.php @@ -5,6 +5,8 @@ use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Process; use Illuminate\Support\Str; +use Native\Mobile\Support\BundleExclusions; +use Native\Mobile\Support\BundleFileManager; use Symfony\Component\Process\Process as SymfonyProcess; trait PreparesBuild @@ -225,21 +227,12 @@ protected function prepareLaravelBundle(bool $excludeDevDependencies = true): vo unlink($destinationZip); } - $excludedDirs = $this->getExcludedPaths(); - - // Respect export-ignore from vendor package .gitattributes files - foreach ($this->loadVendorExportIgnorePatterns($source) as $prefix => $patterns) { - foreach ($patterns as $pattern) { - $excludedDirs[] = $prefix.ltrim($pattern, '/'); - } - } - - $this->logToFile(' Excluded directories: '.implode(', ', $excludedDirs)); + $configExcludes = config('nativephp.cleanup_exclude_files', []); $srcDir = base_path('vendor/nativephp/mobile/bootstrap/android'); $this->logToFile(' Copying Laravel source...'); - $this->components->task('Copying Laravel source', fn () => $this->platformOptimizedCopy($source, $tempDir, $excludedDirs)); + $this->components->task('Copying Laravel source', fn () => BundleFileManager::copy($source, $tempDir, $configExcludes)); $composerArgs = $excludeDevDependencies ? '--no-dev --no-interaction' : '--no-interaction'; @@ -271,6 +264,9 @@ protected function prepareLaravelBundle(bool $excludeDevDependencies = true): vo return $result->successful(); }); + $this->logToFile(' Removing unnecessary files...'); + $this->components->task('Removing unnecessary files', fn () => BundleFileManager::removeUnnecessaryFiles($tempDir, $configExcludes)); + $version = config('nativephp.version', now()->format('Ymd-His')); $versionCode = config('nativephp.version_code', 1); $bundleVersionId = $version === 'DEBUG' ? 'DEBUG' : "{$version}b{$versionCode}"; @@ -291,7 +287,7 @@ protected function prepareLaravelBundle(bool $excludeDevDependencies = true): vo } $this->logToFile(' Creating bundle archive...'); - $this->components->task('Creating bundle archive', fn () => $this->createZipBundle($tempDir, $destinationZip, $excludedDirs)); + $this->components->task('Creating bundle archive', fn () => $this->createZipBundle($tempDir, $destinationZip)); if (! file_exists($destinationZip) || filesize($destinationZip) <= 1000) { $this->logToFile('ERROR: Failed to create valid zip file'); @@ -327,133 +323,10 @@ protected function prepareLaravelBundle(bool $excludeDevDependencies = true): vo } } - /** - * Paths to exclude from the app bundle. - * - * Patterns without a leading / match at any depth (e.g. inside vendor packages). - * Patterns with a leading / are anchored to the project root. - * Used by rsync during the initial copy step. - */ - protected function getExcludedPaths(): array - { - // Any depth (project root + inside vendor packages) - $excludes = [ - '.git', - '.github', - 'node_modules', - 'tests', - '.DS_Store', - '.gitignore', - '.gitattributes', - '.gitkeep', - '.editorconfig', - - // Non-runtime files inside vendor packages - '*.md', - 'LICENSE*', - 'docs', - '*.yml', - '*.yaml', - '*.neon', - '*.neon.dist', - - // Vendor-specific - 'vendor/nativephp/mobile/resources', - 'vendor/*/*/vendor', - 'vendor/livewire/livewire/src/Features/SupportFileUploads/browser_test_image_big.jpg', - ]; - - // Platform-specific project-level excludes - if (PHP_OS_FAMILY === 'Windows') { - $excludes[] = '/nativephp'; - } else { - $excludes[] = '/nativephp/ios'; - $excludes[] = '/nativephp/android'; - } - - // Project-level directories - $excludes = array_merge($excludes, [ - '/output', - '/build', - '/dist', - '/artifacts', - '/storage/logs', - '/storage/framework', - '/public/storage', - ]); - - // Project-level files - $excludes = array_merge($excludes, [ - '/*.js', - '/*.md', - '/*.lock', - '/*.xml', - '/.env.example', - '/artisan', - ]); - - // User-configured exclusions - $excludes = array_merge($excludes, config('nativephp.cleanup_exclude_files', [])); - - return $excludes; - } - - /** - * Load export-ignore patterns from .gitattributes in vendor packages. - * - * @return array - */ - protected function loadVendorExportIgnorePatterns(string $source): array - { - $patterns = []; - $vendorPath = $source.'/vendor/'; - - if (! is_dir($vendorPath)) { - return $patterns; - } - - foreach (new \DirectoryIterator($vendorPath) as $namespace) { - if ($namespace->isDot() || ! $namespace->isDir()) { - continue; - } - - foreach (new \DirectoryIterator($namespace->getPathname()) as $package) { - if ($package->isDot() || ! $package->isDir()) { - continue; - } - - $gitattributes = $package->getPathname().'/.gitattributes'; - if (! file_exists($gitattributes)) { - continue; - } - - $ignores = []; - foreach (file($gitattributes, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { - $line = trim($line); - if ($line === '' || $line[0] === '#' || ! str_contains($line, 'export-ignore')) { - continue; - } - - $path = trim(preg_split('/\s+/', $line, 2)[0] ?? ''); - if ($path !== '') { - $ignores[] = ltrim($path, '/'); - } - } - - if (! empty($ignores)) { - $prefix = 'vendor/'.$namespace->getFilename().'/'.$package->getFilename().'/'; - $patterns[$prefix] = $ignores; - } - } - } - - return $patterns; - } - /** * Create ZIP bundle with cross-platform support */ - protected function createZipBundle(string $source, string $destination, array $excludedDirs = []): void + protected function createZipBundle(string $source, string $destination): void { if (PHP_OS_FAMILY === 'Windows') { $sevenZip = config('nativephp.android.7zip-location'); @@ -480,16 +353,9 @@ protected function createZipBundle(string $source, string $destination, array $e exit(1); } - $this->addDirectoryToZip($zip, $source, '', $excludedDirs); - - $requiredDirs = [ - 'bootstrap/cache', - 'storage/framework/cache', - 'storage/framework/sessions', - 'storage/framework/views', - ]; + $this->addDirectoryToZip($zip, $source); - foreach ($requiredDirs as $dir) { + foreach (BundleExclusions::ANDROID_REQUIRED_DIRS as $dir) { if (! $zip->statName($dir)) { $zip->addEmptyDir($dir); } @@ -505,7 +371,7 @@ protected function createZipBundle(string $source, string $destination, array $e /** * Add directory contents to ZIP archive */ - protected function addDirectoryToZip(\ZipArchive $zip, string $source, string $prefix = '', array $excludedDirs = []): void + protected function addDirectoryToZip(\ZipArchive $zip, string $source, string $prefix = ''): void { $source = rtrim(str_replace('\\', '/', $source), '/').'/'; @@ -518,32 +384,8 @@ protected function addDirectoryToZip(\ZipArchive $zip, string $source, string $p $filePath = str_replace('\\', '/', $file->getRealPath()); $relativePath = ltrim(str_replace('\\', '/', substr($filePath, strlen($source))), '/'); - // Check against configured exclusions first - $shouldExclude = false; - foreach ($excludedDirs as $excludedDir) { - // Handle wildcard patterns (e.g., "public/fonts/*") - if (str_contains($excludedDir, '*')) { - $pattern = str_replace('*', '.*', preg_quote($excludedDir, '/')); - if (preg_match('/^'.$pattern.'/', $relativePath)) { - $shouldExclude = true; - break; - } - } else { - // Exact directory matching - if (Str::startsWith($relativePath, rtrim($excludedDir, '/').'/') || $relativePath === rtrim($excludedDir, '/')) { - $shouldExclude = true; - break; - } - } - } - - // Safety net for files that may be created between rsync and zip - if ($shouldExclude || - Str::startsWith($relativePath, 'storage/framework/views/') || - Str::startsWith($relativePath, 'storage/framework/cache/') || - Str::startsWith($relativePath, 'storage/framework/sessions/') || - Str::startsWith($relativePath, 'storage/app/native-build') || - Str::startsWith($relativePath, 'bootstrap/cache/') || + // Safety net for files that may be created between copy/cleanup and zip + if (Str::startsWith($relativePath, 'bootstrap/cache/') || Str::startsWith($relativePath, '.idea') || Str::endsWith($relativePath, '.jks') || Str::endsWith($relativePath, '.zip')) { @@ -1024,6 +866,4 @@ abstract protected function updateIcuConfiguration(): void; abstract protected function updateFirebaseConfiguration(): void; abstract protected function removeDirectory(string $path): void; - - abstract protected function platformOptimizedCopy(string $source, string $destination, array $excludedDirs): void; } diff --git a/tests/Feature/AndroidBundleCopyTest.php b/tests/Feature/AndroidBundleCopyTest.php new file mode 100644 index 00000000..0bc8beb5 --- /dev/null +++ b/tests/Feature/AndroidBundleCopyTest.php @@ -0,0 +1,225 @@ +testProjectPath = sys_get_temp_dir().'/nativephp_android_bundle_test_'.uniqid(); + File::makeDirectory($this->testProjectPath, 0755, true); + + app()->setBasePath($this->testProjectPath); + } + + protected function tearDown(): void + { + File::deleteDirectory($this->testProjectPath); + parent::tearDown(); + } + + public function test_prepare_uses_bundle_file_manager_for_copy(): void + { + $appPath = $this->fakeRsyncAndGetAppPath(); + + BundleFileManager::copy(base_path(), $appPath); + + Process::assertRan(function ($process) { + $cmd = $process->command; + + return str_contains($cmd, 'rsync -a --copy-links') + && str_contains($cmd, "--exclude='node_modules'") + && str_contains($cmd, "--exclude='.git'") + && str_contains($cmd, "--exclude='/nativephp'"); + }); + } + + public function test_prepare_runs_cleanup_after_composer_install(): void + { + $appPath = $this->createAppPath([ + 'node_modules' => ['package' => ['index.js' => '{}']], + 'artisan' => '#!/usr/bin/env php', + 'composer.lock' => '{}', + 'tests' => ['Unit' => ['Test.php' => ' ['Models' => ['User.php' => 'assertDirectoryDoesNotExist($appPath.'node_modules'); + $this->assertDirectoryDoesNotExist($appPath.'tests'); + $this->assertFileDoesNotExist($appPath.'artisan'); + $this->assertFileDoesNotExist($appPath.'composer.lock'); + $this->assertDirectoryExists($appPath.'app/Models'); + } + + public function test_zip_excludes_bootstrap_cache_contents(): void + { + $tempDir = $this->createAppPath([ + 'bootstrap' => [ + 'cache' => ['packages.php' => ' ' ['Http' => ['Kernel.php' => 'testProjectPath.'/test_bundle.zip'; + $this->createZipFromDirectory($tempDir, $zipPath); + + $zip = new \ZipArchive; + $zip->open($zipPath); + + $this->assertFalse($zip->statName('bootstrap/cache/packages.php')); + $this->assertNotFalse($zip->statName('bootstrap/app.php')); + $this->assertNotFalse($zip->statName('app/Http/Kernel.php')); + + $zip->close(); + } + + public function test_zip_adds_required_empty_directories(): void + { + $tempDir = $this->createAppPath([ + 'app' => ['Http' => ['Kernel.php' => 'testProjectPath.'/test_bundle.zip'; + $this->createZipFromDirectory($tempDir, $zipPath); + + $zip = new \ZipArchive; + $zip->open($zipPath); + + foreach (BundleExclusions::ANDROID_REQUIRED_DIRS as $dir) { + $this->assertNotFalse( + $zip->statName($dir.'/') ?: $zip->statName($dir), + "Required dir '{$dir}' missing from zip" + ); + } + + $zip->close(); + } + + public function test_zip_excludes_jks_and_zip_files(): void + { + $tempDir = $this->createAppPath([ + 'app' => ['Http' => ['Kernel.php' => ' 'binary keystore data', + 'old_bundle.zip' => 'binary zip data', + 'config' => ['app.php' => 'testProjectPath.'/test_bundle.zip'; + $this->createZipFromDirectory($tempDir, $zipPath); + + $zip = new \ZipArchive; + $zip->open($zipPath); + + $this->assertFalse($zip->statName('keystore.jks')); + $this->assertFalse($zip->statName('old_bundle.zip')); + $this->assertNotFalse($zip->statName('app/Http/Kernel.php')); + $this->assertNotFalse($zip->statName('config/app.php')); + + $zip->close(); + } + + public function test_zip_excludes_idea_directory(): void + { + $tempDir = $this->createAppPath([ + '.idea' => ['workspace.xml' => ''], + 'app' => ['Http' => ['Kernel.php' => 'testProjectPath.'/test_bundle.zip'; + $this->createZipFromDirectory($tempDir, $zipPath); + + $zip = new \ZipArchive; + $zip->open($zipPath); + + $this->assertFalse($zip->statName('.idea/workspace.xml')); + $this->assertNotFalse($zip->statName('app/Http/Kernel.php')); + + $zip->close(); + } + + public function test_android_required_dirs_constant_has_expected_entries(): void + { + $this->assertContains('bootstrap/cache', BundleExclusions::ANDROID_REQUIRED_DIRS); + $this->assertContains('storage/framework/cache', BundleExclusions::ANDROID_REQUIRED_DIRS); + $this->assertContains('storage/framework/sessions', BundleExclusions::ANDROID_REQUIRED_DIRS); + $this->assertContains('storage/framework/views', BundleExclusions::ANDROID_REQUIRED_DIRS); + } + + // Helpers + + protected function fakeRsyncAndGetAppPath(int $exitCode = 0): string + { + Process::fake([ + 'rsync*' => Process::result(output: '', errorOutput: $exitCode ? 'error' : '', exitCode: $exitCode), + ]); + + $appPath = $this->testProjectPath.'/nativephp/android/laravel/'; + File::makeDirectory($appPath, 0755, true); + + return $appPath; + } + + protected function createAppPath(array $structure): string + { + $appPath = $this->testProjectPath.'/nativephp/android/laravel/'; + $this->createDirectoryStructure($appPath, $structure); + + return $appPath; + } + + /** + * Create a zip using the same logic as PreparesBuild::addDirectoryToZip + required dirs. + */ + protected function createZipFromDirectory(string $sourceDir, string $zipPath): void + { + $zip = new \ZipArchive; + $zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); + + $source = rtrim(str_replace('\\', '/', realpath($sourceDir)), '/').'/'; + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($iterator as $file) { + $filePath = str_replace('\\', '/', $file->getRealPath()); + $relativePath = ltrim(str_replace('\\', '/', substr($filePath, strlen($source))), '/'); + + // Same safety net as PreparesBuild::addDirectoryToZip + if (str_starts_with($relativePath, 'bootstrap/cache/') || + str_starts_with($relativePath, '.idea') || + str_ends_with($relativePath, '.jks') || + str_ends_with($relativePath, '.zip')) { + continue; + } + + if ($file->isDir()) { + $zip->addEmptyDir($relativePath); + } else { + $zip->addFile($filePath, $relativePath); + } + } + + foreach (BundleExclusions::ANDROID_REQUIRED_DIRS as $dir) { + if (! $zip->statName($dir)) { + $zip->addEmptyDir($dir); + } + } + + $zip->close(); + } +} From 62779b2796c4fd4d060b295c8e560c466be63f85 Mon Sep 17 00:00:00 2001 From: Ben James Date: Sat, 21 Mar 2026 10:11:40 +0000 Subject: [PATCH 3/5] refactor(android): use BundleFileManager::copyRaw for installs Add copyRaw() to BundleFileManager for plain directory copies without bundle exclusions. Update InstallsAndroid to use it for template and binary copies where applying exclusions would strip essential files like AndroidManifest.xml and resource XMLs. Update v3.1 tests to use Process::fake() and assert rsync commands rather than relying on the removed platformOptimizedCopy() method. Signed-off-by: Ben James --- src/Support/BundleFileManager.php | 27 +++++++++++++ src/Traits/InstallsAndroid.php | 5 ++- .../Unit/CrossPlatformFileOperationsTest.php | 38 +++++++++--------- tests/Unit/EdgeCasesAndErrorHandlingTest.php | 9 +++-- tests/Unit/Traits/InstallsAndroidTest.php | 25 +++++++++--- .../Traits/PlatformFileOperationsTest.php | 39 ++++++++++--------- 6 files changed, 94 insertions(+), 49 deletions(-) diff --git a/src/Support/BundleFileManager.php b/src/Support/BundleFileManager.php index cf6fd5f6..055f26c2 100644 --- a/src/Support/BundleFileManager.php +++ b/src/Support/BundleFileManager.php @@ -68,6 +68,33 @@ public static function copy(string $source, string $destination, array $configPa } } + /** + * Copy a source directory to a destination without applying any exclusions. + * Useful for copying templates and binary artifacts that should be transferred as-is. + */ + public static function copyRaw(string $source, string $destination): void + { + $source = rtrim($source, '/'); + $destination = rtrim($destination, '/'); + + File::ensureDirectoryExists($destination); + File::cleanDirectory($destination); + + if (PHP_OS_FAMILY === 'Windows') { + $result = Process::run("robocopy \"{$source}\" \"{$destination}\" /MIR /NFL /NDL /NJH /NJS /NP /R:0 /W:0"); + + if ($result->exitCode() >= 8) { + throw new \Exception('Failed to copy directory (robocopy exit code '.$result->exitCode().')'); + } + } else { + $result = Process::run("rsync -a --copy-links \"{$source}/\" \"{$destination}/\""); + + if (! $result->successful()) { + throw new \Exception('Failed to copy directory: '.$result->errorOutput()); + } + } + } + private static function copyWithRsync(string $source, string $destination, array $configPaths): void { $excludes = self::excludes($configPaths, $source); diff --git a/src/Traits/InstallsAndroid.php b/src/Traits/InstallsAndroid.php index 7e81477b..68c3fde4 100644 --- a/src/Traits/InstallsAndroid.php +++ b/src/Traits/InstallsAndroid.php @@ -5,6 +5,7 @@ use GuzzleHttp\Client; use GuzzleHttp\Exception\RequestException; use Illuminate\Support\Facades\File; +use Native\Mobile\Support\BundleFileManager; use ZipArchive; use function Laravel\Prompts\error; @@ -53,7 +54,7 @@ private function createAndroidStudioProject(): void $source = base_path('vendor/nativephp/mobile/resources/androidstudio'); - $this->components->task('Creating Android project', fn () => $this->platformOptimizedCopy($source, $androidPath)); + $this->components->task('Creating Android project', fn () => BundleFileManager::copyRaw($source, $androidPath)); } private function installPHPAndroid(): void @@ -197,7 +198,7 @@ private function installPHPAndroid(): void $destination = base_path('nativephp/android/app/src/main'); File::ensureDirectoryExists($destination); - $this->components->task('Installing Android libraries', fn () => $this->platformOptimizedCopy($extractPath, $destination)); + $this->components->task('Installing Android libraries', fn () => BundleFileManager::copyRaw($extractPath, $destination)); try { $this->removeDirectory($extractPath); diff --git a/tests/Unit/CrossPlatformFileOperationsTest.php b/tests/Unit/CrossPlatformFileOperationsTest.php index 562adc32..7cd0fe8c 100644 --- a/tests/Unit/CrossPlatformFileOperationsTest.php +++ b/tests/Unit/CrossPlatformFileOperationsTest.php @@ -3,6 +3,8 @@ namespace Tests\Unit; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Process; +use Native\Mobile\Support\BundleFileManager; use Native\Mobile\Traits\PlatformFileOperations; use Tests\TestCase; @@ -30,6 +32,7 @@ protected function tearDown(): void */ public function test_file_operations_on_different_platforms($platform, $expectedCommand) { + Process::fake(['rsync*' => Process::result(), 'robocopy*' => Process::result()]); $this->mockOperatingSystem($platform); $source = $this->testDir.'/source'; @@ -38,13 +41,9 @@ public function test_file_operations_on_different_platforms($platform, $expected File::makeDirectory($source); File::put($source.'/test.txt', 'content'); - // We can't actually test the exec() commands without executing them - // So we'll test that the method runs without errors - $this->platformOptimizedCopy($source, $dest); + BundleFileManager::copyRaw($source, $dest); - // The actual copy might not work in test environment - // but we can verify the method executed without throwing - $this->assertTrue(true); + Process::assertRan(fn ($process) => str_contains($process->command, 'rsync') || str_contains($process->command, 'robocopy')); } public static function platformProvider(): array @@ -103,26 +102,23 @@ public function test_directory_removal_windows_vs_unix() public function test_file_operations_with_special_characters() { + Process::fake(['rsync*' => Process::result()]); + $source = $this->testDir.'/source with spaces'; $dest = $this->testDir.'/dest with spaces'; File::makeDirectory($source); File::put($source.'/file with spaces.txt', 'content'); - // Test copy with spaces in path - $this->platformOptimizedCopy($source, $dest); + BundleFileManager::copyRaw($source, $dest); - // Manual verification since exec might not work in test - if (File::exists($dest.'/file with spaces.txt')) { - $this->assertFileExists($dest.'/file with spaces.txt'); - } else { - // If exec didn't work, at least verify no exception was thrown - $this->assertTrue(true); - } + Process::assertRan(fn ($process) => str_contains($process->command, 'source with spaces')); } public function test_exclusion_handling_across_platforms() { + Process::fake(['rsync*' => Process::result()]); + $source = $this->testDir.'/source'; $dest = $this->testDir.'/dest'; @@ -135,12 +131,14 @@ public function test_exclusion_handling_across_platforms() File::put($source.'/.git/config', 'git config'); File::put($source.'/src/index.php', 'platformOptimizedCopy($source, $dest, ['node_modules', '.git']); + // Test copy with bundle exclusions applied + BundleFileManager::copy($source, $dest); - // The actual exclusion might not work with exec in tests - // but we verify the method handles exclusions without errors - $this->assertTrue(true); + Process::assertRan(function ($process) { + return str_contains($process->command, 'rsync') + && str_contains($process->command, "--exclude='node_modules'") + && str_contains($process->command, "--exclude='.git'"); + }); } /** diff --git a/tests/Unit/EdgeCasesAndErrorHandlingTest.php b/tests/Unit/EdgeCasesAndErrorHandlingTest.php index 822fcfd6..2684fca7 100644 --- a/tests/Unit/EdgeCasesAndErrorHandlingTest.php +++ b/tests/Unit/EdgeCasesAndErrorHandlingTest.php @@ -3,6 +3,8 @@ namespace Tests\Unit; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Process; +use Native\Mobile\Support\BundleFileManager; use Native\Mobile\Traits\InstallsAndroid; use Native\Mobile\Traits\PlatformFileOperations; use Native\Mobile\Traits\RunsAndroid; @@ -287,12 +289,13 @@ public function test_handles_circular_symlinks() symlink($dir2, $dir1.'/link_to_dir2'); symlink($dir1, $dir2.'/link_to_dir1'); - // Try to copy - should not hang + // Try to copy - rsync handles circular symlinks gracefully + Process::fake(['rsync*' => Process::result()]); $dest = $this->testProjectPath.'/dest'; - $this->platformOptimizedCopy($dir1, $dest); + BundleFileManager::copyRaw($dir1, $dest); // If we get here, it didn't hang - $this->assertTrue(true); + Process::assertRan(fn ($process) => str_contains($process->command, 'rsync')); } public function test_handles_network_paths() diff --git a/tests/Unit/Traits/InstallsAndroidTest.php b/tests/Unit/Traits/InstallsAndroidTest.php index 0c492ab6..04e28000 100644 --- a/tests/Unit/Traits/InstallsAndroidTest.php +++ b/tests/Unit/Traits/InstallsAndroidTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\Traits; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Process; use Mockery; use Native\Mobile\Traits\InstallsAndroid; use Orchestra\Testbench\TestCase; @@ -57,6 +58,8 @@ protected function tearDown(): void public function test_create_android_studio_project_copies_boilerplate() { + Process::fake(['rsync*' => Process::result()]); + // Create mock vendor directory with boilerplate $vendorPath = $this->testProjectPath.'/vendor/nativephp/mobile/resources/androidstudio'; File::makeDirectory($vendorPath, 0755, true); @@ -67,16 +70,21 @@ public function test_create_android_studio_project_copies_boilerplate() // Execute $this->createAndroidStudioProject(); - // Assert files were copied + // Assert rsync was called to copy the boilerplate $androidPath = $this->testProjectPath.'/nativephp/android'; $this->assertDirectoryExists($androidPath); - $this->assertFileExists($androidPath.'/build.gradle'); - $this->assertFileExists($androidPath.'/app/build.gradle.kts'); - $this->assertEquals('test content', File::get($androidPath.'/build.gradle')); + + Process::assertRan(function ($process) use ($vendorPath, $androidPath) { + return str_contains($process->command, 'rsync') + && str_contains($process->command, $vendorPath) + && str_contains($process->command, $androidPath); + }); } public function test_create_android_studio_project_with_force_removes_existing() { + Process::fake(['rsync*' => Process::result()]); + // Create existing android directory $androidPath = $this->testProjectPath.'/nativephp/android'; File::makeDirectory($androidPath, 0755, true); @@ -93,9 +101,14 @@ public function test_create_android_studio_project_with_force_removes_existing() // Execute $this->createAndroidStudioProject(); - // Assert old file was removed and new file exists + // Assert old file was removed (cleanDirectory is called before rsync) $this->assertFileDoesNotExist($androidPath.'/existing.txt'); - $this->assertFileExists($androidPath.'/new.txt'); + + Process::assertRan(function ($process) use ($vendorPath, $androidPath) { + return str_contains($process->command, 'rsync') + && str_contains($process->command, $vendorPath) + && str_contains($process->command, $androidPath); + }); } public function test_install_php_android_with_icu_lock() diff --git a/tests/Unit/Traits/PlatformFileOperationsTest.php b/tests/Unit/Traits/PlatformFileOperationsTest.php index fcfd8c11..8fbb02c5 100644 --- a/tests/Unit/Traits/PlatformFileOperationsTest.php +++ b/tests/Unit/Traits/PlatformFileOperationsTest.php @@ -3,6 +3,8 @@ namespace Tests\Unit\Traits; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Process; +use Native\Mobile\Support\BundleFileManager; use Native\Mobile\Traits\PlatformFileOperations; use Orchestra\Testbench\TestCase; @@ -35,29 +37,30 @@ protected function tearDown(): void parent::tearDown(); } - public function test_platform_optimized_copy_copies_files() + public function test_copy_raw_copies_files() { + Process::fake(['rsync*' => Process::result()]); + // Create test files File::put($this->testSourceDir.'/file1.txt', 'content1'); File::put($this->testSourceDir.'/file2.txt', 'content2'); File::makeDirectory($this->testSourceDir.'/subdir'); File::put($this->testSourceDir.'/subdir/file3.txt', 'content3'); - // Execute copy - $this->platformOptimizedCopy($this->testSourceDir, $this->testDestDir); - - // Assert files were copied - $this->assertFileExists($this->testDestDir.'/file1.txt'); - $this->assertFileExists($this->testDestDir.'/file2.txt'); - $this->assertFileExists($this->testDestDir.'/subdir/file3.txt'); + BundleFileManager::copyRaw($this->testSourceDir, $this->testDestDir); - $this->assertEquals('content1', File::get($this->testDestDir.'/file1.txt')); - $this->assertEquals('content2', File::get($this->testDestDir.'/file2.txt')); - $this->assertEquals('content3', File::get($this->testDestDir.'/subdir/file3.txt')); + Process::assertRan(function ($process) { + return str_contains($process->command, 'rsync -a --copy-links') + && str_contains($process->command, $this->testSourceDir) + && str_contains($process->command, $this->testDestDir) + && ! str_contains($process->command, '--exclude'); + }); } - public function test_platform_optimized_copy_with_excluded_dirs() + public function test_copy_with_exclusions() { + Process::fake(['rsync*' => Process::result()]); + // Create test files File::put($this->testSourceDir.'/file1.txt', 'content1'); File::makeDirectory($this->testSourceDir.'/node_modules'); @@ -65,13 +68,13 @@ public function test_platform_optimized_copy_with_excluded_dirs() File::makeDirectory($this->testSourceDir.'/.git'); File::put($this->testSourceDir.'/.git/config', 'git config'); - // Execute copy with exclusions - $this->platformOptimizedCopy($this->testSourceDir, $this->testDestDir, ['node_modules', '.git']); + BundleFileManager::copy($this->testSourceDir, $this->testDestDir); - // Assert excluded directories were not copied - $this->assertFileExists($this->testDestDir.'/file1.txt'); - $this->assertDirectoryDoesNotExist($this->testDestDir.'/node_modules'); - $this->assertDirectoryDoesNotExist($this->testDestDir.'/.git'); + Process::assertRan(function ($process) { + return str_contains($process->command, 'rsync') + && str_contains($process->command, "--exclude='node_modules'") + && str_contains($process->command, "--exclude='.git'"); + }); } public function test_remove_directory_removes_directory() From 5ac8b60d7f87015f95f93b7332a758e66b58a052 Mon Sep 17 00:00:00 2001 From: Ben James Date: Sat, 21 Mar 2026 10:17:09 +0000 Subject: [PATCH 4/5] refactor(ios): use BundleFileManager::copyRaw for installs Replace File::copyDirectory() with BundleFileManager::copyRaw() for Xcode template, PHP library, and bridge file copies during iOS installation. Consistent with the Android install path. Signed-off-by: Ben James --- src/Support/BundleFileManager.php | 1 - src/Traits/InstallsIos.php | 9 +++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Support/BundleFileManager.php b/src/Support/BundleFileManager.php index 055f26c2..d70f1f59 100644 --- a/src/Support/BundleFileManager.php +++ b/src/Support/BundleFileManager.php @@ -78,7 +78,6 @@ public static function copyRaw(string $source, string $destination): void $destination = rtrim($destination, '/'); File::ensureDirectoryExists($destination); - File::cleanDirectory($destination); if (PHP_OS_FAMILY === 'Windows') { $result = Process::run("robocopy \"{$source}\" \"{$destination}\" /MIR /NFL /NDL /NJH /NJS /NP /R:0 /W:0"); diff --git a/src/Traits/InstallsIos.php b/src/Traits/InstallsIos.php index 7c96dabc..b2758c5d 100644 --- a/src/Traits/InstallsIos.php +++ b/src/Traits/InstallsIos.php @@ -6,6 +6,7 @@ use GuzzleHttp\Exception\RequestException; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Process; +use Native\Mobile\Support\BundleFileManager; use ZipArchive; use function Laravel\Prompts\error; @@ -49,7 +50,7 @@ private function createXcodeProject(): void mkdir($this->iosPath, 0755, true); } - $this->components->task('Creating Xcode project', fn () => File::copyDirectory( + $this->components->task('Creating Xcode project', fn () => BundleFileManager::copyRaw( base_path('vendor/nativephp/mobile/resources/xcode'), $this->iosPath )); @@ -164,8 +165,8 @@ private function installPHPIos(): void File::ensureDirectoryExists($this->iosPath); $this->components->task('Installing iOS libraries', function () use ($extractPath) { - File::copyDirectory($extractPath.'/Libraries', $this->iosPath.'/Libraries'); - File::copyDirectory($extractPath.'/Include', $this->iosPath.'/Include'); + BundleFileManager::copyRaw($extractPath.'/Libraries', $this->iosPath.'/Libraries'); + BundleFileManager::copyRaw($extractPath.'/Include', $this->iosPath.'/Include'); }); // Re-copy our custom Bridge files (PHP.c, PHP.h) which contain the persistent @@ -173,7 +174,7 @@ private function installPHPIos(): void $bridgeSrc = __DIR__.'/../../resources/xcode/Include/Bridge'; $bridgeDst = $this->iosPath.'/Include/Bridge'; if (is_dir($bridgeSrc)) { - File::copyDirectory($bridgeSrc, $bridgeDst); + BundleFileManager::copyRaw($bridgeSrc, $bridgeDst); } try { From e4c594e075a29cd34e9314586007098cbc8f56d4 Mon Sep 17 00:00:00 2001 From: Ben James Date: Sat, 21 Mar 2026 10:30:13 +0000 Subject: [PATCH 5/5] refactor(build): deduplicate copyRaw by reusing private copy methods Move excludes resolution into copy() and pass the pre-built array to copyWithRsync/copyWithRobocopy. These private methods now default to an empty excludes array, allowing copyRaw to delegate directly without duplicating rsync/robocopy logic. Signed-off-by: Ben James --- src/Support/BundleFileManager.php | 39 +++++++++++++----------------- tests/Feature/IosBuildCopyTest.php | 2 +- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/src/Support/BundleFileManager.php b/src/Support/BundleFileManager.php index d70f1f59..26bd3d6b 100644 --- a/src/Support/BundleFileManager.php +++ b/src/Support/BundleFileManager.php @@ -61,10 +61,12 @@ public static function copy(string $source, string $destination, array $configPa File::ensureDirectoryExists($destination); File::cleanDirectory($destination); + $excludes = self::excludes($configPaths, $source); + if (PHP_OS_FAMILY === 'Windows') { - self::copyWithRobocopy($source, $destination, $configPaths); + self::copyWithRobocopy($source, $destination, $excludes); } else { - self::copyWithRsync($source, $destination, $configPaths); + self::copyWithRsync($source, $destination, $excludes); } } @@ -80,38 +82,31 @@ public static function copyRaw(string $source, string $destination): void File::ensureDirectoryExists($destination); if (PHP_OS_FAMILY === 'Windows') { - $result = Process::run("robocopy \"{$source}\" \"{$destination}\" /MIR /NFL /NDL /NJH /NJS /NP /R:0 /W:0"); - - if ($result->exitCode() >= 8) { - throw new \Exception('Failed to copy directory (robocopy exit code '.$result->exitCode().')'); - } + self::copyWithRobocopy($source, $destination); } else { - $result = Process::run("rsync -a --copy-links \"{$source}/\" \"{$destination}/\""); - - if (! $result->successful()) { - throw new \Exception('Failed to copy directory: '.$result->errorOutput()); - } + self::copyWithRsync($source, $destination); } } - private static function copyWithRsync(string $source, string $destination, array $configPaths): void + private static function copyWithRsync(string $source, string $destination, array $excludes = []): void { - $excludes = self::excludes($configPaths, $source); - $excludeFlags = implode(' ', array_map(fn ($d) => "--exclude='".str_replace("'", "'\\''", $d)."'", $excludes)); + $excludeFlags = ''; + + if (! empty($excludes)) { + $excludeFlags = implode(' ', array_map(fn ($d) => "--exclude='".str_replace("'", "'\\''", $d)."'", $excludes)).' '; + } - $result = Process::run("rsync -a --copy-links {$excludeFlags} \"{$source}/\" \"{$destination}/\""); + $result = Process::run("rsync -a --copy-links {$excludeFlags}\"{$source}/\" \"{$destination}/\""); if (! $result->successful()) { - throw new \Exception('Failed to copy app bundle: '.$result->errorOutput()); + throw new \Exception('Failed to copy directory: '.$result->errorOutput()); } } - private static function copyWithRobocopy(string $source, string $destination, array $configPaths): void + private static function copyWithRobocopy(string $source, string $destination, array $excludes = []): void { - $excludes = self::excludes($configPaths, $source); - - // Robocopy uses /XD for directories with absolute paths $excludeArgs = ''; + foreach ($excludes as $pattern) { $dir = ltrim($pattern, '/\\'); $dir = str_replace('/', '\\', $dir); @@ -122,7 +117,7 @@ private static function copyWithRobocopy(string $source, string $destination, ar // Robocopy exit codes < 8 are success if ($result->exitCode() >= 8) { - throw new \Exception('Failed to copy app bundle (robocopy exit code '.$result->exitCode().')'); + throw new \Exception('Failed to copy directory (robocopy exit code '.$result->exitCode().')'); } } diff --git a/tests/Feature/IosBuildCopyTest.php b/tests/Feature/IosBuildCopyTest.php index 52b1d7b2..7b60c06c 100644 --- a/tests/Feature/IosBuildCopyTest.php +++ b/tests/Feature/IosBuildCopyTest.php @@ -152,7 +152,7 @@ public function test_copy_throws_on_rsync_failure(): void $appPath = $this->fakeRsyncAndGetAppPath(exitCode: 1); $this->expectException(\Exception::class); - $this->expectExceptionMessage('Failed to copy app bundle'); + $this->expectExceptionMessage('Failed to copy directory'); BundleFileManager::copy(base_path(), $appPath); }