diff --git a/.github/workflows/clu-android.yml b/.github/workflows/clu-android.yml new file mode 100644 index 000000000..a980c29e4 --- /dev/null +++ b/.github/workflows/clu-android.yml @@ -0,0 +1,228 @@ +name: CLU Android IDE + +# Runs on changes to the CLU Android sub-project. +# Modelled after examples/demo-compose-app.yml — independent Gradle project, +# separate working-directory, same JDK/Gradle toolchain versions. + +on: + push: + branches: ["main", "develop"] + paths: + - 'clu-android/**' + - '.github/workflows/clu-android.yml' + pull_request: + paths: + - 'clu-android/**' + - '.github/workflows/clu-android.yml' + workflow_dispatch: + +# Cancel superseded runs on the same branch/PR, but never cancel main or develop. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/develop' }} + +env: + JAVA_VERSION: '17' + JAVA_DISTRIBUTION: 'corretto' + # Must match distributionUrl in clu-android/gradle/wrapper/gradle-wrapper.properties + GRADLE_VERSION: '9.2.1' + # Gradle JVM memory for the Android build daemon + GRADLE_OPTS: '-Xmx4g -Dfile.encoding=UTF-8' + +# ───────────────────────────────────────────────────────────────────────────── +jobs: + + # ── 1. Lint ────────────────────────────────────────────────────────────── + lint: + name: Lint + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Configure Git line endings + run: git config --global core.autocrlf input + + - uses: actions/checkout@v6 + + - name: Set up JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v5 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: ${{ env.JAVA_DISTRIBUTION }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + with: + gradle-home-cache-includes: | + caches + notifications + + - name: Accept Android SDK licenses + run: | + yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --licenses \ + > /dev/null 2>&1 || true + + - name: Install Android compileSdk platform (API 36) + run: | + "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \ + "platforms;android-36" \ + "build-tools;36.0.0" \ + > /dev/null 2>&1 || true + + - name: Lint + working-directory: ./clu-android + run: ./gradlew :app:lintDebug --stacktrace + + - name: Upload lint report + if: always() + uses: actions/upload-artifact@v7 + with: + name: lint-report + path: clu-android/app/build/reports/lint-results-debug.html + retention-days: 7 + + # ── 2. Unit tests (JVM — no emulator required) ─────────────────────────── + unit-tests: + name: Unit Tests (JVM) + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Configure Git line endings + run: git config --global core.autocrlf input + + - uses: actions/checkout@v6 + + - name: Set up JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v5 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: ${{ env.JAVA_DISTRIBUTION }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + with: + gradle-home-cache-includes: | + caches + notifications + + - name: Accept Android SDK licenses + run: | + yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --licenses \ + > /dev/null 2>&1 || true + + - name: Install Android compileSdk platform (API 36) + run: | + "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \ + "platforms;android-36" \ + "build-tools;36.0.0" \ + > /dev/null 2>&1 || true + + - name: Run unit tests + working-directory: ./clu-android + # --continue: collect all test failures before stopping + run: ./gradlew :app:testDebugUnitTest --continue --stacktrace + + - name: Publish test report + uses: mikepenz/action-junit-report@v6 + if: ${{ !cancelled() }} + with: + report_paths: 'clu-android/app/build/test-results/**/TEST-*.xml' + detailed_summary: true + flaky_summary: true + include_empty_in_summary: false + annotate_only: true + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: unit-test-results + path: clu-android/app/build/reports/tests/testDebugUnitTest/ + retention-days: 7 + + # ── 3. Assemble debug APK (gated on unit tests passing) ────────────────── + assemble-debug: + name: Assemble Debug APK + runs-on: ubuntu-latest + needs: unit-tests + permissions: + contents: read + + steps: + - name: Configure Git line endings + run: git config --global core.autocrlf input + + - uses: actions/checkout@v6 + + - name: Set up JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v5 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: ${{ env.JAVA_DISTRIBUTION }} + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + with: + gradle-home-cache-includes: | + caches + notifications + + - name: Accept Android SDK licenses + run: | + yes | "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" --licenses \ + > /dev/null 2>&1 || true + + - name: Install Android compileSdk platform (API 36) + run: | + "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" \ + "platforms;android-36" \ + "build-tools;36.0.0" \ + > /dev/null 2>&1 || true + + - name: Assemble debug APK + working-directory: ./clu-android + run: ./gradlew :app:assembleDebug --stacktrace + + - name: Upload debug APK + uses: actions/upload-artifact@v7 + with: + name: clu-debug-${{ github.sha }} + path: clu-android/app/build/outputs/apk/debug/app-debug.apk + if-no-files-found: error + retention-days: 14 + + - name: Upload build reports on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: build-failure-reports + path: clu-android/app/build/reports/ + retention-days: 3 + + # ── 4. Summary gate — branch protection should require this job ────────── + # Matrix jobs and skipped jobs don't report status correctly for required + # checks. This job always runs and reflects the real result. + build-result: + name: Build Result + needs: [lint, unit-tests, assemble-debug] + if: always() + runs-on: ubuntu-latest + steps: + - name: Check all jobs + run: | + if [[ "${{ needs.lint.result }}" == "failure" ]]; then + echo "Lint failed" + exit 1 + fi + if [[ "${{ needs.unit-tests.result }}" == "failure" ]]; then + echo "Unit tests failed" + exit 1 + fi + if [[ "${{ needs.assemble-debug.result }}" == "failure" ]]; then + echo "APK assembly failed" + exit 1 + fi + echo "All checks passed" diff --git a/clu-android/app/src/main/kotlin/ai/clu/CluApplication.kt b/clu-android/app/src/main/kotlin/ai/clu/CluApplication.kt index a8f3b46e1..60bf38ab4 100644 --- a/clu-android/app/src/main/kotlin/ai/clu/CluApplication.kt +++ b/clu-android/app/src/main/kotlin/ai/clu/CluApplication.kt @@ -1,7 +1,10 @@ package ai.clu import android.app.Application +import android.content.ComponentCallbacks2 import android.util.Log +import ai.clu.build.BuildGate +import ai.clu.build.RamDisk import ai.clu.memory.ObjectBoxStore import ai.clu.shell.LogcatReader import ai.clu.shell.ShellSessionManager @@ -14,38 +17,65 @@ import kotlinx.coroutines.launch class CluApplication : Application() { /** - * App-wide [CoroutineExceptionHandler] that intercepts any unhandled exception - * from agentic loops, build runners, or background indexing tasks. - * Exceptions are logged and piped to the logcat ring buffer — never crash the JVM. + * App-wide [CoroutineExceptionHandler] that intercepts any unhandled exception from + * agentic loops, build runners, or background indexing tasks. Exceptions are logged and + * captured by the logcat ring buffer — the agent can query them via [ReadLogcatTool]. + * The handler is installed on the supervisor scope so a single failure never kills the JVM. */ - val globalExceptionHandler = CoroutineExceptionHandler { context, throwable -> - Log.e(TAG, "Unhandled coroutine exception in $context", throwable) - // Ring buffer already captures this via LogcatReader — agent can query it + val globalExceptionHandler = CoroutineExceptionHandler { ctx, throwable -> + Log.e(TAG, "Unhandled coroutine exception in $ctx", throwable) } - /** Supervisor-scoped coroutine scope for app-lifetime background work. */ + /** Supervisor-scoped scope for app-lifetime background work. */ val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + globalExceptionHandler) + /** tmpfs RAM disk for on-device builds — mounted after the shell session is live. */ + lateinit var ramDisk: RamDisk + private set + override fun onCreate() { super.onCreate() instance = this - // Phase 3: Initialize ObjectBox (on-device RAG vector database) + // Phase 3: Initialize ObjectBox (on-device RAG vector database). runCatching { ObjectBoxStore.init(this) }.onFailure { e -> Log.e(TAG, "ObjectBox init failed — RAG disabled", e) } - // Phase 2: Bootstrap the persistent shell session manager - runCatching { ShellSessionManager.init(this) }.onFailure { e -> + // Phase 2 + 4: Construct the RamDisk first so ShellSessionManager can mount it + // inside the same coroutine that confirms the libsu session is live. This eliminates + // the race condition where mount() fires before the shell session is ready. + ramDisk = RamDisk(this) { cmd -> ShellSessionManager.getExecutor().execute(cmd) } + runCatching { ShellSessionManager.init(this, ramDisk) }.onFailure { e -> Log.e(TAG, "ShellSessionManager init failed", e) + // Fallback: mount with no shell → JVM_CACHE_FALLBACK path + appScope.launch(Dispatchers.IO) { ramDisk.mount() } } - // Phase 4: Start logcat ring buffer for agent self-diagnostics - appScope.launch { LogcatReader.instance.stream(appScope).collect { /* ring buffer fills automatically */ } } + // Phase 4: Start logcat ring buffer for agent self-diagnostics. + appScope.launch { LogcatReader.instance.stream(appScope).collect { /* ring buffer fills */ } } + } + + /** + * Suspend on-device Gradle builds when the system signals critical memory pressure. + * [BuildGate] is re-enabled when the system reports pressure below RUNNING_LOW. + */ + override fun onTrimMemory(level: Int) { + super.onTrimMemory(level) + when { + level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> { + BuildGate.suspend() + Log.w(TAG, "Memory pressure level=$level — on-device builds suspended") + } + level < ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> { + BuildGate.resume() + } + } } override fun onTerminate() { super.onTerminate() + appScope.launch(Dispatchers.IO) { runCatching { ramDisk.unmount() } } runCatching { ObjectBoxStore.close() } runCatching { ShellSessionManager.getExecutor().close() } LogcatReader.instance.stop() diff --git a/clu-android/app/src/main/kotlin/ai/clu/brain/tools/NativeExecutionTools.kt b/clu-android/app/src/main/kotlin/ai/clu/brain/tools/NativeExecutionTools.kt new file mode 100644 index 000000000..52ad78b35 --- /dev/null +++ b/clu-android/app/src/main/kotlin/ai/clu/brain/tools/NativeExecutionTools.kt @@ -0,0 +1,161 @@ +package ai.clu.brain.tools + +import ai.clu.build.AndroidBuildRunner +import ai.clu.build.BuildGate +import ai.clu.build.RamDisk +import ai.clu.deploy.ShizukuDeployer +import ai.clu.shell.LogcatReader +import dev.langchain4j.agent.tool.P +import dev.langchain4j.agent.tool.Tool +import kotlinx.coroutines.runBlocking +import java.io.ByteArrayOutputStream +import java.io.File + +/** + * LangChain4j tool-calling layer for CLU's autonomous agent. + * + * Each method is annotated with [Tool] so that a LangChain4j [dev.langchain4j.service.AiServices] + * instance can expose it to any LLM with tool-calling support (Ollama/Gemma3, Anthropic, etc.). + * The four tools here map 1-to-1 with CLU's core capabilities: + * + * • [scaffoldComposeComponent] — pure Kotlin file manipulation, no external binaries + * • [gradleBuild] — in-memory tmpfs Gradle pipeline via [AndroidBuildRunner] + * • [shizukuDeploy] — privileged pm install + am start via [ShizukuDeployer] + * • [readLogcat] — 2,000-line ring buffer for autonomous crash diagnosis + * + * All blocking operations use [runBlocking] because LangChain4j invokes tools synchronously + * on a background dispatcher thread — never call these from the main thread directly. + */ +class NativeExecutionTools( + private val projectDir: File, + private val buildRunner: AndroidBuildRunner, + private val ramDisk: RamDisk, + private val deployer: ShizukuDeployer, +) { + + /** + * Scaffold, update, or delete a Jetpack Compose source file inside the project. + * Creates parent directories automatically. No external binaries — pure Kotlin file I/O. + */ + @Tool( + "Scaffold or mutate a Jetpack Compose source file inside the project directory. " + + "Use operation='create' or 'update' to write complete Kotlin source to the given relative path. " + + "Use operation='delete' to remove the file entirely. Parent directories are created automatically. " + + "Always write complete, compilable Kotlin source — never partial fragments." + ) + fun scaffoldComposeComponent( + @P("File path relative to project root, e.g. app/src/main/kotlin/ai/clu/ui/HomeScreen.kt") + relativePath: String, + @P("Complete Kotlin source code. Required for create/update, ignored for delete.") + sourceCode: String, + @P("Operation to perform: 'create', 'update', or 'delete'") + operation: String, + ): String { + val target = File(projectDir, relativePath) + return when (operation.trim().lowercase()) { + "create", "update" -> { + if (sourceCode.isBlank()) { + return "ERROR: sourceCode must not be empty for operation '$operation'" + } + target.parentFile?.mkdirs() + target.writeText(sourceCode) + "OK: wrote ${relativePath} (${sourceCode.length} chars)" + } + "delete" -> { + if (!target.exists()) "SKIP: not found — $relativePath" + else { target.delete(); "OK: deleted $relativePath" } + } + else -> "ERROR: unknown operation '$operation'. Valid values: create, update, delete" + } + } + + /** + * Compile the Android project on-device via the Gradle Tooling API using the in-memory + * tmpfs build pipeline. Intermediate class/dex files stay in RAM; only the final APK is + * written to persistent storage. Blocked immediately if [BuildGate] is suspended. + */ + @Tool( + "Compile the Android project on-device using the Gradle Tooling API and the in-memory " + + "tmpfs build pipeline. Intermediate outputs (class files, dex, resources) stay in RAM — " + + "only the final APK touches persistent storage. Returns BUILD SUCCESS with the APK path " + + "on success, or BUILD FAILED with the first 2,000 chars of error output on failure." + ) + fun gradleBuild( + @P("Gradle task to run: 'assembleDebug' (default) or 'testDebugUnitTest'") + task: String, + ): String = runBlocking { + val stdout = ByteArrayOutputStream() + val stderr = ByteArrayOutputStream() + val result = when (task.trim()) { + "testDebugUnitTest" -> buildRunner.runTests(ramDisk, stdout, stderr) + else -> buildRunner.assembleDebug(ramDisk, stdout, stderr) + } + buildString { + appendLine(if (result.isSuccess) "BUILD SUCCESS" else "BUILD FAILED") + result.apkPath?.let { appendLine("APK: $it") } + result.errorMessage?.let { appendLine("Error: $it") } + val errText = stderr.toString(Charsets.UTF_8).trim() + if (errText.isNotBlank()) appendLine(errText.takeLast(2000)) + }.trim() + } + + /** + * Silently install an APK and launch the target app using Shizuku elevated privileges. + * Uses the local ADB loop — no USB cable required. Requires Shizuku to be running on device. + */ + @Tool( + "Install an APK and launch the app using Shizuku elevated privileges (local ADB loop — " + + "no USB required). Runs 'pm install -r -d ' then 'am start -n /'. " + + "Requires Shizuku to be active and permission to be granted. Returns OK or an error message." + ) + fun shizukuDeploy( + @P("Absolute path to the APK file to install, e.g. /data/user/0/ai.clu/files/apks/app-debug.apk") + apkPath: String, + @P("Android package name of the app to deploy, e.g. ai.clu.sample") + packageName: String, + @P("Fully-qualified main Activity class, e.g. ai.clu.sample.MainActivity") + activityClass: String, + ): String { + val apk = File(apkPath) + if (!apk.exists()) return "ERROR: APK not found — $apkPath" + + return runBlocking { + val installResult = deployer.install(apk) + if (!installResult.success) { + return@runBlocking "INSTALL FAILED: ${installResult.message}" + } + val launchResult = deployer.launch(packageName, activityClass) + if (launchResult.success) "OK: deployed and launched $packageName" + else "INSTALL OK but LAUNCH FAILED: ${launchResult.message}" + } + } + + /** + * Read filtered output from the in-memory 2,000-line logcat ring buffer. + * Use [priorityFilter]="E" or "F" plus [packageFilter] to retrieve crash stack traces + * for autonomous self-healing: parse the trace → fix the source → rebuild → re-deploy. + */ + @Tool( + "Read filtered lines from the in-memory 2,000-line logcat ring buffer accumulated since " + + "app start. Use priorityFilter='E' or 'F' with packageFilter to extract crash stack traces " + + "for autonomous debugging. Returns up to maxLines lines, or a '(no output)' sentinel." + ) + fun readLogcat( + @P("Maximum number of log lines to return (1-200, default 100)") + maxLines: Int, + @P("Logcat tag prefix to filter by, e.g. 'CluAgent'. Pass empty string for no filter.") + tagFilter: String, + @P("Minimum log priority to include: V D I W E F. Pass empty string for all.") + priorityFilter: String, + @P("Package name for crash attribution, e.g. 'ai.clu.sample'. Pass empty string for all.") + packageFilter: String, + ): String { + val lines = LogcatReader.instance.readBuffer( + maxLines = maxLines.coerceIn(1, 200), + tagFilter = tagFilter.takeIf { it.isNotBlank() }, + priorityFilter = priorityFilter.takeIf { it.isNotBlank() }, + packageFilter = packageFilter.takeIf { it.isNotBlank() }, + ) + return if (lines.isEmpty()) "(no logcat output matching filter)" else lines.joinToString("\n") + } +} diff --git a/clu-android/app/src/main/kotlin/ai/clu/build/AndroidBuildRunner.kt b/clu-android/app/src/main/kotlin/ai/clu/build/AndroidBuildRunner.kt new file mode 100644 index 000000000..f7051004b --- /dev/null +++ b/clu-android/app/src/main/kotlin/ai/clu/build/AndroidBuildRunner.kt @@ -0,0 +1,174 @@ +package ai.clu.build + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.gradle.tooling.GradleConnector +import java.io.File +import java.io.OutputStream + +/** + * Wraps the Gradle Tooling API so the Koog agent can compile APKs on-device. + * + * When a [RamDisk] is supplied, builds run entirely in RAM: + * - An `init.gradle` script redirects every project's `buildDirectory` to the ramdisk. + * - `--project-cache-dir` points Gradle's own cache at the ramdisk. + * - `--no-daemon --max-workers=2` avoids spawning long-lived JVM processes that the + * Android Phantom Process Killer would terminate mid-build. + * After a successful assemble the output APK is copied to persistent storage and the + * ramdisk is flushed in the `finally` block, even on failure. + */ +class AndroidBuildRunner( + private val context: Context, + private val projectDir: File, +) { + private val persistentApkDir: File get() = File(context.filesDir, "apks") + + suspend fun assembleDebug( + ramDisk: RamDisk? = null, + outputStream: OutputStream = System.out, + errorStream: OutputStream = System.err, + ): BuildResult = withContext(Dispatchers.IO) { + if (BuildGate.isSuspended) { + return@withContext BuildResult( + success = false, + tasks = listOf("assembleDebug"), + errorMessage = "Build suspended: insufficient memory", + ) + } + + val initScript = ramDisk?.let { writeInitScript(it.buildCacheDir) } + try { + val result = runGradleBuild( + tasks = listOf("assembleDebug"), + outputStream = outputStream, + errorStream = errorStream, + ramDisk = ramDisk, + initScript = initScript, + ) + if (result.success && ramDisk != null) { + val apk = copyApkFromRamDisk(ramDisk) + return@withContext result.copy(apkPath = apk?.absolutePath) + } + result + } finally { + initScript?.delete() + ramDisk?.flush() + } + } + + suspend fun runTests( + ramDisk: RamDisk? = null, + outputStream: OutputStream = System.out, + errorStream: OutputStream = System.err, + ): BuildResult = withContext(Dispatchers.IO) { + if (BuildGate.isSuspended) { + return@withContext BuildResult( + success = false, + tasks = listOf("testDebugUnitTest"), + errorMessage = "Build suspended: insufficient memory", + ) + } + + val initScript = ramDisk?.let { writeInitScript(it.buildCacheDir) } + try { + runGradleBuild( + tasks = listOf("testDebugUnitTest"), + outputStream = outputStream, + errorStream = errorStream, + ramDisk = ramDisk, + initScript = initScript, + ) + } finally { + initScript?.delete() + ramDisk?.flush() + } + } + + suspend fun clean( + outputStream: OutputStream = System.out, + errorStream: OutputStream = System.err, + ): BuildResult = withContext(Dispatchers.IO) { + runGradleBuild( + tasks = listOf("clean"), + outputStream = outputStream, + errorStream = errorStream, + ) + } + + private fun runGradleBuild( + tasks: List, + outputStream: OutputStream, + errorStream: OutputStream, + ramDisk: RamDisk? = null, + initScript: File? = null, + ): BuildResult { + return try { + val connector = GradleConnector.newConnector() + .forProjectDirectory(projectDir) + .connect() + + connector.use { connection -> + val args = mutableListOf("--no-daemon", "--max-workers=2") + ramDisk?.let { args += listOf("--project-cache-dir", it.gradleCacheDir.absolutePath) } + initScript?.let { args += listOf("--init-script", it.absolutePath) } + + connection.newBuild() + .forTasks(*tasks.toTypedArray()) + .withArguments(*args.toTypedArray()) + .setStandardOutput(outputStream) + .setStandardError(errorStream) + .setJvmArguments("-Xmx2g", "-Dfile.encoding=UTF-8") + .run() + + BuildResult(success = true, tasks = tasks) + } + } catch (e: Exception) { + BuildResult(success = false, tasks = tasks, errorMessage = e.message) + } + } + + private fun writeInitScript(ramBuildDir: File): File { + val script = File(context.cacheDir, "clu-init.gradle") + val path = ramBuildDir.absolutePath + script.writeText( + """ + allprojects { + layout.buildDirectory.set(file("$path/${'$'}{project.name}")) + } + """.trimIndent() + ) + return script + } + + private fun copyApkFromRamDisk(ramDisk: RamDisk): File? { + val apkInRam = ramDisk.buildCacheDir.walk() + .filter { it.isFile && it.extension == "apk" && it.name.contains("debug") } + .firstOrNull() ?: return null + + val dest = File(persistentApkDir, apkInRam.name) + persistentApkDir.mkdirs() + apkInRam.copyTo(dest, overwrite = true) + return dest + } + + fun findOutputApk(variant: String = "debug", ramDisk: RamDisk? = null): File? { + if (ramDisk != null) { + val ramApk = ramDisk.buildCacheDir.walk() + .filter { it.isFile && it.extension == "apk" && it.parentFile?.name == variant } + .firstOrNull() + if (ramApk != null) return ramApk + } + val apkDir = File(projectDir, "app/build/outputs/apk/$variant") + return apkDir.listFiles { f -> f.extension == "apk" }?.firstOrNull() + } +} + +data class BuildResult( + val success: Boolean, + val tasks: List, + val errorMessage: String? = null, + val apkPath: String? = null, +) { + val isSuccess: Boolean get() = success +} diff --git a/clu-android/app/src/main/kotlin/ai/clu/build/BuildGate.kt b/clu-android/app/src/main/kotlin/ai/clu/build/BuildGate.kt new file mode 100644 index 000000000..7d692d33a --- /dev/null +++ b/clu-android/app/src/main/kotlin/ai/clu/build/BuildGate.kt @@ -0,0 +1,18 @@ +package ai.clu.build + +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Global on/off switch for on-device Gradle builds. + * Flipped to suspended by [ai.clu.CluApplication.onTrimMemory] when the system + * reports TRIM_MEMORY_RUNNING_CRITICAL, preventing OOM during build + agent work. + */ +object BuildGate { + private val _suspended = AtomicBoolean(false) + + val isSuspended: Boolean get() = _suspended.get() + + fun suspend() { _suspended.set(true) } + + fun resume() { _suspended.set(false) } +} diff --git a/clu-android/app/src/main/kotlin/ai/clu/build/RamDisk.kt b/clu-android/app/src/main/kotlin/ai/clu/build/RamDisk.kt new file mode 100644 index 000000000..3d3a2133a --- /dev/null +++ b/clu-android/app/src/main/kotlin/ai/clu/build/RamDisk.kt @@ -0,0 +1,79 @@ +package ai.clu.build + +import ai.clu.shell.ShellResult +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +/** + * Manages a tmpfs RAM disk for Gradle build artifacts, protecting NAND flash + * from write amplification during iterative compile cycles on Pixel 10 Pro. + * + * Attempts `mount -t tmpfs -o size=G` via the root shell. On failure (no root, + * permission denied, etc.) transparently falls back to [Context.cacheDir] so builds + * still work — just without wear protection. + */ +class RamDisk( + private val context: Context, + private val shellExecute: suspend (String) -> ShellResult, + private val sizeGb: Int = 4, +) { + enum class Type { TMPFS, JVM_CACHE_FALLBACK } + + private var _mountPoint: File? = null + private var _type: Type = Type.JVM_CACHE_FALLBACK + private var _isActive: Boolean = false + + val mountPoint: File get() = _mountPoint ?: fallbackDir() + val type: Type get() = _type + val isActive: Boolean get() = _isActive + + /** Gradle build outputs land here (per-project subdirs). */ + val buildCacheDir: File get() = File(mountPoint, "build") + + /** Gradle dependency/artifact cache (`--project-cache-dir`). */ + val gradleCacheDir: File get() = File(mountPoint, "gradle-cache") + + /** + * Mounts the tmpfs or falls back to cache dir. + * Always returns true — callers can check [type] to know which path was taken. + */ + suspend fun mount(): Boolean = withContext(Dispatchers.IO) { + val target = File(context.cacheDir, "clu-ramdisk") + target.mkdirs() + + val result = shellExecute("mount -t tmpfs -o size=${sizeGb}G tmpfs ${target.absolutePath}") + + if (result.isSuccess) { + _mountPoint = target + _type = Type.TMPFS + } else { + _mountPoint = fallbackDir().also { it.mkdirs() } + _type = Type.JVM_CACHE_FALLBACK + } + _isActive = true + buildCacheDir.mkdirs() + gradleCacheDir.mkdirs() + _isActive + } + + /** Wipes all build artifacts from the mount point but keeps directory structure. */ + suspend fun flush(): Unit = withContext(Dispatchers.IO) { + _mountPoint?.listFiles()?.forEach { it.deleteRecursively() } + } + + /** Unmounts tmpfs (or deletes fallback dir) and resets state. */ + suspend fun unmount(): Unit = withContext(Dispatchers.IO) { + val mp = _mountPoint ?: return@withContext + if (_type == Type.TMPFS) { + shellExecute("umount ${mp.absolutePath}") + } else { + mp.deleteRecursively() + } + _isActive = false + _mountPoint = null + } + + private fun fallbackDir() = File(context.cacheDir, "clu-build-cache") +} diff --git a/clu-android/app/src/main/kotlin/ai/clu/deploy/ShizukuDeployer.kt b/clu-android/app/src/main/kotlin/ai/clu/deploy/ShizukuDeployer.kt index 52bc4c8c1..ab356ac90 100644 --- a/clu-android/app/src/main/kotlin/ai/clu/deploy/ShizukuDeployer.kt +++ b/clu-android/app/src/main/kotlin/ai/clu/deploy/ShizukuDeployer.kt @@ -85,6 +85,17 @@ class ShizukuDeployer(private val context: Context) { } } + /** Convenience: install then immediately launch. Stops at install if it fails. */ + suspend fun installAndLaunch( + apkFile: File, + packageName: String, + activityName: String, + ): DeployResult { + val installResult = install(apkFile) + if (!installResult.success) return installResult + return launch(packageName, activityName) + } + suspend fun uninstall(packageName: String): DeployResult = withContext(Dispatchers.IO) { if (!isPermissionGranted) { return@withContext DeployResult(false, "Shizuku permission not granted") diff --git a/clu-android/app/src/main/kotlin/ai/clu/shell/ShellSessionManager.kt b/clu-android/app/src/main/kotlin/ai/clu/shell/ShellSessionManager.kt index cd53c8430..dec1919fb 100644 --- a/clu-android/app/src/main/kotlin/ai/clu/shell/ShellSessionManager.kt +++ b/clu-android/app/src/main/kotlin/ai/clu/shell/ShellSessionManager.kt @@ -1,5 +1,6 @@ package ai.clu.shell +import ai.clu.build.RamDisk import android.content.Context import android.util.Log import kotlinx.coroutines.CoroutineExceptionHandler @@ -20,11 +21,30 @@ object ShellSessionManager { private lateinit var executor: ShellExecutor - fun init(context: Context) { + /** + * Initialises the shell executor and, once the session is confirmed live, mounts the + * [RamDisk]. Mounting is sequenced after shell init so the tmpfs mount command has an + * active libsu session to run through. If shell init fails the ramdisk is still mounted + * (it will automatically fall back to [RamDisk.Type.JVM_CACHE_FALLBACK]). + */ + fun init(context: Context, ramDisk: RamDisk? = null) { executor = ShellExecutor(context.applicationContext) scope.launch { executor.init() - .onFailure { Log.w(TAG, "Shell init failed — commands will return error results", it) } + .onSuccess { + ramDisk?.let { + it.mount() + Log.i(TAG, "RamDisk mounted — type=${it.type} path=${it.mountPoint}") + } + } + .onFailure { throwable -> + Log.w(TAG, "Shell init failed — commands will return error results", throwable) + // Mount ramdisk anyway; without a shell session it falls back to JVM cache. + ramDisk?.let { + it.mount() + Log.i(TAG, "RamDisk mounted (fallback) — type=${it.type}") + } + } } } diff --git a/clu-android/app/src/test/kotlin/ai/clu/brain/tools/NativeExecutionToolsTest.kt b/clu-android/app/src/test/kotlin/ai/clu/brain/tools/NativeExecutionToolsTest.kt new file mode 100644 index 000000000..ab8e5a8d5 --- /dev/null +++ b/clu-android/app/src/test/kotlin/ai/clu/brain/tools/NativeExecutionToolsTest.kt @@ -0,0 +1,176 @@ +package ai.clu.brain.tools + +import ai.clu.build.AndroidBuildRunner +import ai.clu.build.BuildGate +import ai.clu.build.BuildResult +import ai.clu.build.RamDisk +import ai.clu.deploy.DeployResult +import ai.clu.deploy.ShizukuDeployer +import ai.clu.shell.ShellResult +import android.content.Context +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File + +class NativeExecutionToolsTest { + + private lateinit var tempDir: File + private lateinit var mockContext: Context + private lateinit var mockBuildRunner: AndroidBuildRunner + private lateinit var mockRamDisk: RamDisk + private lateinit var mockDeployer: ShizukuDeployer + private lateinit var tools: NativeExecutionTools + + @Before + fun setUp() { + BuildGate.resume() + tempDir = createTempDir("native_tools_test") + mockContext = mockk { every { cacheDir } returns tempDir; every { filesDir } returns tempDir } + mockBuildRunner = mockk() + mockRamDisk = mockk(relaxed = true) + mockDeployer = mockk() + tools = NativeExecutionTools(tempDir, mockBuildRunner, mockRamDisk, mockDeployer) + } + + @After + fun tearDown() { + BuildGate.resume() + tempDir.deleteRecursively() + } + + // ── scaffoldComposeComponent ───────────────────────────────────────────── + + @Test + fun testScaffoldCreateWritesFile() { + val result = tools.scaffoldComposeComponent( + relativePath = "app/src/main/kotlin/ai/clu/ui/TestScreen.kt", + sourceCode = "package ai.clu.ui\n\nimport androidx.compose.runtime.Composable\n\n@Composable\nfun TestScreen() {}", + operation = "create", + ) + assertTrue("Expected OK prefix", result.startsWith("OK:")) + val written = File(tempDir, "app/src/main/kotlin/ai/clu/ui/TestScreen.kt") + assertTrue("File must exist after create", written.exists()) + assertTrue("File must contain source", written.readText().contains("TestScreen")) + } + + @Test + fun testScaffoldUpdateOverwritesExistingFile() { + val path = "app/src/main/kotlin/ai/clu/ui/UpdateMe.kt" + val target = File(tempDir, path).also { it.parentFile?.mkdirs(); it.writeText("old content") } + tools.scaffoldComposeComponent(path, "new content", "update") + assertEquals("new content", target.readText()) + } + + @Test + fun testScaffoldDeleteRemovesFile() { + val path = "app/src/main/kotlin/ai/clu/ui/DeleteMe.kt" + File(tempDir, path).also { it.parentFile?.mkdirs(); it.createNewFile() } + val result = tools.scaffoldComposeComponent(path, "", "delete") + assertTrue(result.startsWith("OK:")) + assertFalse(File(tempDir, path).exists()) + } + + @Test + fun testScaffoldDeleteMissingFileReturnsSkip() { + val result = tools.scaffoldComposeComponent("nonexistent/File.kt", "", "delete") + assertTrue("Expected SKIP", result.startsWith("SKIP:")) + } + + @Test + fun testScaffoldUnknownOperationReturnsError() { + val result = tools.scaffoldComposeComponent("some/File.kt", "code", "rename") + assertTrue("Expected ERROR", result.startsWith("ERROR:")) + } + + @Test + fun testScaffoldCreateWithEmptySourceReturnsError() { + val result = tools.scaffoldComposeComponent("some/File.kt", "", "create") + assertTrue("Expected ERROR for blank sourceCode", result.startsWith("ERROR:")) + } + + @Test + fun testScaffoldCreatesMissingParentDirectories() { + val deepPath = "a/b/c/d/e/Deep.kt" + tools.scaffoldComposeComponent(deepPath, "package x", "create") + assertTrue(File(tempDir, deepPath).exists()) + } + + // ── gradleBuild (BuildGate gating only — Gradle not invoked in unit tests) ── + + @Test + fun testGradleBuildReturnsSuspendedWhenGateClosed() { + BuildGate.suspend() + coEvery { mockBuildRunner.assembleDebug(any(), any(), any()) } returns + BuildResult(false, listOf("assembleDebug"), errorMessage = "Build suspended: insufficient memory") + val result = tools.gradleBuild("assembleDebug") + assertTrue("Expected BUILD FAILED", result.contains("BUILD FAILED")) + } + + @Test + fun testGradleBuildSuccessMessageContainsApkPath() = runTest { + coEvery { mockBuildRunner.assembleDebug(any(), any(), any()) } returns + BuildResult(true, listOf("assembleDebug"), apkPath = "/data/apks/app-debug.apk") + val result = tools.gradleBuild("assembleDebug") + assertTrue(result.contains("BUILD SUCCESS")) + assertTrue(result.contains("/data/apks/app-debug.apk")) + } + + @Test + fun testGradleBuildFailureIncludesErrorMessage() = runTest { + coEvery { mockBuildRunner.assembleDebug(any(), any(), any()) } returns + BuildResult(false, listOf("assembleDebug"), errorMessage = "Compilation error in Foo.kt") + val result = tools.gradleBuild("assembleDebug") + assertTrue(result.contains("BUILD FAILED")) + assertTrue(result.contains("Compilation error")) + } + + // ── shizukuDeploy ──────────────────────────────────────────────────────── + + @Test + fun testShizukuDeployReturnsMissingApkError() { + val result = tools.shizukuDeploy("/nonexistent/app.apk", "ai.clu.sample", "ai.clu.sample.MainActivity") + assertTrue("Expected APK not found error", result.startsWith("ERROR: APK not found")) + } + + @Test + fun testShizukuDeployInstallFailureShortCircuits() = runTest { + val apk = File(tempDir, "test.apk").also { it.createNewFile() } + coEvery { mockDeployer.install(apk) } returns DeployResult(false, "pm: failed") + val result = tools.shizukuDeploy(apk.absolutePath, "ai.clu.sample", "ai.clu.sample.MainActivity") + assertTrue(result.startsWith("INSTALL FAILED:")) + } + + @Test + fun testShizukuDeploySuccessReturnsOk() = runTest { + val apk = File(tempDir, "test.apk").also { it.createNewFile() } + coEvery { mockDeployer.install(apk) } returns DeployResult(true, "Success") + coEvery { mockDeployer.launch("ai.clu.sample", "ai.clu.sample.MainActivity") } returns + DeployResult(true, "") + val result = tools.shizukuDeploy(apk.absolutePath, "ai.clu.sample", "ai.clu.sample.MainActivity") + assertTrue(result.startsWith("OK:")) + assertTrue(result.contains("ai.clu.sample")) + } + + // ── readLogcat ─────────────────────────────────────────────────────────── + + @Test + fun testReadLogcatReturnsNoOutputSentinelOnEmptyBuffer() { + val result = tools.readLogcat(10, "", "", "") + assertEquals("(no logcat output matching filter)", result) + } + + @Test + fun testReadLogcatClampsMaxLinesToBounds() { + // 0 and 999 should be clamped to [1, 200] — just verify no exception thrown + tools.readLogcat(0, "", "", "") + tools.readLogcat(999, "", "", "") + } +} diff --git a/clu-android/app/src/test/kotlin/ai/clu/build/AndroidBuildRunnerTest.kt b/clu-android/app/src/test/kotlin/ai/clu/build/AndroidBuildRunnerTest.kt new file mode 100644 index 000000000..88d44f095 --- /dev/null +++ b/clu-android/app/src/test/kotlin/ai/clu/build/AndroidBuildRunnerTest.kt @@ -0,0 +1,124 @@ +package ai.clu.build + +import ai.clu.shell.ShellResult +import android.content.Context +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File + +class AndroidBuildRunnerTest { + + private lateinit var mockContext: Context + private lateinit var tempDir: File + private lateinit var runner: AndroidBuildRunner + + @Before + fun setUp() { + BuildGate.resume() + tempDir = createTempDir("build_test") + mockContext = mockk { + every { filesDir } returns tempDir + every { cacheDir } returns tempDir + } + runner = AndroidBuildRunner(mockContext, tempDir) + } + + @After + fun tearDown() { + BuildGate.resume() + tempDir.deleteRecursively() + } + + // ── BuildResult ─────────────────────────────────────────────────────────── + + @Test + fun testBuildResultIsSuccess() { + val result = BuildResult(success = true, tasks = listOf("assembleDebug")) + assertTrue(result.isSuccess) + } + + @Test + fun testBuildResultIsFailure() { + val result = BuildResult(success = false, tasks = listOf("assembleDebug"), errorMessage = "Compilation failed") + assertFalse(result.isSuccess) + assertNotNull(result.errorMessage) + } + + @Test + fun testBuildResultCarriesApkPath() { + val result = BuildResult(success = true, tasks = listOf("assembleDebug"), apkPath = "/data/apks/app-debug.apk") + assertEquals("/data/apks/app-debug.apk", result.apkPath) + } + + // ── findOutputApk ───────────────────────────────────────────────────────── + + @Test + fun testFindOutputApkReturnsNullWhenNoApk() { + assertNull(runner.findOutputApk("debug")) + } + + @Test + fun testFindOutputApkFindsApkWhenPresent() { + val apkDir = File(tempDir, "app/build/outputs/apk/debug") + apkDir.mkdirs() + File(apkDir, "app-debug.apk").createNewFile() + val apk = runner.findOutputApk("debug") + assertNotNull(apk) + assertTrue(apk!!.name.endsWith(".apk")) + } + + @Test + fun testFindOutputApkPrefersRamDiskOverProjectDir() = runTest { + val ramDisk = RamDisk(mockContext) { ShellResult(1, emptyList(), emptyList()) } + ramDisk.mount() + + // APK in ramdisk at a path where parentFile.name == "debug" + val outputsDir = File(ramDisk.buildCacheDir, "app/outputs/apk/debug") + outputsDir.mkdirs() + val ramApk = File(outputsDir, "app-debug.apk").also { it.createNewFile() } + + // Decoy APK in project dir (should NOT be returned) + val projectApkDir = File(tempDir, "app/build/outputs/apk/debug") + projectApkDir.mkdirs() + File(projectApkDir, "old-app-debug.apk").createNewFile() + + val found = runner.findOutputApk("debug", ramDisk) + assertNotNull(found) + assertEquals(ramApk.absolutePath, found?.absolutePath) + } + + // ── BuildGate ───────────────────────────────────────────────────────────── + + @Test + fun testAssembleDebugReturnsSuspendedErrorWhenGateClosed() = runTest { + BuildGate.suspend() + val result = runner.assembleDebug() + assertFalse(result.isSuccess) + assertTrue(result.errorMessage?.contains("suspended") == true) + } + + @Test + fun testRunTestsReturnsSuspendedErrorWhenGateClosed() = runTest { + BuildGate.suspend() + val result = runner.runTests() + assertFalse(result.isSuccess) + assertTrue(result.errorMessage?.contains("suspended") == true) + } + + @Test + fun testGateResumeAllowsBuilds() { + BuildGate.suspend() + assertTrue(BuildGate.isSuspended) + BuildGate.resume() + assertFalse(BuildGate.isSuspended) + } +} diff --git a/clu-android/app/src/test/kotlin/ai/clu/build/RamDiskTest.kt b/clu-android/app/src/test/kotlin/ai/clu/build/RamDiskTest.kt new file mode 100644 index 000000000..1a6ca6674 --- /dev/null +++ b/clu-android/app/src/test/kotlin/ai/clu/build/RamDiskTest.kt @@ -0,0 +1,106 @@ +package ai.clu.build + +import ai.clu.shell.ShellResult +import android.content.Context +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.File + +class RamDiskTest { + + private lateinit var tempDir: File + + @Before + fun setUp() { + tempDir = createTempDir("ramdisk_test") + } + + @After + fun tearDown() { + tempDir.deleteRecursively() + } + + private fun fakeContext(): Context = mockk { every { cacheDir } returns tempDir } + + private fun makeRamDisk(mountExitCode: Int = 0): RamDisk = + RamDisk(fakeContext()) { ShellResult(mountExitCode, emptyList(), emptyList()) } + + @Test + fun testMountSuccessUsesTmpfs() = runTest { + val ramDisk = makeRamDisk(mountExitCode = 0) + val result = ramDisk.mount() + assertTrue(result) + assertTrue(ramDisk.isActive) + assertEquals(RamDisk.Type.TMPFS, ramDisk.type) + assertTrue(ramDisk.buildCacheDir.exists()) + assertTrue(ramDisk.gradleCacheDir.exists()) + } + + @Test + fun testMountFailureFallsBackToCacheDir() = runTest { + val ramDisk = makeRamDisk(mountExitCode = 1) + val result = ramDisk.mount() + assertTrue(result) + assertTrue(ramDisk.isActive) + assertEquals(RamDisk.Type.JVM_CACHE_FALLBACK, ramDisk.type) + assertTrue(ramDisk.buildCacheDir.exists()) + assertTrue(ramDisk.gradleCacheDir.exists()) + } + + @Test + fun testBuildCacheDirIsUnderMountPoint() = runTest { + val ramDisk = makeRamDisk(mountExitCode = 1) + ramDisk.mount() + assertTrue(ramDisk.buildCacheDir.absolutePath.startsWith(ramDisk.mountPoint.absolutePath)) + } + + @Test + fun testGradleCacheDirIsUnderMountPoint() = runTest { + val ramDisk = makeRamDisk(mountExitCode = 1) + ramDisk.mount() + assertTrue(ramDisk.gradleCacheDir.absolutePath.startsWith(ramDisk.mountPoint.absolutePath)) + } + + @Test + fun testFlushDeletesContents() = runTest { + val ramDisk = makeRamDisk(mountExitCode = 1) + ramDisk.mount() + val testFile = File(ramDisk.buildCacheDir, "test.apk").also { it.createNewFile() } + assertTrue(testFile.exists()) + ramDisk.flush() + assertFalse(testFile.exists()) + } + + @Test + fun testUnmountResetsActiveState() = runTest { + val ramDisk = makeRamDisk(mountExitCode = 1) + ramDisk.mount() + assertTrue(ramDisk.isActive) + ramDisk.unmount() + assertFalse(ramDisk.isActive) + } + + @Test + fun testMountThenUnmountThenRemountWorks() = runTest { + val ramDisk = makeRamDisk(mountExitCode = 1) + ramDisk.mount() + ramDisk.unmount() + val result = ramDisk.mount() + assertTrue(result) + assertTrue(ramDisk.isActive) + } + + @Test + fun testFlushOnUnmountedDiskIsNoOp() = runTest { + val ramDisk = makeRamDisk(mountExitCode = 1) + // flush before mount must not throw + ramDisk.flush() + } +} diff --git a/clu-android/gradle/wrapper/gradle-wrapper.jar b/clu-android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..f8e1ee312 Binary files /dev/null and b/clu-android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/clu-android/gradle/wrapper/gradle-wrapper.properties b/clu-android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..23449a2b5 --- /dev/null +++ b/clu-android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/clu-android/gradlew b/clu-android/gradlew new file mode 100755 index 000000000..adff685a0 --- /dev/null +++ b/clu-android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/clu-android/gradlew.bat b/clu-android/gradlew.bat new file mode 100644 index 000000000..c4bdd3ab8 --- /dev/null +++ b/clu-android/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega