diff --git a/.github/workflows/debug.yml b/.github/workflows/debug.yml index 81ef84edcb..2391a24705 100644 --- a/.github/workflows/debug.yml +++ b/.github/workflows/debug.yml @@ -159,6 +159,11 @@ jobs: fi echo "BUILD_TYPE=$BUILD_TYPE" >> $GITHUB_ENV + - name: Install jq + run: | + sudo apt-get update + sudo apt-get install -y jq + - name: Get PR and Commit Information id: pr_info run: | @@ -184,6 +189,11 @@ jobs: run: | JIRA_TICKET=$(echo "$BRANCH_NAME" | grep -o 'ADFA-[0-9]\+' | head -1) + # If no Jira ticket found in branch name, check the commit message + if [ -z "$JIRA_TICKET" ]; then + JIRA_TICKET=$(echo "$COMMIT_MSG" | grep -o 'ADFA-[0-9]\+' | head -1) + fi + if [ -n "$JIRA_TICKET" ]; then JIRA_URL="https://appdevforall.atlassian.net/browse/${JIRA_TICKET}" @@ -292,15 +302,88 @@ jobs: echo "FIREBASE_CONSOLE_URL=$FIREBASE_URL" >> $GITHUB_OUTPUT + - name: Update Jira Fix Version + if: github.ref == 'refs/heads/stage' + env: + JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }} + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + NEXT_RELEASE_VERSION: ${{ vars.NEXT_RELEASE_VERSION }} + run: | + # Try to get Jira ticket from extract_jira step output first (handles branch name and commit message) + JIRA_TICKET="${{ steps.extract_jira.outputs.JIRA_TICKET }}" + + # If extract_jira didn't find a ticket (returned "N/A"), try merge commit message + if [ "$JIRA_TICKET" == "N/A" ] || [ -z "$JIRA_TICKET" ]; then + MERGE_COMMIT_MSG=$(git log -1 --pretty=%B 2>/dev/null || echo "") + JIRA_TICKET=$(echo "$MERGE_COMMIT_MSG" | grep -o 'ADFA-[0-9]\+' | head -1 || echo "") + fi + + if [ -z "$JIRA_TICKET" ] || [ "$JIRA_TICKET" == "N/A" ]; then + echo "ERROR: No Jira ticket found in branch name, commit message, or merge commit" + echo "Branch name or commit message must contain a Jira ticket in format ADFA-XXXX" + exit 1 + fi + + echo "Found Jira ticket: $JIRA_TICKET" + + # Validate NEXT_RELEASE_VERSION exists and exactly two digits, dot, two digits + if [ -z "$NEXT_RELEASE_VERSION" ]; then + echo "ERROR: NEXT_RELEASE_VERSION variable is not set" + exit 1 + fi + + if [[ ! "$NEXT_RELEASE_VERSION" =~ ^[0-9]{2}\.[0-9]{2}$ ]]; then + echo "ERROR: NEXT_RELEASE_VERSION format is invalid: $NEXT_RELEASE_VERSION" + echo "Expected format: YY.ww (two digits, dot, two digits, e.g., 25.50)" + exit 1 + fi + + echo "Validated NEXT_RELEASE_VERSION: $NEXT_RELEASE_VERSION" + + # Update Jira fix version via API + JIRA_BASE_URL="https://appdevforall.atlassian.net" + JIRA_API_URL="${JIRA_BASE_URL}/rest/api/3/issue/${JIRA_TICKET}" + + # Prepare JSON payload for fixVersions update + # Assumes NEXT_RELEASE_VERSION has already been created in Jira. + JSON_PAYLOAD=$(jq -n \ + --arg version "$NEXT_RELEASE_VERSION" \ + '{ + fields: { + fixVersions: [{ + name: $version + }] + } + }') + + echo "Updating Jira ticket $JIRA_TICKET with fix version $NEXT_RELEASE_VERSION..." + + HTTP_RESPONSE=$(curl -s -w "\n%{http_code}" \ + -X PUT \ + -u "$JIRA_EMAIL:$JIRA_API_TOKEN" \ + -H "Accept: application/json" \ + -H "Content-Type: application/json" \ + -d "$JSON_PAYLOAD" \ + "$JIRA_API_URL") + + HTTP_BODY=$(echo "$HTTP_RESPONSE" | sed -n '1p') + HTTP_STATUS=$(echo "$HTTP_RESPONSE" | sed -n '2p') + + if [ "$HTTP_STATUS" -ge 200 ] && [ "$HTTP_STATUS" -lt 300 ]; then + echo "Successfully updated Jira ticket $JIRA_TICKET with fix version $NEXT_RELEASE_VERSION" + else + echo "ERROR: Failed to update Jira ticket" + echo "HTTP Status: $HTTP_STATUS" + echo "Response: $HTTP_BODY" + exit 1 + fi + - name: Clean up build folder after upload run: | echo "Cleaning up build folder after Firebase upload..." rm -rf app/build/ echo "Build folder cleanup completed" - - name: Install jq - run: sudo apt-get update && sudo apt-get install -y jq - - name: Send Rich Slack Notification env: SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e4d5246fd4..009fe74319 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -465,6 +465,78 @@ jobs: echo "FIREBASE_CONSOLE_URL=$FIREBASE_URL" >> $GITHUB_OUTPUT + - name: Set up SSH key + env: + GREENGEEKS_HOST: ${{ vars.GREENGEEKS_SSH_HOST }} + GREENGEEKS_KEY: ${{ secrets.GREENGEEKS_SSH_PRIVATE_KEY }} + GREENGEEKS_USER: ${{ vars.GREENGEEKS_SSH_USER }} + run: | + mkdir -p ~/.ssh + if [ -z "$GREENGEEKS_HOST" ]; then + echo "Error: SSH_HOST variable is not set" + exit 1 + fi + # Write the SSH key, ensuring proper formatting + echo "$GREENGEEKS_KEY" > ~/.ssh/id_rsa + # Remove any trailing newlines and ensure proper key format + sed -i '' -e '$ { /^$/ d; }' ~/.ssh/id_rsa 2>/dev/null || sed -i '$ { /^$/ d; }' ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + # Verify key format + if ! grep -q "BEGIN.*PRIVATE KEY" ~/.ssh/id_rsa; then + echo "Error: SSH key does not appear to be in correct format" + exit 1 + fi + # Configure SSH to use only the key file and disable other auth methods + cat > ~/.ssh/config </dev/null || true + ssh-keyscan -H "$GREENGEEKS_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true + + - name: Upload APK to server + env: + APK_PATH: ${{ steps.find_apk.outputs.APK_PATH }} + GREENGEEKS_HOST: ${{ vars.GREENGEEKS_SSH_HOST }} + GREENGEEKS_USER: ${{ vars.GREENGEEKS_SSH_USER }} + run: | + if [ -z "$APK_PATH" ]; then + echo "Error: APK_PATH is not set" + exit 1 + fi + if [ ! -f "$APK_PATH" ]; then + echo "Error: APK file not found at $APK_PATH" + exit 1 + fi + echo "Uploading $APK_PATH to $GREENGEEKS_HOST..." + scp -o StrictHostKeyChecking=no "$APK_PATH" "$GREENGEEKS_USER@$GREENGEEKS_HOST:public_html/apk_repo/" + - name: Clean up build folder after upload run: | echo "Cleaning up build folder after Firebase upload..." @@ -567,4 +639,16 @@ jobs: if: always() run: | rm -f app/google-services.json - echo "google-services.json cleaned up successfully" \ No newline at end of file + echo "google-services.json cleaned up successfully" + - name: Cleanup ssh + if: always() + run: | + # Remove SSH key + rm -f ~/.ssh/id_rsa + # Clean up SSH known_hosts (remove the entry for this host) + SSH_HOST="${{ vars.GREENGEEKS_SSH_HOST }}" + if [ -n "$SSH_HOST" ]; then + ssh-keygen -R "$SSH_HOST" 2>/dev/null || true + fi + # Remove entire .ssh directory if empty + rmdir ~/.ssh 2>/dev/null || true \ No newline at end of file diff --git a/.gitignore b/.gitignore index e81e4427a6..489614cdc7 100755 --- a/.gitignore +++ b/.gitignore @@ -131,4 +131,21 @@ tests/test-home **/build # Release files -*.zim \ No newline at end of file +*.zim + +# Other IDEs +.cursor/ +.vscode/ + +# bin directories +annotation-processors-ksp/bin/ +annotation-processors/bin/ +annotations/bin/ +build-info/bin/ +composite-builds/**/bin/ +gradle-plugin/bin/ +logger/bin/ +lookup/bin/ +shared/bin/ +subprojects/**/bin/ +testing/**/bin/ diff --git a/actions/src/main/java/com/itsaky/androidide/actions/SidebarSlotExceededException.kt b/actions/src/main/java/com/itsaky/androidide/actions/SidebarSlotExceededException.kt new file mode 100644 index 0000000000..26cdc3fa32 --- /dev/null +++ b/actions/src/main/java/com/itsaky/androidide/actions/SidebarSlotExceededException.kt @@ -0,0 +1,20 @@ + + +package com.itsaky.androidide.actions + +class SidebarSlotExceededException( + requestedSlots: Int, + availableSlots: Int, + pluginId: String? = null +) : RuntimeException( + buildMessage(requestedSlots, availableSlots, pluginId) +) { + companion object { + private fun buildMessage(requested: Int, available: Int, pluginId: String?): String { + val pluginInfo = pluginId?.let { " Plugin '$it'" } ?: "" + return "Sidebar slot limit exceeded.$pluginInfo declared $requested sidebar item(s), " + + "but only $available slot(s) available. " + + "IdeNavigationRailView supports a maximum of ${SidebarSlotManager.MAX_NAVIGATION_RAIL_ITEMS} items." + } + } +} diff --git a/actions/src/main/java/com/itsaky/androidide/actions/SidebarSlotManager.kt b/actions/src/main/java/com/itsaky/androidide/actions/SidebarSlotManager.kt new file mode 100644 index 0000000000..ab2f6bc468 --- /dev/null +++ b/actions/src/main/java/com/itsaky/androidide/actions/SidebarSlotManager.kt @@ -0,0 +1,54 @@ + +package com.itsaky.androidide.actions + +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger + +object SidebarSlotManager { + + const val MAX_NAVIGATION_RAIL_ITEMS = 12 + + private val builtInItemCount = AtomicInteger(0) + private val reservedPluginSlots = ConcurrentHashMap() + + fun setBuiltInItemCount(count: Int) { + require(count in 0..MAX_NAVIGATION_RAIL_ITEMS) { + "Built-in item count must be between 0 and $MAX_NAVIGATION_RAIL_ITEMS" + } + builtInItemCount.set(count) + } + + fun getBuiltInItemCount(): Int = builtInItemCount.get() + + fun getReservedPluginSlotCount(): Int = reservedPluginSlots.values.sum() + + fun getTotalItemCount(): Int = builtInItemCount.get() + getReservedPluginSlotCount() + + fun getAvailableSlotsForPlugins(): Int = + (MAX_NAVIGATION_RAIL_ITEMS - builtInItemCount.get() - getReservedPluginSlotCount()) + .coerceAtLeast(0) + + fun canAddPluginItems(count: Int): Boolean = count <= getAvailableSlotsForPlugins() + + fun getDeclaredSlots(pluginId: String): Int = reservedPluginSlots[pluginId] ?: 0 + + @Throws(SidebarSlotExceededException::class) + fun reservePluginSlots(pluginId: String, count: Int) { + if (count <= 0) return + + val available = getAvailableSlotsForPlugins() + if (count > available) { + throw SidebarSlotExceededException(count, available, pluginId) + } + reservedPluginSlots[pluginId] = count + } + + fun releasePluginSlots(pluginId: String) { + reservedPluginSlots.remove(pluginId) + } + + fun reset() { + builtInItemCount.set(0) + reservedPluginSlots.clear() + } +} diff --git a/apk-viewer-plugin/src/main/AndroidManifest.xml b/apk-viewer-plugin/src/main/AndroidManifest.xml index c567563a99..f829d54701 100644 --- a/apk-viewer-plugin/src/main/AndroidManifest.xml +++ b/apk-viewer-plugin/src/main/AndroidManifest.xml @@ -38,6 +38,10 @@ android:name="plugin.main_class" android:value="com.example.sampleplugin.ApkViewer" /> + + diff --git a/apk-viewer-plugin/src/main/kotlin/com/example/sampleplugin/ApkViewer.kt b/apk-viewer-plugin/src/main/kotlin/com/example/sampleplugin/ApkViewer.kt index 4794dd9f00..c49da24a8a 100644 --- a/apk-viewer-plugin/src/main/kotlin/com/example/sampleplugin/ApkViewer.kt +++ b/apk-viewer-plugin/src/main/kotlin/com/example/sampleplugin/ApkViewer.kt @@ -8,6 +8,7 @@ import com.itsaky.androidide.plugins.extensions.EditorTabExtension import com.itsaky.androidide.plugins.extensions.MenuItem import com.itsaky.androidide.plugins.extensions.TabItem import com.itsaky.androidide.plugins.extensions.EditorTabItem +import com.itsaky.androidide.plugins.extensions.NavigationItem import com.itsaky.androidide.plugins.services.IdeEditorTabService import com.example.sampleplugin.fragments.ApkAnalyzerFragment @@ -79,6 +80,22 @@ class ApkViewer : IPlugin, UIExtension, EditorTabExtension { ) } + // UIExtension - Sidebar navigation item + override fun getSideMenuItems(): List { + return listOf( + NavigationItem( + id = "apk_analyzer_sidebar", + title = "APK Analyzer", + icon = android.R.drawable.ic_menu_info_details, + isEnabled = true, + isVisible = true, + group = "tools", + order = 0, + action = { openApkAnalyzerTab() } + ) + ) + } + // EditorTabExtension - Main editor tab to display the analyzer override fun getMainEditorTabs(): List { return listOf( diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 37d7c5ff33..0e85f10408 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,14 +16,41 @@ import java.net.URL import java.nio.file.Files import java.nio.file.StandardCopyOption import java.nio.file.attribute.FileTime +import java.util.Locale import java.util.Properties +import java.util.zip.CRC32 import java.util.zip.Deflater import java.util.zip.ZipEntry import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream -import java.util.zip.CRC32 import kotlin.reflect.jvm.javaMethod +fun TaskContainer.registerD8Task( + taskName: String, + inputJar: File, + outputDex: File +): org.gradle.api.tasks.TaskProvider { + val androidSdkDir = android.sdkDirectory.absolutePath + val buildToolsVersion = android.buildToolsVersion // Gets the version from your project + val d8Executable = File("$androidSdkDir/build-tools/$buildToolsVersion/d8") + + if (!d8Executable.exists()) { + throw FileNotFoundException("D8 executable not found at: ${d8Executable.absolutePath}") + } + + return register(taskName) { + inputs.file(inputJar) + outputs.file(outputDex) + + commandLine( + d8Executable.absolutePath, + "--release", // Enables optimizations + "--output", outputDex.parent, // D8 outputs to a directory + inputJar.absolutePath + ) + } +} + plugins { id("com.android.application") id("kotlin-android") @@ -120,6 +147,11 @@ android { excludes.add("META-INF/gradle/incremental.annotation.processors") } } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + isCoreLibraryDesugaringEnabled = true + } } kapt { arguments { arg("eventBusIndex", "${BuildConfig.PACKAGE_NAME}.events.AppEventsIndex") } } @@ -271,10 +303,9 @@ dependencies { implementation(libs.common.markwon.linkify) implementation(libs.commons.text.v1140) - implementation("com.squareup.okhttp3:okhttp:4.12.0") - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") + implementation(libs.kotlinx.serialization.json) // Koin for Dependency Injection - implementation("io.insert-koin:koin-android:3.5.3") + implementation(libs.koin.android) implementation(libs.androidx.security.crypto) // Sentry Android SDK (core + replay for quality configuration) @@ -290,8 +321,8 @@ dependencies { implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.google.genai) - "v7Implementation"(files("libs/v7/llama-v7-release.aar")) - "v8Implementation"(files("libs/v8/llama-v8-release.aar")) + implementation(project(":llama-api")) + coreLibraryDesugaring(libs.desugar.jdk.libs.v215) } tasks.register("downloadDocDb") { @@ -341,30 +372,118 @@ tasks.register("downloadDocDb") { } fun createAssetsZip(arch: String) { - val outputDir = - project.layout.buildDirectory - .dir("outputs/assets") - .get() - .asFile - if (!outputDir.exists()) { - outputDir.mkdirs() - println("Creating output directory: ${outputDir.absolutePath}") - } + val outputDir = + project.layout.buildDirectory + .dir("outputs/assets") + .get() + .asFile + if (!outputDir.exists()) { + outputDir.mkdirs() + } + val zipFile = outputDir.resolve("assets-$arch.zip") + + // --- Part 1: Get the classes.jar from our llama-impl AAR --- + val llamaAarName = when (arch) { + "arm64-v8a" -> "llama-impl-v8-release.aar" + "armeabi-v7a" -> "llama-impl-v7-release.aar" + else -> throw IllegalArgumentException("Unsupported architecture for Llama AAR: $arch") + } + val originalLlamaAarFile = project.rootDir.resolve("llama-impl/build/outputs/aar/$llamaAarName") + + val tempDir = project.layout.buildDirectory.dir("tmp/d8/$arch").get().asFile + tempDir.deleteRecursively() + tempDir.mkdirs() + val tempClassesJar = File(tempDir, "classes.jar") + + // Extract just the classes.jar from our target AAR + ZipInputStream(originalLlamaAarFile.inputStream()).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + if (entry.name == "classes.jar") { + tempClassesJar.outputStream().use { fos -> zis.copyTo(fos) } + break + } + entry = zis.nextEntry + } + } + if (!tempClassesJar.exists()) { + throw GradleException("classes.jar not found inside ${originalLlamaAarFile.name}") + } - val zipFile = outputDir.resolve("assets-$arch.zip") - val sourceDir = project.rootDir.resolve("assets") - val bootstrapName = "bootstrap-$arch.zip" - val androidSdkName = "android-sdk-$arch.zip" - - ZipOutputStream(zipFile.outputStream()).use { zipOut -> - arrayOf( - androidSdkName, - "localMvnRepository.zip", - "gradle-8.14.3-bin.zip", - "gradle-api-8.14.3.jar.zip", - "documentation.db", - bootstrapName, - ).forEach { fileName -> + val llamaImplProject = project.project(":llama-impl") + val flavorName = if (arch == "arm64-v8a") "v8" else "v7" + val configName = "${flavorName}ReleaseRuntimeClasspath" + val runtimeClasspathFiles = llamaImplProject.configurations.getByName(configName).files + + val explodedAarsDir = project.layout.buildDirectory.dir("tmp/exploded-aars/$arch").get().asFile + explodedAarsDir.mkdirs() + + val d8Classpath = mutableListOf() + runtimeClasspathFiles.forEach { file -> + if (file.name.endsWith(".jar")) { + d8Classpath.add(file) + } else if (file.name.endsWith(".aar")) { + // It's an AAR, extract its classes.jar + project.copy { + from(project.zipTree(file)) { + include("classes.jar") + } + into(explodedAarsDir) + // Rename to avoid collisions + rename { "${file.nameWithoutExtension}-classes.jar" } + } + d8Classpath.add(File(explodedAarsDir, "${file.nameWithoutExtension}-classes.jar")) + } + } + + // --- Part 3: Run D8 with the corrected command-line arguments --- + val dexOutputFile = File(tempDir, "classes.dex") + project.exec { + val androidSdkDir = android.sdkDirectory.absolutePath + val buildToolsVersion = android.buildToolsVersion + val d8Executable = File("$androidSdkDir/build-tools/$buildToolsVersion/d8") + + // 1. Start building the command arguments list + val d8Command = mutableListOf() + d8Command.add(d8Executable.absolutePath) + d8Command.add("--release") + d8Command.add("--min-api") + d8Command.add(android.defaultConfig.minSdk.toString()) // Add minSdk for better desugaring + + // 2. Add the --classpath flag REPEATEDLY for each dependency file + d8Classpath.forEach { file -> + if (file.exists()) { + d8Command.add("--classpath") + d8Command.add(file.absolutePath) + } + } + + // 3. Add the final output and input arguments + d8Command.add("--output") + d8Command.add(tempDir.absolutePath) + d8Command.add(tempClassesJar.absolutePath) + + // 4. Set the full command line + commandLine(d8Command) + }.assertNormalExitValue() + + if (!dexOutputFile.exists()) { + throw GradleException("D8 task failed to produce classes.dex") + } + + // --- Part 4: Repackage everything into the final assets-*.zip (Unchanged) --- + val sourceDir = project.rootDir.resolve("assets") + val bootstrapName = "bootstrap-$arch.zip" + val androidSdkName = "android-sdk-$arch.zip" + ZipOutputStream(zipFile.outputStream()).use { zipOut -> + arrayOf( + androidSdkName, + "localMvnRepository.zip", + "gradle-8.14.3-bin.zip", + "gradle-api-8.14.3.jar.zip", + "documentation.db", + bootstrapName, + ).forEach { fileName -> val filePath = sourceDir.resolve(fileName) if (!filePath.exists()) { throw FileNotFoundException(filePath.absolutePath) @@ -382,18 +501,126 @@ fun createAssetsZip(arch: String) { filePath.inputStream().use { input -> input.copyTo(zipOut) } zipOut.closeEntry() } + project.logger.lifecycle("Repackaging Llama AAR with classes.dex...") + + // Create the entry for our modified AAR inside assets-*.zip + zipOut.putNextEntry(ZipEntry("dynamic_libs/llama.aar")) + + // Use another ZipOutputStream to build the new AAR in memory and stream it + ZipOutputStream(zipOut).use { aarZipOut -> + // Copy all files from the original AAR *except* classes.jar + ZipInputStream(originalLlamaAarFile.inputStream()).use { originalAarStream -> + var entry = originalAarStream.nextEntry + while (entry != null) { + if (entry.name != "classes.jar") { + aarZipOut.putNextEntry(ZipEntry(entry.name)) + originalAarStream.copyTo(aarZipOut) + aarZipOut.closeEntry() + } + entry = originalAarStream.nextEntry + } + } + aarZipOut.putNextEntry(ZipEntry("classes.dex")) + dexOutputFile.inputStream().use { dexInput -> dexInput.copyTo(aarZipOut) } + aarZipOut.closeEntry() + } + println("Created ${zipFile.name} successfully at ${zipFile.parentFile.absolutePath}") + } +} - println("Created ${zipFile.name} successfully at ${zipFile.parentFile.absolutePath}") - } +fun registerBundleLlamaAssetsTask(flavor: String, arch: String): TaskProvider { + val capitalized = + flavor.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() } + return tasks.register("bundle${capitalized}LlamaAssets") { + dependsOn("assemble${capitalized}Assets") + + doLast { + val assetsZip = + project.layout.buildDirectory + .file("outputs/assets/assets-$arch.zip") + .get() + .asFile + if (!assetsZip.exists()) { + throw GradleException("Assets zip not found: ${assetsZip.absolutePath}. Run assemble${capitalized}Assets first.") + } + + val tempAar = Files.createTempFile("llama-$flavor", ".aar").toFile() + var found = false + ZipInputStream(assetsZip.inputStream()).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + if (entry.name == "dynamic_libs/llama.aar") { + tempAar.outputStream().use { zis.copyTo(it) } + found = true + break + } + entry = zis.nextEntry + } + } + + if (!found) { + tempAar.delete() + throw GradleException("dynamic_libs/llama.aar not found inside ${assetsZip.name}") + } + + val targetDir = project.rootProject.file("assets/release/$flavor/dynamic_libs") + targetDir.mkdirs() + val destBr = File(targetDir, "llama-$flavor.aar.br") + val destAar = File(targetDir, "llama-$flavor.aar") + + destBr.delete() + destAar.delete() + + val brotliAvailable = + try { + val result = + project.exec { + commandLine("brotli", "--version") + isIgnoreExitValue = true + } + result.exitValue == 0 + } catch (_: Exception) { + false + } + + if (brotliAvailable) { + project.exec { + commandLine("brotli", "-f", "-o", destBr.absolutePath, tempAar.absolutePath) + } + project.logger.lifecycle( + "Bundled llama AAR compressed to ${ + destBr.relativeTo( + project.rootProject.projectDir + ) + }" + ) + destAar.delete() + } else { + project.logger.warn( + "brotli CLI not found; bundling llama AAR uncompressed at ${ + destAar.relativeTo( + project.rootProject.projectDir + ) + }" + ) + tempAar.copyTo(destAar, overwrite = true) + destBr.delete() + } + + tempAar.delete() + } + } } tasks.register("assembleV8Assets") { + dependsOn(":llama-impl:assembleV8Release") doLast { createAssetsZip("arm64-v8a") } } tasks.register("assembleV7Assets") { + dependsOn(":llama-impl:assembleV7Release") doLast { createAssetsZip("armeabi-v7a") } @@ -403,6 +630,9 @@ tasks.register("assembleAssets") { dependsOn("assembleV8Assets", "assembleV7Assets") } +val bundleLlamaV7Assets = registerBundleLlamaAssetsTask(flavor = "v7", arch = "armeabi-v7a") +val bundleLlamaV8Assets = registerBundleLlamaAssetsTask(flavor = "v8", arch = "arm64-v8a") + tasks.register("recompressApk") { doLast { val abi: String = extensions.extraProperties["abi"].toString() @@ -438,6 +668,8 @@ afterEvaluate { extensions.extraProperties["noCompressExtensions"] = noCompress } } + + dependsOn(bundleLlamaV8Assets) } tasks.named("assembleV7Release").configure { @@ -450,6 +682,8 @@ afterEvaluate { extensions.extraProperties["noCompressExtensions"] = noCompress } } + + dependsOn(bundleLlamaV7Assets) } tasks.named("assembleV8Debug").configure { diff --git a/app/src/main/java/com/itsaky/androidide/activities/OnboardingActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/OnboardingActivity.kt index 2e18ce99e1..38b3add335 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/OnboardingActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/OnboardingActivity.kt @@ -24,6 +24,9 @@ import android.os.Bundle import android.util.TypedValue import android.view.View import android.view.ViewTreeObserver +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import android.widget.ImageButton import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat import androidx.core.view.ViewCompat @@ -62,6 +65,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.slf4j.LoggerFactory +import androidx.core.view.isVisible class OnboardingActivity : AppIntro2() { private val activityScope = @@ -70,8 +74,10 @@ class OnboardingActivity : AppIntro2() { private var listJdkInstallationsJob: Job? = null private lateinit var feedbackButton: FloatingActionButton private var feedbackButtonManager: FeedbackButtonManager? = null + private lateinit var nextButton: ImageButton + private lateinit var pulseAnimation: Animation - companion object { + companion object { private val logger = LoggerFactory.getLogger(OnboardingActivity::class.java) private const val KEY_ARCHCONFIG_WARNING_IS_SHOWN = "ide.archConfig.experimentalWarning.isShown" @@ -115,7 +121,10 @@ class OnboardingActivity : AppIntro2() { isIndicatorEnabled = true isWizardMode = true - addSlide(GreetingFragment()) + nextButton = findViewById(R.id.next) + pulseAnimation = AnimationUtils.loadAnimation(this, R.anim.pulse_animation) + + addSlide(GreetingFragment()) addSlide(PermissionsInfoFragment()) if (!PackageUtils.isCurrentUserThePrimaryUser(this)) { @@ -246,6 +255,14 @@ class OnboardingActivity : AppIntro2() { isIndicatorEnabled = true isButtonsEnabled = true } + + if (nextButton.isVisible) { + if (nextButton.animation == null) { + nextButton.startAnimation(pulseAnimation) + } + } else { + nextButton.clearAnimation() + } } private fun checkToolsIsInstalled(): Boolean = diff --git a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt index b0f2e8917e..3e42aa960b 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt @@ -21,6 +21,9 @@ import com.itsaky.androidide.ui.models.PluginManagerUiEffect import com.itsaky.androidide.ui.models.PluginManagerUiEvent import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess +import com.itsaky.androidide.utils.flashbarBuilder +import com.itsaky.androidide.utils.errorIcon +import com.itsaky.androidide.utils.showOnUiThread import com.itsaky.androidide.viewmodels.PluginManagerViewModel import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel @@ -162,7 +165,10 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { private fun handleUiEffect(effect: PluginManagerUiEffect) { when (effect) { is PluginManagerUiEffect.ShowError -> { - flashError(effect.message) + flashbarBuilder(duration = 5000L) + .errorIcon() + .message(effect.message) + .showOnUiThread() } is PluginManagerUiEffect.ShowSuccess -> { flashSuccess(effect.message) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt index 1b3b09ec50..0913b2998a 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/BaseEditorActivity.kt @@ -123,6 +123,7 @@ import com.itsaky.androidide.viewmodel.DebuggerViewModel import com.itsaky.androidide.viewmodel.EditorViewModel import com.itsaky.androidide.viewmodel.FileManagerViewModel import com.itsaky.androidide.viewmodel.FileOpResult +import com.itsaky.androidide.viewmodel.RecentProjectsViewModel import com.itsaky.androidide.viewmodel.WADBViewModel import com.itsaky.androidide.xml.resources.ResourceTableRegistry import com.itsaky.androidide.xml.versions.ApiVersionsRegistry @@ -176,6 +177,8 @@ abstract class BaseEditorActivity : var uiDesignerResultLauncher: ActivityResultLauncher? = null val editorViewModel by viewModels() + + val recentProjectsViewModel by viewModels() val debuggerViewModel by viewModels() val wadbViewModel by viewModels() val bottomSheetViewModel by viewModels() @@ -847,15 +850,12 @@ abstract class BaseEditorActivity : open fun handleSearchResults(map: Map>?) { val results = map ?: emptyMap() - setSearchResultAdapter(SearchListAdapter(results, { file -> - doOpenFile(file, null) - hideBottomSheet() - }) { match -> - doOpenFile(match.file, match) - hideBottomSheet() - }) + editorViewModel.onSearchResultsReady(results) - bottomSheetViewModel.setSheetState(currentTab = BottomSheetViewModel.TAB_SEARCH_RESULT) + bottomSheetViewModel.setSheetState( + sheetState = BottomSheetBehavior.STATE_HALF_EXPANDED, + currentTab = BottomSheetViewModel.TAB_SEARCH_RESULT + ) doDismissSearchProgress() } diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index 969bc178af..c33470d710 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -1199,6 +1199,9 @@ open class EditorHandlerActivity : runOnUiThread { performCloseAllFiles(manualFinish = true) } + recentProjectsViewModel.updateProjectModifiedDate( + editorViewModel.getProjectName(), + ) } } diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index 79c22aaf4b..c5e51bffca 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -68,6 +68,7 @@ import com.itsaky.androidide.tooling.api.messages.result.failure import com.itsaky.androidide.tooling.api.messages.result.isSuccessful import com.itsaky.androidide.tooling.api.models.BuildVariantInfo import com.itsaky.androidide.tooling.api.models.mapToSelectedVariants +import com.itsaky.androidide.tooling.api.sync.ProjectSyncHelper import com.itsaky.androidide.utils.DURATION_INDEFINITE import com.itsaky.androidide.utils.DialogUtils.newMaterialDialogBuilder import com.itsaky.androidide.utils.RecursiveFileSearcher @@ -548,7 +549,7 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { protected open fun onProjectInitialized(result: InitializeResult.Success) { editorActivityScope.launch(Dispatchers.IO) { val manager = ProjectManagerImpl.getInstance() - val gradleBuildResult = manager.readGradleBuild() + val gradleBuildResult = ProjectSyncHelper.readGradleBuild(result.cacheFile) if (gradleBuildResult.isFailure) { val error = gradleBuildResult.exceptionOrNull() log.error("Failed to read project cache", error) diff --git a/app/src/main/java/com/itsaky/androidide/adapters/DeleteProjectListAdapter.kt b/app/src/main/java/com/itsaky/androidide/adapters/DeleteProjectListAdapter.kt index e8da3a6e12..00cec3f13a 100644 --- a/app/src/main/java/com/itsaky/androidide/adapters/DeleteProjectListAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/adapters/DeleteProjectListAdapter.kt @@ -4,10 +4,10 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView +import com.itsaky.androidide.R import com.itsaky.androidide.databinding.DeleteProjectsItemBinding +import com.itsaky.androidide.utils.formatDate import org.appdevforall.codeonthego.layouteditor.ProjectFile -import java.text.SimpleDateFormat -import java.util.Locale class DeleteProjectListAdapter( private var projects: List, @@ -36,12 +36,16 @@ class DeleteProjectListAdapter( notifyDataSetChanged() } + fun renderDate(binding: DeleteProjectsItemBinding, project: ProjectFile) { + binding.projectDate.text = project.renderDateText(binding.root.context) + } + inner class ProjectViewHolder(private val binding: DeleteProjectsItemBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(project: ProjectFile) { binding.projectName.text = project.name - binding.projectDate.text = formatDate(project.date ?: "") + renderDate(binding, project) binding.icon.text = project.name.take(2).uppercase() binding.checkbox.visibility = View.VISIBLE @@ -59,25 +63,5 @@ class DeleteProjectListAdapter( } } } - - private fun formatDate(dateString: String): String { - return try { - val inputFormat = - SimpleDateFormat("EEE MMM dd HH:mm:ss z yyyy", Locale.getDefault()) - val date = inputFormat.parse(dateString) - val day = SimpleDateFormat("d", Locale.ENGLISH).format(date).toInt() - val suffix = when { - day in 11..13 -> "th" - day % 10 == 1 -> "st" - day % 10 == 2 -> "nd" - day % 10 == 3 -> "rd" - else -> "th" - } - val outputFormat = SimpleDateFormat("d'$suffix', MMMM yyyy", Locale.getDefault()) - outputFormat.format(date) - } catch (e: Exception) { - dateString.take(5) - } - } } diff --git a/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt b/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt index ec9d289ba4..23474e0f37 100644 --- a/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/adapters/RecentProjectsAdapter.kt @@ -20,6 +20,7 @@ import com.itsaky.androidide.databinding.SavedRecentProjectItemBinding import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag.DELETE_PROJECT import com.itsaky.androidide.idetooltips.TooltipTag.DELETE_PROJECT_DIALOG +import com.itsaky.androidide.idetooltips.TooltipTag.PROJECT_INFO_TOOLTIP import com.itsaky.androidide.idetooltips.TooltipTag.PROJECT_RECENT_RENAME import com.itsaky.androidide.idetooltips.TooltipTag.PROJECT_RECENT_TOP import com.itsaky.androidide.idetooltips.TooltipTag.PROJECT_RENAME_DIALOG @@ -31,14 +32,13 @@ import org.appdevforall.codeonthego.layouteditor.ProjectFile import org.appdevforall.codeonthego.layouteditor.databinding.TextinputlayoutBinding import org.slf4j.LoggerFactory import java.io.File -import java.text.SimpleDateFormat -import java.util.Locale class RecentProjectsAdapter( private var projects: List, private val onProjectClick: (File) -> Unit, private val onRemoveProjectClick: (ProjectFile) -> Unit, private val onFileRenamed: (RenamedFile) -> Unit, + private val onInfoClick: (ProjectFile) -> Unit, ) : RecyclerView.Adapter() { private var projectOptionsPopup: PopupWindow? = null @@ -68,12 +68,18 @@ class RecentProjectsAdapter( notifyDataSetChanged() } + fun renderDate(binding: SavedRecentProjectItemBinding, project: ProjectFile) { + binding.projectDate.text = project.renderDateText(binding.root.context) + } + inner class ProjectViewHolder(private val binding: SavedRecentProjectItemBinding) : RecyclerView.ViewHolder(binding.root) { fun bind(project: ProjectFile, position: Int) { binding.projectName.text = project.name - binding.projectDate.text = formatDate(project.date ?: "") + + renderDate(binding, project) + binding.icon.text = project.name .split(" ") .mapNotNull { it.firstOrNull()?.uppercaseChar() } @@ -103,26 +109,7 @@ class RecentProjectsAdapter( showPopupMenu(view, project, position) } } - - private fun formatDate(dateString: String): String { - return try { - val inputFormat = - SimpleDateFormat("EEE MMM dd HH:mm:ss z yyyy", Locale.getDefault()) - val date = inputFormat.parse(dateString) - val day = SimpleDateFormat("d", Locale.ENGLISH).format(date).toInt() - val suffix = when { - day in 11..13 -> "th" - day % 10 == 1 -> "st" - day % 10 == 2 -> "nd" - day % 10 == 3 -> "rd" - else -> "th" - } - SimpleDateFormat("d'$suffix', MMMM yyyy", Locale.getDefault()).format(date) - } catch (_: Exception) { - dateString.take(5) - } - } - } + } private fun showPopupMenu(view: View, project: ProjectFile, position: Int) { val inflater = LayoutInflater.from(view.context) @@ -141,9 +128,25 @@ class RecentProjectsAdapter( val popupWindow = projectOptionsPopup!! + val infoItem = popupView.findViewById(R.id.menu_info) val renameItem = popupView.findViewById(R.id.menu_rename) val deleteItem = popupView.findViewById(R.id.menu_delete) + infoItem.setOnClickListener { + popupWindow.dismiss() + onInfoClick(project) + } + + infoItem.setOnLongClickListener { + popupWindow.dismiss() + TooltipManager.showIdeCategoryTooltip( + context = view.context, + anchorView = view, + tag = PROJECT_INFO_TOOLTIP + ) + true + } + renameItem.setOnClickListener { promptRenameProject(view, project, position) popupWindow.dismiss() diff --git a/app/src/main/java/com/itsaky/androidide/agent/ChatSession.kt b/app/src/main/java/com/itsaky/androidide/agent/ChatSession.kt index d2e5aa4851..3c6a3c4273 100644 --- a/app/src/main/java/com/itsaky/androidide/agent/ChatSession.kt +++ b/app/src/main/java/com/itsaky/androidide/agent/ChatSession.kt @@ -11,7 +11,7 @@ data class ChatSession( val messages: MutableList = mutableListOf() ) { val title: String - get() = messages.firstOrNull()?.text ?: "New Chat" + get() = messages.firstOrNull { it.sender == Sender.USER } ?.text ?: "New Chat" val formattedDate: String get() = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()).format(Date(createdAt)) diff --git a/app/src/main/java/com/itsaky/androidide/agent/fragments/AiSettingsFragment.kt b/app/src/main/java/com/itsaky/androidide/agent/fragments/AiSettingsFragment.kt index 513253c139..a56a17da3a 100644 --- a/app/src/main/java/com/itsaky/androidide/agent/fragments/AiSettingsFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/agent/fragments/AiSettingsFragment.kt @@ -21,6 +21,7 @@ import com.google.android.material.textfield.TextInputLayout import com.itsaky.androidide.R import com.itsaky.androidide.agent.repository.AiBackend import com.itsaky.androidide.agent.viewmodel.AiSettingsViewModel +import com.itsaky.androidide.agent.viewmodel.EngineState import com.itsaky.androidide.agent.viewmodel.ModelLoadingState import com.itsaky.androidide.databinding.FragmentAiSettingsBinding import com.itsaky.androidide.utils.flashInfo @@ -45,11 +46,7 @@ class AiSettingsFragment : Fragment(R.layout.fragment_ai_settings) { requireContext().contentResolver.takePersistableUriPermission(it, takeFlags) val uriString = it.toString() - // The fragment's only job is to save the path via the ViewModel. - viewModel.saveLocalModelPath(uriString) viewModel.loadModelFromUri(uriString, requireContext()) - // It also updates its own UI. - updateLocalLlmUi(binding.backendSpecificSettingsContainer) flashInfo("Attempting to load selected model...") } } @@ -84,7 +81,6 @@ class AiSettingsFragment : Fragment(R.layout.fragment_ai_settings) { binding.backendAutocomplete.setOnItemClickListener { _, _, position, _ -> val selectedBackend = backends[position] - // Its only job is to save the backend selection. viewModel.saveBackend(selectedBackend) updateBackendSpecificUi(selectedBackend) } @@ -100,7 +96,6 @@ class AiSettingsFragment : Fragment(R.layout.fragment_ai_settings) { .inflate(R.layout.layout_settings_local_llm, container, true) updateLocalLlmUi(localLlmView) } - AiBackend.GEMINI -> { val geminiApiView = LayoutInflater.from(requireContext()) .inflate(R.layout.layout_settings_gemini_api, container, true) @@ -114,8 +109,7 @@ class AiSettingsFragment : Fragment(R.layout.fragment_ai_settings) { val browseButton = view.findViewById