diff --git a/app/src/main/java/org/godotengine/godot_gradle_build_environment/BuildEnvironment.kt b/app/src/main/java/org/godotengine/godot_gradle_build_environment/BuildEnvironment.kt index c0dd9ee..4216f67 100644 --- a/app/src/main/java/org/godotengine/godot_gradle_build_environment/BuildEnvironment.kt +++ b/app/src/main/java/org/godotengine/godot_gradle_build_environment/BuildEnvironment.kt @@ -135,10 +135,16 @@ class BuildEnvironment(private val context: Context, private val rootfs: String, Log.d(TAG, "Cmd: " + sanitizeCommand(cmd)) } - currentProcess = ProcessBuilder(cmd).apply { - directory(context.filesDir) - environment().putAll(env) - }.start() + try { + currentProcess = ProcessBuilder(cmd).apply { + directory(context.filesDir) + environment().putAll(env) + }.start() + } catch (e: Exception) { + outputHandler(OUTPUT_STDERR, "> CRITICAL ERROR: Android OS blocked the Linux environment from starting!") + outputHandler(OUTPUT_STDERR, "> Details: ${e.message}") + return 255 + } val stdoutThread = logAndCaptureStream(BufferedReader(InputStreamReader(currentProcess?.inputStream))) { line -> Log.i(STDOUT_TAG, line) @@ -157,13 +163,23 @@ class BuildEnvironment(private val context: Context, private val rootfs: String, val exitCode = currentProcess?.waitFor() ?: 255 Log.i(TAG, "ExitCode: $exitCode") + + if (exitCode != 0) { + outputHandler(OUTPUT_STDERR, "> Linux Environment crashed instantly with Exit Code: $exitCode") + } currentProcess = null return exitCode } - - private fun setupProject(projectPath: String, gradleBuildDir: String, outputHandler: (Int, String) -> Unit): File { + + private fun setupProject( + projectPath: String, + gradleBuildDir: String, + isPackagingPhase: Boolean, + outputHandler: (Int, String) -> Unit + ): File { val workDir = Utils.getProjectCacheDir(context, projectPath, gradleBuildDir) + val actionText = if (isPackagingPhase) "Packaging" else "Importing" if (BuildConfig.FLAVOR == "picoos" || BuildConfig.FLAVOR == "horizonos") { val sourceDir = File(projectPath, gradleBuildDir) @@ -178,7 +194,7 @@ class BuildEnvironment(private val context: Context, private val rootfs: String, workDir.mkdirs() } - outputHandler(OUTPUT_INFO, "> Importing project files...") + outputHandler(OUTPUT_INFO, "> $actionText project files...") if (!FileUtils.tryCopyDirectory(sourceDir, workDir)) { throw IOException("Failed to copy $sourceDir to $workDir") } @@ -233,14 +249,29 @@ class BuildEnvironment(private val context: Context, private val rootfs: String, ProjectInfo.writeToDirectory(context, workDir, projectPath, gradleBuildDir, projectTreeUri) } - outputHandler(OUTPUT_INFO, "> Importing project files...") - FileUtils.importAndroidProject(context, projectTreeUri, gradleBuildDir, workDir) + outputHandler(OUTPUT_INFO, "> $actionText project files...") + + // Dynamically intercept the strings coming from FileUtils and replace them if we are packaging! + FileUtils.importAndroidProject(context, projectTreeUri, gradleBuildDir, workDir) { fileName -> + val finalMsg = if (isPackagingPhase) fileName.replace("Importing", "Packaging") else fileName + outputHandler(OUTPUT_INFO, finalMsg) + } + + val gradlewFile = File(workDir, "gradlew") + if (gradlewFile.exists()) { + gradlewFile.setExecutable(true, false) + } + + val finishText = if (isPackagingPhase) "Packaged" else "Imported" + outputHandler(OUTPUT_INFO, "> Project $finishText!") return workDir } private fun fixGradleArgs(projectPath: String, rawGradleArgs: List): List { val normalizedProjectPath = projectPath.trimEnd('/') - return rawGradleArgs.map { arg -> + val verboseArgs = rawGradleArgs.toMutableList() + + return verboseArgs.map { arg -> when { arg.startsWith("-Pdebug_keystore_file=") -> "-Pdebug_keystore_file=/project/.android/debug.keystore" arg.startsWith("-Prelease_keystore_file=") -> "-Prelease_keystore_file=/project/.android/release.keystore" @@ -249,11 +280,17 @@ class BuildEnvironment(private val context: Context, private val rootfs: String, arg.startsWith("-Pplugins_local_binaries=") -> { val prefix = "-Pplugins_local_binaries=" val value = arg.removePrefix(prefix) - val updated = value.replace( - "$normalizedProjectPath/${FileUtils.ADDONS_DIR_NAME}", - "/project/${FileUtils.ADDONS_DIR_NAME}" - ) - prefix + updated + + // Godot sends a comma-separated list of absolute Android OS paths. + // Since FileUtils.kt now dumps all V1 plugins directly into the PRoot workspace, + // we just isolate the file name and point Gradle to /project/FileName.aar + val updatedPaths = value.split(",").map { rawPath -> + val cleanPath = rawPath.trim().removeSurrounding("\"").removeSurrounding("'") + val fileName = File(cleanPath).name + "/project/$fileName" + }.joinToString(",") + + prefix + updatedPaths } else -> arg @@ -286,10 +323,19 @@ class BuildEnvironment(private val context: Context, private val rootfs: String, rootfs.mkdirs() + val progressCallback: (Int, Long) -> Unit = { count, elapsedMs -> + val totalSeconds = (elapsedMs / 1000).toInt() + val minutes = totalSeconds / 60 + val seconds = totalSeconds % 60 + val timeString = String.format("%02d:%02d", minutes, seconds) + + outputHandler(99, "Extracted: $count files ($timeString)") + } + val version: String if (localUri != null) { outputHandler(OUTPUT_INFO, "> Extracting rootfs from local file...") - TarXzExtractor.extractLocalTarXz(context, localUri, rootfs) + TarXzExtractor.extractLocalTarXz(context, localUri, rootfs, progressCallback) version = ROOTFS_VERSION_CUSTOM } else { val hasAsset = try { @@ -299,7 +345,7 @@ class BuildEnvironment(private val context: Context, private val rootfs: String, } if (hasAsset) { outputHandler(OUTPUT_INFO, "> Extracting rootfs from assets...") - TarXzExtractor.extractAssetTarXz(context, ROOTFS_ASSET_PATH, rootfs) + TarXzExtractor.extractAssetTarXz(context, ROOTFS_ASSET_PATH, rootfs, progressCallback) version = ROOTFS_VERSION_CUSTOM } else { val tempFile = File(context.cacheDir, ROOTFS_FILENAME) @@ -314,7 +360,7 @@ class BuildEnvironment(private val context: Context, private val rootfs: String, version = releaseTag outputHandler(OUTPUT_INFO, "> Extracting rootfs...") - TarXzExtractor.extractFileTarXz(tempFile, rootfs) + TarXzExtractor.extractFileTarXz(tempFile, rootfs, progressCallback) } finally { if (tempFile.exists()) { tempFile.delete() @@ -443,8 +489,11 @@ class BuildEnvironment(private val context: Context, private val rootfs: String, return 255 } + // Check if Godot is running the final build/packaging tasks + val isPackagingPhase = !rawGradleArgs.any { it.contains("assemble") || it.contains("bundle") } + val workDir = try { - setupProject(projectPath, gradleBuildDir, outputHandler) + setupProject(projectPath, gradleBuildDir, isPackagingPhase, outputHandler) } catch (e: Exception) { outputHandler(OUTPUT_STDERR, "Unable to setup project: ${e.message}") return 255 @@ -459,15 +508,28 @@ class BuildEnvironment(private val context: Context, private val rootfs: String, } outputHandler(type, line) } + + val baseArgs = if (BuildConfig.FLAVOR == "picoos" || BuildConfig.FLAVOR == "horizonos") { + rawGradleArgs + } else { + fixGradleArgs(projectPath, rawGradleArgs) + } + + val gradleArgs = baseArgs.toMutableList() + // Let the Linux shell dynamically find the exact path to AAPT2 (e.g., /opt/sdk/build-tools/36.1.0/aapt2) + // Since the gradleCmd string uses double quotes, bash will evaluate $(which aapt2) perfectly! + gradleArgs.add("-Pandroid.aapt2FromMavenOverride=\$(which aapt2)") + + var result = executeGradleInternal(gradleArgs, workDir, captureOutputHandler) - val gradleArgs = if (BuildConfig.FLAVOR == "picoos" || BuildConfig.FLAVOR == "horizonos") { + + /**val gradleArgs = if (BuildConfig.FLAVOR == "picoos" || BuildConfig.FLAVOR == "horizonos") { // GABE has full storage access on XR devices, so we are not pulling addons dir, or keystore files. rawGradleArgs } else { fixGradleArgs(projectPath, rawGradleArgs) } - - var result = executeGradleInternal(gradleArgs, workDir, captureOutputHandler) + var result = executeGradleInternal(gradleArgs, workDir, captureOutputHandler)**/ val stderr = stderrBuilder.toString() if (result == 0 && stderr.contains("BUILD FAILED")) { @@ -493,8 +555,8 @@ class BuildEnvironment(private val context: Context, private val rootfs: String, // Now, try running Gradle again! outputHandler(OUTPUT_INFO, "> Retrying Gradle build...") result = executeGradleInternal(gradleArgs, workDir, captureOutputHandler) - val stderr = stderrBuilder.toString() - if (result == 0 && stderr.contains("BUILD FAILED")) { + val stderr2 = stderrBuilder.toString() + if (result == 0 && stderr2.contains("BUILD FAILED")) { result = 1 } } diff --git a/app/src/main/java/org/godotengine/godot_gradle_build_environment/BuildEnvironmentService.kt b/app/src/main/java/org/godotengine/godot_gradle_build_environment/BuildEnvironmentService.kt index 8a111ad..3ad825f 100644 --- a/app/src/main/java/org/godotengine/godot_gradle_build_environment/BuildEnvironmentService.kt +++ b/app/src/main/java/org/godotengine/godot_gradle_build_environment/BuildEnvironmentService.kt @@ -28,6 +28,9 @@ class BuildEnvironmentService : Service() { const val MSG_INSTALL_ROOTFS = 7 const val MSG_DELETE_ROOTFS = 8 const val MSG_BUILD_DIR_ACCESS_GRANTED = 9 + + // NEW: Constant for live timer updates sent to the UI + const val MSG_EXTRACTION_PROGRESS = 99 const val EXTRA_LOCAL_ROOTFS_URI = "local_rootfs_uri" } @@ -201,15 +204,31 @@ class BuildEnvironmentService : Service() { try { mBuildEnvironment.installRootfs(localUri) { type, line -> - val outputMsg = Message.obtain(null, MSG_COMMAND_OUTPUT, id, type) - val outputData = Bundle() - outputData.putString("line", line) - outputMsg.data = outputData - - try { - msg.replyTo.send(outputMsg) - } catch (e: RemoteException) { - Log.e(TAG, "Error sending output to client: ${e.message}") + + // NEW: If type is 99, route it to the UI's progress timer state + if (type == MSG_EXTRACTION_PROGRESS) { + val progressMsg = Message.obtain(null, MSG_EXTRACTION_PROGRESS) + val bundle = Bundle() + bundle.putString("timerText", line) + progressMsg.data = bundle + + try { + msg.replyTo.send(progressMsg) + } catch (e: RemoteException) { + Log.e(TAG, "Error sending progress to client: ${e.message}") + } + } else { + // Standard command output routing + val outputMsg = Message.obtain(null, MSG_COMMAND_OUTPUT, id, type) + val outputData = Bundle() + outputData.putString("line", line) + outputMsg.data = outputData + + try { + msg.replyTo.send(outputMsg) + } catch (e: RemoteException) { + Log.e(TAG, "Error sending output to client: ${e.message}") + } } } } catch (e: Exception) { diff --git a/app/src/main/java/org/godotengine/godot_gradle_build_environment/FileUtils.kt b/app/src/main/java/org/godotengine/godot_gradle_build_environment/FileUtils.kt index bb95c40..3f19db6 100644 --- a/app/src/main/java/org/godotengine/godot_gradle_build_environment/FileUtils.kt +++ b/app/src/main/java/org/godotengine/godot_gradle_build_environment/FileUtils.kt @@ -92,7 +92,13 @@ object FileUtils { return String.format("%.1f %s", value, units[unitIndex]) } - fun importAndroidProject(context: Context, projectTreeUri: Uri, gradleBuildDir: String, destDir: File) { + fun importAndroidProject( + context: Context, + projectTreeUri: Uri, + gradleBuildDir: String, + destDir: File, + onProgress: (String) -> Unit + ) { val root = DocumentFile.fromTreeUri(context, projectTreeUri) ?: throw IOException("Invalid tree uri") @@ -103,6 +109,8 @@ object FileUtils { it.isDirectory && it.name == ADDONS_DIR_NAME } + val pluginsDir = findDirByPath(root, "android/plugins") + if (destDir.exists()) { val apkAssetsDir = File(destDir, "src/main/assets") if (apkAssetsDir.exists()) apkAssetsDir.deleteRecursively() @@ -113,7 +121,8 @@ object FileUtils { destDir.mkdirs() } - copyDirectoryMerge(context, gradleDir, destDir) + onProgress("> Importing Gradle build directory...") + copyDirectoryMerge(context, gradleDir, destDir, onProgress) if (addonsDir != null) { val localAddons = File(destDir, ADDONS_DIR_NAME) @@ -121,10 +130,27 @@ object FileUtils { localAddons.deleteRecursively() } localAddons.mkdirs() - copyDirectoryMerge(context, addonsDir, localAddons) + onProgress("> Importing Addons directory...") + copyDirectoryMerge(context, addonsDir, localAddons, onProgress) + } + + if (pluginsDir != null) { + // Force V1 plugins to copy directly into the project root directory! + onProgress("> Importing v1 Plugins directory...") + copyDirectoryMerge(context, pluginsDir, destDir, onProgress) } } + /*if (pluginsDir != null) { + val localPlugins = File(destDir.parentFile, "plugins") + if (localPlugins.exists()) { + localPlugins.deleteRecursively() + } + localPlugins.mkdirs() + onProgress("> Importing v1 Plugins directory...") + copyDirectoryMerge(context, pluginsDir, localPlugins, onProgress) + }*/ + private fun findDirByPath(parent: DocumentFile, relativePath: String): DocumentFile? { var current: DocumentFile? = parent @@ -139,16 +165,34 @@ object FileUtils { return current } - private fun copyDirectoryMerge(context: Context, src: DocumentFile, dest: File) { + private fun copyDirectoryMerge( + context: Context, + src: DocumentFile, + dest: File, + onProgress: (String) -> Unit + ) { src.listFiles().forEach { file -> val name = file.name ?: return@forEach if (file.isDirectory) { val newDir = File(dest, name) if (!newDir.exists()) newDir.mkdirs() - copyDirectoryMerge(context, file, newDir) + + // Log directory milestones instead of every internal file + onProgress("Importing Directory: $name") + copyDirectoryMerge(context, file, newDir, onProgress) } else { val outFile = File(dest, name) + + // Only log high-value plugin binaries, configuration, or build files to the UI + if (name.endsWith(".aar", true) || + name.endsWith(".jar", true) || + name.endsWith(".gdap", true) || + name.endsWith(".gradle", true) || + name == "AndroidManifest.xml") { + onProgress("Importing Plugin/Config: $name") + } + context.contentResolver.openInputStream(file.uri).use { input -> FileOutputStream(outFile, false).use { output -> input?.copyTo(output) diff --git a/app/src/main/java/org/godotengine/godot_gradle_build_environment/TarXzExtractor.kt b/app/src/main/java/org/godotengine/godot_gradle_build_environment/TarXzExtractor.kt index ee2c6b5..a78e59b 100644 --- a/app/src/main/java/org/godotengine/godot_gradle_build_environment/TarXzExtractor.kt +++ b/app/src/main/java/org/godotengine/godot_gradle_build_environment/TarXzExtractor.kt @@ -13,39 +13,77 @@ import java.io.IOException import java.io.InputStream object TarXzExtractor { - fun extractLocalTarXz(context: Context, localTarXzUri: Uri, destDir: File) { + + fun extractLocalTarXz( + context: Context, + localTarXzUri: Uri, + destDir: File, + onProgress: ((Int, Long) -> Unit)? = null + ) { context.contentResolver.openInputStream(localTarXzUri).use { inputStream -> if (inputStream == null) { throw IOException("Failed to open local rootfs file") } - extractTarXz(inputStream, destDir) + extractTarXz(inputStream, destDir, onProgress) } } - fun extractAssetTarXz(context: Context, assetTarXz: String, destDir: File) { + fun extractAssetTarXz( + context: Context, + assetTarXz: String, + destDir: File, + onProgress: ((Int, Long) -> Unit)? = null + ) { context.assets.open(assetTarXz).use { inputStream -> - extractTarXz(inputStream, destDir) + extractTarXz(inputStream, destDir, onProgress) } } - fun extractFileTarXz(sourceFile: File, destDir: File) { + fun extractFileTarXz( + sourceFile: File, + destDir: File, + onProgress: ((Int, Long) -> Unit)? = null + ) { sourceFile.inputStream().use { inputStream -> - extractTarXz(inputStream, destDir) + extractTarXz(inputStream, destDir, onProgress) } } - private fun extractTarXz(inputStream: InputStream, destDir: File) { + private fun extractTarXz( + inputStream: InputStream, + destDir: File, + onProgress: ((Int, Long) -> Unit)? + ) { if (!destDir.exists() && !destDir.mkdirs()) { throw IllegalStateException("Could not create destination dir: ${destDir.absolutePath}") } val destRoot = destDir.canonicalFile + val sharedBuffer = ByteArray(128 * 1024) + + val startTime = System.currentTimeMillis() + var lastUpdateTime = startTime + var fileCount = 0 + + // NEW: We define a reusable function to check the time and update the UI + val tickProgress = { + val currentTime = System.currentTimeMillis() + if (currentTime - lastUpdateTime >= 50) { + lastUpdateTime = currentTime + onProgress?.invoke(fileCount, currentTime - startTime) + } + } BufferedInputStream(inputStream).use { buf -> XZCompressorInputStream(buf).use { xz -> TarArchiveInputStream(xz).use { tar -> var entry = tar.nextTarEntry while (entry != null) { + fileCount++ + + // Check time before starting a new file + tickProgress() + val outFile = File(destDir, entry.name) val outCanonical = outFile.canonicalFile @@ -79,7 +117,8 @@ object TarXzExtractor { try { Os.link(target, outCanonical.path) } catch (e: ErrnoException) { - copyFromFile(File(target), outCanonical) + // Pass the tickProgress function down + copyFromFile(File(target), outCanonical, sharedBuffer, tickProgress) } applyMode(outCanonical, entry.mode) applyMtime(outCanonical, entry.modTime.time) @@ -88,7 +127,8 @@ object TarXzExtractor { else -> { outCanonical.parentFile?.let { if (!it.exists()) it.mkdirs() } FileOutputStream(outCanonical).use { fos -> - copyStream(tar, fos) + // Pass the tickProgress function down + copyStream(tar, fos, sharedBuffer, tickProgress) } applyMode(outCanonical, entry.mode) applyMtime(outCanonical, entry.modTime.time) @@ -100,26 +140,42 @@ object TarXzExtractor { } } } + + // Final UI update when 100% complete + val finalElapsedMs = System.currentTimeMillis() - startTime + onProgress?.invoke(fileCount, finalElapsedMs) } - private fun copyStream(input: TarArchiveInputStream, output: FileOutputStream) { - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + // NEW: Accepts tickProgress and calls it while looping through large files + private fun copyStream( + input: TarArchiveInputStream, + output: FileOutputStream, + buffer: ByteArray, + tickProgress: () -> Unit + ) { while (true) { val read = input.read(buffer) if (read <= 0) break output.write(buffer, 0, read) + tickProgress() // Update UI during massive file writes! } output.flush() } - private fun copyFromFile(src: File, dst: File) { + // NEW: Accepts tickProgress and calls it while looping through large files + private fun copyFromFile( + src: File, + dst: File, + buffer: ByteArray, + tickProgress: () -> Unit + ) { src.inputStream().use { `in` -> dst.outputStream().use { out -> - val buf = ByteArray(DEFAULT_BUFFER_SIZE) while (true) { - val n = `in`.read(buf) + val n = `in`.read(buffer) if (n <= 0) break - out.write(buf, 0, n) + out.write(buffer, 0, n) + tickProgress() // Update UI during massive file copies! } } } diff --git a/app/src/main/java/org/godotengine/godot_gradle_build_environment/screens/RootfsScreen.kt b/app/src/main/java/org/godotengine/godot_gradle_build_environment/screens/RootfsScreen.kt index 38031e6..6a19688 100644 --- a/app/src/main/java/org/godotengine/godot_gradle_build_environment/screens/RootfsScreen.kt +++ b/app/src/main/java/org/godotengine/godot_gradle_build_environment/screens/RootfsScreen.kt @@ -152,6 +152,10 @@ fun RootfsInstallOrDeleteButton( var isLoading by rememberSaveable { mutableStateOf(false) } var errorMessage by rememberSaveable { mutableStateOf(null) } var progressMessages by rememberSaveable { mutableStateOf>(emptyList()) } + + // New state for live timer updates + var extractionProgress by rememberSaveable { mutableStateOf("") } + var commandId by remember { mutableIntStateOf(0) } var serviceMessenger by remember { mutableStateOf(null) } @@ -166,6 +170,7 @@ fun RootfsInstallOrDeleteButton( isLoading = true errorMessage = null progressMessages = emptyList() + extractionProgress = "" // Reset progress on new extraction commandId++ val msg = Message.obtain(null, msgType, commandId, 0) @@ -200,6 +205,12 @@ fun RootfsInstallOrDeleteButton( val handler = object : Handler(Looper.getMainLooper()) { override fun handleMessage(msg: Message) { when (msg.what) { + // Catching the new timer message from BuildEnvironmentService + BuildEnvironmentService.MSG_EXTRACTION_PROGRESS -> { + val timerText = msg.data.getString("timerText") ?: "" + extractionProgress = timerText + } + BuildEnvironmentService.MSG_COMMAND_OUTPUT -> { val line = msg.data.getString("line") ?: "" progressMessages = progressMessages + line @@ -208,6 +219,7 @@ fun RootfsInstallOrDeleteButton( BuildEnvironmentService.MSG_COMMAND_RESULT -> { val result = msg.arg2 isLoading = false + extractionProgress = "" // Clear the timer when finished if (result == 0) { fileExists = rootfsReadyFile.exists() @@ -243,10 +255,21 @@ fun RootfsInstallOrDeleteButton( isLoading -> { CircularProgressIndicator() Spacer(modifier = Modifier.height(20.dp)) + if (fileExists) { Text(stringResource(R.string.deleting_rootfs_message)) } else { Text(stringResource(R.string.installing_rootfs_message)) + + // Display the live timer progress if data is available + if (extractionProgress.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = extractionProgress, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary + ) + } } Spacer(modifier = Modifier.height(10.dp))