diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 000000000..842eef73d
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,20 @@
+.gradle
+.idea
+.kotlin
+build/
+app/build/
+core/main/build/
+core/components/build/
+core/resources/build/
+terminal-view/build/
+terminal-emulator/build/
+soraX/editor/build/
+soraX/oniguruma-native/build/
+soraX/editor-lsp/build/
+soraX/language-textmate/build/
+baselineprofile/build/
+benchmark/build/
+benchmark2/build/
+core/proot/build/
+local.properties
+*.apk
diff --git a/.github/scripts/scan-translations.js b/.github/scripts/scan-translations.js
new file mode 100644
index 000000000..b1511558a
--- /dev/null
+++ b/.github/scripts/scan-translations.js
@@ -0,0 +1,121 @@
+const fs = require("fs");
+const path = require("path");
+const { execSync } = require("child_process");
+const { GoogleGenerativeAI } = require("@google/generative-ai");
+
+const apiKey = process.env.GEMINI_API_KEY;
+if (!apiKey) {
+ console.error("Missing GEMINI_API_KEY environment variable.");
+ process.exit(1);
+}
+
+const genAI = new GoogleGenerativeAI(apiKey);
+
+// Helper to safely run git commands and get output string
+function runGit(command) {
+ try {
+ return execSync(command, { encoding: "utf8" }).trim();
+ } catch (error) {
+ return "";
+ }
+}
+
+async function main() {
+ const eventName = process.env.GITHUB_EVENT_NAME;
+ let baseDiffTarget = "";
+
+ if (eventName === "pull_request") {
+ const baseRef = process.env.GITHUB_BASE_REF;
+ runGit(`git fetch origin ${baseRef}`);
+ baseDiffTarget = `origin/${baseRef}...HEAD`;
+ } else {
+ let beforeSha = process.env.GITHUB_BEFORE_SHA;
+ const currentSha = process.env.GITHUB_CURRENT_SHA;
+
+ if (!beforeSha || beforeSha === "0000000000000000000000000000000000000000") {
+ beforeSha = "HEAD~1";
+ }
+ baseDiffTarget = `${beforeSha} ${currentSha}`;
+ }
+
+ // 1. Get list of changed files ending with strings.xml
+ const rawFiles = runGit(`git diff --name-only ${baseDiffTarget}`);
+ if (!rawFiles) {
+ console.log("No file changes detected.");
+ return;
+ }
+
+ const files = rawFiles.split("\n").filter(file => file.endsWith("strings.xml"));
+
+ if (files.length === 0) {
+ console.log("No strings.xml changes detected.");
+ return;
+ }
+
+ const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash" });
+ let workflowFailed = false;
+
+ // 2. Loop through each modified strings.xml file and scan its diff
+ for (const file of files) {
+ const diff = runGit(`git diff ${baseDiffTarget} -- "${file}"`);
+ if (!diff.trim()) continue;
+
+ const prompt = [
+ "You are a translation moderation system.",
+ "",
+ "Analyze this Android strings.xml git diff.",
+ "",
+ "Check ONLY newly added or modified translations.",
+ "",
+ "Detect:",
+ "- profanity",
+ "- slurs",
+ "- hate speech",
+ "- sexual content",
+ "- abusive language",
+ "- offensive slang",
+ "",
+ "Ignore:",
+ "- XML syntax",
+ "- placeholders like %s or %d",
+ "- harmless casual language",
+ "",
+ "Reply ONLY with:",
+ "SAFE",
+ "",
+ "or",
+ "",
+ "UNSAFE: ",
+ "",
+ "Git diff:",
+ diff
+ ].join("\n");
+
+ try {
+ const result = await model.generateContent(prompt);
+ const text = result.response.text().trim();
+
+ console.log(`\n=== Scanning: ${file} ===`);
+ console.log(text);
+
+ if (!text.startsWith("SAFE")) {
+ workflowFailed = true;
+ }
+ } catch (err) {
+ console.error(`Error scanning ${file}:`, err.message);
+ workflowFailed = true;
+ }
+ }
+
+ if (workflowFailed) {
+ console.error("\nTranslation moderation failed. Unsafe content detected.");
+ process.exit(1);
+ }
+
+ console.log("\nAll translations are safe.");
+}
+
+main().catch((err) => {
+ console.error("Execution error:", err);
+ process.exit(1);
+});
diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml
index c22053105..bbd72484a 100644
--- a/.github/workflows/android.yml
+++ b/.github/workflows/android.yml
@@ -27,6 +27,7 @@ jobs:
java-version: "21"
distribution: "temurin"
cache: gradle
+
- name: Cache Gradle Build Files
uses: actions/cache@v5
with:
@@ -52,6 +53,9 @@ jobs:
- name: Clone submodules
run: git submodule update --init --recursive
+ - name: Accept Android SDK licenses
+ run: yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
+
- name: Build with Gradle
run: ./gradlew assembleRelease --no-daemon && mv app/build/outputs/apk/release/*.apk app/xed-editor-${{ env.COMMIT_HASH }}.apk
env:
@@ -73,12 +77,11 @@ jobs:
env:
COMMIT_MSG: ${{ github.event.head_commit.message }}
run: |
- CAPTION=$(printf "%s" "$COMMIT_MSG" | jq -Rs .)
curl -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_TOKEN }}/sendDocument" \
-F chat_id="-1002408175863" \
-F message_thread_id="582" \
- -F caption="$CAPTION" \
- -F document=@"app/xed-editor-${{ env.COMMIT_HASH }}.apk"
+ -F document=@"app/xed-editor-${{ env.COMMIT_HASH }}.apk" \
+ --form-string "caption=$COMMIT_MSG"
- name: Generate Plugin Sdk
run: |
diff --git a/.github/workflows/ktfmt-check.yml b/.github/workflows/ktfmt-check.yml
index 20e556227..e018aa0ee 100644
--- a/.github/workflows/ktfmt-check.yml
+++ b/.github/workflows/ktfmt-check.yml
@@ -29,6 +29,9 @@ jobs:
- name: Clone submodules
run: git submodule update --init --recursive
+ - name: Accept Android SDK licenses
+ run: yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
+
- name: Run ktfmt format
run: ./gradlew ktfmtFormat --no-daemon
diff --git a/.github/workflows/update-soraX.yml b/.github/workflows/update-soraX.yml
new file mode 100644
index 000000000..a36078d26
--- /dev/null
+++ b/.github/workflows/update-soraX.yml
@@ -0,0 +1,33 @@
+# .github/workflows/update-submodule.yml
+name: Update soraX Submodule
+
+on:
+ workflow_dispatch:
+
+jobs:
+ update-submodule:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ with:
+ submodules: true
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Configure Git
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+
+ - name: Update soraX to latest commit
+ run: |
+ git submodule update --remote soraX
+
+ - name: Commit and push if changed
+ run: |
+ git diff --quiet && echo "soraX already up to date, nothing to commit." && exit 0
+ COMMIT=$(git -C soraX rev-parse --short HEAD)
+ git add soraX
+ git commit -m "chore: update soraX submodule to $COMMIT"
+ git push origin main
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index ee59cbc64..bf2aaf478 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,4 +21,17 @@ Todo.md
*/build
**/build
.vscode
-core/wasm3
\ No newline at end of file
+core/wasm3
+
+# Binary artifacts
+*.exe
+*.o
+*.so
+*.a
+*.la
+*.lo
+*.lai
+*.pyc
+__pycache__/
+.DS_Store
+*~
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 000000000..a8e53e7d2
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,63 @@
+# ==========================================
+# Stage 1: Build Environment
+# ==========================================
+FROM eclipse-temurin:21-jdk AS builder
+
+# Set environment variables for Android SDK
+ENV ANDROID_HOME=/opt/android-sdk
+ENV PATH=${PATH}:${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools
+
+# Install system dependencies
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ git \
+ wget \
+ unzip \
+ build-essential \
+ && rm -rf /var/lib/apt/lists/*
+
+# Download and install Android SDK command-line tools
+RUN mkdir -p ${ANDROID_HOME}/cmdline-tools && \
+ wget -q https://dl.google.com/android/repository/commandlinetools-linux-14742923_latest.zip -O /tmp/cmdline-tools.zip && \
+ unzip -q /tmp/cmdline-tools.zip -d /tmp && \
+ mv /tmp/cmdline-tools ${ANDROID_HOME}/cmdline-tools/latest && \
+ rm /tmp/cmdline-tools.zip
+
+# Accept Android SDK licenses
+RUN yes | sdkmanager --licenses || true
+
+# Set the working directory
+WORKDIR /app
+
+# Copy the project files
+# Note: Ensure submodules are checked out on host before building:
+# git submodule update --init --recursive
+COPY . .
+
+# Set up build arguments for release signing (optional)
+# BUILD_TASK can be assembleDebug, assembleRelease, etc.
+ARG BUILD_TASK=assembleDebug
+ARG RELEASE_KEYSTORE_BASE64=""
+ARG RELEASE_PROPERTIES_BASE64=""
+
+# Set env to match the signing logic in app/build.gradle.kts
+ENV GITHUB_ACTIONS=true
+
+# Decode credentials (if provided), ensure submodules are present, and run the Gradle build task
+RUN if [ -n "$RELEASE_KEYSTORE_BASE64" ] && [ -n "$RELEASE_PROPERTIES_BASE64" ]; then \
+ echo "Decoding release keys..." && \
+ echo "$RELEASE_KEYSTORE_BASE64" | base64 -d > /tmp/xed.keystore && \
+ echo "$RELEASE_PROPERTIES_BASE64" | base64 -d > /tmp/signing.properties; \
+ fi && \
+ if [ ! -f soraX/editor/build.gradle.kts ]; then \
+ echo "soraX submodule not found locally, cloning dynamically..." && \
+ rm -rf soraX && \
+ git clone --recursive https://github.com/RohitKushvaha01/soraX.git soraX; \
+ fi && \
+ chmod +x gradlew && \
+ ./gradlew ${BUILD_TASK} --no-daemon
+
+# ==========================================
+# Stage 2: Export Built APKs (BuildKit friendly)
+# ==========================================
+FROM scratch AS export-stage
+COPY --from=builder /app/app/build/outputs/apk/ /
diff --git a/README.md b/README.md
index d2b0aa531..2ecd3a38e 100644
--- a/README.md
+++ b/README.md
@@ -57,6 +57,27 @@ documentation: https://xed-editor.github.io/Xed-Docs/
---
+## Building the Project
+
+
+1. Build the **Debug APK** (signed with the included testkey):
+ ```bash
+ ./gradlew assembleDebug
+ ```
+ The compiled APK will be located at `app/build/outputs/apk/debug/app-debug.apk`.
+---
+
+### Option 2: Building with Docker
+
+If you do not have the Android SDK or JDK 21 installed locally, you can compile the project in a container using Docker.
+This command builds the APK and exports it directly to your host machine:
+```bash
+DOCKER_BUILDKIT=1 docker build --target export-stage --output ./out .
+```
+The output debug APK will be generated at `out/debug/app-debug.apk`.
+
+---
+
## Contributing
We welcome contributions! Please read the [`/docs/CONTRIBUTING.md`](/docs/CONTRIBUTING.md) file to
diff --git a/app/.gitignore b/app/.gitignore
index 934892346..6db4ffc0a 100644
--- a/app/.gitignore
+++ b/app/.gitignore
@@ -3,3 +3,6 @@
src/main/jniLibs/arm64-v8a/libproot-loader*.so
src/main/jniLibs/armeabi-v7a/libproot-loader*.so
src/main/jniLibs/x86_64/libproot-loader*.so
+src/main/jniLibs/arm64-v8a/libloader*.so
+src/main/jniLibs/armeabi-v7a/libloader*.so
+src/main/jniLibs/x86_64/libloader*.so
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 9bcd1ef7a..b30bf7a65 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -3,25 +3,23 @@ import java.util.Properties
plugins {
alias(libs.plugins.android.baselineprofile)
alias(libs.plugins.android.application)
- alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ktfmt)
}
android {
namespace = "com.rk.application"
- compileSdk = 36
+ compileSdk = 37
defaultConfig {
applicationId = "com.rk.xededitor"
minSdk = 26
- //noinspection ExpiredTargetSdkVersion
- targetSdk = 28
+ targetSdk = 37
// versioning
- versionCode = 87
- versionName = "3.2.9"
+ versionCode = 95
+ versionName = "3.3.1"
vectorDrawables { useSupportLibrary = true }
}
@@ -40,7 +38,6 @@ android {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
- kotlin { jvmToolchain(21) }
packaging { jniLibs { useLegacyPackaging = true } }
@@ -85,9 +82,10 @@ android {
release {
isMinifyEnabled = false
isShrinkResources = false
-
isCrunchPngs = false
+
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
+
signingConfig = signingConfigs.getByName("release")
}
@@ -105,6 +103,8 @@ android {
}
}
+kotlin { jvmToolchain(21) }
+
dependencies {
implementation(libs.androidx.profileinstaller)
coreLibraryDesugaring(libs.desugar)
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index e57f7bf33..037cdb373 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -204,28 +204,107 @@
-keep class * extends com.google.gson.TypeAdapter { *; }
--keepclasseswithmembernames class com.rk.plugin.server.api.API {*;}
--keepclasseswithmembernames class com.rk.plugin.server.api.PluginLifeCycle {*;}
--keepclasseswithmembernames class com.rk.plugin.server.** {*;}
--keep class com.rk.xededitor.MainActivity.MainActivity {*;}
--keepclasseswithmembernames class com.rk.App {*;}
--keepclasseswithmembernames class com.rk.xededitor.BaseActivity {*;}
-
--keepclassmembernames class com.rk.plugin.server.api.API {*;}
--keepclassmembernames class com.rk.plugin.server.api.PluginLifeCycle {*;}
--keepclassmembernames class com.rk.plugin.server.** {*;}
--keepclassmembernames class com.rk.App {*;}
--keepclassmembernames class com.rk.xededitor.BaseActivity {*;}
-
--keepnames class com.rk.plugin.server.api.API {*;}
--keepnames class com.rk.plugin.server.api.PluginLifeCycle {*;}
--keepnames class com.rk.plugin.server.** {*;}
--keepnames class com.rk.App {*;}
--keepnames class com.rk.xededitor.BaseActivity {*;}
+-keep class com.rk.plugin.server.** { *; }
+-dontwarn com.rk.plugin.server.**
+
+# Skip shrinking class if it contains the xed extension point or related to extension related code
+-keep @com.rk.extension.api.XedExtensionPoint class * { *; }
+-keepclassmembers class * {
+ @com.rk.extension.api.XedExtensionPoint *;
+}
+-keep class * {
+ @com.rk.extension.api.XedExtensionPoint ;
+ @com.rk.extension.api.XedExtensionPoint ;
+}
+
+
+# Preserve well-known classes that plugins may try to access and keep their names
+-keep class com.rk.commands.** { *; }
+-keep class com.rk.editor.** { *; }
+-keep class com.rk.file.** { *; }
+-keep class com.rk.filetree.** { *; }
+-keep class com.rk.lsp.** { *; }
+-keep class com.rk.runner.** { *; }
+-keep class com.rk.tabs.** { *; }
+-keep class com.rk.utils.** { *; }
+-keep class com.rk.proot.** { *; }
+-keep class com.rk.components.** { *; }
+-keep class com.rk.App { *; }
+-keep class com.rk.XedConstants { *; }
+-keep class com.rk.extension.** { *; }
+-keep class java.io.** { *; }
+-keep class kotlin.** { *; }
+-keep class kotlinx.** { *; }
+-keep class okio.** { *; }
+
+# JVM Libraries Reflection & Compatibility Rules
+
+# LSP4J
+-keep class org.eclipse.lsp4j.** { *; }
+-dontwarn org.eclipse.lsp4j.**
+
+# JGit
+-keep class org.eclipse.jgit.** { *; }
+-dontwarn org.eclipse.jgit.**
+
+# Joni / Jcodings (Regex Library)
+-keep class org.jruby.joni.** { *; }
+-keep class org.jruby.jcodings.** { *; }
+-dontwarn org.jruby.joni.**
+-dontwarn org.jruby.jcodings.**
+
+# SnakeYAML
+-keep class org.yaml.snakeyaml.** { *; }
+-keep class org.snakeyaml.engine.** { *; }
+-dontwarn org.yaml.snakeyaml.**
+-dontwarn org.snakeyaml.engine.**
+
+# Moshi
+-keep class com.squareup.moshi.** { *; }
+-dontwarn com.squareup.moshi.**
+
+# Ec4j Core
+-keep class org.ec4j.core.** { *; }
+-dontwarn org.ec4j.core.**
+
+# Tree-sitter
+-keep class com.itsaky.androidide.treesitter.** { *; }
+-dontwarn com.itsaky.androidide.treesitter.**
+
+# Rosemoe Sora Editor
+-keep class io.github.rosemoe.sora.** { *; }
+-dontwarn io.github.rosemoe.sora.**
+
+# Oniguruma Native
+-keep class io.github.rosemoe.oniguruma.** { *; }
+-dontwarn io.github.rosemoe.oniguruma.**
+
+# UtilCode
+-keep class com.blankj.utilcode.** { *; }
+-dontwarn com.blankj.utilcode.**
+
+# Semver
+-keep class io.github.z4kn4fein.semver.** { *; }
+-dontwarn io.github.z4kn4fein.semver.**
+
+# ANRWatchdog
+-keep class com.github.anrwatchdog.** { *; }
+-dontwarn com.github.anrwatchdog.**
+
+# AndroidSVG
+-keep class com.caverock.androidsvg.** { *; }
+-dontwarn com.caverock.androidsvg.**
-dontwarn sun.security.x509.X509Key
--dontobfuscate
--dontshrink
--keepattributes *Annotation*
+-keepattributes *Annotation*,Signature,InnerClasses,EnclosingMethod,Metadata
+
+# slf4j missing static binders
+-dontwarn org.slf4j.impl.**
+-dontobfuscate
+# Fix for AbstractMethodError: abstract method "double androidx.compose.ui.graphics.colorspace.DoubleFunction.invoke(double)"
+-keep,allowoptimization interface androidx.compose.ui.graphics.colorspace.DoubleFunction { *; }
+-keep,allowoptimization class androidx.compose.ui.graphics.colorspace.TransferParameters { *; }
+-keep,allowoptimization class androidx.compose.ui.graphics.colorspace.Rgb { *; }
+-keep class coil.util.** { *; }
diff --git a/baselineprofile/build.gradle.kts b/baselineprofile/build.gradle.kts
index 0fcd85736..28c84e2a1 100644
--- a/baselineprofile/build.gradle.kts
+++ b/baselineprofile/build.gradle.kts
@@ -1,16 +1,15 @@
plugins {
alias(libs.plugins.android.test)
- alias(libs.plugins.kotlin.android)
alias(libs.plugins.android.baselineprofile)
}
android {
namespace = "com.rk.baselineprofile"
- compileSdk = 36
+ compileSdk = 37
defaultConfig {
minSdk = 28
- targetSdk = 36
+ targetSdk = 37
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/benchmark/build.gradle.kts b/benchmark/build.gradle.kts
index 1cbd95f89..ff3c986ad 100644
--- a/benchmark/build.gradle.kts
+++ b/benchmark/build.gradle.kts
@@ -1,15 +1,14 @@
plugins {
alias(libs.plugins.android.test)
- alias(libs.plugins.kotlin.android)
}
android {
namespace = "com.rk.benchmark"
- compileSdk = 36
+ compileSdk = 37
defaultConfig {
minSdk = 26
- targetSdk = 36
+ targetSdk = 37
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
diff --git a/benchmark2/build.gradle.kts b/benchmark2/build.gradle.kts
index cd4f183b5..0f618ccc5 100644
--- a/benchmark2/build.gradle.kts
+++ b/benchmark2/build.gradle.kts
@@ -1,16 +1,14 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.android.benchmark)
- alias(libs.plugins.kotlin.android)
}
android {
namespace = "com.rk.benchmark2"
- compileSdk = 36
+ compileSdk = 37
defaultConfig {
minSdk = 26
- targetSdk = 36
testInstrumentationRunner = "androidx.benchmark.junit4.AndroidBenchmarkRunner"
}
@@ -18,9 +16,6 @@ android {
testBuildType = "release"
buildTypes {
debug {
- // Since isDebuggable can"t be modified by gradle for library modules,
- // it must be done in a manifest - see src/androidTest/AndroidManifest.xml
- isMinifyEnabled = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "benchmark-proguard-rules.pro")
}
release { isDefault = true }
@@ -29,6 +24,12 @@ android {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
+ lint {
+ targetSdk = 37
+ }
+ testOptions {
+ targetSdk = 37
+ }
kotlin { jvmToolchain(21) }
}
diff --git a/build.gradle.kts b/build.gradle.kts
index dbd0351f7..b0a9c7bea 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -2,7 +2,6 @@ plugins {
alias(libs.plugins.android.baselineprofile) apply false
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
- alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.parcelize) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.jvm) apply false
@@ -18,9 +17,3 @@ subprojects {
}
}
}
-
-buildscript{
- dependencies {
- classpath(libs.r8)
- }
-}
\ No newline at end of file
diff --git a/chnagelog.txt b/chnagelog.txt
deleted file mode 100644
index 458631663..000000000
--- a/chnagelog.txt
+++ /dev/null
@@ -1,148 +0,0 @@
-fix: inverted snackbar color
-feat: refactor extension system (#1340)
-style: reorder imports and remove debug println
-feat: add toggle for exit confirmation dialog (Closes #1332)
-feat: add auto-closing bracket setting (#1333)
-fix: remove issue title workflow
-Added translation using Weblate (Dutch)
-style: auto-format Kotlin with ktfmt
-fix: lsp server config
-feat: implement plugin download
-feat: improved caching
-feat: use new extension registry
-feat: add integrated color picker (#1280)
-fix: ensure keyboard is hidden on terminal screen disposal
-feat: add integrated color picker and improve terminal navigation
-feat: enhance properties, use slider and migrate from Gson (#1279)
-feat: fix LSP persistence migration issues
-feat: replace Gson with Kotlin Serialization and migrate LSP storage
-feat: refactor `ValueSlider` and improve settings screens
-feat: enhance properties dialog with Git status and file information
-chore: enhance file drawer and remove debug extension (#1278)
-chore: enhance file drawer and remove debug extensions
-perf: optimize code search and indexing in `SearchViewModel` (#1277)
-fix: preserve insertion order in code search results
-fix: fix code search crashes and improve file stream handling
-perf: optimize code search and indexing in `SearchViewModel`
-ci: safely handle commit messages in Telegram uploads (#1276)
-ci: safely handle commit messages in Telegram uploads
-feat(terminal): fix terminal focus issue & modernize (#1275)
-feat: extend terminal settings and customization
-refactor(terminal): replace legacy `EditText` with Compose `TextField` and fix focus issues
-chore: format and cleanup drawable resources (#1273)
-chore: format and cleanup drawable resources
-feat: implement extension detail screen (#1265)
-refactor: refactor and modularize extension and LSP settings UI
-feat: add extension repository button and implement placeholder for reviews tab
-feat: enhance extension discovery screen with sorting, searching and filtering capabilities
-feat: enhance extension details with size and download statistics
-Added translation using Weblate (Korean)
-Added translation using Weblate (Finnish)
-feat: add review section, show author icon, make extension detail page refreshable
-feat: enhance extension details and UI
-style: enhance UI layout in About and Extension detail screens
-feat: enhance about screen and improve extension/language settings
-fix: attempt to fix race condition and improving reload command (#1264)
-fix: fix duplicate command keys, fix broken session restoration, fix toolbar actions not recomposing, fix syntax highlighting issue
-feat: enhance extension management and refactor extension metadata
-feat: tiny UI improvements
-feat: implement extension detail screen and navigation
-fix: attempt to fix race condition and improving reload command
-fix: fix tab wipe by adding unique keys to pager (#1261)
-fix: fix tab wipe by adding unique keys to pager
-feat: implement font management for app, editor, and terminal (#1260)
-feat: improve warning UI in terminal font screen
-feat: implement comprehensive font management for app, editor, and terminal (Closes #1236)
-fix: improve duplicate issue detection (#1257)
-fix: improve duplicate issue detection by including closed issues and adding version checking
-feat: add URI tap detection and link opening action in editor (#1254)
-refactor: cleanup
-refactor: decouple tab and editor management in `MainViewModel`
-feat: proper focus handling and new `Tab` methods
-feat: add URI tap detection and link opening action in editor
-feat: minimap support (#1250)
-feat: minimap support, theme and font fixes, move OOB and binary settings to category "Other"
-support new marker
-close duplicate issues
-chore: cleanup project (#1242)
-feat: implement multi-file selection and refactor (#1240)
-fix: fix rememberCoroutineScope left the composition exception on pasting
-fix: fix inconsistent background TODO and fix `open_in_terminal` casing
-chore: fix merge conflicts
-refactor: remove duplicate methods
-fix: fix `no_instances` text not showing if no visible instances
-fix: hide `NOT_RUNNING` instances that are not forced
-feat: improve delete confirmation dialog text for multiple files
-fix: revert properties action to be a single file action
-chore: resolve file tree focus TODO and fix bug where only one file tree node was marked as cut
-fix: fix crash on closing project
-feat: implement multi-file selection and refactor
-fix: compile
-feat: improve drawer UI and animations
-feat: fullscreen-mode, smart toolbar and fix various crashes (#1217)
-chore: move smart toolbar handling out of EditorTab and change folder color
-feat: implement smart toolbar setting
-feat: add fullscreen-mode support (Closes #1178)
-fix: fix terminal restore/backup crash (Closes #1209)
-fix: fix LSP status not showing if no icon is present and fix `MonotonicFrameClock` error (Closes #1214)
-feat: enhance LSP management and infrastructure (#1208)
-fix: cleanup `DefinitionPrevention` project cache when list/map is empty
-fix: fix expensive `isUpdatable` call slowing down lsp connection
-fix: fix greptile issues
-fix: show warning for unsaved files on close other/close all
-fix: enable language servers by default, fix icon inconsistency in toolbar, fix language server not connecting when no TextMate scope exists
-feat: enhance LSP management and infrastructure
-feat: enhance editor theming and LSP logging capabilities (#1198)
-feat: enhance editor theming and LSP logging capabilities
-fix: fix editor not loading content (#1196)
-fix: fix editor not loading content
-fix(lsp): fix LSP project and workspace paths (#1195)
-fix(lsp): use project root instead of parent file for workspace and fix workspace URI
-feat: added toggles for oom prediction dialog
-fix(extension-api): fix extension language server installation (#1193)
-fix(extension-api): fix extension language server installation prompt not showing
-fix(extension-api): fix extension language servers not connecting (#1192)
-fix(extension-api): fix extension language servers not connecting
-feat: R language support & dynamic `FileType` registration (#1191)
-fix(core): fix color scheme of exclude files editor
-feat(extension-api): allow dynamic registration of `FileType`s
-feat: add R syntax highlighting support
-fix: fix index database crash and improve binary detection (#1190)
-fix: fix SQLiteClosable IllegalStateException (Closes #1176)
-fix: improve binary detection (Closes #1188)
-feat: add proper exclusion system (#1169)
-feat(core): improve LSP functionality (#1031)
-feat: add proper disable-feature toggles
-feat: add syntax highlighting for luau (Closes #1183)
-feat: added 'file' output in file properties
-feat: auto connect to all editors when language server is installed
-fix: fix `setLanguage()` crash, implement auto start/stop for enabling/disabling language server
-feat: improve start/stop, refactor
-feat: implement proper exclusion system
-fix: fix crashes (#1165)
-feat: add replace everywhere button (#1164)
-feat: add replace everywhere button
-feat: center terminal leave warning
-feat: improve language server status, separate by instances
-fix: remove unwanted `READ_PHONE_STATE` permission (#1163)
-fix(core): remove indexing key on project removal
-fix: remove unwanted `READ_PHONE_STATE` permission
-fix: fix build issue
-fix: fix lsp installation info and id of language servers
-feat(core): add external server editing (Closes #1116)
-feat(core): remove external lsp extension limitation (see #1116)
-feat(core): add app logs to debug options
-chore: remove TODO
-chore: resolve merge conflicts
-fix(core): fix `ExternalLspDialog` not following Material Design guidelines, sync extensions value of the dialog
-chore: format code
-chore: fix merge conflicts and improve `LspRegistry`
-fix(core): fix extension settings layout (use `PreferenceLayout` in order for scroll stretch to work)
-feat(core): use snackbar for language server installation prompt, fix snackbar overlapping extra keys
-refactor(core): move report code into separate new method
-fix(core): fix theming
-fix(core): fix `IllegalFormatConversionException`
-chore(core): extract hardcoded strings into string resources, reduce duplicate code with additional `setThemeColors(...)` method
-fix(core): fix build failing because of wrong signature of `onStatusChange`
-feat(core): add LSP server detail page, add lsp logging mechanism, add setting for auto-completion on enter, fix duplicate language server connection
-feat(core): improve LSP settings UI, add saving mechanism for restoring external LSP servers after restart
diff --git a/core/components/build.gradle.kts b/core/components/build.gradle.kts
index 2953521d8..16f19162e 100644
--- a/core/components/build.gradle.kts
+++ b/core/components/build.gradle.kts
@@ -1,13 +1,12 @@
plugins {
alias(libs.plugins.android.library)
- alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ktfmt)
}
android {
namespace = "org.robok.engine.core.components"
- compileSdk = 36
+ compileSdk = 37
defaultConfig {
minSdk = 26
diff --git a/core/components/src/main/java/com/rk/components/compose/preferences/base/DividerColumn.kt b/core/components/src/main/java/com/rk/components/compose/preferences/base/DividerColumn.kt
index 61c48415f..63c439ce5 100644
--- a/core/components/src/main/java/com/rk/components/compose/preferences/base/DividerColumn.kt
+++ b/core/components/src/main/java/com/rk/components/compose/preferences/base/DividerColumn.kt
@@ -61,8 +61,10 @@ fun DividerColumn(
}
val width = constraints.maxWidth
- val dividersHeight = thicknessPx.roundToInt() * (placeables.size - 1)
- val height = placeables.sumOf { it.height } + dividersHeight
+ val childCount = measurables.size
+ val dividersHeight = if (childCount <= 1) 0 else thicknessPx.roundToInt() * (childCount - 1)
+ val contentHeight = placeables.sumOf { it.height }
+ val height = (contentHeight + dividersHeight).coerceAtLeast(0)
layout(width, height) {
val dividerPositions = mutableListOf()
diff --git a/core/extension/build.gradle.kts b/core/extension/build.gradle.kts
deleted file mode 100644
index 5efb222e0..000000000
--- a/core/extension/build.gradle.kts
+++ /dev/null
@@ -1,42 +0,0 @@
-plugins {
- alias(libs.plugins.android.library)
- alias(libs.plugins.kotlin.android)
- alias(libs.plugins.kotlin.serialization)
- alias(libs.plugins.ktfmt)
-}
-
-android {
- namespace = "com.rk.extension"
- compileSdk = 36
-
- defaultConfig {
- minSdk = 26
-
- testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- consumerProguardFiles("consumer-rules.pro")
- }
-
- buildTypes {
- release {
- isMinifyEnabled = false
- proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
- }
- }
-
- compileOptions {
- sourceCompatibility = JavaVersion.VERSION_21
- targetCompatibility = JavaVersion.VERSION_21
- }
- kotlin { jvmToolchain(21) }
-}
-
-dependencies {
- implementation(libs.androidx.core.ktx)
- implementation(libs.gson)
- implementation(libs.okhttp)
- implementation(libs.androidx.compose.runtime)
- implementation(libs.kotlinx.serialization.json)
- testImplementation(libs.junit)
- androidTestImplementation(libs.androidx.test.junit)
- androidTestImplementation(libs.androidx.test.espresso)
-}
diff --git a/core/extension/src/androidTest/java/com/rk/extension/ExampleInstrumentedTest.kt b/core/extension/src/androidTest/java/com/rk/extension/ExampleInstrumentedTest.kt
deleted file mode 100644
index bc8da6099..000000000
--- a/core/extension/src/androidTest/java/com/rk/extension/ExampleInstrumentedTest.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package com.rk.extension
-
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import org.junit.Assert.*
-import org.junit.Test
-import org.junit.runner.RunWith
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-@RunWith(AndroidJUnit4::class)
-class ExampleInstrumentedTest {
- @Test
- fun useAppContext() {
- // Context of the app under test.
- val appContext = InstrumentationRegistry.getInstrumentation().targetContext
- assertEquals("com.rk.extension.test", appContext.packageName)
- }
-}
diff --git a/core/extension/src/test/java/com/rk/extension/ExampleUnitTest.kt b/core/extension/src/test/java/com/rk/extension/ExampleUnitTest.kt
deleted file mode 100644
index e1b6fec6f..000000000
--- a/core/extension/src/test/java/com/rk/extension/ExampleUnitTest.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package com.rk.extension
-
-import org.junit.Assert.*
-import org.junit.Test
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
- @Test
- fun addition_isCorrect() {
- assertEquals(4, 2 + 2)
- }
-}
diff --git a/core/extension/.gitignore b/core/link2symlink/.gitignore
similarity index 100%
rename from core/extension/.gitignore
rename to core/link2symlink/.gitignore
diff --git a/core/link2symlink/build.gradle.kts b/core/link2symlink/build.gradle.kts
new file mode 100644
index 000000000..cd06ceddc
--- /dev/null
+++ b/core/link2symlink/build.gradle.kts
@@ -0,0 +1,43 @@
+plugins {
+ alias(libs.plugins.android.library)
+}
+
+android {
+ namespace = "com.rk.link2symlink"
+ ndkVersion = "29.0.13846066"
+ compileSdk {
+ version = release(36) {
+ minorApiLevel = 1
+ }
+ }
+
+ defaultConfig {
+ minSdk = 26
+
+ externalNativeBuild {
+ cmake {
+ cppFlags("")
+ }
+ }
+ }
+
+ externalNativeBuild {
+ cmake {
+ path("src/main/cpp/CMakeLists.txt")
+ version = "3.22.1"
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.appcompat)
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.material)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.test.espresso)
+ androidTestImplementation(libs.androidx.test.junit)
+}
\ No newline at end of file
diff --git a/core/extension/src/main/AndroidManifest.xml b/core/link2symlink/src/main/AndroidManifest.xml
similarity index 100%
rename from core/extension/src/main/AndroidManifest.xml
rename to core/link2symlink/src/main/AndroidManifest.xml
diff --git a/core/link2symlink/src/main/cpp/CMakeLists.txt b/core/link2symlink/src/main/cpp/CMakeLists.txt
new file mode 100644
index 000000000..5ab81f52a
--- /dev/null
+++ b/core/link2symlink/src/main/cpp/CMakeLists.txt
@@ -0,0 +1,13 @@
+cmake_minimum_required(VERSION 3.22.1)
+project("link2symlink")
+add_library(${CMAKE_PROJECT_NAME} SHARED
+ # List C/C++ source files with relative paths to this CMakeLists.txt.
+ link2symlink.cpp)
+
+# Ensure reproducible builds by mapping absolute source paths to relative ones in debug info
+if (CMAKE_C_COMPILER_ID MATCHES "GNU|Clang" OR CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
+ add_compile_options("-ffile-prefix-map=${CMAKE_CURRENT_SOURCE_DIR}=.")
+endif()
+
+target_link_libraries(${CMAKE_PROJECT_NAME}
+ android log)
\ No newline at end of file
diff --git a/core/link2symlink/src/main/cpp/link2symlink.cpp b/core/link2symlink/src/main/cpp/link2symlink.cpp
new file mode 100644
index 000000000..558e664b2
--- /dev/null
+++ b/core/link2symlink/src/main/cpp/link2symlink.cpp
@@ -0,0 +1,153 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#ifdef __ANDROID__
+#include
+#define LOG_TAG "link2symlink"
+#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
+#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
+#else
+#define LOGI(...) do { fprintf(stderr, "[link2symlink] INFO: "); fprintf(stderr, __VA_ARGS__); fprintf(stderr, "\n"); } while(0)
+#define LOGE(...) do { fprintf(stderr, "[link2symlink] ERROR: "); fprintf(stderr, __VA_ARGS__); fprintf(stderr, "\n"); } while(0)
+#endif
+
+#ifndef AT_EMPTY_PATH
+#define AT_EMPTY_PATH 0x1000
+#endif
+#ifndef AT_SYMLINK_FOLLOW
+#define AT_SYMLINK_FOLLOW 0x400
+#endif
+
+#define EXPORT __attribute__((visibility("default")))
+
+// Helper to resolve the absolute path of oldpath relative to dirfd.
+// Writes the result to out_path, which must be at least PATH_MAX bytes.
+// Returns 0 on success, -1 on error (sets errno).
+static int resolve_to_absolute(int dirfd, const char *path, int flags, char *out_path) {
+ if (!path || !out_path) {
+ errno = EINVAL;
+ return -1;
+ }
+
+ // Handle AT_EMPTY_PATH: if path is empty, reference the fd directly
+ if ((flags & AT_EMPTY_PATH) && (path[0] == '\0')) {
+ char fd_path[64];
+ snprintf(fd_path, sizeof(fd_path), "/proc/self/fd/%d", dirfd);
+ ssize_t len = readlink(fd_path, out_path, PATH_MAX - 1);
+ if (len == -1) {
+ return -1;
+ }
+ out_path[len] = '\0';
+ return 0;
+ }
+
+ // If path is already absolute, just copy it
+ if (path[0] == '/') {
+ strncpy(out_path, path, PATH_MAX - 1);
+ out_path[PATH_MAX - 1] = '\0';
+ return 0;
+ }
+
+ char base_path[PATH_MAX];
+ if (dirfd == AT_FDCWD) {
+ if (getcwd(base_path, sizeof(base_path)) == NULL) {
+ return -1;
+ }
+ } else {
+ char fd_path[64];
+ snprintf(fd_path, sizeof(fd_path), "/proc/self/fd/%d", dirfd);
+ ssize_t len = readlink(fd_path, base_path, sizeof(base_path) - 1);
+ if (len == -1) {
+ return -1;
+ }
+ base_path[len] = '\0';
+ }
+
+ // Concatenate base_path and path
+ size_t base_len = strlen(base_path);
+ if (base_len > 0 && base_path[base_len - 1] == '/') {
+ snprintf(out_path, PATH_MAX, "%s%s", base_path, path);
+ } else {
+ snprintf(out_path, PATH_MAX, "%s/%s", base_path, path);
+ }
+
+ return 0;
+}
+
+extern "C" {
+
+EXPORT int link(const char *oldpath, const char *newpath) {
+ LOGI("link: Intercepted call link(\"%s\", \"%s\")", oldpath ? oldpath : "NULL", newpath ? newpath : "NULL");
+
+ // Verify source exists
+ struct stat st;
+ if (fstatat(AT_FDCWD, oldpath, &st, AT_SYMLINK_NOFOLLOW) != 0) {
+ LOGE("link: Source file verification failed (errno: %d, %s)", errno, strerror(errno));
+ return -1;
+ }
+
+ char target[PATH_MAX];
+ if (resolve_to_absolute(AT_FDCWD, oldpath, 0, target) != 0) {
+ LOGE("link: Failed to resolve path \"%s\" (errno: %d, %s)", oldpath, errno, strerror(errno));
+ return -1;
+ }
+
+ LOGI("link: Redirecting to symlink(\"%s\", \"%s\")", target, newpath);
+ int ret = symlink(target, newpath);
+ if (ret == -1) {
+ LOGE("link: symlink failed (errno: %d, %s)", errno, strerror(errno));
+ } else {
+ LOGI("link: symlink succeeded");
+ }
+ return ret;
+}
+
+EXPORT int linkat(int olddirfd, const char *oldpath, int newdirfd, const char *newpath, int flags) {
+ LOGI("linkat: Intercepted call linkat(%d, \"%s\", %d, \"%s\", 0x%x)",
+ olddirfd, oldpath ? oldpath : "NULL", newdirfd, newpath ? newpath : "NULL", flags);
+
+ // Verify source exists
+ struct stat st;
+ int fstat_flags = 0;
+ if (!(flags & AT_SYMLINK_FOLLOW)) {
+ fstat_flags |= AT_SYMLINK_NOFOLLOW;
+ }
+ if (flags & AT_EMPTY_PATH) {
+ fstat_flags |= AT_EMPTY_PATH;
+ }
+ if (fstatat(olddirfd, oldpath, &st, fstat_flags) != 0) {
+ LOGE("linkat: Source file verification failed (errno: %d, %s)", errno, strerror(errno));
+ return -1;
+ }
+
+ char target[PATH_MAX];
+ if (resolve_to_absolute(olddirfd, oldpath, flags, target) != 0) {
+ LOGE("linkat: Failed to resolve oldpath (errno: %d, %s)", errno, strerror(errno));
+ return -1;
+ }
+
+ if (flags & AT_SYMLINK_FOLLOW) {
+ char final_target[PATH_MAX];
+ if (realpath(target, final_target) != NULL) {
+ strncpy(target, final_target, PATH_MAX - 1);
+ target[PATH_MAX - 1] = '\0';
+ }
+ }
+
+ LOGI("linkat: Redirecting to symlinkat(\"%s\", %d, \"%s\")", target, newdirfd, newpath);
+ int ret = symlinkat(target, newdirfd, newpath);
+ if (ret == -1) {
+ LOGE("linkat: symlinkat failed (errno: %d, %s)", errno, strerror(errno));
+ } else {
+ LOGI("linkat: symlinkat succeeded");
+ }
+ return ret;
+}
+
+}
diff --git a/core/main/build.gradle.kts b/core/main/build.gradle.kts
index 7f8fc7121..08187b22d 100644
--- a/core/main/build.gradle.kts
+++ b/core/main/build.gradle.kts
@@ -2,25 +2,44 @@ import groovy.json.JsonOutput
plugins {
alias(libs.plugins.android.library)
- alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.ksp)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ktfmt)
}
-val gitCommitHash: Provider =
- providers.exec { commandLine("git", "rev-parse", "--short=8", "HEAD") }.standardOutput.asText.map { it.trim() }
+val isGitRepo = generateSequence(rootProject.projectDir) { it.parentFile }.any { File(it, ".git").exists() }
-val fullGitCommitHash: Provider =
- providers.exec { commandLine("git", "rev-parse", "HEAD") }.standardOutput.asText.map { it.trim() }
+val gitCommitHash: Provider = if (isGitRepo) {
+ providers.exec {
+ commandLine("git", "rev-parse", "--short=8", "HEAD")
+ isIgnoreExitValue = true
+ }.standardOutput.asText.map { it.trim().ifEmpty { "unknown" } }
+} else {
+ providers.provider { "unknown" }
+}
-val gitCommitDate: Provider =
- providers.exec { commandLine("git", "show", "-s", "--format=%cI", "HEAD") }.standardOutput.asText.map { it.trim() }
+val fullGitCommitHash: Provider = if (isGitRepo) {
+ providers.exec {
+ commandLine("git", "rev-parse", "HEAD")
+ isIgnoreExitValue = true
+ }.standardOutput.asText.map { it.trim().ifEmpty { "unknown" } }
+} else {
+ providers.provider { "unknown" }
+}
+
+val gitCommitDate: Provider = if (isGitRepo) {
+ providers.exec {
+ commandLine("git", "show", "-s", "--format=%cI", "HEAD")
+ isIgnoreExitValue = true
+ }.standardOutput.asText.map { it.trim().ifEmpty { "unknown" } }
+} else {
+ providers.provider { "unknown" }
+}
android {
namespace = "com.rk.xededitor"
- compileSdk = 36
+ compileSdk = 37
defaultConfig {
minSdk = 26
@@ -111,21 +130,25 @@ dependencies {
implementation(libs.ec4j.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.jgit)
+ implementation(libs.semver)
debugImplementation(libs.leakcanary)
// implementation("androidx.compose.material:material-icons-extended:1.7.8")
implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler)
+ implementation(libs.kotlinx.coroutines)
+
// Modules
implementation(project(":core:resources"))
implementation(project(":core:components"))
- implementation(project(":core:extension"))
- implementation(project(":core:terminal-view"))
- implementation(project(":core:terminal-emulator"))
+ implementation(project(":terminal-view"))
+ implementation(project(":terminal-emulator"))
implementation(project(":editor"))
implementation(project(":editor-lsp"))
implementation(project(":language-textmate"))
+ implementation(project(":core:proot"))
+ implementation(project(":core:link2symlink"))
}
abstract class GenerateSupportedLocales : DefaultTask() {
diff --git a/core/main/src/main/assets/terminal/init.sh b/core/main/src/main/assets/terminal/init.sh
index 036d5710e..c8e7ae965 100644
--- a/core/main/src/main/assets/terminal/init.sh
+++ b/core/main/src/main/assets/terminal/init.sh
@@ -8,6 +8,10 @@ export PS1="\[\e[1;32m\]\u@\h\[\e[0m\]:\[\e[1;34m\]\w\[\e[0m\] \\$ "
source "$LOCAL/bin/utils"
+if [ -f "$LOCAL/.sandbox_degraded" ]; then
+ warn "Running in degraded mode. Some features may not work"
+fi
+
# Set timezone
CONTAINER_TIMEZONE="UTC" # or any timezone like "Asia/Kolkata"
@@ -29,7 +33,7 @@ fi
ensure_packages_once() {
local marker_file="/.cache/.packages_ensured"
- local PACKAGES=("command-not-found" "sudo" "xkb-data")
+ local PACKAGES=("command-not-found" "sudo" "xkb-data" "libjemalloc-dev")
# Exit early if already done
[[ -f "$marker_file" ]] && return 0
diff --git a/core/main/src/main/assets/terminal/lsp/json.sh b/core/main/src/main/assets/terminal/lsp/json.sh
deleted file mode 100644
index 62e644c81..000000000
--- a/core/main/src/main/assets/terminal/lsp/json.sh
+++ /dev/null
@@ -1,40 +0,0 @@
-set -e
-
-source "$LOCAL/bin/utils"
-
-info 'Preparing...'
-apt update && apt upgrade -y
-
-install() {
- if ! command_exists node || ! command_exists npm; then
- install_nodejs
- fi
-
- info 'Installing extracted VSCode language servers...'
- npm install -g --prefix /usr vscode-langservers-extracted
- info 'JSON language server installed successfully.'
- exit 0
-}
-
-uninstall() {
- if ask "Are you sure you want to uninstall the extracted VSCode language servers? This will also remove the HTML, CSS and JSON language servers."; then
- info 'Uninstalling extracted VSCode language servers...'
- npm uninstall -g --prefix /usr vscode-langservers-extracted
- info 'Extracted VSCode language servers uninstalled successfully.'
- uninstall_nodejs
- exit 0
- fi
-}
-
-update() {
- info 'Updating extracted VSCode language servers...'
- npm update -g --prefix /usr vscode-langservers-extracted
- info 'Extracted VSCode language servers updated successfully.'
- exit 0
-}
-
-case "$1" in
- --uninstall) uninstall;;
- --update) update;;
- *) install;;
-esac
diff --git a/core/main/src/main/assets/terminal/lsp/python.sh b/core/main/src/main/assets/terminal/lsp/python.sh
deleted file mode 100644
index a3669b15b..000000000
--- a/core/main/src/main/assets/terminal/lsp/python.sh
+++ /dev/null
@@ -1,46 +0,0 @@
-set -e
-
-source "$LOCAL/bin/utils"
-
-info 'Preparing...'
-apt update && apt upgrade -y
-
-install() {
- info 'Installing pipx...'
- apt install -y pipx
- pipx ensurepath
-
- info 'Installing Python language server...'
- pipx install 'python-lsp-server[all]'
-
- info 'Python language server installed successfully.'
- exit 0
-}
-
-uninstall() {
- info 'Uninstalling Python language server...'
- pipx uninstall python-lsp-server
- info 'Python language server uninstalled successfully.'
-
- if ask "Do you want to uninstall pipx? It was installed as a dependency of this language server."; then
- info "Uninstalling pipx..."
- apt remove -y pipx
- apt autoremove -y
- info "Pipx uninstalled successfully."
- fi
- exit 0
-}
-
-update() {
- info 'Updating Python language server...'
- pipx upgrade python-lsp-server
- info 'Python language server updated successfully.'
- exit 0
-}
-
-case "$1" in
- --uninstall) uninstall;;
- --update) update;;
- *) install;;
-esac
-
diff --git a/core/main/src/main/assets/terminal/lsp/xml.sh b/core/main/src/main/assets/terminal/lsp/xml.sh
index 4bb52c7c8..71f980f06 100644
--- a/core/main/src/main/assets/terminal/lsp/xml.sh
+++ b/core/main/src/main/assets/terminal/lsp/xml.sh
@@ -5,21 +5,24 @@ source "$LOCAL/bin/utils"
info 'Preparing...'
apt update && apt upgrade -y
+LLEMINX_VERSION="0.31.0"
+INSTALL_DIR="$HOME/.lsp/lemminx"
+
install() {
info 'Installing LemMinX language server...'
- mkdir -p $HOME/.lsp/lemminx
- cd $HOME/.lsp/lemminx
- apt install -y curl ca-certificates
- curl -L -o server.jar https://download.eclipse.org/staging/2025-09/plugins/org.eclipse.lemminx.uber-jar_0.31.0.jar
- echo "0.31.0" > version.txt
- apt install -y default-jdk
+
+ mkdir -p "$INSTALL_DIR"
+ cd "$INSTALL_DIR"
+ apt install -y curl ca-certificates default-jdk
+ curl -L -o "server.jar" "https://download.eclipse.org/staging/2025-09/plugins/org.eclipse.lemminx.uber-jar_${LLEMINX_VERSION}.jar"
+ echo "$LLEMINX_VERSION" > version.txt
info 'LemMinX language server installed successfully.'
exit 0
}
uninstall() {
info 'Uninstalling LemMinX language server...'
- rm -rf $HOME/.lsp/lemminx
+ rm -rf "$INSTALL_DIR"
info 'LemMinX language server uninstalled successfully.'
if ask "Do you want to uninstall OpenJDK? It was installed as a dependency of this language server."; then
@@ -33,10 +36,10 @@ uninstall() {
update() {
info 'Updating LemMinX language server...'
- cd $HOME/.lsp/lemminx
- rm server.jar
- curl -L -o server.jar https://download.eclipse.org/staging/2025-09/plugins/org.eclipse.lemminx.uber-jar_0.31.0.jar
- echo "0.31.0" > version.txt
+ cd "$INSTALL_DIR"
+ rm "server.jar"
+ curl -L -o "server.jar" "https://download.eclipse.org/staging/2025-09/plugins/org.eclipse.lemminx.uber-jar_${LLEMINX_VERSION}.jar"
+ echo "$LLEMINX_VERSION" > version.txt
info 'LemMinX language server updated successfully.'
exit 0
}
@@ -45,4 +48,4 @@ case "$1" in
--uninstall) uninstall;;
--update) update;;
*) install;;
-esac
+esac
\ No newline at end of file
diff --git a/core/main/src/main/assets/terminal/sandbox.sh b/core/main/src/main/assets/terminal/sandbox.sh
index 534a5ad13..4bb5e4ea1 100644
--- a/core/main/src/main/assets/terminal/sandbox.sh
+++ b/core/main/src/main/assets/terminal/sandbox.sh
@@ -63,17 +63,9 @@ ARGS="$ARGS -L"
chmod -R +x $LOCAL/bin
-if [ "$FDROID" = false ]; then
- if [ $# -gt 0 ]; then
- $LINKER $LOCAL/bin/proot $ARGS /bin/bash --rcfile $LOCAL/bin/init -i -c "$*"
- else
- $LINKER $LOCAL/bin/proot $ARGS /bin/bash --rcfile $LOCAL/bin/init -i
- fi
+if [ $# -gt 0 ]; then
+ $PROOT $ARGS /bin/bash --rcfile $LOCAL/bin/init -i -c "$*"
else
- if [ $# -gt 0 ]; then
- $LOCAL/bin/proot $ARGS /bin/bash --rcfile $LOCAL/bin/init -i -c "$*"
- else
- $LOCAL/bin/proot $ARGS /bin/bash --rcfile $LOCAL/bin/init -i
- fi
+ $PROOT $ARGS /bin/bash --rcfile $LOCAL/bin/init -i
fi
diff --git a/core/main/src/main/assets/terminal/setup.sh b/core/main/src/main/assets/terminal/setup.sh
index e20563363..9ea921979 100644
--- a/core/main/src/main/assets/terminal/setup.sh
+++ b/core/main/src/main/assets/terminal/setup.sh
@@ -53,12 +53,35 @@ ARGS="$ARGS --link2symlink"
ARGS="$ARGS --sysvipc"
ARGS="$ARGS -L"
+
COMMAND="(cd $LOCAL/sandbox && tar -xf $TMP_DIR/sandbox.tar.gz)"
-if [ "$FDROID" = false ]; then
- $LINKER $LOCAL/bin/proot $ARGS /system/bin/sh -c "$COMMAND"
-else
- $LOCAL/bin/proot $ARGS /system/bin/sh -c "$COMMAND"
+
+set +e
+# Samsung devices doesn't like running system binaries under proot
+$PROOT $ARGS /system/bin/sh -c "$COMMAND"
+ret=$?
+set -e
+
+DEGRADED_MARKER="$LOCAL/.sandbox_degraded"
+
+if [ "$ret" -ne 0 ]; then
+ warn "PRoot extraction failed (exit code $ret), falling back to direct extraction..."
+
+ set +e
+ LD_PRELOAD="$(realpath "$NATIVE_LIB_DIR/liblink2symlink.so")"
+ export LD_PRELOAD
+ /bin/sh -c "$COMMAND"
+ unset LD_PRELOAD
+ ret=$?
+ set -e
+
+ if [ "$ret" -ne 0 ]; then
+ warn "Extraction failed (exit code $ret), continuing in degraded mode"
+ warn "Sandbox may be incomplete and some features may not work"
+
+ touch "$DEGRADED_MARKER"
+ fi
fi
@@ -127,6 +150,83 @@ rm "$TMP_DIR"/sandbox.tar.gz
# DO NOT REMOVE THIS FILE JUST DON'T, TRUST ME
touch $LOCAL/.terminal_setup_ok_DO_NOT_REMOVE
+
+
+info "Installing Node.js APT hook…"
+
+mkdir -p "$SANDBOX_DIR/etc/apt/apt.conf.d"
+mkdir -p "$SANDBOX_DIR/usr/local/bin"
+
+cat > "$SANDBOX_DIR/etc/apt/apt.conf.d/99node-hook" << 'EOF'
+DPkg::Post-Invoke {
+ "if [ -x /usr/bin/node ]; then /usr/local/bin/node-postinstall.sh; fi";
+};
+EOF
+
+cat > "$SANDBOX_DIR/usr/local/bin/node-postinstall.sh" << 'EOF'
+#!/bin/sh
+set -e
+
+echo "[node-hook] Running Node.js post-install hook..."
+
+JEMALLOC=""
+
+echo "[node-hook] Searching for jemalloc..."
+
+for path in \
+ /usr/lib/*/libjemalloc.so* \
+ /usr/lib/libjemalloc.so* \
+ /lib/*/libjemalloc.so* \
+ /lib/libjemalloc.so*; do
+
+ if [ -e "$path" ]; then
+ JEMALLOC="$path"
+ echo "[node-hook] Found jemalloc: $JEMALLOC"
+ break
+ fi
+done
+
+if [ -z "$JEMALLOC" ]; then
+ echo "[node-hook] jemalloc not installed, skipping"
+ exit 0
+fi
+
+if [ ! -e /usr/bin/node ]; then
+ echo "[node-hook] Node binary not found, skipping"
+ exit 0
+fi
+
+if [ -e /usr/bin/node.distrib ]; then
+ echo "[node-hook] Node already wrapped, skipping"
+ exit 0
+fi
+
+echo "[node-hook] Verifying node binary..."
+
+if file /usr/bin/node | grep -q ELF; then
+ echo "[node-hook] Wrapping Node.js with jemalloc..."
+
+ mv /usr/bin/node /usr/bin/node.distrib
+
+ cat > /usr/bin/node << WRAP
+#!/bin/sh
+LD_PRELOAD=$JEMALLOC exec /usr/bin/node.distrib "\$@"
+WRAP
+
+ chmod +x /usr/bin/node
+
+ echo "[node-hook] Node wrapper installed successfully"
+else
+ echo "[node-hook] /usr/bin/node is not an ELF binary, skipping"
+fi
+EOF
+
+chmod +x "$SANDBOX_DIR/usr/local/bin/node-postinstall.sh"
+
+info "Node.js APT hook installed"
+
+
+
if [ $# -gt 0 ]; then
sh $@
else
diff --git a/core/main/src/main/assets/terminal/universal_runner.sh b/core/main/src/main/assets/terminal/universal_runner.sh
index 48817f3e1..74b9a598b 100644
--- a/core/main/src/main/assets/terminal/universal_runner.sh
+++ b/core/main/src/main/assets/terminal/universal_runner.sh
@@ -2,7 +2,7 @@ set -e
file="$1"
if [ ! -f "$file" ]; then
- echo "Error: File not found -> $file"
+ error "Error: File not found -> $file"
exit 1
fi
@@ -24,20 +24,28 @@ run_code() {
install_package() {
local packages="$1"
+
+ # If curl is requested, ensure ca-certificates is also installed
+ if echo "$packages" | grep -qw curl; then
+ if ! echo "$packages" | grep -qw ca-certificates; then
+ packages="$packages ca-certificates"
+ fi
+ fi
+
info "Installing $packages..."
apt update -y && apt upgrade -y
apt install -y $packages
}
install_nodejs() {
- echo "Installing Node.js LTS..."
+ info "Installing Node.js LTS..."
install_package "curl"
curl -fsSL https://deb.nodesource.com/setup_lts.x | bash -
apt install -y nodejs
}
install_rust() {
- echo "Installing Rust..."
+ info "Installing Rust..."
install_package "curl"
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source "$HOME/.cargo/env"
@@ -46,7 +54,7 @@ install_rust() {
}
install_dotnet() {
- echo "Installing .NET SDK..."
+ info "Installing .NET SDK..."
wget https://packages.microsoft.com/config/ubuntu/22.04/packages-microsoft-prod.deb -O packages-microsoft-prod.deb
dpkg -i packages-microsoft-prod.deb
rm packages-microsoft-prod.deb
@@ -56,7 +64,7 @@ install_dotnet() {
install_kotlin() {
install_package "unzip curl"
- echo "Fetching latest Kotlin compiler..."
+ info "Fetching latest Kotlin compiler..."
url=$(curl -s https://api.github.com/repos/JetBrains/kotlin/releases/latest \
| grep "browser_download_url" \
| grep "kotlin-compiler-.*zip" \
@@ -64,21 +72,21 @@ install_kotlin() {
| cut -d '"' -f 4)
if [ -z "$url" ]; then
- echo "Error: Could not find Kotlin compiler download URL."
+ error "Error: Could not find Kotlin compiler download URL."
return 1
fi
- echo "Downloading from: $url"
+ info "Downloading from: $url"
curl -L -o /tmp/kotlin.zip "$url"
- echo "Extracting to /opt/kotlinc ..."
+ info "Extracting to /opt/kotlinc ..."
mkdir -p /opt/kotlinc
unzip -qo /tmp/kotlin.zip -d /opt
ln -sf /opt/kotlinc/bin/kotlinc /usr/local/bin/kotlinc
ln -sf /opt/kotlinc/bin/kotlin /usr/local/bin/kotlin
- echo "Kotlin installed at /opt/kotlinc"
+ info "Kotlin installed at /opt/kotlinc"
}
case "$file" in
@@ -102,7 +110,7 @@ case "$file" in
fi
if ! command_exists tsc; then
- echo "Installing TypeScript compiler..."
+ info "Installing TypeScript compiler..."
npm install -g typescript
fi
@@ -132,11 +140,11 @@ case "$file" in
*.kt)
if ! command_exists java; then
- echo "Installing Java..."
+ info "Installing Java..."
install_package "default-jdk"
fi
if ! command_exists kotlinc; then
- echo "Installing Kotlin..."
+ info "Installing Kotlin..."
install_kotlin
fi
kotlinc "$file" -include-runtime -d temp.jar && java -jar temp.jar
@@ -263,14 +271,14 @@ case "$file" in
*.elm)
if ! command_exists elm; then
- echo "Installing Elm..."
+ info "Installing Elm..."
if ! command_exists node; then
install_nodejs
fi
npm install -g elm
fi
elm make "$file" --output=temp.html
- echo "Elm compiled to temp.html - transfer to browser to view"
+ info "Elm compiled to temp.html - transfer to browser to view"
;;
*.fsx|*.fs)
diff --git a/core/main/src/main/assets/textmate/languages.json b/core/main/src/main/assets/textmate/languages.json
index 178fdae3f..b4286d7da 100644
--- a/core/main/src/main/assets/textmate/languages.json
+++ b/core/main/src/main/assets/textmate/languages.json
@@ -308,6 +308,12 @@
"scopeName": "source.sql",
"grammar": "textmate/sql/syntaxes/sql.tmLanguage.json",
"languageConfiguration": "textmate/sql/language-configuration.json"
+ },
+ {
+ "grammar": "textmate/nix/syntaxes/nix.tmLanguage.json",
+ "name": "nix",
+ "scopeName": "source.nix",
+ "languageConfiguration": "textmate/nix/language-configuration.json"
}
]
}
diff --git a/core/main/src/main/assets/textmate/nix/language-configuration.json b/core/main/src/main/assets/textmate/nix/language-configuration.json
new file mode 100644
index 000000000..9dfca3fc6
--- /dev/null
+++ b/core/main/src/main/assets/textmate/nix/language-configuration.json
@@ -0,0 +1,40 @@
+{
+ "comments": {
+ "lineComment": "#",
+ "blockComment": ["/*", "*/"]
+ },
+ "brackets": [
+ ["{", "}"],
+ ["[", "]"],
+ ["(", ")"],
+ ["''", "''"],
+ ["${", "}"]
+ ],
+ "autoClosingPairs": [
+ { "open": "{", "close": "}" },
+ { "open": "[", "close": "]" },
+ { "open": "(", "close": ")" },
+ { "open": "\"", "close": "\"", "notIn": ["string"] },
+ { "open": "''", "close": "''", "notIn": ["string", "comment"] },
+ { "open": "${", "close": "}", "notIn": ["string", "comment"] }
+ ],
+ "surroundingPairs": [
+ ["{", "}"],
+ ["[", "]"],
+ ["(", ")"],
+ ["\"", "\""],
+ ["''", "''"]
+ ],
+ "autoCloseBefore": ";:.,=}])> \n\t",
+ "wordPattern": "(-?\\d*\\.\\d\\w*)|([^\\=<>\\!\\?\\s\\(\\)\\[\\]\\{\\}\\;\\:\\'\\\"\\,\\.\\~\\@\\#\\%\\^\\&\\*\\+\\|\\\\\\/\\$]+)",
+ "indentationRules": {
+ "increaseIndentPattern": "^.*\\{[^}\"']*$",
+ "decreaseIndentPattern": "^\\s*\\}"
+ },
+ "folding": {
+ "markers": {
+ "start": "^\\s*#\\s*region\\b",
+ "end": "^\\s*#\\s*endregion\\b"
+ }
+ }
+}
diff --git a/core/main/src/main/assets/textmate/nix/syntaxes/nix.tmLanguage.json b/core/main/src/main/assets/textmate/nix/syntaxes/nix.tmLanguage.json
new file mode 100644
index 000000000..1d02a5b9e
--- /dev/null
+++ b/core/main/src/main/assets/textmate/nix/syntaxes/nix.tmLanguage.json
@@ -0,0 +1,1188 @@
+{
+ "name": "Nix",
+ "scopeName": "source.nix",
+ "fileTypes": [
+ "nix"
+ ],
+ "uuid": "0514fd5f-acb6-436d-b42c-7643e6d36c8f",
+ "patterns": [
+ {
+ "include": "#expression"
+ }
+ ],
+ "repository": {
+ "expression": {
+ "patterns": [
+ {
+ "include": "#parens-and-cont"
+ },
+ {
+ "include": "#list-and-cont"
+ },
+ {
+ "include": "#string"
+ },
+ {
+ "include": "#interpolation"
+ },
+ {
+ "include": "#with-assert"
+ },
+ {
+ "include": "#function-for-sure"
+ },
+ {
+ "include": "#attrset-for-sure"
+ },
+ {
+ "include": "#attrset-or-function"
+ },
+ {
+ "include": "#let"
+ },
+ {
+ "include": "#if"
+ },
+ {
+ "include": "#operator-unary"
+ },
+ {
+ "include": "#operator-binary"
+ },
+ {
+ "include": "#constants"
+ },
+ {
+ "include": "#bad-reserved"
+ },
+ {
+ "include": "#parameter-name-and-cont"
+ },
+ {
+ "include": "#others"
+ }
+ ]
+ },
+ "expression-cont": {
+ "begin": "(?=.?)",
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "patterns": [
+ {
+ "include": "#parens"
+ },
+ {
+ "include": "#list"
+ },
+ {
+ "include": "#string"
+ },
+ {
+ "include": "#interpolation"
+ },
+ {
+ "include": "#function-for-sure"
+ },
+ {
+ "include": "#attrset-for-sure"
+ },
+ {
+ "include": "#attrset-or-function"
+ },
+ {
+ "include": "#operator-binary"
+ },
+ {
+ "include": "#constants"
+ },
+ {
+ "include": "#bad-reserved"
+ },
+ {
+ "include": "#parameter-name"
+ },
+ {
+ "include": "#others"
+ }
+ ]
+ },
+ "parens": {
+ "begin": "\\(",
+ "beginCaptures": {
+ "0": {
+ "name": "punctuation.definition.expression.nix"
+ }
+ },
+ "end": "\\)",
+ "endCaptures": {
+ "0": {
+ "name": "punctuation.definition.expression.nix"
+ }
+ },
+ "patterns": [
+ {
+ "include": "#expression"
+ }
+ ]
+ },
+ "parens-and-cont": {
+ "begin": "(?=\\()",
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "patterns": [
+ {
+ "include": "#parens"
+ },
+ {
+ "include": "#expression-cont"
+ }
+ ]
+ },
+ "list": {
+ "begin": "\\[",
+ "beginCaptures": {
+ "0": {
+ "name": "punctuation.definition.list.nix"
+ }
+ },
+ "end": "\\]",
+ "endCaptures": {
+ "0": {
+ "name": "punctuation.definition.list.nix"
+ }
+ },
+ "patterns": [
+ {
+ "include": "#expression"
+ }
+ ]
+ },
+ "list-and-cont": {
+ "begin": "(?=\\[)",
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "patterns": [
+ {
+ "include": "#list"
+ },
+ {
+ "include": "#expression-cont"
+ }
+ ]
+ },
+ "attrset-for-sure": {
+ "patterns": [
+ {
+ "begin": "(?=\\brec\\b)",
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "patterns": [
+ {
+ "begin": "\\brec\\b",
+ "end": "(?=\\{)",
+ "beginCaptures": {
+ "0": {
+ "name": "keyword.other.nix"
+ }
+ },
+ "patterns": [
+ {
+ "include": "#others"
+ }
+ ]
+ },
+ {
+ "include": "#attrset-definition"
+ },
+ {
+ "include": "#others"
+ }
+ ]
+ },
+ {
+ "begin": "(?=\\{\\s*(\\}|[^,?]*(=|;)))",
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "patterns": [
+ {
+ "include": "#attrset-definition"
+ },
+ {
+ "include": "#others"
+ }
+ ]
+ }
+ ]
+ },
+ "attrset-definition": {
+ "begin": "(?=\\{)",
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "patterns": [
+ {
+ "begin": "(\\{)",
+ "end": "(\\})",
+ "beginCaptures": {
+ "0": {
+ "name": "punctuation.definition.attrset.nix"
+ }
+ },
+ "endCaptures": {
+ "0": {
+ "name": "punctuation.definition.attrset.nix"
+ }
+ },
+ "patterns": [
+ {
+ "include": "#attrset-contents"
+ }
+ ]
+ },
+ {
+ "begin": "(?<=\\})",
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "patterns": [
+ {
+ "include": "#expression-cont"
+ }
+ ]
+ }
+ ]
+ },
+ "attrset-definition-brace-opened": {
+ "patterns": [
+ {
+ "begin": "(?<=\\})",
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "patterns": [
+ {
+ "include": "#expression-cont"
+ }
+ ]
+ },
+ {
+ "begin": "(?=.?)",
+ "end": "\\}",
+ "endCaptures": {
+ "0": {
+ "name": "punctuation.definition.attrset.nix"
+ }
+ },
+ "patterns": [
+ {
+ "include": "#attrset-contents"
+ }
+ ]
+ }
+ ]
+ },
+ "attrset-contents": {
+ "patterns": [
+ {
+ "include": "#attribute-inherit"
+ },
+ {
+ "include": "#bad-reserved"
+ },
+ {
+ "include": "#attribute-bind"
+ },
+ {
+ "include": "#others"
+ }
+ ]
+ },
+ "function-header-open-brace": {
+ "begin": "\\{",
+ "end": "(?=\\})",
+ "beginCaptures": {
+ "0": {
+ "name": "punctuation.definition.entity.function.2.nix"
+ }
+ },
+ "patterns": [
+ {
+ "include": "#function-contents"
+ }
+ ]
+ },
+ "function-header-close-brace-no-arg": {
+ "begin": "\\}",
+ "end": "(?=\\:)",
+ "beginCaptures": {
+ "0": {
+ "name": "punctuation.definition.entity.function.nix"
+ }
+ },
+ "patterns": [
+ {
+ "include": "#others"
+ }
+ ]
+ },
+ "function-header-terminal-arg": {
+ "begin": "(?=@)",
+ "end": "(?=\\:)",
+ "patterns": [
+ {
+ "begin": "\\@",
+ "end": "(?=\\:)",
+ "patterns": [
+ {
+ "begin": "(\\b[a-zA-Z\\_][a-zA-Z0-9\\_\\'\\-]*)",
+ "end": "(?=\\:)",
+ "name": "variable.parameter.function.3.nix"
+ },
+ {
+ "include": "#others"
+ }
+ ]
+ },
+ {
+ "include": "#others"
+ }
+ ]
+ },
+ "function-header-close-brace-with-arg": {
+ "begin": "\\}",
+ "end": "(?=\\:)",
+ "beginCaptures": {
+ "0": {
+ "name": "punctuation.definition.entity.function.nix"
+ }
+ },
+ "patterns": [
+ {
+ "include": "#function-header-terminal-arg"
+ },
+ {
+ "include": "#others"
+ }
+ ]
+ },
+ "function-header-until-colon-no-arg": {
+ "begin": "(?=\\{)",
+ "end": "(?=\\:)",
+ "patterns": [
+ {
+ "include": "#function-header-open-brace"
+ },
+ {
+ "include": "#function-header-close-brace-no-arg"
+ }
+ ]
+ },
+ "function-header-until-colon-with-arg": {
+ "begin": "(?=\\{)",
+ "end": "(?=\\:)",
+ "patterns": [
+ {
+ "include": "#function-header-open-brace"
+ },
+ {
+ "include": "#function-header-close-brace-with-arg"
+ }
+ ]
+ },
+ "function-body-from-colon": {
+ "begin": "(\\:)",
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "beginCaptures": {
+ "0": {
+ "name": "punctuation.definition.function.nix"
+ }
+ },
+ "patterns": [
+ {
+ "include": "#expression"
+ }
+ ]
+ },
+ "function-definition": {
+ "begin": "(?=.?)",
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "patterns": [
+ {
+ "include": "#function-body-from-colon"
+ },
+ {
+ "begin": "(?=.?)",
+ "end": "(?=\\:)",
+ "patterns": [
+ {
+ "begin": "(\\b[a-zA-Z\\_][a-zA-Z0-9\\_\\'\\-]*)",
+ "end": "(?=\\:)",
+ "beginCaptures": {
+ "0": {
+ "name": "variable.parameter.function.4.nix"
+ }
+ },
+ "patterns": [
+ {
+ "begin": "\\@",
+ "end": "(?=\\:)",
+ "patterns": [
+ {
+ "include": "#function-header-until-colon-no-arg"
+ },
+ {
+ "include": "#others"
+ }
+ ]
+ },
+ {
+ "include": "#others"
+ }
+ ]
+ },
+ {
+ "begin": "(?=\\{)",
+ "end": "(?=\\:)",
+ "patterns": [
+ {
+ "include": "#function-header-until-colon-with-arg"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "include": "#others"
+ }
+ ]
+ },
+ "function-definition-brace-opened": {
+ "begin": "(?=.?)",
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "patterns": [
+ {
+ "include": "#function-body-from-colon"
+ },
+ {
+ "begin": "(?=.?)",
+ "end": "(?=\\:)",
+ "patterns": [
+ {
+ "include": "#function-header-close-brace-with-arg"
+ },
+ {
+ "begin": "(?=.?)",
+ "end": "(?=\\})",
+ "patterns": [
+ {
+ "include": "#function-contents"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "include": "#others"
+ }
+ ]
+ },
+ "function-for-sure": {
+ "patterns": [
+ {
+ "begin": "(?=(\\b[a-zA-Z\\_][a-zA-Z0-9\\_\\'\\-]*\\s*[:@]|\\{[^}\\\"']*\\}\\s*:|\\{[^#}\"'/=]*[,\\?]))",
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "patterns": [
+ {
+ "include": "#function-definition"
+ }
+ ]
+ }
+ ]
+ },
+ "function-contents": {
+ "patterns": [
+ {
+ "include": "#bad-reserved"
+ },
+ {
+ "include": "#function-parameter"
+ },
+ {
+ "include": "#others"
+ }
+ ]
+ },
+ "attrset-or-function": {
+ "begin": "\\{",
+ "beginCaptures": {
+ "0": {
+ "name": "punctuation.definition.attrset-or-function.nix"
+ }
+ },
+ "end": "(?=([\\])};]|\\b(else|then)\\b))",
+ "patterns": [
+ {
+ "begin": "(?=(\\s*\\}|\\\"|\\binherit\\b|\\$\\{|\\b[a-zA-Z\\_][a-zA-Z0-9\\_\\'\\-]*(\\s*\\.|\\s*=[^=])))",
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "patterns": [
+ {
+ "include": "#attrset-definition-brace-opened"
+ }
+ ]
+ },
+ {
+ "begin": "(?=(\\.\\.\\.|\\b[a-zA-Z\\_][a-zA-Z0-9\\_\\'\\-]*\\s*[,?]))",
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "patterns": [
+ {
+ "include": "#function-definition-brace-opened"
+ }
+ ]
+ },
+ {
+ "include": "#bad-reserved"
+ },
+ {
+ "begin": "\\b[a-zA-Z\\_][a-zA-Z0-9\\_\\'\\-]*",
+ "end": "(?=([\\])};]|\\b(else|then)\\b))",
+ "beginCaptures": {
+ "0": {
+ "name": "variable.parameter.function.maybe.nix"
+ }
+ },
+ "patterns": [
+ {
+ "begin": "(?=\\.)",
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "patterns": [
+ {
+ "include": "#attrset-definition-brace-opened"
+ }
+ ]
+ },
+ {
+ "begin": "\\s*(\\,)",
+ "beginCaptures": {
+ "1": {
+ "name": "keyword.operator.nix"
+ }
+ },
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "patterns": [
+ {
+ "include": "#function-definition-brace-opened"
+ }
+ ]
+ },
+ {
+ "begin": "(?=\\=)",
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "patterns": [
+ {
+ "include": "#attribute-bind-from-equals"
+ },
+ {
+ "include": "#attrset-definition-brace-opened"
+ }
+ ]
+ },
+ {
+ "begin": "(?=\\?)",
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "patterns": [
+ {
+ "include": "#function-parameter-default"
+ },
+ {
+ "begin": "\\,",
+ "beginCaptures": {
+ "0": {
+ "name": "keyword.operator.nix"
+ }
+ },
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "patterns": [
+ {
+ "include": "#function-definition-brace-opened"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "include": "#others"
+ }
+ ]
+ },
+ {
+ "include": "#others"
+ }
+ ]
+ },
+ "with-assert": {
+ "begin": "(?)",
+ "beginCaptures": {
+ "0": {
+ "name": "string.unquoted.spath.nix"
+ }
+ },
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "patterns": [
+ {
+ "include": "#expression-cont"
+ }
+ ]
+ },
+ {
+ "begin": "([a-zA-Z][a-zA-Z0-9\\+\\-\\.]*\\:[a-zA-Z0-9\\%\\/\\?\\:\\@\\&\\=\\+\\$\\,\\-\\_\\.\\!\\~\\*\\']+)",
+ "beginCaptures": {
+ "0": {
+ "name": "string.unquoted.url.nix"
+ }
+ },
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "patterns": [
+ {
+ "include": "#expression-cont"
+ }
+ ]
+ }
+ ]
+ },
+ "parameter-name": {
+ "match": "\\b[a-zA-Z\\_][a-zA-Z0-9\\_\\'\\-]*",
+ "captures": {
+ "0": {
+ "name": "variable.parameter.name.nix"
+ }
+ }
+ },
+ "parameter-name-and-cont": {
+ "begin": "\\b[a-zA-Z\\_][a-zA-Z0-9\\_\\'\\-]*",
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "beginCaptures": {
+ "0": {
+ "name": "variable.parameter.name.nix"
+ }
+ },
+ "patterns": [
+ {
+ "include": "#expression-cont"
+ }
+ ]
+ },
+ "attribute-name-single": {
+ "match": "\\b[a-zA-Z\\_][a-zA-Z0-9\\_\\'\\-]*",
+ "name": "entity.other.attribute-name.single.nix"
+ },
+ "attribute-name": {
+ "patterns": [
+ {
+ "match": "\\b[a-zA-Z\\_][a-zA-Z0-9\\_\\'\\-]*",
+ "name": "entity.other.attribute-name.multipart.nix"
+ },
+ {
+ "match": "\\."
+ },
+ {
+ "include": "#string-quoted"
+ },
+ {
+ "include": "#interpolation"
+ }
+ ]
+ },
+ "function-parameter-default": {
+ "begin": "\\?",
+ "beginCaptures": {
+ "0": {
+ "name": "keyword.operator.nix"
+ }
+ },
+ "end": "(?=[,}])",
+ "patterns": [
+ {
+ "include": "#expression"
+ }
+ ]
+ },
+ "function-parameter": {
+ "patterns": [
+ {
+ "begin": "(\\.\\.\\.)",
+ "end": "(,|(?=\\}))",
+ "name": "keyword.operator.nix",
+ "patterns": [
+ {
+ "include": "#others"
+ }
+ ]
+ },
+ {
+ "begin": "\\b[a-zA-Z\\_][a-zA-Z0-9\\_\\'\\-]*",
+ "beginCaptures": {
+ "0": {
+ "name": "variable.parameter.function.1.nix"
+ }
+ },
+ "end": "(,|(?=\\}))",
+ "endCaptures": {
+ "0": {
+ "name": "keyword.operator.nix"
+ }
+ },
+ "patterns": [
+ {
+ "include": "#whitespace"
+ },
+ {
+ "include": "#comment"
+ },
+ {
+ "include": "#function-parameter-default"
+ },
+ {
+ "include": "#expression"
+ }
+ ]
+ },
+ {
+ "include": "#others"
+ }
+ ]
+ },
+ "attribute-inherit": {
+ "begin": "\\binherit\\b",
+ "beginCaptures": {
+ "0": {
+ "name": "keyword.other.inherit.nix"
+ }
+ },
+ "end": "\\;",
+ "endCaptures": {
+ "0": {
+ "name": "punctuation.terminator.inherit.nix"
+ }
+ },
+ "patterns": [
+ {
+ "begin": "\\(",
+ "end": "(?=\\;)",
+ "beginCaptures": {
+ "0": {
+ "name": "punctuation.section.function.arguments.nix"
+ }
+ },
+ "patterns": [
+ {
+ "begin": "\\)",
+ "end": "(?=\\;)",
+ "beginCaptures": {
+ "0": {
+ "name": "punctuation.section.function.arguments.nix"
+ }
+ },
+ "patterns": [
+ {
+ "include": "#bad-reserved"
+ },
+ {
+ "include": "#attribute-name-single"
+ },
+ {
+ "include": "#others"
+ }
+ ]
+ },
+ {
+ "include": "#expression"
+ }
+ ]
+ },
+ {
+ "begin": "(?=[a-zA-Z\\_])",
+ "end": "(?=\\;)",
+ "patterns": [
+ {
+ "include": "#bad-reserved"
+ },
+ {
+ "include": "#attribute-name-single"
+ },
+ {
+ "include": "#others"
+ }
+ ]
+ },
+ {
+ "include": "#others"
+ }
+ ]
+ },
+ "attribute-bind-from-equals": {
+ "begin": "\\=",
+ "beginCaptures": {
+ "0": {
+ "name": "keyword.operator.bind.nix"
+ }
+ },
+ "end": "\\;",
+ "endCaptures": {
+ "0": {
+ "name": "punctuation.terminator.bind.nix"
+ }
+ },
+ "patterns": [
+ {
+ "include": "#expression"
+ }
+ ]
+ },
+ "attribute-bind": {
+ "patterns": [
+ {
+ "include": "#attribute-name"
+ },
+ {
+ "include": "#attribute-bind-from-equals"
+ }
+ ]
+ },
+ "operator-unary": {
+ "name": "keyword.operator.unary.nix",
+ "match": "(!|-)"
+ },
+ "operator-binary": {
+ "name": "keyword.operator.nix",
+ "match": "(\\bor\\b|\\.|\\|\\>|\\<\\||==|!=|!|\\<\\=|\\<|\\>\\=|\\>|&&|\\|\\||-\\>|//|\\?|\\+\\+|-|\\*|/(?=([^*]|$))|\\+)"
+ },
+ "constants": {
+ "patterns": [
+ {
+ "begin": "\\b(builtins|true|false|null)\\b",
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "beginCaptures": {
+ "0": {
+ "name": "constant.language.nix"
+ }
+ },
+ "patterns": [
+ {
+ "include": "#expression-cont"
+ }
+ ]
+ },
+ {
+ "beginCaptures": {
+ "0": {
+ "name": "support.function.nix"
+ }
+ },
+ "begin": "\\b(scopedImport|import|isNull|abort|throw|baseNameOf|dirOf|removeAttrs|map|toString|derivationStrict|derivation)\\b",
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "patterns": [
+ {
+ "include": "#expression-cont"
+ }
+ ]
+ },
+ {
+ "beginCaptures": {
+ "0": {
+ "name": "constant.numeric.nix"
+ }
+ },
+ "begin": "\\b[0-9]+\\b",
+ "end": "(?=([\\])};,]|\\b(else|then)\\b))",
+ "patterns": [
+ {
+ "include": "#expression-cont"
+ }
+ ]
+ }
+ ]
+ },
+ "whitespace": {
+ "match": "\\s+"
+ },
+ "illegal": {
+ "match": ".",
+ "name": "invalid.illegal"
+ },
+ "others": {
+ "patterns": [
+ {
+ "include": "#whitespace"
+ },
+ {
+ "include": "#comment"
+ },
+ {
+ "include": "#illegal"
+ }
+ ]
+ },
+ "bad-reserved": {
+ "match": "(? Unit
@@ -58,7 +62,7 @@ class MainActivity : AppCompatActivity() {
isPaused = true
GlobalScope.launch(Dispatchers.IO) {
SessionManager.saveSession(viewModel.tabs, viewModel.currentTabIndex)
- DrawerPersistence.saveState()
+ DrawerPersistence.saveState(drawerViewModel)
foregroundListener.values.forEach { it.invoke(false) }
LspRegistry.updateConfiguration(this@MainActivity)
@@ -73,8 +77,6 @@ class MainActivity : AppCompatActivity() {
lifecycleScope.launch(Dispatchers.IO) {
handleIntent(intent)
foregroundListener.values.forEach { it.invoke(true) }
- delay(1000)
- handleSupport()
val lspConfigChanges = LspRegistry.getConfigurationChanges(this@MainActivity)
if (lspConfigChanges.isNotEmpty()) {
@@ -84,6 +86,9 @@ class MainActivity : AppCompatActivity() {
.filter { affectedExtensions.contains(it.file.getExtension()) }
.forEach { tab -> tab.applyHighlightingAndConnectLSP() }
}
+
+ delay(1000.milliseconds)
+ handleSupport()
}
}
@@ -95,7 +100,7 @@ class MainActivity : AppCompatActivity() {
suspend fun handleIntent(intent: Intent) {
if (Intent.ACTION_VIEW == intent.action || Intent.ACTION_EDIT == intent.action) {
if (intent.data == null) {
- errorDialog(strings.invalid_intent.getFilledString(intent.toString()))
+ errorDialog(msg = strings.invalid_intent.getFilledString(intent.toString()))
return
}
@@ -132,18 +137,22 @@ class MainActivity : AppCompatActivity() {
setContent {
val navController = rememberNavController()
+ val startDestination = remember {
+ if (Settings.shown_disclaimer) {
+ MainRoutes.Main.route
+ } else {
+ MainRoutes.Disclaimer.route
+ }
+ }
NavHost(
navController = navController,
- startDestination =
- if (Settings.shown_disclaimer) {
- MainRoutes.Main.route
- } else {
- MainRoutes.Disclaimer.route
- },
+ startDestination = startDestination,
) {
composable(MainRoutes.Main.route) {
MainContentHost()
- LaunchedEffect(Unit) { FilePermission.verifyStoragePermission(this@MainActivity) }
+ LaunchedEffect(Unit) {
+ FilePermission.verifyStoragePermission(this@MainActivity)
+ }
}
composable(MainRoutes.Disclaimer.route) { DisclaimerScreen(navController) { finishAffinity() } }
}
diff --git a/core/main/src/main/java/com/rk/activities/main/MainContent.kt b/core/main/src/main/java/com/rk/activities/main/MainContent.kt
index 84f7e11f0..cc89672db 100644
--- a/core/main/src/main/java/com/rk/activities/main/MainContent.kt
+++ b/core/main/src/main/java/com/rk/activities/main/MainContent.kt
@@ -48,15 +48,16 @@ import com.mohamedrejeb.compose.dnd.reorder.rememberReorderState
import com.rk.commands.CommandPalette
import com.rk.commands.CommandProvider
import com.rk.components.compose.utils.addIf
+import com.rk.drawer.DrawerViewModel
import com.rk.editor.preloadSelectionColor
import com.rk.filetree.FileAction
import com.rk.filetree.FileActionContext
import com.rk.filetree.FileActionDialogs
+import com.rk.filetree.FileActionProvider
import com.rk.filetree.FileIcon
import com.rk.filetree.FileTreeViewModel
import com.rk.filetree.MultiFileAction
import com.rk.filetree.MultiFileActionContext
-import com.rk.filetree.getActions
import com.rk.icons.XedIcon
import com.rk.resources.drawables
import com.rk.resources.getString
@@ -64,7 +65,7 @@ import com.rk.resources.strings
import com.rk.settings.Settings
import com.rk.tabs.base.Tab
import com.rk.tabs.editor.EditorTab
-import com.rk.utils.dialog
+import com.rk.utils.dialogRes
import com.rk.utils.drawErrorUnderline
import com.rk.utils.getGitColor
import com.rk.utils.getUnderlineColor
@@ -74,6 +75,7 @@ import kotlinx.coroutines.launch
fun MainContent(
innerPadding: PaddingValues,
mainViewModel: MainViewModel,
+ drawerViewModel: DrawerViewModel,
fileTreeViewModel: FileTreeViewModel,
drawerState: DrawerState,
) {
@@ -82,7 +84,7 @@ fun MainContent(
preloadSelectionColor()
- FileActionDialogs(fileTreeViewModel, scope, context)
+ FileActionDialogs(drawerViewModel, fileTreeViewModel, scope, context)
if (mainViewModel.isDraggingPalette || mainViewModel.showCommandPalette) {
val lastUsedCommand = CommandProvider.getForId(Settings.last_used_command)
@@ -146,12 +148,12 @@ fun MainContent(
if (tabIndex == -1) return@TabItem
if (tabState is EditorTab && tabState.editorState.isDirty) {
- dialog(
+ dialogRes(
title = strings.file_unsaved.getString(),
msg = strings.ask_unsaved.getString(),
onOk = { mainViewModel.tabManager.removeTab(tabIndex) },
onCancel = {},
- okString = strings.discard,
+ okRes = strings.discard,
)
} else {
mainViewModel.tabManager.removeTab(tabIndex)
@@ -165,12 +167,12 @@ fun MainContent(
tabIndex != index && (tab as? EditorTab)?.editorState?.isDirty == true
}
if (unsavedOtherTabs.isNotEmpty()) {
- dialog(
+ dialogRes(
title = strings.files_unsaved.getString(),
msg = strings.ask_multiple_unsaved.getString(),
onOk = { mainViewModel.tabManager.removeOtherTabs() },
onCancel = {},
- okString = strings.discard,
+ okRes = strings.discard,
)
} else {
mainViewModel.tabManager.removeOtherTabs()
@@ -182,12 +184,12 @@ fun MainContent(
(tab as? EditorTab)?.editorState?.isDirty == true
}
if (unsavedTabs.isNotEmpty()) {
- dialog(
+ dialogRes(
title = strings.files_unsaved.getString(),
msg = strings.ask_multiple_unsaved.getString(),
onOk = { mainViewModel.tabManager.removeAllTabs() },
onCancel = {},
- okString = strings.discard,
+ okRes = strings.discard,
)
} else {
mainViewModel.tabManager.removeAllTabs()
@@ -297,6 +299,8 @@ private fun TabItemContent(
val context = LocalContext.current
val density = LocalDensity.current
+ val drawerViewModel = (context as MainActivity).drawerViewModel
+
val isSelected = mainViewModel.currentTabIndex == index
val backgroundColor = MaterialTheme.colorScheme.surfaceVariant
@@ -372,7 +376,7 @@ private fun TabItemContent(
tabState.file?.let {
DropdownMenu(expanded = showFileActionMenu, onDismissRequest = { showFileActionMenu = false }) {
val root = (tabState as? EditorTab)?.projectRoot
- val actions = remember(it) { getActions(it, root) }
+ val actions = remember(it) { FileActionProvider.getActions(it, root) }
actions.forEach { action ->
when (action) {
@@ -382,7 +386,8 @@ private fun TabItemContent(
leadingIcon = { XedIcon(action.icon, contentDescription = action.title) },
enabled = action.isEnabled(it),
onClick = {
- val context = FileActionContext(it, root, fileTreeViewModel, context)
+ val context =
+ FileActionContext(it, root, fileTreeViewModel, drawerViewModel, context)
action.action(context)
showFileActionMenu = false
},
@@ -395,7 +400,8 @@ private fun TabItemContent(
leadingIcon = { XedIcon(action.icon, contentDescription = action.title) },
enabled = action.isEnabled(files),
onClick = {
- val context = MultiFileActionContext(files, root, fileTreeViewModel, context)
+ val context =
+ MultiFileActionContext(files, root, fileTreeViewModel, drawerViewModel, context)
action.action(context)
showFileActionMenu = false
},
diff --git a/core/main/src/main/java/com/rk/activities/main/MainContentHost.kt b/core/main/src/main/java/com/rk/activities/main/MainContentHost.kt
index 39c171341..adedd27d4 100644
--- a/core/main/src/main/java/com/rk/activities/main/MainContentHost.kt
+++ b/core/main/src/main/java/com/rk/activities/main/MainContentHost.kt
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.isImeVisible
+import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DrawerState
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.MaterialTheme
@@ -30,11 +31,9 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import com.rk.components.ResponsiveDrawer
-import com.rk.filetree.DrawerContent
-import com.rk.filetree.DrawerPersistence
+import com.rk.drawer.DrawerContent
+import com.rk.drawer.DrawerPersistence
import com.rk.filetree.FileTreeViewModel
-import com.rk.filetree.createServices
-import com.rk.filetree.isLoading
import com.rk.git.GitViewModel
import com.rk.resources.getString
import com.rk.resources.strings
@@ -42,12 +41,12 @@ import com.rk.search.SearchViewModel
import com.rk.settings.Settings
import com.rk.tabs.editor.EditorTab
import com.rk.theme.XedTheme
-import com.rk.utils.dialog
-import java.lang.ref.WeakReference
+import com.rk.utils.dialogRes
import kotlinx.coroutines.launch
+import java.lang.ref.WeakReference
-var fileTreeViewModel = WeakReference(null)
var gitViewModel = WeakReference(null)
+var fileTreeViewModel = WeakReference(null)
var searchViewModel = WeakReference(null)
var snackbarHostStateRef: WeakReference = WeakReference(null)
@@ -60,8 +59,8 @@ fun MainActivity.MainContentHost(
fileTreeViewModel: FileTreeViewModel = viewModel(),
searchViewModel: SearchViewModel = viewModel(),
) {
- com.rk.activities.main.fileTreeViewModel = WeakReference(fileTreeViewModel)
com.rk.activities.main.gitViewModel = WeakReference(gitViewModel)
+ com.rk.activities.main.fileTreeViewModel = WeakReference(fileTreeViewModel)
com.rk.activities.main.searchViewModel = WeakReference(searchViewModel)
XedTheme {
@@ -113,12 +112,12 @@ fun MainActivity.MainContentHost(
if (drawerState.isOpen) {
scope.launch { drawerState.close() }
} else if (viewModel.tabs.isNotEmpty() && Settings.confirm_exit) {
- dialog(
+ dialogRes(
title = strings.attention.getString(),
msg = strings.confirm_exit.getString(),
onCancel = {},
onOk = { finish() },
- okString = strings.exit,
+ okRes = strings.exit,
)
} else {
finish()
@@ -140,7 +139,10 @@ fun MainActivity.MainContentHost(
contentWindowInsets =
if (Settings.fullscreen) WindowInsets() else ScaffoldDefaults.contentWindowInsets,
snackbarHost = {
- SnackbarHost(hostState = snackbarHostState) { data ->
+ SnackbarHost(
+ hostState = snackbarHostState,
+ modifier = Modifier.padding(bottom = snackbarBottomPadding),
+ ) { data ->
Snackbar(
snackbarData = data,
containerColor = MaterialTheme.colorScheme.surfaceVariant,
@@ -155,6 +157,7 @@ fun MainActivity.MainContentHost(
XedTopBar(
drawerState = drawerState,
viewModel = viewModel,
+ drawerViewModel = drawerViewModel,
fullScreen = Settings.fullscreen,
onDrag = { dragAmount ->
accumulator += dragAmount
@@ -182,19 +185,20 @@ fun MainActivity.MainContentHost(
) { innerPadding ->
MainContent(
innerPadding = innerPadding,
- drawerState = drawerState,
mainViewModel = viewModel,
+ drawerViewModel = drawerViewModel,
fileTreeViewModel = fileTreeViewModel,
+ drawerState = drawerState,
)
}
}
val sheetContent: @Composable ColumnScope.() -> Unit = {
LaunchedEffect(Unit) {
- isLoading = true
- DrawerPersistence.restoreState()
- createServices()
- isLoading = false
+ drawerViewModel.isLoading = true
+ drawerViewModel.setupBuiltinServices(gitViewModel)
+ DrawerPersistence.restoreState(drawerViewModel)
+ drawerViewModel.isLoading = false
}
DrawerContent(Settings.fullscreen)
}
diff --git a/core/main/src/main/java/com/rk/activities/main/TopBar.kt b/core/main/src/main/java/com/rk/activities/main/TopBar.kt
index 902510105..270d50ad4 100644
--- a/core/main/src/main/java/com/rk/activities/main/TopBar.kt
+++ b/core/main/src/main/java/com/rk/activities/main/TopBar.kt
@@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import com.rk.components.GlobalToolbarActions
import com.rk.components.isPermanentDrawer
+import com.rk.drawer.DrawerViewModel
import com.rk.resources.strings
import com.rk.terminal.isV
import com.rk.utils.toast
@@ -29,6 +30,7 @@ import kotlinx.coroutines.launch
fun XedTopBar(
drawerState: DrawerState,
viewModel: MainViewModel,
+ drawerViewModel: DrawerViewModel,
fullScreen: Boolean,
onDrag: (Float) -> Unit = {},
onDragEnd: () -> Unit = {},
@@ -59,7 +61,7 @@ fun XedTopBar(
}
},
actions = {
- GlobalToolbarActions(viewModel)
+ GlobalToolbarActions(viewModel, drawerViewModel)
if (viewModel.tabs.isNotEmpty()) {
val tab =
diff --git a/core/main/src/main/java/com/rk/activities/settings/SettingsNavHost.kt b/core/main/src/main/java/com/rk/activities/settings/SettingsNavHost.kt
index 8a1c75db2..7233b7068 100644
--- a/core/main/src/main/java/com/rk/activities/settings/SettingsNavHost.kt
+++ b/core/main/src/main/java/com/rk/activities/settings/SettingsNavHost.kt
@@ -1,8 +1,6 @@
package com.rk.activities.settings
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.setValue
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
@@ -23,10 +21,12 @@ import com.rk.settings.editor.EditExtraKeys
import com.rk.settings.editor.EditToolbarActions
import com.rk.settings.editor.EditorFontScreen
import com.rk.settings.editor.ExcludeFiles
+import com.rk.settings.editor.FormatterSettings
import com.rk.settings.editor.SettingsEditorScreen
import com.rk.settings.editor.TerminalFontScreen
import com.rk.settings.extension.ExtensionDetail
import com.rk.settings.extension.ExtensionScreen
+import com.rk.settings.extension.ExtensionSettings
import com.rk.settings.git.GitSettings
import com.rk.settings.keybinds.KeybindingsScreen
import com.rk.settings.language.LanguageScreen
@@ -37,6 +37,7 @@ import com.rk.settings.runners.HtmlRunnerSettings
import com.rk.settings.runners.RunnerSettings
import com.rk.settings.support.Support
import com.rk.settings.terminal.SettingsTerminalScreen
+import com.rk.settings.terminal.TerminalCheckScreen
import com.rk.settings.terminal.TerminalExtraKeys
import com.rk.settings.theme.ThemeScreen
@@ -56,6 +57,7 @@ fun SettingsNavHost(navController: NavHostController, activity: SettingsActivity
composable(SettingsRoutes.Keybindings.route) { KeybindingsScreen() }
composable(SettingsRoutes.TerminalSettings.route) { SettingsTerminalScreen() }
composable(SettingsRoutes.TerminalExtraKeys.route) { TerminalExtraKeys() }
+ composable(SettingsRoutes.TerminalCheck.route) { TerminalCheckScreen() }
composable(SettingsRoutes.About.route) { AboutScreen() }
composable(SettingsRoutes.EditorFontScreen.route) { EditorFontScreen() }
composable(SettingsRoutes.AppFontScreen.route) { AppFontScreen() }
@@ -77,7 +79,8 @@ fun SettingsNavHost(navController: NavHostController, activity: SettingsActivity
composable(SettingsRoutes.LanguageScreen.route) { LanguageScreen() }
composable(SettingsRoutes.Runners.route) { RunnerSettings(navController = navController) }
composable(SettingsRoutes.HtmlRunner.route) { HtmlRunnerSettings() }
- composable(SettingsRoutes.LspSettings.route) { LspSettings(navController = navController) }
+ composable(SettingsRoutes.Formatters.route) { FormatterSettings(navController) }
+ composable(SettingsRoutes.LspSettings.route) { LspSettings(navController) }
composable(
"${SettingsRoutes.LspServerDetail.route}/{serverId}",
arguments = listOf(navArgument("serverId", builder = { type = NavType.StringType })),
@@ -107,7 +110,15 @@ fun SettingsNavHost(navController: NavHostController, activity: SettingsActivity
) {
val extensionId = it.arguments?.getString("extensionId")
val extension = extensionId?.let { App.extensionManager.getExtension(it) }
- ExtensionDetail(extension)
+ ExtensionDetail(extension, navController)
+ }
+ composable(
+ "${SettingsRoutes.ExtensionSettings.route}/{extensionId}",
+ arguments = listOf(navArgument("extensionId", builder = { type = NavType.StringType })),
+ ) {
+ val extensionId = it.arguments?.getString("extensionId")
+ val extension = extensionId?.let { App.extensionManager.getExtension(it) }
+ ExtensionSettings(extension)
}
composable(SettingsRoutes.Git.route) { GitSettings() }
}
diff --git a/core/main/src/main/java/com/rk/activities/settings/SettingsRoutes.kt b/core/main/src/main/java/com/rk/activities/settings/SettingsRoutes.kt
index 8f1d3b12d..590a742f4 100644
--- a/core/main/src/main/java/com/rk/activities/settings/SettingsRoutes.kt
+++ b/core/main/src/main/java/com/rk/activities/settings/SettingsRoutes.kt
@@ -13,6 +13,8 @@ sealed class SettingsRoutes(val route: String) {
data object TerminalExtraKeys : SettingsRoutes("terminal_extra_keys")
+ data object TerminalCheck : SettingsRoutes("terminal_check")
+
data object About : SettingsRoutes("about")
data object EditorFontScreen : SettingsRoutes("editor_font_screen")
@@ -35,12 +37,16 @@ sealed class SettingsRoutes(val route: String) {
data object ExtensionDetail : SettingsRoutes("extension_detail")
+ data object ExtensionSettings : SettingsRoutes("extension_settings")
+
data object DeveloperOptions : SettingsRoutes("developer_options")
data object AppLogs : SettingsRoutes("app_logs")
data object Support : SettingsRoutes("support")
+ data object Formatters : SettingsRoutes("formatters")
+
data object LanguageScreen : SettingsRoutes("language")
data object Runners : SettingsRoutes("runners")
diff --git a/core/main/src/main/java/com/rk/activities/terminal/Terminal.kt b/core/main/src/main/java/com/rk/activities/terminal/Terminal.kt
index bf8578b43..47ec180b2 100644
--- a/core/main/src/main/java/com/rk/activities/terminal/Terminal.kt
+++ b/core/main/src/main/java/com/rk/activities/terminal/Terminal.kt
@@ -47,7 +47,6 @@ import com.rk.XedConstants
import com.rk.exec.isTerminalInstalled
import com.rk.file.child
import com.rk.file.localBinDir
-import com.rk.file.localLibDir
import com.rk.file.sandboxDir
import com.rk.resources.getString
import com.rk.resources.strings
@@ -215,36 +214,7 @@ class Terminal : AppCompatActivity() {
try {
val abi = Build.SUPPORTED_ABIS
- val filesToDownload =
- listOf(
- DownloadFile(
- url =
- if (abi.contains("x86_64")) {
- XedConstants.TALLOC_X64
- } else if (abi.contains("arm64-v8a")) {
- XedConstants.TALLOC_ARM64
- } else if (abi.contains("armeabi-v7a")) {
- XedConstants.TALLOC_ARM
- } else {
- throw RuntimeException("Unsupported CPU")
- },
- outputFile = localLibDir().child("libtalloc.so.2"),
- ),
- DownloadFile(
- url =
- if (abi.contains("x86_64")) {
- XedConstants.PROOT_X64
- } else if (abi.contains("arm64-v8a")) {
- XedConstants.PROOT_ARM64
- } else if (abi.contains("armeabi-v7a")) {
- XedConstants.PROOT_ARM
- } else {
- throw RuntimeException("Unsupported CPU")
- },
- outputFile = localBinDir().child("proot"),
- ),
- )
- .toMutableList()
+ val filesToDownload = mutableListOf()
if (isTerminalInstalled().not()) {
filesToDownload.add(
@@ -304,7 +274,7 @@ class Terminal : AppCompatActivity() {
File(getTempDir(), "sandbox.tar.gz").delete()
}
}
- errorDialog("Setup failed: ${error.message}")
+ errorDialog(msg = "Setup failed: ${error.message}")
}
}
finish()
diff --git a/core/main/src/main/java/com/rk/commands/CommandAPI.kt b/core/main/src/main/java/com/rk/commands/CommandAPI.kt
index 0b3601b82..50b792db3 100644
--- a/core/main/src/main/java/com/rk/commands/CommandAPI.kt
+++ b/core/main/src/main/java/com/rk/commands/CommandAPI.kt
@@ -1,15 +1,23 @@
package com.rk.commands
import android.app.Activity
+import com.rk.activities.main.MainActivity
import com.rk.activities.main.MainViewModel
+import com.rk.drawer.DrawerViewModel
import com.rk.editor.Editor
import com.rk.icons.Icon
import com.rk.lsp.LspConnector
import com.rk.tabs.editor.EditorTab
-data class CommandContext(private val provider: () -> MainViewModel) {
+data class CommandContext(
+ private val mainViewModelProvider: () -> MainViewModel,
+ private val drawerViewModelProvider: () -> DrawerViewModel,
+) {
val mainViewModel: MainViewModel
- get() = provider()
+ get() = mainViewModelProvider()
+
+ val drawerViewModel: DrawerViewModel
+ get() = drawerViewModelProvider()
}
data class ActionContext(val currentActivity: Activity)
@@ -27,20 +35,25 @@ data class LspActionContext(
data class LspNonActionContext(val editorTab: EditorTab, val lspConnector: LspConnector)
-abstract class Command(val commandContext: CommandContext) {
+abstract class Command {
+ protected val commandContext =
+ CommandContext(
+ mainViewModelProvider = { MainActivity.instance!!.viewModel },
+ drawerViewModelProvider = { MainActivity.instance!!.drawerViewModel },
+ )
abstract val id: String
open val prefix: String? = null
abstract fun getLabel(): String
+ abstract fun getIcon(): Icon
+
abstract fun action(actionContext: ActionContext)
open fun isEnabled(): Boolean = true
open fun isSupported(): Boolean = true
- abstract fun getIcon(): Icon
-
open val preferText: Boolean = false
open val childCommands: List = emptyList()
@@ -73,7 +86,7 @@ abstract class Command(val commandContext: CommandContext) {
sectionId: Int = this.sectionId,
defaultKeybinds: KeyCombination? = this.defaultKeybinds,
): Command {
- return object : Command(commandContext) {
+ return object : Command() {
override val id: String = id
override val prefix: String? = prefix
@@ -115,9 +128,9 @@ interface ToggleableCommand {
fun isOn(): Boolean
}
-abstract class GlobalCommand(commandContext: CommandContext) : Command(commandContext)
+abstract class GlobalCommand : Command()
-abstract class EditorCommand(commandContext: CommandContext) : Command(commandContext) {
+abstract class EditorCommand : Command() {
final override fun action(actionContext: ActionContext) {
val currentTab = commandContext.mainViewModel.currentTab
val editor = (currentTab as? EditorTab)?.editorState?.editor?.get() ?: return
@@ -143,7 +156,7 @@ abstract class EditorCommand(commandContext: CommandContext) : Command(commandCo
open fun isEnabled(editorNonActionContext: EditorNonActionContext): Boolean = true
}
-abstract class LspCommand(commandContext: CommandContext) : EditorCommand(commandContext) {
+abstract class LspCommand : EditorCommand() {
final override fun action(editorActionContext: EditorActionContext) {
val currentTab = editorActionContext.editorTab
val editor = editorActionContext.editor
diff --git a/core/main/src/main/java/com/rk/commands/CommandPalette.kt b/core/main/src/main/java/com/rk/commands/CommandPalette.kt
index de25a9a56..69e4eae21 100644
--- a/core/main/src/main/java/com/rk/commands/CommandPalette.kt
+++ b/core/main/src/main/java/com/rk/commands/CommandPalette.kt
@@ -80,7 +80,7 @@ fun CommandPalette(
derivedStateOf {
buildList {
lastUsedCommand?.let { add(it) }
- addAll(commands.filter { it != lastUsedCommand })
+ addAll(commands.filter { it != lastUsedCommand }.sortedBy { it.getLabel() })
}
}
}
@@ -90,10 +90,14 @@ fun CommandPalette(
val filteredCommands by
remember(visibleCommands, searchQuery) {
derivedStateOf {
- visibleCommands.filter {
- it.getLabel().contains(searchQuery, ignoreCase = true) ||
- it.prefix?.contains(searchQuery, ignoreCase = true) == true
- }
+ visibleCommands
+ .filter {
+ it.getLabel().contains(searchQuery, ignoreCase = true) ||
+ it.prefix?.contains(searchQuery, ignoreCase = true) == true
+ }
+ .filter {
+ it.isSupported()
+ }
}
}
@@ -188,7 +192,7 @@ fun CommandItem(
isSubpage: Boolean,
) {
val activity = LocalActivity.current
- val enabled by remember { derivedStateOf { command.isSupported() && command.isEnabled() } }
+ val enabled by remember { derivedStateOf { command.isEnabled() } }
val childCommands = command.childCommands
val keyCombination = KeybindingsManager.getKeyCombinationForCommand(command.id)
diff --git a/core/main/src/main/java/com/rk/commands/CommandProvider.kt b/core/main/src/main/java/com/rk/commands/CommandProvider.kt
index bd3a49343..aa057d4c5 100644
--- a/core/main/src/main/java/com/rk/commands/CommandProvider.kt
+++ b/core/main/src/main/java/com/rk/commands/CommandProvider.kt
@@ -1,6 +1,6 @@
package com.rk.commands
-import com.rk.activities.main.MainActivity
+import androidx.compose.runtime.mutableStateListOf
import com.rk.commands.editor.CopyCommand
import com.rk.commands.editor.CutCommand
import com.rk.commands.editor.DuplicateLineCommand
@@ -17,6 +17,8 @@ import com.rk.commands.editor.SearchCommand
import com.rk.commands.editor.SelectAllCommand
import com.rk.commands.editor.SelectWordCommand
import com.rk.commands.editor.ShareCommand
+import com.rk.commands.editor.SortLinesAscendingCommand
+import com.rk.commands.editor.SortLinesDescendingCommand
import com.rk.commands.editor.SyntaxHighlightingCommand
import com.rk.commands.editor.ToggleReadOnlyCommand
import com.rk.commands.editor.ToggleWordWrapCommand
@@ -35,11 +37,12 @@ import com.rk.commands.lsp.FormatSelectionCommand
import com.rk.commands.lsp.GoToDefinitionCommand
import com.rk.commands.lsp.GoToReferencesCommand
import com.rk.commands.lsp.RenameSymbolCommand
+import com.rk.extension.api.XedExtensionPoint
object CommandProvider {
- private val _commandList = mutableListOf()
+ private val _commandList = mutableStateListOf()
val commandList: List
- get() = _commandList.toList()
+ get() = _commandList
lateinit var DocumentationCommand: DocumentationCommand
lateinit var TerminalCommand: TerminalCommand
@@ -68,6 +71,8 @@ object CommandProvider {
lateinit var SyntaxHighlightingCommand: SyntaxHighlightingCommand
lateinit var ToggleWordWrapCommand: ToggleWordWrapCommand
lateinit var JumpToLineCommand: JumpToLineCommand
+ lateinit var SortLinesAscendingCommand: SortLinesAscendingCommand
+ lateinit var SortLinesDescendingCommand: SortLinesDescendingCommand
lateinit var ShareCommand: ShareCommand
lateinit var EmulateKeyCommand: EmulateKeyCommand
lateinit var GoToDefinitionCommand: GoToDefinitionCommand
@@ -78,42 +83,42 @@ object CommandProvider {
fun buildCommands() =
synchronized(this) {
- val commandContext = CommandContext { MainActivity.instance!!.viewModel }
-
- registerBuiltin(DocumentationCommand(commandContext)) { DocumentationCommand = it }
- registerBuiltin(TerminalCommand(commandContext)) { TerminalCommand = it }
- registerBuiltin(SettingsCommand(commandContext)) { SettingsCommand = it }
- registerBuiltin(NewFileCommand(commandContext)) { NewFileCommand = it }
- registerBuiltin(CommandPaletteCommand(commandContext)) { CommandPaletteCommand = it }
- registerBuiltin(SearchFileFolderCommand(commandContext)) { SearchFileFolderCommand = it }
- registerBuiltin(SearchCodeCommand(commandContext)) { SearchCodeCommand = it }
- registerBuiltin(CutCommand(commandContext)) { CutCommand = it }
- registerBuiltin(CopyCommand(commandContext)) { CopyCommand = it }
- registerBuiltin(PasteCommand(commandContext)) { PasteCommand = it }
- registerBuiltin(SelectAllCommand(commandContext)) { SelectAllCommand = it }
- registerBuiltin(SelectWordCommand(commandContext)) { SelectWordCommand = it }
- registerBuiltin(DuplicateLineCommand(commandContext)) { DuplicateLineCommand = it }
- registerBuiltin(LowerCaseCommand(commandContext)) { LowerCaseCommand = it }
- registerBuiltin(UpperCaseCommand(commandContext)) { UpperCaseCommand = it }
- registerBuiltin(SaveCommand(commandContext)) { SaveCommand = it }
- registerBuiltin(SaveAllCommand(commandContext)) { SaveAllCommand = it }
- registerBuiltin(UndoCommand(commandContext)) { UndoCommand = it }
- registerBuiltin(RedoCommand(commandContext)) { RedoCommand = it }
- registerBuiltin(RunCommand(commandContext)) { RunCommand = it }
- registerBuiltin(ToggleReadOnlyCommand(commandContext)) { ToggleReadOnlyCommand = it }
- registerBuiltin(SearchCommand(commandContext)) { SearchCommand = it }
- registerBuiltin(ReplaceCommand(commandContext)) { ReplaceCommand = it }
- registerBuiltin(RefreshCommand(commandContext)) { RefreshCommand = it }
- registerBuiltin(SyntaxHighlightingCommand(commandContext)) { SyntaxHighlightingCommand = it }
- registerBuiltin(ToggleWordWrapCommand(commandContext)) { ToggleWordWrapCommand = it }
- registerBuiltin(JumpToLineCommand(commandContext)) { JumpToLineCommand = it }
- registerBuiltin(ShareCommand(commandContext)) { ShareCommand = it }
- registerBuiltin(EmulateKeyCommand(commandContext)) { EmulateKeyCommand = it }
- registerBuiltin(GoToDefinitionCommand(commandContext)) { GoToDefinitionCommand = it }
- registerBuiltin(GoToReferencesCommand(commandContext)) { GoToReferencesCommand = it }
- registerBuiltin(RenameSymbolCommand(commandContext)) { RenameSymbolCommand = it }
- registerBuiltin(FormatDocumentCommand(commandContext)) { FormatDocumentCommand = it }
- registerBuiltin(FormatSelectionCommand(commandContext)) { FormatSelectionCommand = it }
+ registerBuiltin(DocumentationCommand()) { DocumentationCommand = it }
+ registerBuiltin(TerminalCommand()) { TerminalCommand = it }
+ registerBuiltin(SettingsCommand()) { SettingsCommand = it }
+ registerBuiltin(NewFileCommand()) { NewFileCommand = it }
+ registerBuiltin(CommandPaletteCommand()) { CommandPaletteCommand = it }
+ registerBuiltin(SearchFileFolderCommand()) { SearchFileFolderCommand = it }
+ registerBuiltin(SearchCodeCommand()) { SearchCodeCommand = it }
+ registerBuiltin(CutCommand()) { CutCommand = it }
+ registerBuiltin(CopyCommand()) { CopyCommand = it }
+ registerBuiltin(PasteCommand()) { PasteCommand = it }
+ registerBuiltin(SelectAllCommand()) { SelectAllCommand = it }
+ registerBuiltin(SelectWordCommand()) { SelectWordCommand = it }
+ registerBuiltin(DuplicateLineCommand()) { DuplicateLineCommand = it }
+ registerBuiltin(LowerCaseCommand()) { LowerCaseCommand = it }
+ registerBuiltin(UpperCaseCommand()) { UpperCaseCommand = it }
+ registerBuiltin(SaveCommand()) { SaveCommand = it }
+ registerBuiltin(SaveAllCommand()) { SaveAllCommand = it }
+ registerBuiltin(UndoCommand()) { UndoCommand = it }
+ registerBuiltin(RedoCommand()) { RedoCommand = it }
+ registerBuiltin(RunCommand()) { RunCommand = it }
+ registerBuiltin(ToggleReadOnlyCommand()) { ToggleReadOnlyCommand = it }
+ registerBuiltin(SearchCommand()) { SearchCommand = it }
+ registerBuiltin(ReplaceCommand()) { ReplaceCommand = it }
+ registerBuiltin(RefreshCommand()) { RefreshCommand = it }
+ registerBuiltin(SyntaxHighlightingCommand()) { SyntaxHighlightingCommand = it }
+ registerBuiltin(ToggleWordWrapCommand()) { ToggleWordWrapCommand = it }
+ registerBuiltin(JumpToLineCommand()) { JumpToLineCommand = it }
+ registerBuiltin(SortLinesAscendingCommand()) { SortLinesAscendingCommand = it }
+ registerBuiltin(SortLinesDescendingCommand()) { SortLinesDescendingCommand = it }
+ registerBuiltin(ShareCommand()) { ShareCommand = it }
+ registerBuiltin(EmulateKeyCommand()) { EmulateKeyCommand = it }
+ registerBuiltin(GoToDefinitionCommand()) { GoToDefinitionCommand = it }
+ registerBuiltin(GoToReferencesCommand()) { GoToReferencesCommand = it }
+ registerBuiltin(RenameSymbolCommand()) { RenameSymbolCommand = it }
+ registerBuiltin(FormatDocumentCommand()) { FormatDocumentCommand = it }
+ registerBuiltin(FormatSelectionCommand()) { FormatSelectionCommand = it }
}
private fun registerBuiltin(command: T, assign: (T) -> Unit) {
@@ -122,12 +127,17 @@ object CommandProvider {
_commandList.add(command)
}
+ @XedExtensionPoint
fun registerCommand(command: Command) {
- if (!_commandList.contains(command)) {
+ val index = _commandList.indexOf(command)
+ if (index >= 0) {
+ _commandList[index] = command
+ } else {
_commandList.add(command)
}
}
+ @XedExtensionPoint
fun unregisterCommand(command: Command) {
_commandList.remove(command)
}
diff --git a/core/main/src/main/java/com/rk/commands/ToolbarConfiguration.kt b/core/main/src/main/java/com/rk/commands/ToolbarConfiguration.kt
new file mode 100644
index 000000000..f6755089d
--- /dev/null
+++ b/core/main/src/main/java/com/rk/commands/ToolbarConfiguration.kt
@@ -0,0 +1,102 @@
+package com.rk.commands
+
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.snapshots.SnapshotStateList
+import com.rk.extension.api.XedExtensionPoint
+import com.rk.settings.Settings
+
+object ToolbarConfiguration {
+ const val DEFAULT_EDITOR_TOOLBAR_COMMANDS =
+ "editor.undo|editor.redo|editor.save|editor.run|global.new_file|editor.editable|editor.search|editor.refresh|global.terminal|global.settings"
+
+ val editorCommands: List
+ get() = Settings.action_items.split("|").mapNotNull { CommandProvider.getForId(it) }
+
+ @XedExtensionPoint
+ fun addEditorToolbarCommand(commandId: String, index: Int? = null) {
+ val items = Settings.action_items.split("|").toMutableList()
+
+ if (commandId in items) {
+ if (index == null) return
+
+ items.remove(commandId)
+ items.add(index.coerceIn(0, items.size), commandId)
+ Settings.action_items = items.joinToString("|")
+ return
+ }
+
+ if (index != null) {
+ items.add(index.coerceIn(0, items.size), commandId)
+ } else {
+ items.add(commandId)
+ }
+
+ Settings.action_items = items.joinToString("|")
+ }
+
+ @XedExtensionPoint
+ fun addEditorToolbarCommand(command: Command, index: Int? = null) {
+ addEditorToolbarCommand(command.id, index)
+ }
+
+ @XedExtensionPoint
+ fun removeEditorToolbarCommand(commandId: String) {
+ val items = Settings.action_items.split("|").toMutableList()
+
+ if (!items.remove(commandId)) {
+ return
+ }
+
+ Settings.action_items = items.joinToString("|")
+ }
+
+ @XedExtensionPoint
+ fun removeEditorToolbarCommand(command: Command) {
+ removeEditorToolbarCommand(command.id)
+ }
+
+ private var _globalCommands: SnapshotStateList =
+ mutableStateListOf(
+ CommandProvider.NewFileCommand,
+ CommandProvider.TerminalCommand,
+ CommandProvider.SettingsCommand,
+ )
+
+ val globalCommands: List
+ get() = _globalCommands
+
+ @XedExtensionPoint
+ fun addGlobalToolbarCommand(command: Command, index: Int? = null) {
+ val existingIndex = _globalCommands.indexOf(command)
+
+ if (existingIndex != -1) {
+ _globalCommands.removeAt(existingIndex)
+ }
+
+ val insertIndex =
+ when {
+ index != null -> index
+ existingIndex != -1 -> existingIndex
+ else -> _globalCommands.size
+ }
+
+ _globalCommands.add(insertIndex.coerceIn(0, _globalCommands.size), command)
+ }
+
+ @XedExtensionPoint
+ fun addGlobalToolbarCommand(commandId: String, index: Int? = null) {
+ val command = CommandProvider.getForId(commandId) ?: return
+ addGlobalToolbarCommand(command, index)
+ }
+
+ @XedExtensionPoint
+ fun removeGlobalToolbarCommand(command: Command) {
+ _globalCommands.remove(command)
+ }
+
+ @XedExtensionPoint
+ fun removeGlobalToolbarCommand(commandId: String) {
+ val command = CommandProvider.getForId(commandId) ?: return
+ removeGlobalToolbarCommand(command)
+ }
+}
diff --git a/core/main/src/main/java/com/rk/commands/editor/CopyCommand.kt b/core/main/src/main/java/com/rk/commands/editor/CopyCommand.kt
index dc66aeac5..48ccf8aff 100644
--- a/core/main/src/main/java/com/rk/commands/editor/CopyCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/editor/CopyCommand.kt
@@ -1,7 +1,6 @@
package com.rk.commands.editor
import android.view.KeyEvent
-import com.rk.commands.CommandContext
import com.rk.commands.EditorActionContext
import com.rk.commands.EditorCommand
import com.rk.commands.KeyCombination
@@ -10,7 +9,7 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class CopyCommand(commandContext: CommandContext) : EditorCommand(commandContext) {
+class CopyCommand : EditorCommand() {
override val id: String = "editor.copy"
override fun getLabel(): String = strings.copy.getString()
@@ -19,7 +18,7 @@ class CopyCommand(commandContext: CommandContext) : EditorCommand(commandContext
editorActionContext.editor.copyText()
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.copy)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.copy)
override val defaultKeybinds: KeyCombination = KeyCombination(keyCode = KeyEvent.KEYCODE_C, ctrl = true)
}
diff --git a/core/main/src/main/java/com/rk/commands/editor/CutCommand.kt b/core/main/src/main/java/com/rk/commands/editor/CutCommand.kt
index 9ace35740..7e0513966 100644
--- a/core/main/src/main/java/com/rk/commands/editor/CutCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/editor/CutCommand.kt
@@ -1,7 +1,6 @@
package com.rk.commands.editor
import android.view.KeyEvent
-import com.rk.commands.CommandContext
import com.rk.commands.EditorActionContext
import com.rk.commands.EditorCommand
import com.rk.commands.EditorNonActionContext
@@ -11,7 +10,7 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class CutCommand(commandContext: CommandContext) : EditorCommand(commandContext) {
+class CutCommand : EditorCommand() {
override val id: String = "editor.cut"
override fun getLabel(): String = strings.cut.getString()
@@ -24,7 +23,7 @@ class CutCommand(commandContext: CommandContext) : EditorCommand(commandContext)
return editorNonActionContext.editorTab.editorState.editable
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.cut)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.cut)
override val defaultKeybinds: KeyCombination = KeyCombination(keyCode = KeyEvent.KEYCODE_X, ctrl = true)
}
diff --git a/core/main/src/main/java/com/rk/commands/editor/DuplicateLineCommand.kt b/core/main/src/main/java/com/rk/commands/editor/DuplicateLineCommand.kt
index 850247939..22ca2450b 100644
--- a/core/main/src/main/java/com/rk/commands/editor/DuplicateLineCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/editor/DuplicateLineCommand.kt
@@ -1,7 +1,6 @@
package com.rk.commands.editor
import android.view.KeyEvent
-import com.rk.commands.CommandContext
import com.rk.commands.EditorActionContext
import com.rk.commands.EditorCommand
import com.rk.commands.EditorNonActionContext
@@ -11,7 +10,7 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class DuplicateLineCommand(commandContext: CommandContext) : EditorCommand(commandContext) {
+class DuplicateLineCommand : EditorCommand() {
override val id: String = "editor.duplicate_line"
override fun getLabel(): String = strings.duplicate_line.getString()
@@ -24,7 +23,7 @@ class DuplicateLineCommand(commandContext: CommandContext) : EditorCommand(comma
return editorNonActionContext.editorTab.editorState.editable
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.duplicate_line)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.duplicate_line)
override val defaultKeybinds: KeyCombination = KeyCombination(keyCode = KeyEvent.KEYCODE_D, ctrl = true)
}
diff --git a/core/main/src/main/java/com/rk/commands/editor/EmulateKeyCommand.kt b/core/main/src/main/java/com/rk/commands/editor/EmulateKeyCommand.kt
index aebe5db0f..a06a98634 100644
--- a/core/main/src/main/java/com/rk/commands/editor/EmulateKeyCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/editor/EmulateKeyCommand.kt
@@ -5,7 +5,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.rk.commands.Command
-import com.rk.commands.CommandContext
import com.rk.commands.EditorActionContext
import com.rk.commands.EditorCommand
import com.rk.commands.ToggleableCommand
@@ -38,7 +37,7 @@ object EmulateKeyCommandSection {
const val KEY_SECTION = 1
}
-class EmulateKeyCommand(commandContext: CommandContext) : EditorCommand(commandContext) {
+class EmulateKeyCommand : EditorCommand() {
val modifierState = ModifierState()
override val id: String = "editor.emulate_key"
@@ -47,7 +46,7 @@ class EmulateKeyCommand(commandContext: CommandContext) : EditorCommand(commandC
override fun action(editorActionContext: EditorActionContext) {}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.keyboard)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.keyboard)
override val childCommands: List by lazy {
val keyEvents = KeyEvent::class.java.fields
@@ -83,7 +82,7 @@ class EmulateKeyCommand(commandContext: CommandContext) : EditorCommand(commandC
val keyDisplayName = KeyUtils.getKeyDisplayName(metaEvent.metaKeyCode)
val keyIcon = KeyUtils.getKeyIcon(metaEvent.keyCode)
- object : EditorCommand(commandContext), ToggleableCommand {
+ object : EditorCommand(), ToggleableCommand {
override val id: String = "editor.emulate_key.${keyDisplayName.lowercase()}"
override val preferText: Boolean = metaEvent.keyCode != KeyEvent.KEYCODE_SHIFT_LEFT
@@ -117,7 +116,7 @@ class EmulateKeyCommand(commandContext: CommandContext) : EditorCommand(commandC
// To avoid confusion, do not show KEYCODE variants of modifier keys
if (KeyUtils.isModifierKey(keyCode)) return@mapNotNull null
- object : EditorCommand(commandContext) {
+ object : EditorCommand() {
override val id: String = "editor.emulate_key.${keyName.lowercase()}"
override fun getLabel(): String = keyDisplayName
diff --git a/core/main/src/main/java/com/rk/commands/editor/JumpToLineCommand.kt b/core/main/src/main/java/com/rk/commands/editor/JumpToLineCommand.kt
index 3dee0f8d0..10d652c51 100644
--- a/core/main/src/main/java/com/rk/commands/editor/JumpToLineCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/editor/JumpToLineCommand.kt
@@ -1,7 +1,6 @@
package com.rk.commands.editor
import android.view.KeyEvent
-import com.rk.commands.CommandContext
import com.rk.commands.EditorActionContext
import com.rk.commands.EditorCommand
import com.rk.commands.KeyCombination
@@ -10,7 +9,7 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class JumpToLineCommand(commandContext: CommandContext) : EditorCommand(commandContext) {
+class JumpToLineCommand : EditorCommand() {
override val id: String = "editor.jump_to_line"
override fun getLabel(): String = strings.jump_to_line.getString()
@@ -19,7 +18,7 @@ class JumpToLineCommand(commandContext: CommandContext) : EditorCommand(commandC
editorActionContext.editorTab.editorState.showJumpToLineDialog = true
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.arrow_outward)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.arrow_outward)
override val defaultKeybinds: KeyCombination = KeyCombination(keyCode = KeyEvent.KEYCODE_G, ctrl = true)
}
diff --git a/core/main/src/main/java/com/rk/commands/editor/LowerCaseCommand.kt b/core/main/src/main/java/com/rk/commands/editor/LowerCaseCommand.kt
index c77ffbd2f..03ef19557 100644
--- a/core/main/src/main/java/com/rk/commands/editor/LowerCaseCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/editor/LowerCaseCommand.kt
@@ -1,6 +1,5 @@
package com.rk.commands.editor
-import com.rk.commands.CommandContext
import com.rk.commands.EditorActionContext
import com.rk.commands.EditorCommand
import com.rk.icons.Icon
@@ -8,7 +7,7 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class LowerCaseCommand(commandContext: CommandContext) : EditorCommand(commandContext) {
+class LowerCaseCommand : EditorCommand() {
override val id: String = "editor.lowercase"
override fun getLabel(): String = strings.transform_lowercase.getString()
@@ -23,5 +22,5 @@ class LowerCaseCommand(commandContext: CommandContext) : EditorCommand(commandCo
}
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.letters)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.letters)
}
diff --git a/core/main/src/main/java/com/rk/commands/editor/PasteCommand.kt b/core/main/src/main/java/com/rk/commands/editor/PasteCommand.kt
index 735d5600b..533b8b85c 100644
--- a/core/main/src/main/java/com/rk/commands/editor/PasteCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/editor/PasteCommand.kt
@@ -1,7 +1,6 @@
package com.rk.commands.editor
import android.view.KeyEvent
-import com.rk.commands.CommandContext
import com.rk.commands.EditorActionContext
import com.rk.commands.EditorCommand
import com.rk.commands.EditorNonActionContext
@@ -11,7 +10,7 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class PasteCommand(commandContext: CommandContext) : EditorCommand(commandContext) {
+class PasteCommand : EditorCommand() {
override val id: String = "editor.paste"
override fun getLabel(): String = strings.paste.getString()
@@ -24,7 +23,7 @@ class PasteCommand(commandContext: CommandContext) : EditorCommand(commandContex
return editorNonActionContext.editorTab.editorState.editable
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.paste)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.paste)
override val defaultKeybinds: KeyCombination = KeyCombination(keyCode = KeyEvent.KEYCODE_V, ctrl = true)
}
diff --git a/core/main/src/main/java/com/rk/commands/editor/RedoCommand.kt b/core/main/src/main/java/com/rk/commands/editor/RedoCommand.kt
index b35454d00..d8242bf92 100644
--- a/core/main/src/main/java/com/rk/commands/editor/RedoCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/editor/RedoCommand.kt
@@ -1,7 +1,6 @@
package com.rk.commands.editor
import android.view.KeyEvent
-import com.rk.commands.CommandContext
import com.rk.commands.EditorActionContext
import com.rk.commands.EditorCommand
import com.rk.commands.EditorNonActionContext
@@ -11,7 +10,7 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class RedoCommand(commandContext: CommandContext) : EditorCommand(commandContext) {
+class RedoCommand : EditorCommand() {
override val id: String = "editor.redo"
override fun getLabel(): String = strings.redo.getString()
@@ -27,7 +26,7 @@ class RedoCommand(commandContext: CommandContext) : EditorCommand(commandContext
return editorState.editable && editorState.canRedo
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.redo)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.redo)
override val defaultKeybinds: KeyCombination = KeyCombination(keyCode = KeyEvent.KEYCODE_Y, ctrl = true)
}
diff --git a/core/main/src/main/java/com/rk/commands/editor/RefreshCommand.kt b/core/main/src/main/java/com/rk/commands/editor/RefreshCommand.kt
index ae0e29433..41edf04e9 100644
--- a/core/main/src/main/java/com/rk/commands/editor/RefreshCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/editor/RefreshCommand.kt
@@ -1,7 +1,6 @@
package com.rk.commands.editor
import android.view.KeyEvent
-import com.rk.commands.CommandContext
import com.rk.commands.EditorActionContext
import com.rk.commands.EditorCommand
import com.rk.commands.KeyCombination
@@ -9,9 +8,9 @@ import com.rk.icons.Icon
import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-import com.rk.utils.dialog
+import com.rk.utils.dialogRes
-class RefreshCommand(commandContext: CommandContext) : EditorCommand(commandContext) {
+class RefreshCommand : EditorCommand() {
override val id: String = "editor.refresh"
override fun getLabel(): String = strings.refresh.getString()
@@ -19,11 +18,11 @@ class RefreshCommand(commandContext: CommandContext) : EditorCommand(commandCont
override fun action(editorActionContext: EditorActionContext) {
val currentTab = editorActionContext.editorTab
if (currentTab.editorState.isDirty) {
- dialog(
- context = editorActionContext.currentActivity,
+ dialogRes(
+ activity = editorActionContext.currentActivity,
title = strings.attention.getString(),
msg = strings.ask_refresh.getString(),
- okString = strings.refresh,
+ okRes = strings.refresh,
onCancel = {},
onOk = { currentTab.refresh() },
)
@@ -32,7 +31,7 @@ class RefreshCommand(commandContext: CommandContext) : EditorCommand(commandCont
}
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.refresh)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.refresh)
override val defaultKeybinds: KeyCombination =
KeyCombination(keyCode = KeyEvent.KEYCODE_R, ctrl = true, shift = true)
diff --git a/core/main/src/main/java/com/rk/commands/editor/ReplaceCommand.kt b/core/main/src/main/java/com/rk/commands/editor/ReplaceCommand.kt
index 8964bedea..4e81f41a9 100644
--- a/core/main/src/main/java/com/rk/commands/editor/ReplaceCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/editor/ReplaceCommand.kt
@@ -1,7 +1,7 @@
package com.rk.commands.editor
import android.view.KeyEvent
-import com.rk.commands.CommandContext
+import androidx.compose.ui.text.TextRange
import com.rk.commands.EditorActionContext
import com.rk.commands.EditorCommand
import com.rk.commands.KeyCombination
@@ -10,20 +10,22 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class ReplaceCommand(commandContext: CommandContext) : EditorCommand(commandContext) {
+class ReplaceCommand : EditorCommand() {
override val id: String = "editor.replace"
override fun getLabel(): String = strings.replace.getString()
override fun action(editorActionContext: EditorActionContext) {
editorActionContext.editorTab.editorState.apply {
- editorActionContext.editor.getSelectedText()?.let { searchKeyword = it }
+ editorActionContext.editor.getSelectedText()?.let {
+ searchKeyword = searchKeyword.copy(text = it, selection = TextRange(it.length))
+ }
isSearching = true
isReplaceShown = true
}
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.find_replace)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.find_replace)
override val defaultKeybinds: KeyCombination = KeyCombination(keyCode = KeyEvent.KEYCODE_H, ctrl = true)
}
diff --git a/core/main/src/main/java/com/rk/commands/editor/RunCommand.kt b/core/main/src/main/java/com/rk/commands/editor/RunCommand.kt
index 53461257b..a7d35c892 100644
--- a/core/main/src/main/java/com/rk/commands/editor/RunCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/editor/RunCommand.kt
@@ -2,7 +2,6 @@ package com.rk.commands.editor
import android.view.KeyEvent
import com.rk.DefaultScope
-import com.rk.commands.CommandContext
import com.rk.commands.CommandProvider
import com.rk.commands.EditorActionContext
import com.rk.commands.EditorCommand
@@ -12,13 +11,13 @@ import com.rk.icons.Icon
import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-import com.rk.runner.Runner
+import com.rk.runner.RunnerManager
import com.rk.settings.Settings
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.launch
@OptIn(DelicateCoroutinesApi::class)
-class RunCommand(commandContext: CommandContext) : EditorCommand(commandContext) {
+class RunCommand : EditorCommand() {
override val id: String = "editor.run"
override fun getLabel(): String = strings.run.getString()
@@ -29,8 +28,8 @@ class RunCommand(commandContext: CommandContext) : EditorCommand(commandContext)
CommandProvider.SaveCommand.action(editorActionContext)
DefaultScope.launch {
Settings.runs += 1
- Runner.run(
- context = activity,
+ RunnerManager.run(
+ activity = activity,
fileObject = editorTab.file,
onMultipleRunners = {
editorTab.editorState.showRunnerDialog = true
@@ -41,10 +40,10 @@ class RunCommand(commandContext: CommandContext) : EditorCommand(commandContext)
}
override fun isSupported(editorNonActionContext: EditorNonActionContext): Boolean {
- return Runner.isRunnable(editorNonActionContext.editorTab.file)
+ return RunnerManager.isRunnable(editorNonActionContext.editorTab.file)
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.run)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.run)
override val defaultKeybinds: KeyCombination = KeyCombination(keyCode = KeyEvent.KEYCODE_F5)
}
diff --git a/core/main/src/main/java/com/rk/commands/editor/SaveCommand.kt b/core/main/src/main/java/com/rk/commands/editor/SaveCommand.kt
index 9d7610592..96ab4f59f 100644
--- a/core/main/src/main/java/com/rk/commands/editor/SaveCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/editor/SaveCommand.kt
@@ -1,7 +1,6 @@
package com.rk.commands.editor
import android.view.KeyEvent
-import com.rk.commands.CommandContext
import com.rk.commands.EditorActionContext
import com.rk.commands.EditorCommand
import com.rk.commands.EditorNonActionContext
@@ -14,7 +13,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
-class SaveCommand(commandContext: CommandContext) : EditorCommand(commandContext) {
+class SaveCommand : EditorCommand() {
override val id: String = "editor.save"
override fun getLabel(): String = strings.save.getString()
@@ -27,7 +26,7 @@ class SaveCommand(commandContext: CommandContext) : EditorCommand(commandContext
return editorNonActionContext.editorTab.file.canWrite()
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.save)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.save)
override val defaultKeybinds: KeyCombination = KeyCombination(keyCode = KeyEvent.KEYCODE_S, ctrl = true)
}
diff --git a/core/main/src/main/java/com/rk/commands/editor/SearchCommand.kt b/core/main/src/main/java/com/rk/commands/editor/SearchCommand.kt
index 28cee56f7..3dc182e89 100644
--- a/core/main/src/main/java/com/rk/commands/editor/SearchCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/editor/SearchCommand.kt
@@ -1,7 +1,7 @@
package com.rk.commands.editor
import android.view.KeyEvent
-import com.rk.commands.CommandContext
+import androidx.compose.ui.text.TextRange
import com.rk.commands.EditorActionContext
import com.rk.commands.EditorCommand
import com.rk.commands.KeyCombination
@@ -10,20 +10,22 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class SearchCommand(commandContext: CommandContext) : EditorCommand(commandContext) {
+class SearchCommand : EditorCommand() {
override val id: String = "editor.search"
override fun getLabel(): String = strings.search.getString()
override fun action(editorActionContext: EditorActionContext) {
editorActionContext.editorTab.editorState.apply {
- editorActionContext.editor.getSelectedText()?.let { searchKeyword = it }
+ editorActionContext.editor.getSelectedText()?.let {
+ searchKeyword = searchKeyword.copy(text = it, selection = TextRange(it.length))
+ }
isSearching = true
isReplaceShown = false
}
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.search)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.search)
override val defaultKeybinds: KeyCombination = KeyCombination(keyCode = KeyEvent.KEYCODE_F, ctrl = true)
}
diff --git a/core/main/src/main/java/com/rk/commands/editor/SelectAllCommand.kt b/core/main/src/main/java/com/rk/commands/editor/SelectAllCommand.kt
index b403942e7..1979f73e3 100644
--- a/core/main/src/main/java/com/rk/commands/editor/SelectAllCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/editor/SelectAllCommand.kt
@@ -1,7 +1,6 @@
package com.rk.commands.editor
import android.view.KeyEvent
-import com.rk.commands.CommandContext
import com.rk.commands.EditorActionContext
import com.rk.commands.EditorCommand
import com.rk.commands.KeyCombination
@@ -10,7 +9,7 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class SelectAllCommand(commandContext: CommandContext) : EditorCommand(commandContext) {
+class SelectAllCommand : EditorCommand() {
override val id: String = "editor.select_all"
override fun getLabel(): String = strings.select_all.getString()
@@ -19,7 +18,7 @@ class SelectAllCommand(commandContext: CommandContext) : EditorCommand(commandCo
editorActionContext.editor.selectAll()
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.select_all)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.select_all)
override val defaultKeybinds: KeyCombination = KeyCombination(keyCode = KeyEvent.KEYCODE_A, ctrl = true)
}
diff --git a/core/main/src/main/java/com/rk/commands/editor/SelectWordCommand.kt b/core/main/src/main/java/com/rk/commands/editor/SelectWordCommand.kt
index 983e5c2d2..f9742d636 100644
--- a/core/main/src/main/java/com/rk/commands/editor/SelectWordCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/editor/SelectWordCommand.kt
@@ -1,7 +1,6 @@
package com.rk.commands.editor
import android.view.KeyEvent
-import com.rk.commands.CommandContext
import com.rk.commands.EditorActionContext
import com.rk.commands.EditorCommand
import com.rk.commands.KeyCombination
@@ -10,7 +9,7 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class SelectWordCommand(commandContext: CommandContext) : EditorCommand(commandContext) {
+class SelectWordCommand : EditorCommand() {
override val id: String = "editor.select_word"
override fun getLabel(): String = strings.select_word.getString()
@@ -19,7 +18,7 @@ class SelectWordCommand(commandContext: CommandContext) : EditorCommand(commandC
editorActionContext.editor.selectCurrentWord()
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.select)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.select)
override val defaultKeybinds: KeyCombination = KeyCombination(keyCode = KeyEvent.KEYCODE_W, ctrl = true)
}
diff --git a/core/main/src/main/java/com/rk/commands/editor/ShareCommand.kt b/core/main/src/main/java/com/rk/commands/editor/ShareCommand.kt
index b3f7f2a4b..386cf6da2 100644
--- a/core/main/src/main/java/com/rk/commands/editor/ShareCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/editor/ShareCommand.kt
@@ -4,7 +4,6 @@ import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import com.rk.DefaultScope
-import com.rk.commands.CommandContext
import com.rk.commands.EditorActionContext
import com.rk.commands.EditorCommand
import com.rk.file.FileWrapper
@@ -15,7 +14,7 @@ import com.rk.resources.strings
import com.rk.utils.toast
import kotlinx.coroutines.launch
-class ShareCommand(commandContext: CommandContext) : EditorCommand(commandContext) {
+class ShareCommand : EditorCommand() {
override val id: String = "editor.share"
override fun getLabel(): String = strings.share.getString()
@@ -49,5 +48,5 @@ class ShareCommand(commandContext: CommandContext) : EditorCommand(commandContex
}
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.send)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.send)
}
diff --git a/core/main/src/main/java/com/rk/commands/editor/SortLinesAscendingCommand.kt b/core/main/src/main/java/com/rk/commands/editor/SortLinesAscendingCommand.kt
new file mode 100644
index 000000000..ecf6b5c4b
--- /dev/null
+++ b/core/main/src/main/java/com/rk/commands/editor/SortLinesAscendingCommand.kt
@@ -0,0 +1,38 @@
+package com.rk.commands.editor
+
+import com.rk.commands.EditorActionContext
+import com.rk.commands.EditorCommand
+import com.rk.icons.Icon
+import com.rk.resources.drawables
+import com.rk.resources.getString
+import com.rk.resources.strings
+
+class SortLinesAscendingCommand : EditorCommand() {
+ override val id = "editor.sort_lines_ascending"
+
+ override fun getLabel() = strings.sort_lines_ascending.getString()
+
+ override fun action(editorActionContext: EditorActionContext) {
+ val editor = editorActionContext.editor
+
+ val cursor = editor.cursor
+
+ var startLine: Int
+ var endLine: Int
+ if (!cursor.isSelected) {
+ startLine = 0
+ endLine = editor.text.lineCount - 1
+ } else {
+ startLine = minOf(cursor.leftLine, cursor.rightLine)
+ endLine = maxOf(cursor.leftLine, cursor.rightLine)
+ }
+ val endLineColumn = editor.text.getColumnCount(endLine)
+
+ val lines = editor.text.subContent(startLine, 0, endLine, endLineColumn).lines()
+ val ascendingLines = lines.sorted().joinToString("\n")
+
+ editor.text.replace(startLine, 0, endLine, endLineColumn, ascendingLines)
+ }
+
+ override fun getIcon() = Icon.ResourceIcon(drawables.sort_by_alphabet)
+}
diff --git a/core/main/src/main/java/com/rk/commands/editor/SortLinesDescendingCommand.kt b/core/main/src/main/java/com/rk/commands/editor/SortLinesDescendingCommand.kt
new file mode 100644
index 000000000..62f481bc3
--- /dev/null
+++ b/core/main/src/main/java/com/rk/commands/editor/SortLinesDescendingCommand.kt
@@ -0,0 +1,38 @@
+package com.rk.commands.editor
+
+import com.rk.commands.EditorActionContext
+import com.rk.commands.EditorCommand
+import com.rk.icons.Icon
+import com.rk.resources.drawables
+import com.rk.resources.getString
+import com.rk.resources.strings
+
+class SortLinesDescendingCommand : EditorCommand() {
+ override val id = "editor.sort_lines_descending"
+
+ override fun getLabel() = strings.sort_lines_descending.getString()
+
+ override fun action(editorActionContext: EditorActionContext) {
+ val editor = editorActionContext.editor
+
+ val cursor = editor.cursor
+
+ var startLine: Int
+ var endLine: Int
+ if (!cursor.isSelected) {
+ startLine = 0
+ endLine = editor.text.lineCount - 1
+ } else {
+ startLine = minOf(cursor.leftLine, cursor.rightLine)
+ endLine = maxOf(cursor.leftLine, cursor.rightLine)
+ }
+ val endLineColumn = editor.text.getColumnCount(endLine)
+
+ val lines = editor.text.subContent(startLine, 0, endLine, endLineColumn).lines()
+ val descendingLine = lines.sortedDescending().joinToString("\n")
+
+ editor.text.replace(startLine, 0, endLine, endLineColumn, descendingLine)
+ }
+
+ override fun getIcon() = Icon.ResourceIcon(drawables.sort_by_alphabet)
+}
diff --git a/core/main/src/main/java/com/rk/commands/editor/SyntaxHighlightingCommand.kt b/core/main/src/main/java/com/rk/commands/editor/SyntaxHighlightingCommand.kt
index 3a2ea9d3d..45f2c0ca6 100644
--- a/core/main/src/main/java/com/rk/commands/editor/SyntaxHighlightingCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/editor/SyntaxHighlightingCommand.kt
@@ -1,7 +1,6 @@
package com.rk.commands.editor
import com.rk.commands.Command
-import com.rk.commands.CommandContext
import com.rk.commands.EditorActionContext
import com.rk.commands.EditorCommand
import com.rk.file.FileTypeManager
@@ -10,20 +9,20 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class SyntaxHighlightingCommand(commandContext: CommandContext) : EditorCommand(commandContext) {
+class SyntaxHighlightingCommand : EditorCommand() {
override val id: String = "editor.syntax_highlighting"
override fun getLabel(): String = strings.highlighting.getString()
override fun action(editorActionContext: EditorActionContext) {}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.edit_note)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.edit_note)
override val childCommands: List by lazy {
FileTypeManager.allTypes()
.filter { it.textmateScope != null }
.map { fileType ->
- object : EditorCommand(commandContext) {
+ object : EditorCommand() {
override val id: String = "editor.syntax_highlighting.${fileType.name.lowercase()}"
override fun getLabel(): String = fileType.title
@@ -32,7 +31,7 @@ class SyntaxHighlightingCommand(commandContext: CommandContext) : EditorCommand(
editorActionContext.editorTab.editorState.textmateScope = fileType.textmateScope!!
}
- override fun getIcon(): Icon = fileType.getIcon()
+ override fun getIcon(): Icon = fileType.getResolvedIcon()
}
}
}
diff --git a/core/main/src/main/java/com/rk/commands/editor/ToggleReadOnlyCommand.kt b/core/main/src/main/java/com/rk/commands/editor/ToggleReadOnlyCommand.kt
index 46ff63529..2d1f5318c 100644
--- a/core/main/src/main/java/com/rk/commands/editor/ToggleReadOnlyCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/editor/ToggleReadOnlyCommand.kt
@@ -1,7 +1,6 @@
package com.rk.commands.editor
import android.view.KeyEvent
-import com.rk.commands.CommandContext
import com.rk.commands.EditorActionContext
import com.rk.commands.EditorCommand
import com.rk.commands.EditorNonActionContext
@@ -12,7 +11,7 @@ import com.rk.resources.getString
import com.rk.resources.strings
import com.rk.tabs.editor.EditorTab
-class ToggleReadOnlyCommand(commandContext: CommandContext) : EditorCommand(commandContext) {
+class ToggleReadOnlyCommand : EditorCommand() {
override val id: String = "editor.editable"
override fun getLabel(): String {
@@ -37,9 +36,9 @@ class ToggleReadOnlyCommand(commandContext: CommandContext) : EditorCommand(comm
override fun getIcon(): Icon {
val editorTab = commandContext.mainViewModel.currentTab as? EditorTab
return if (editorTab?.editorState?.editable == true) {
- Icon.DrawableRes(drawables.lock)
+ Icon.ResourceIcon(drawables.lock)
} else {
- Icon.DrawableRes(drawables.edit)
+ Icon.ResourceIcon(drawables.edit)
}
}
diff --git a/core/main/src/main/java/com/rk/commands/editor/ToggleWordWrapCommand.kt b/core/main/src/main/java/com/rk/commands/editor/ToggleWordWrapCommand.kt
index 20c04ca9c..b4d783703 100644
--- a/core/main/src/main/java/com/rk/commands/editor/ToggleWordWrapCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/editor/ToggleWordWrapCommand.kt
@@ -1,7 +1,6 @@
package com.rk.commands.editor
import android.view.KeyEvent
-import com.rk.commands.CommandContext
import com.rk.commands.EditorActionContext
import com.rk.commands.EditorCommand
import com.rk.commands.KeyCombination
@@ -10,7 +9,7 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class ToggleWordWrapCommand(commandContext: CommandContext) : EditorCommand(commandContext) {
+class ToggleWordWrapCommand : EditorCommand() {
override val id: String = "editor.toggle_word_wrap"
override fun getLabel(): String = strings.toggle_word_wrap.getString()
@@ -20,7 +19,7 @@ class ToggleWordWrapCommand(commandContext: CommandContext) : EditorCommand(comm
editor.setWordwrap(!editor.isWordwrap, true, true)
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.edit_note)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.edit_note)
override val defaultKeybinds: KeyCombination = KeyCombination(keyCode = KeyEvent.KEYCODE_Z, alt = true)
}
diff --git a/core/main/src/main/java/com/rk/commands/editor/UndoCommand.kt b/core/main/src/main/java/com/rk/commands/editor/UndoCommand.kt
index b158f976c..229a15ed3 100644
--- a/core/main/src/main/java/com/rk/commands/editor/UndoCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/editor/UndoCommand.kt
@@ -1,7 +1,6 @@
package com.rk.commands.editor
import android.view.KeyEvent
-import com.rk.commands.CommandContext
import com.rk.commands.EditorActionContext
import com.rk.commands.EditorCommand
import com.rk.commands.EditorNonActionContext
@@ -11,7 +10,7 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class UndoCommand(commandContext: CommandContext) : EditorCommand(commandContext) {
+class UndoCommand : EditorCommand() {
override val id: String = "editor.undo"
override fun getLabel(): String = strings.undo.getString()
@@ -27,7 +26,7 @@ class UndoCommand(commandContext: CommandContext) : EditorCommand(commandContext
return editorState.editable && editorState.canUndo
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.undo)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.undo)
override val defaultKeybinds: KeyCombination = KeyCombination(keyCode = KeyEvent.KEYCODE_Z, ctrl = true)
}
diff --git a/core/main/src/main/java/com/rk/commands/editor/UpperCaseCommand.kt b/core/main/src/main/java/com/rk/commands/editor/UpperCaseCommand.kt
index 285663535..dce5e736d 100644
--- a/core/main/src/main/java/com/rk/commands/editor/UpperCaseCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/editor/UpperCaseCommand.kt
@@ -1,6 +1,5 @@
package com.rk.commands.editor
-import com.rk.commands.CommandContext
import com.rk.commands.EditorActionContext
import com.rk.commands.EditorCommand
import com.rk.icons.Icon
@@ -8,7 +7,7 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class UpperCaseCommand(commandContext: CommandContext) : EditorCommand(commandContext) {
+class UpperCaseCommand : EditorCommand() {
override val id: String = "editor.uppercase"
override fun getLabel(): String = strings.transform_uppercase.getString()
@@ -23,5 +22,5 @@ class UpperCaseCommand(commandContext: CommandContext) : EditorCommand(commandCo
}
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.letters)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.letters)
}
diff --git a/core/main/src/main/java/com/rk/commands/global/CommandPaletteCommand.kt b/core/main/src/main/java/com/rk/commands/global/CommandPaletteCommand.kt
index 8ae0fb20b..96ae2c6b7 100644
--- a/core/main/src/main/java/com/rk/commands/global/CommandPaletteCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/global/CommandPaletteCommand.kt
@@ -2,7 +2,6 @@ package com.rk.commands.global
import android.view.KeyEvent
import com.rk.commands.ActionContext
-import com.rk.commands.CommandContext
import com.rk.commands.GlobalCommand
import com.rk.commands.KeyCombination
import com.rk.icons.Icon
@@ -10,7 +9,7 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class CommandPaletteCommand(commandContext: CommandContext) : GlobalCommand(commandContext) {
+class CommandPaletteCommand : GlobalCommand() {
override val id: String = "global.command_palette"
override fun getLabel(): String = strings.command_palette.getString()
@@ -19,7 +18,7 @@ class CommandPaletteCommand(commandContext: CommandContext) : GlobalCommand(comm
commandContext.mainViewModel.showCommandPalette()
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.command_palette)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.command_palette)
override val defaultKeybinds: KeyCombination =
KeyCombination(keyCode = KeyEvent.KEYCODE_P, ctrl = true, shift = true)
diff --git a/core/main/src/main/java/com/rk/commands/global/DocumentationCommand.kt b/core/main/src/main/java/com/rk/commands/global/DocumentationCommand.kt
index d05ebd172..9577d2746 100644
--- a/core/main/src/main/java/com/rk/commands/global/DocumentationCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/global/DocumentationCommand.kt
@@ -1,10 +1,7 @@
package com.rk.commands.global
-import android.content.Intent
import android.view.KeyEvent
-import androidx.core.net.toUri
import com.rk.commands.ActionContext
-import com.rk.commands.CommandContext
import com.rk.commands.GlobalCommand
import com.rk.commands.KeyCombination
import com.rk.icons.Icon
@@ -12,19 +9,19 @@ import com.rk.icons.Menu_book
import com.rk.icons.XedIcons
import com.rk.resources.getString
import com.rk.resources.strings
+import com.rk.utils.openUrl
-class DocumentationCommand(commandContext: CommandContext) : GlobalCommand(commandContext) {
+class DocumentationCommand : GlobalCommand() {
override val id: String = "global.documentation"
override fun getLabel(): String = strings.docs.getString()
+ override fun getIcon(): Icon = Icon.VectorIcon(XedIcons.Menu_book)
+
override fun action(actionContext: ActionContext) {
val url = "https://xed-editor.github.io/Xed-Docs/"
- val intent = Intent(Intent.ACTION_VIEW, url.toUri())
- actionContext.currentActivity.startActivity(intent)
+ actionContext.currentActivity.openUrl(url)
}
- override fun getIcon(): Icon = Icon.VectorIcon(XedIcons.Menu_book)
-
override val defaultKeybinds: KeyCombination = KeyCombination(keyCode = KeyEvent.KEYCODE_F1)
}
diff --git a/core/main/src/main/java/com/rk/commands/global/NewFileCommand.kt b/core/main/src/main/java/com/rk/commands/global/NewFileCommand.kt
index 546a37cc1..1e4887f44 100644
--- a/core/main/src/main/java/com/rk/commands/global/NewFileCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/global/NewFileCommand.kt
@@ -2,7 +2,6 @@ package com.rk.commands.global
import android.view.KeyEvent
import com.rk.commands.ActionContext
-import com.rk.commands.CommandContext
import com.rk.commands.GlobalCommand
import com.rk.commands.KeyCombination
import com.rk.components.addDialog
@@ -11,7 +10,7 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class NewFileCommand(commandContext: CommandContext) : GlobalCommand(commandContext) {
+class NewFileCommand : GlobalCommand() {
override val id: String = "global.new_file"
override fun getLabel(): String = strings.new_file.getString()
@@ -20,7 +19,7 @@ class NewFileCommand(commandContext: CommandContext) : GlobalCommand(commandCont
addDialog = true
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.add)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.add)
override val defaultKeybinds: KeyCombination = KeyCombination(keyCode = KeyEvent.KEYCODE_N, ctrl = true)
}
diff --git a/core/main/src/main/java/com/rk/commands/global/SaveAllCommand.kt b/core/main/src/main/java/com/rk/commands/global/SaveAllCommand.kt
index f7467fee7..78c892cd1 100644
--- a/core/main/src/main/java/com/rk/commands/global/SaveAllCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/global/SaveAllCommand.kt
@@ -2,7 +2,6 @@ package com.rk.commands.global
import android.view.KeyEvent
import com.rk.commands.ActionContext
-import com.rk.commands.CommandContext
import com.rk.commands.GlobalCommand
import com.rk.commands.KeyCombination
import com.rk.icons.Icon
@@ -14,7 +13,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
-class SaveAllCommand(commandContext: CommandContext) : GlobalCommand(commandContext) {
+class SaveAllCommand : GlobalCommand() {
override val id: String = "global.save_all"
override fun getLabel(): String = strings.save_all.getString()
@@ -29,7 +28,7 @@ class SaveAllCommand(commandContext: CommandContext) : GlobalCommand(commandCont
return commandContext.mainViewModel.tabs.isNotEmpty()
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.save)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.save)
override val defaultKeybinds: KeyCombination =
KeyCombination(keyCode = KeyEvent.KEYCODE_S, ctrl = true, shift = true)
diff --git a/core/main/src/main/java/com/rk/commands/global/SearchCodeCommand.kt b/core/main/src/main/java/com/rk/commands/global/SearchCodeCommand.kt
index b059122aa..aba9d6f39 100644
--- a/core/main/src/main/java/com/rk/commands/global/SearchCodeCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/global/SearchCodeCommand.kt
@@ -2,17 +2,15 @@ package com.rk.commands.global
import android.view.KeyEvent
import com.rk.commands.ActionContext
-import com.rk.commands.CommandContext
import com.rk.commands.GlobalCommand
import com.rk.commands.KeyCombination
import com.rk.components.codeSearchDialog
-import com.rk.filetree.currentDrawerTab
import com.rk.icons.Icon
import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class SearchCodeCommand(commandContext: CommandContext) : GlobalCommand(commandContext) {
+class SearchCodeCommand : GlobalCommand() {
override val id: String = "global.search_code"
override fun getLabel(): String = strings.search_code.getString()
@@ -22,10 +20,10 @@ class SearchCodeCommand(commandContext: CommandContext) : GlobalCommand(commandC
}
override fun isEnabled(): Boolean {
- return currentDrawerTab != null
+ return commandContext.drawerViewModel.currentDrawerTab != null
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.search)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.search)
override val defaultKeybinds: KeyCombination =
KeyCombination(keyCode = KeyEvent.KEYCODE_F, ctrl = true, shift = true)
diff --git a/core/main/src/main/java/com/rk/commands/global/SearchFileFolderCommand.kt b/core/main/src/main/java/com/rk/commands/global/SearchFileFolderCommand.kt
index ad510a6aa..32f6ef840 100644
--- a/core/main/src/main/java/com/rk/commands/global/SearchFileFolderCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/global/SearchFileFolderCommand.kt
@@ -2,18 +2,16 @@ package com.rk.commands.global
import android.view.KeyEvent
import com.rk.commands.ActionContext
-import com.rk.commands.CommandContext
import com.rk.commands.GlobalCommand
import com.rk.commands.KeyCombination
import com.rk.components.fileSearchDialog
import com.rk.filetree.FileTreeTab
-import com.rk.filetree.currentDrawerTab
import com.rk.icons.Icon
import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class SearchFileFolderCommand(commandContext: CommandContext) : GlobalCommand(commandContext) {
+class SearchFileFolderCommand : GlobalCommand() {
override val id: String = "global.search_file_folder"
override fun getLabel(): String = strings.search_file_folder.getString()
@@ -23,10 +21,10 @@ class SearchFileFolderCommand(commandContext: CommandContext) : GlobalCommand(co
}
override fun isEnabled(): Boolean {
- return currentDrawerTab is FileTreeTab
+ return commandContext.drawerViewModel.currentDrawerTab is FileTreeTab
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.search)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.search)
override val defaultKeybinds: KeyCombination = KeyCombination(keyCode = KeyEvent.KEYCODE_P, ctrl = true)
}
diff --git a/core/main/src/main/java/com/rk/commands/global/SettingsCommand.kt b/core/main/src/main/java/com/rk/commands/global/SettingsCommand.kt
index 09d18175d..896f56af5 100644
--- a/core/main/src/main/java/com/rk/commands/global/SettingsCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/global/SettingsCommand.kt
@@ -4,7 +4,6 @@ import android.content.Intent
import android.view.KeyEvent
import com.rk.activities.settings.SettingsActivity
import com.rk.commands.ActionContext
-import com.rk.commands.CommandContext
import com.rk.commands.GlobalCommand
import com.rk.commands.KeyCombination
import com.rk.icons.Icon
@@ -12,7 +11,7 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class SettingsCommand(commandContext: CommandContext) : GlobalCommand(commandContext) {
+class SettingsCommand : GlobalCommand() {
override val id: String = "global.settings"
override fun getLabel(): String = strings.settings.getString()
@@ -22,7 +21,7 @@ class SettingsCommand(commandContext: CommandContext) : GlobalCommand(commandCon
activity.startActivity(Intent(activity, SettingsActivity::class.java))
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.settings)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.settings)
override val defaultKeybinds: KeyCombination = KeyCombination(keyCode = KeyEvent.KEYCODE_COMMA, ctrl = true)
}
diff --git a/core/main/src/main/java/com/rk/commands/global/TerminalCommand.kt b/core/main/src/main/java/com/rk/commands/global/TerminalCommand.kt
index b92c33707..167f14fba 100644
--- a/core/main/src/main/java/com/rk/commands/global/TerminalCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/global/TerminalCommand.kt
@@ -4,7 +4,6 @@ import android.content.Intent
import android.view.KeyEvent
import com.rk.activities.terminal.Terminal
import com.rk.commands.ActionContext
-import com.rk.commands.CommandContext
import com.rk.commands.GlobalCommand
import com.rk.commands.KeyCombination
import com.rk.icons.Icon
@@ -12,42 +11,22 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
import com.rk.settings.app.InbuiltFeatures
-import com.rk.utils.showTerminalNotice
-class TerminalCommand(commandContext: CommandContext) : GlobalCommand(commandContext) {
+class TerminalCommand : GlobalCommand() {
override val id: String = "global.terminal"
override fun getLabel(): String = strings.terminal.getString()
override fun action(actionContext: ActionContext) {
val activity = actionContext.currentActivity
- showTerminalNotice(activity) {
- val intent =
- Intent(activity, Terminal::class.java).apply {
- commandContext.mainViewModel.currentTab?.file?.let { currentFile ->
- // // val currentFile =
- // viewModel.currentTab?.file ?:
- // // return@apply
- // // val currentPath =
- // currentFile.getAbsolutePath()
- // // val project =
- // // tabs
- // // .filter {
- // // currentPath.startsWith(it.fileObject.getAbsolutePath()) }
- // // .maxByOrNull {
- // // it.fileObject.getAbsolutePath().length } ?: return@apply
- // // putExtra("cwd",
-
- // TODO: Fix this
- }
- }
- activity.startActivity(intent)
- }
+ val intent =
+ Intent(activity, Terminal::class.java)
+ activity.startActivity(intent)
}
override fun isSupported(): Boolean = InbuiltFeatures.terminal.state.value
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.terminal)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.terminal)
override val defaultKeybinds: KeyCombination = KeyCombination(keyCode = KeyEvent.KEYCODE_J, ctrl = true)
}
diff --git a/core/main/src/main/java/com/rk/commands/lsp/FormatDocumentCommand.kt b/core/main/src/main/java/com/rk/commands/lsp/FormatDocumentCommand.kt
index 3325d8be4..e0407331e 100644
--- a/core/main/src/main/java/com/rk/commands/lsp/FormatDocumentCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/lsp/FormatDocumentCommand.kt
@@ -1,7 +1,6 @@
package com.rk.commands.lsp
import com.rk.DefaultScope
-import com.rk.commands.CommandContext
import com.rk.commands.LspActionContext
import com.rk.commands.LspCommand
import com.rk.commands.LspNonActionContext
@@ -11,7 +10,7 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class FormatDocumentCommand(commandContext: CommandContext) : LspCommand(commandContext) {
+class FormatDocumentCommand : LspCommand() {
override val id: String = "lsp.format_document"
override fun getLabel(): String = strings.format_document.getString()
@@ -24,5 +23,5 @@ class FormatDocumentCommand(commandContext: CommandContext) : LspCommand(command
return lspNonActionContext.lspConnector.isFormattingSupported()
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.auto_fix)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.auto_fix)
}
diff --git a/core/main/src/main/java/com/rk/commands/lsp/FormatSelectionCommand.kt b/core/main/src/main/java/com/rk/commands/lsp/FormatSelectionCommand.kt
index 1cda019c5..265be25ba 100644
--- a/core/main/src/main/java/com/rk/commands/lsp/FormatSelectionCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/lsp/FormatSelectionCommand.kt
@@ -1,7 +1,6 @@
package com.rk.commands.lsp
import com.rk.DefaultScope
-import com.rk.commands.CommandContext
import com.rk.commands.LspActionContext
import com.rk.commands.LspCommand
import com.rk.commands.LspNonActionContext
@@ -11,7 +10,7 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class FormatSelectionCommand(commandContext: CommandContext) : LspCommand(commandContext) {
+class FormatSelectionCommand : LspCommand() {
override val id: String = "lsp.format_selection"
override fun getLabel(): String = strings.format_selection.getString()
@@ -24,5 +23,5 @@ class FormatSelectionCommand(commandContext: CommandContext) : LspCommand(comman
return lspNonActionContext.lspConnector.isRangeFormattingSupported()
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.auto_fix)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.auto_fix)
}
diff --git a/core/main/src/main/java/com/rk/commands/lsp/GoToDefinitionCommand.kt b/core/main/src/main/java/com/rk/commands/lsp/GoToDefinitionCommand.kt
index 79ed66b59..79b7f14bb 100644
--- a/core/main/src/main/java/com/rk/commands/lsp/GoToDefinitionCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/lsp/GoToDefinitionCommand.kt
@@ -1,7 +1,6 @@
package com.rk.commands.lsp
import com.rk.DefaultScope
-import com.rk.commands.CommandContext
import com.rk.commands.LspActionContext
import com.rk.commands.LspCommand
import com.rk.commands.LspNonActionContext
@@ -11,7 +10,7 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class GoToDefinitionCommand(commandContext: CommandContext) : LspCommand(commandContext) {
+class GoToDefinitionCommand : LspCommand() {
override val id: String = "lsp.go_to_definition"
override fun getLabel(): String = strings.go_to_definition.getString()
@@ -29,5 +28,5 @@ class GoToDefinitionCommand(commandContext: CommandContext) : LspCommand(command
return lspNonActionContext.lspConnector.isGoToDefinitionSupported()
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.jump_to_element)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.jump_to_element)
}
diff --git a/core/main/src/main/java/com/rk/commands/lsp/GoToReferencesCommand.kt b/core/main/src/main/java/com/rk/commands/lsp/GoToReferencesCommand.kt
index c01d6b102..3295c9b3f 100644
--- a/core/main/src/main/java/com/rk/commands/lsp/GoToReferencesCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/lsp/GoToReferencesCommand.kt
@@ -1,7 +1,6 @@
package com.rk.commands.lsp
import com.rk.DefaultScope
-import com.rk.commands.CommandContext
import com.rk.commands.LspActionContext
import com.rk.commands.LspCommand
import com.rk.commands.LspNonActionContext
@@ -11,7 +10,7 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class GoToReferencesCommand(commandContext: CommandContext) : LspCommand(commandContext) {
+class GoToReferencesCommand : LspCommand() {
override val id: String = "lsp.go_to_references"
override fun getLabel(): String = strings.go_to_references.getString()
@@ -29,5 +28,5 @@ class GoToReferencesCommand(commandContext: CommandContext) : LspCommand(command
return lspNonActionContext.lspConnector.isGoToReferencesSupported()
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.manage_search)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.manage_search)
}
diff --git a/core/main/src/main/java/com/rk/commands/lsp/RenameSymbolCommand.kt b/core/main/src/main/java/com/rk/commands/lsp/RenameSymbolCommand.kt
index 698b452ea..67590c46a 100644
--- a/core/main/src/main/java/com/rk/commands/lsp/RenameSymbolCommand.kt
+++ b/core/main/src/main/java/com/rk/commands/lsp/RenameSymbolCommand.kt
@@ -1,7 +1,6 @@
package com.rk.commands.lsp
import com.rk.DefaultScope
-import com.rk.commands.CommandContext
import com.rk.commands.LspActionContext
import com.rk.commands.LspCommand
import com.rk.commands.LspNonActionContext
@@ -11,7 +10,7 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-class RenameSymbolCommand(commandContext: CommandContext) : LspCommand(commandContext) {
+class RenameSymbolCommand : LspCommand() {
override val id: String = "lsp.rename_symbol"
override fun getLabel(): String = strings.rename_symbol.getString()
@@ -24,5 +23,5 @@ class RenameSymbolCommand(commandContext: CommandContext) : LspCommand(commandCo
return lspNonActionContext.lspConnector.isRenameSymbolSupported()
}
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.manage_search)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.manage_search)
}
diff --git a/core/main/src/main/java/com/rk/components/AddDialogItem.kt b/core/main/src/main/java/com/rk/components/AddDialogItem.kt
index f23c0e6a0..9fc837b31 100644
--- a/core/main/src/main/java/com/rk/components/AddDialogItem.kt
+++ b/core/main/src/main/java/com/rk/components/AddDialogItem.kt
@@ -1,5 +1,6 @@
package com.rk.components
+import android.content.res.Resources
import androidx.annotation.DrawableRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
@@ -19,7 +20,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.rk.components.compose.utils.addIf
@@ -29,7 +32,7 @@ import java.io.File
@Composable
fun AddDialogItem(
- @DrawableRes icon: Int,
+ @DrawableRes resId: Int,
title: String,
description: String? = null,
enabled: Boolean = true,
@@ -40,7 +43,33 @@ fun AddDialogItem(
description = description,
onClick = onClick,
enabled = enabled,
- icon = { Icon(painter = painterResource(icon), contentDescription = null, modifier = Modifier.size(24.dp)) },
+ icon = { Icon(painter = painterResource(resId), contentDescription = null, modifier = Modifier.size(24.dp)) },
+ )
+}
+
+@Composable
+fun AddDialogItem(
+ @DrawableRes resId: Int,
+ resources: Resources,
+ title: String,
+ description: String? = null,
+ enabled: Boolean = true,
+ onClick: () -> Unit,
+) {
+ AddDialogItem(
+ title = title,
+ description = description,
+ onClick = onClick,
+ enabled = enabled,
+ icon = {
+ val theme = LocalContext.current.theme
+
+ Icon(
+ imageVector = ImageVector.vectorResource(theme = theme, resId = resId, res = resources),
+ contentDescription = null,
+ modifier = Modifier.size(24.dp),
+ )
+ },
)
}
@@ -94,9 +123,20 @@ fun AddDialogItem(
onClick: () -> Unit,
) {
when (icon) {
- is Icon.DrawableRes -> {
+ is Icon.ResourceIcon -> {
+ AddDialogItem(
+ resId = icon.drawableRes,
+ title = title,
+ description = description,
+ onClick = onClick,
+ enabled = enabled,
+ )
+ }
+
+ is Icon.ExternalResourceIcon -> {
AddDialogItem(
- icon = icon.drawableRes,
+ resId = icon.drawableRes,
+ resources = icon.resources,
title = title,
description = description,
onClick = onClick,
diff --git a/core/main/src/main/java/com/rk/components/EditorSettingsToggle.kt b/core/main/src/main/java/com/rk/components/EditorSettingsItem.kt
similarity index 97%
rename from core/main/src/main/java/com/rk/components/EditorSettingsToggle.kt
rename to core/main/src/main/java/com/rk/components/EditorSettingsItem.kt
index a9e8bf6fd..5e9a5d116 100644
--- a/core/main/src/main/java/com/rk/components/EditorSettingsToggle.kt
+++ b/core/main/src/main/java/com/rk/components/EditorSettingsItem.kt
@@ -16,7 +16,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
-fun EditorSettingsToggle(
+fun EditorSettingsItem(
modifier: Modifier = Modifier,
label: String,
description: String? = null,
@@ -30,7 +30,7 @@ fun EditorSettingsToggle(
isSwitchLocked: Boolean = false,
state: MutableState = remember { mutableStateOf(default) },
) {
- SettingsToggle(
+ SettingsItem(
modifier = modifier,
label = label,
state = state,
diff --git a/core/main/src/main/java/com/rk/components/GlobalToolbarActions.kt b/core/main/src/main/java/com/rk/components/GlobalToolbarActions.kt
index c3e5d82d7..7e899a969 100644
--- a/core/main/src/main/java/com/rk/components/GlobalToolbarActions.kt
+++ b/core/main/src/main/java/com/rk/components/GlobalToolbarActions.kt
@@ -1,21 +1,21 @@
package com.rk.components
-import android.app.Activity
import android.content.Intent
import android.content.pm.PackageManager
+import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
@@ -26,14 +26,14 @@ import com.rk.activities.main.drawerStateRef
import com.rk.activities.main.fileTreeViewModel
import com.rk.activities.main.searchViewModel
import com.rk.commands.ActionContext
-import com.rk.commands.CommandProvider
+import com.rk.commands.ToolbarConfiguration
+import com.rk.drawer.DrawerViewModel
import com.rk.file.FileObject
import com.rk.file.FileWrapper
import com.rk.file.child
import com.rk.file.createFileIfNot
import com.rk.file.toFileObject
import com.rk.filetree.FileTreeTab
-import com.rk.filetree.currentDrawerTab
import com.rk.icons.CreateNewFile
import com.rk.icons.XedIcon
import com.rk.icons.XedIcons
@@ -41,7 +41,6 @@ import com.rk.resources.drawables
import com.rk.resources.strings
import com.rk.search.CodeSearchDialog
import com.rk.search.FileSearchDialog
-import com.rk.settings.app.InbuiltFeatures
import com.rk.utils.application
import com.rk.utils.errorDialog
import com.rk.utils.getTempDir
@@ -54,35 +53,35 @@ var codeSearchDialog by mutableStateOf(false)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun GlobalToolbarActions(viewModel: MainViewModel) {
- val context = LocalContext.current
+fun GlobalToolbarActions(viewModel: MainViewModel, drawerViewModel: DrawerViewModel) {
+ val activity = LocalActivity.current
val scope = rememberCoroutineScope()
var tempFileNameDialog by remember { mutableStateOf(false) }
- if (viewModel.tabs.isEmpty() || viewModel.currentTab?.showGlobalActions == true) {
- val newFileCommand = CommandProvider.NewFileCommand
- val terminalCommand = CommandProvider.TerminalCommand
- val settingsCommand = CommandProvider.SettingsCommand
-
- IconButton(onClick = { newFileCommand.action(ActionContext(context as Activity)) }) {
- XedIcon(newFileCommand.getIcon())
- }
+ val commands by remember { derivedStateOf { ToolbarConfiguration.globalCommands } }
- if (InbuiltFeatures.terminal.state.value) {
- IconButton(onClick = { terminalCommand.action(ActionContext(context as Activity)) }) {
- XedIcon(terminalCommand.getIcon())
+ if (viewModel.tabs.isEmpty() || viewModel.currentTab?.showGlobalActions == true) {
+ for (command in commands) {
+ if (command.isSupported()) {
+ IconButton(
+ enabled = command.isEnabled(),
+ onClick = {
+ activity?.let {
+ command.performCommand(ActionContext(it))
+ }
+ },
+ ) {
+ XedIcon(command.getIcon())
+ }
}
}
-
- IconButton(onClick = { settingsCommand.action(ActionContext(context as Activity)) }) {
- XedIcon(settingsCommand.getIcon())
- }
}
- if (fileSearchDialog && currentDrawerTab is FileTreeTab) {
+ if (fileSearchDialog && drawerViewModel.currentDrawerTab is FileTreeTab) {
FileSearchDialog(
+ mainViewModel = viewModel,
searchViewModel = searchViewModel.get()!!,
- projectFile = (currentDrawerTab as FileTreeTab).root,
+ projectFile = (drawerViewModel.currentDrawerTab as FileTreeTab).root,
onFinish = { fileSearchDialog = false },
onSelect = { projectFile, fileObject ->
scope.launch {
@@ -103,11 +102,11 @@ fun GlobalToolbarActions(viewModel: MainViewModel) {
)
}
- if (codeSearchDialog && currentDrawerTab is FileTreeTab) {
+ if (codeSearchDialog && drawerViewModel.currentDrawerTab is FileTreeTab) {
CodeSearchDialog(
mainViewModel = viewModel,
searchViewModel = searchViewModel.get()!!,
- projectFile = (currentDrawerTab as FileTreeTab).root,
+ projectFile = (drawerViewModel.currentDrawerTab as FileTreeTab).root,
onFinish = { codeSearchDialog = false },
)
}
@@ -115,7 +114,7 @@ fun GlobalToolbarActions(viewModel: MainViewModel) {
if (addDialog) {
ModalBottomSheet(onDismissRequest = { addDialog = false }) {
Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp, top = 0.dp)) {
- AddDialogItem(icon = drawables.file, title = stringResource(strings.temp_file)) {
+ AddDialogItem(resId = drawables.file, title = stringResource(strings.temp_file)) {
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.setType("application/octet-stream")
@@ -162,7 +161,7 @@ fun GlobalToolbarActions(viewModel: MainViewModel) {
}
}
- AddDialogItem(icon = drawables.file_symlink, title = stringResource(strings.open_file)) {
+ AddDialogItem(resId = drawables.file_symlink, title = stringResource(strings.open_file)) {
addDialog = false
MainActivity.instance?.apply {
fileManager.requestOpenFile(mimeType = "*/*") {
diff --git a/core/main/src/main/java/com/rk/components/InfoBlock.kt b/core/main/src/main/java/com/rk/components/InfoBlock.kt
index 8151e31b7..633693e62 100644
--- a/core/main/src/main/java/com/rk/components/InfoBlock.kt
+++ b/core/main/src/main/java/com/rk/components/InfoBlock.kt
@@ -1,6 +1,7 @@
package com.rk.components
import android.annotation.SuppressLint
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -29,10 +30,11 @@ fun InfoBlock(
icon: @Composable (() -> Unit)? = null,
shape: Shape = RoundedCornerShape(12.dp),
warning: Boolean = false,
+ onClick: (() -> Unit)? = null,
) {
PreferenceGroup(modifier = modifier) {
Card(
- modifier = Modifier.fillMaxWidth(),
+ modifier = Modifier.clickable(enabled = onClick != null, onClick = { onClick?.invoke() }).fillMaxWidth(),
shape = shape,
colors =
if (warning) CardDefaults.cardColors(MaterialTheme.colorScheme.warningSurface)
diff --git a/core/main/src/main/java/com/rk/components/SettingsToggle.kt b/core/main/src/main/java/com/rk/components/SettingsItem.kt
similarity index 99%
rename from core/main/src/main/java/com/rk/components/SettingsToggle.kt
rename to core/main/src/main/java/com/rk/components/SettingsItem.kt
index 61d34b9e8..e3fe7d608 100644
--- a/core/main/src/main/java/com/rk/components/SettingsToggle.kt
+++ b/core/main/src/main/java/com/rk/components/SettingsItem.kt
@@ -43,7 +43,7 @@ fun BasicToggle(
@OptIn(ExperimentalFoundationApi::class)
@Composable
-fun SettingsToggle(
+fun SettingsItem(
modifier: Modifier = Modifier,
label: String,
default: Boolean,
diff --git a/core/main/src/main/java/com/rk/components/StyledTextFeild.kt b/core/main/src/main/java/com/rk/components/StyledTextFeild.kt
index c9d4a2a34..31fc6ecb2 100644
--- a/core/main/src/main/java/com/rk/components/StyledTextFeild.kt
+++ b/core/main/src/main/java/com/rk/components/StyledTextFeild.kt
@@ -1,14 +1,13 @@
package com.rk.components
import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalTextStyle
-import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
@@ -17,10 +16,20 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
+private fun TextFieldColors.textColor(enabled: Boolean, isError: Boolean, focused: Boolean): Color =
+ when {
+ !enabled -> disabledTextColor
+ isError -> errorTextColor
+ focused -> focusedTextColor
+ else -> unfocusedTextColor
+ }
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StyledTextField(
@@ -46,29 +55,25 @@ fun StyledTextField(
minLines: Int = 1,
interactionSource: MutableInteractionSource? = null,
shape: Shape = TextFieldDefaults.shape,
- colors: TextFieldColors =
- TextFieldDefaults.colors(
- cursorColor = MaterialTheme.colorScheme.onSurface,
- focusedIndicatorColor = Color.Transparent,
- unfocusedIndicatorColor = Color.Transparent,
- disabledIndicatorColor = Color.Transparent,
- selectionColors =
- TextSelectionColors(
- handleColor = MaterialTheme.colorScheme.primary,
- backgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f),
- ),
- ),
+ colors: TextFieldColors = TextFieldDefaults.colors(),
) {
val finalInteractionSource = interactionSource ?: remember { MutableInteractionSource() }
+ val textColor =
+ textStyle.color.takeOrElse {
+ val focused = finalInteractionSource.collectIsFocusedAsState().value
+ colors.textColor(enabled, isError, focused)
+ }
+ val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
+
BasicTextField(
- cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
+ cursorBrush = SolidColor(if (isError) colors.errorCursorColor else colors.cursorColor),
value = value,
onValueChange = onValueChange,
modifier = modifier,
enabled = enabled,
readOnly = readOnly,
- textStyle = TextStyle(color = MaterialTheme.colorScheme.onSurface),
+ textStyle = mergedTextStyle,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
visualTransformation = visualTransformation,
@@ -85,7 +90,81 @@ fun StyledTextField(
enabled = enabled,
isError = isError,
interactionSource = finalInteractionSource,
- contentPadding = PaddingValues(top = 8.dp, start = 8.dp, end = 8.dp),
+ contentPadding = PaddingValues(top = 8.dp, start = 16.dp, end = 16.dp),
+ placeholder = placeholder,
+ leadingIcon = leadingIcon,
+ trailingIcon = trailingIcon,
+ prefix = prefix,
+ suffix = suffix,
+ supportingText = supportingText,
+ label = label,
+ shape = shape,
+ colors = colors,
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun StyledTextField(
+ value: TextFieldValue,
+ onValueChange: (TextFieldValue) -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ readOnly: Boolean = false,
+ textStyle: TextStyle = LocalTextStyle.current,
+ label: @Composable (() -> Unit)? = null,
+ placeholder: @Composable (() -> Unit)? = null,
+ leadingIcon: @Composable (() -> Unit)? = null,
+ trailingIcon: @Composable (() -> Unit)? = null,
+ prefix: @Composable (() -> Unit)? = null,
+ suffix: @Composable (() -> Unit)? = null,
+ supportingText: @Composable (() -> Unit)? = null,
+ isError: Boolean = false,
+ visualTransformation: VisualTransformation = VisualTransformation.None,
+ keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
+ keyboardActions: KeyboardActions = KeyboardActions.Default,
+ singleLine: Boolean = false,
+ maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
+ minLines: Int = 1,
+ interactionSource: MutableInteractionSource? = null,
+ shape: Shape = TextFieldDefaults.shape,
+ colors: TextFieldColors = TextFieldDefaults.colors(),
+) {
+ val finalInteractionSource = interactionSource ?: remember { MutableInteractionSource() }
+
+ val textColor =
+ textStyle.color.takeOrElse {
+ val focused = finalInteractionSource.collectIsFocusedAsState().value
+ colors.textColor(enabled, isError, focused)
+ }
+ val mergedTextStyle = textStyle.merge(TextStyle(color = textColor))
+
+ BasicTextField(
+ cursorBrush = SolidColor(if (isError) colors.errorCursorColor else colors.cursorColor),
+ value = value,
+ onValueChange = onValueChange,
+ modifier = modifier,
+ enabled = enabled,
+ readOnly = readOnly,
+ textStyle = mergedTextStyle,
+ keyboardOptions = keyboardOptions,
+ keyboardActions = keyboardActions,
+ visualTransformation = visualTransformation,
+ interactionSource = finalInteractionSource,
+ singleLine = singleLine,
+ maxLines = maxLines,
+ minLines = minLines,
+ ) { innerTextField ->
+ TextFieldDefaults.DecorationBox(
+ value = value.text,
+ visualTransformation = visualTransformation,
+ innerTextField = innerTextField,
+ singleLine = singleLine,
+ enabled = enabled,
+ isError = isError,
+ interactionSource = finalInteractionSource,
+ contentPadding = PaddingValues(top = 8.dp, start = 16.dp, end = 16.dp),
placeholder = placeholder,
leadingIcon = leadingIcon,
trailingIcon = trailingIcon,
diff --git a/core/main/src/main/java/com/rk/crashhandler/CrashActivity.kt b/core/main/src/main/java/com/rk/crashhandler/CrashActivity.kt
index ea61f065d..83e8d8e26 100644
--- a/core/main/src/main/java/com/rk/crashhandler/CrashActivity.kt
+++ b/core/main/src/main/java/com/rk/crashhandler/CrashActivity.kt
@@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -33,6 +34,7 @@ import androidx.core.content.pm.PackageInfoCompat
import androidx.core.net.toUri
import com.rk.crashhandler.CrashHandler.logErrorOrExit
import com.rk.editor.Editor
+import com.rk.extension.Extension
import com.rk.resources.getString
import com.rk.resources.strings
import com.rk.theme.XedTheme
@@ -81,6 +83,34 @@ class CrashActivity : ComponentActivity() {
}
return true
}
+
+ fun start(context: Context, extension: Extension, error: Throwable) {
+ val intent = Intent(context, CrashActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK
+ putExtra("is_extension_crash", true)
+ putExtra("extension_id", extension.id)
+ putExtra("extension_name", extension.name)
+ putExtra("extension_version", extension.version)
+ putExtra("extension_author", extension.author.toString())
+ putExtra("repository", extension.repository)
+ putExtra("thread", Thread.currentThread().name)
+ putExtra("force_crash", false)
+ putExtra("msg", error.message)
+
+ var cause = error.cause?.toString() ?: error.toString()
+ val prefix = "java.lang.Throwable:"
+ if (cause.startsWith(prefix)) {
+ cause = cause.removePrefix(prefix)
+ }
+ putExtra("error_cause", cause)
+
+ val stringWriter = java.io.StringWriter()
+ val printWriter = java.io.PrintWriter(stringWriter)
+ error.printStackTrace(printWriter)
+ putExtra("stacktrace", stringWriter.toString())
+ }
+ context.startActivity(intent)
+ }
}
fun isMainThreadCrashed(): Boolean {
@@ -123,7 +153,11 @@ class CrashActivity : ComponentActivity() {
}
) {
Icon(
- imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ imageVector = if (mainThreadCrashed){
+ Icons.Default.Close
+ }else{
+ Icons.AutoMirrored.Filled.ArrowBack
+ },
contentDescription = "Back",
)
}
@@ -193,8 +227,18 @@ class CrashActivity : ComponentActivity() {
}
private fun reportLogs(crashText: String, context: Context) {
+ val repo = intent.getStringExtra("repository")
+ val baseUrl = if (!repo.isNullOrEmpty()) {
+ if (repo.startsWith("http")) {
+ repo.removeSuffix("/")
+ } else {
+ "https://github.com/$repo"
+ }
+ } else {
+ "https://github.com/Xed-Editor/Xed-Editor"
+ }
val url =
- "https://github.com/Xed-Editor/Xed-Editor/issues/new?title=Crash%20Report&body=" +
+ "$baseUrl/issues/new?title=Crash%20Report&body=" +
URLEncoder.encode("``` \n$crashText\n ```", StandardCharsets.UTF_8.toString())
val browserIntent = Intent(Intent.ACTION_VIEW, url.toUri())
context.startActivity(browserIntent)
@@ -210,7 +254,19 @@ class CrashActivity : ComponentActivity() {
val maxMem = runtime.maxMemory() / (1024 * 1024)
return buildString {
- append("Unexpected crash occurred").appendLine().appendLine()
+ val isExtension = intent.getBooleanExtra("is_extension_crash", false)
+ if (isExtension) {
+ append("Extension Crashed").appendLine().appendLine()
+
+
+ append("Extension ID: ").append(intent.getStringExtra("extension_id")).appendLine()
+ append("Extension Name: ").append(intent.getStringExtra("extension_name")).appendLine()
+ append("Extension Version: ").append(intent.getStringExtra("extension_version")).appendLine()
+ append("Extension Author: ").append(intent.getStringExtra("extension_author")).appendLine()
+ appendLine()
+ } else {
+ append("Unexpected crash occurred").appendLine().appendLine()
+ }
append("Thread: ").append(intent.getStringExtra("thread")).appendLine()
append("App version: ").append(versionName).appendLine()
diff --git a/core/main/src/main/java/com/rk/crashhandler/CrashHandler.kt b/core/main/src/main/java/com/rk/crashhandler/CrashHandler.kt
index 39a467f17..708a596be 100644
--- a/core/main/src/main/java/com/rk/crashhandler/CrashHandler.kt
+++ b/core/main/src/main/java/com/rk/crashhandler/CrashHandler.kt
@@ -23,10 +23,11 @@ object CrashHandler : Thread.UncaughtExceptionHandler {
}
if (
- ex.stackTrace.toString().contains("android.view.View${"$"}BaseSavedState") ||
- ex.stackTrace.toString().contains("android.widget.HorizontalScrollView${"$"}SavedState")
+ ex.stackTrace.contentToString().contains($$"android.view.View$BaseSavedState") ||
+ ex.stackTrace.contentToString().contains($$"android.widget.HorizontalScrollView$SavedState")
) {
Log.w("CrashHandler", "Ignoring crash")
+ ex.printStackTrace()
// return@runCatching
}
@@ -58,6 +59,7 @@ object CrashHandler : Thread.UncaughtExceptionHandler {
exitProcess(1)
}
+ //try to keep main thread alive
if (Looper.myLooper() != null) {
while (true) {
try {
diff --git a/core/main/src/main/java/com/rk/drawer/AddProjectSheet.kt b/core/main/src/main/java/com/rk/drawer/AddProjectSheet.kt
new file mode 100644
index 000000000..8f758766b
--- /dev/null
+++ b/core/main/src/main/java/com/rk/drawer/AddProjectSheet.kt
@@ -0,0 +1,158 @@
+package com.rk.drawer
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.os.Environment
+import android.os.storage.StorageManager
+import androidx.activity.compose.ManagedActivityResultLauncher
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.lifecycleScope
+import com.rk.activities.main.MainActivity
+import com.rk.components.AddDialogItem
+import com.rk.file.FileObject
+import com.rk.file.FileWrapper
+import com.rk.file.sandboxHomeDir
+import com.rk.icons.Icon
+import com.rk.resources.drawables
+import com.rk.resources.strings
+import com.rk.settings.Settings
+import com.rk.settings.app.InbuiltFeatures
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AddProjectSheet(
+ onDismiss: () -> Unit,
+ onAddProject: (FileObject) -> Unit,
+ openFolder: ManagedActivityResultLauncher,
+ showPrivateFileWarning: (onOK: () -> Unit) -> Unit,
+ showGitCloneDialog: () -> Unit,
+) {
+ val context = LocalContext.current
+ val activity = context as MainActivity
+ val lifecycleScope = remember { activity.lifecycleScope }
+
+ val viewModel = activity.drawerViewModel
+
+ ModalBottomSheet(onDismissRequest = onDismiss) {
+ Column(modifier = Modifier.Companion.padding(start = 16.dp, end = 16.dp, bottom = 16.dp, top = 0.dp)) {
+ AddDialogItem(
+ icon = Icon.ResourceIcon(drawables.file_symlink),
+ title = stringResource(strings.open_directory),
+ description = stringResource(strings.open_dir_desc),
+ onClick = {
+ openFolder.launch(null)
+ onDismiss()
+ },
+ )
+
+ // Open Path option
+ val is11Plus = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
+ val isManager = is11Plus && Environment.isExternalStorageManager()
+ val legacyPermission =
+ ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) !=
+ PackageManager.PERMISSION_GRANTED ||
+ ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) !=
+ PackageManager.PERMISSION_GRANTED
+
+ val storage = Environment.getExternalStorageDirectory()
+ if ((isManager || (!is11Plus && legacyPermission)) && storage.canWrite() && storage.canRead()) {
+ AddDialogItem(
+ icon = Icon.ResourceIcon(drawables.android),
+ title = stringResource(strings.internal_storage),
+ description = stringResource(strings.open_internal_storage),
+ onClick = {
+ viewModel.addFileTreeTab(FileWrapper(storage))
+ onDismiss()
+ },
+ )
+ }
+
+ if (isManager) {
+ val storageManager = context.getSystemService(StorageManager::class.java)
+ val volumes = storageManager.storageVolumes
+
+ volumes.forEach { volume ->
+ val root = volume.directory ?: return@forEach
+ if (root == storage) return@forEach
+ if (!root.canRead() || !root.canWrite() || root.listFiles() == null) return@forEach
+
+ val name = volume.getDescription(context)
+ val removable = volume.isRemovable
+ val description = if (removable) strings.open_removable_storage else strings.open_internal_storage
+
+ AddDialogItem(
+ icon = Icon.ResourceIcon(drawables.sd_card),
+ title = name,
+ description = stringResource(description),
+ ) {
+ viewModel.addFileTreeTab(FileWrapper(root))
+ onDismiss()
+ }
+ }
+ }
+
+ if (InbuiltFeatures.debugMode.state.value) {
+ AddDialogItem(
+ icon = Icon.ResourceIcon(drawables.build),
+ title = stringResource(strings.private_files),
+ description = stringResource(strings.private_files_desc),
+ onClick = {
+ if (!Settings.has_shown_private_data_dir_warning) {
+ showPrivateFileWarning {
+ Settings.has_shown_private_data_dir_warning = true
+ lifecycleScope.launch { onAddProject(FileWrapper(activity.filesDir.parentFile!!)) }
+ }
+ } else {
+ lifecycleScope.launch { onAddProject(FileWrapper(activity.filesDir.parentFile!!)) }
+ }
+ onDismiss()
+ },
+ )
+ }
+
+ // Clone repository option
+ if (InbuiltFeatures.git.state.value) {
+ AddDialogItem(
+ icon = Icon.ResourceIcon(drawables.git),
+ title = stringResource(strings.clone_repo),
+ description = stringResource(strings.clone_repo_desc),
+ onClick = {
+ showGitCloneDialog()
+ onDismiss()
+ },
+ )
+ }
+
+ // Terminal Home option
+ AddDialogItem(
+ icon = Icon.ResourceIcon(drawables.terminal),
+ title = stringResource(strings.terminal_home),
+ description = stringResource(strings.terminal_home_desc),
+ onClick = {
+ if (!Settings.has_shown_terminal_dir_warning) {
+ showPrivateFileWarning {
+ Settings.has_shown_terminal_dir_warning = true
+ lifecycleScope.launch { onAddProject(FileWrapper(sandboxHomeDir())) }
+ }
+ } else {
+ lifecycleScope.launch { onAddProject(FileWrapper(sandboxHomeDir())) }
+ }
+ onDismiss()
+ },
+ )
+ }
+ }
+}
diff --git a/core/main/src/main/java/com/rk/filetree/Drawer.kt b/core/main/src/main/java/com/rk/drawer/Drawer.kt
similarity index 56%
rename from core/main/src/main/java/com/rk/filetree/Drawer.kt
rename to core/main/src/main/java/com/rk/drawer/Drawer.kt
index ca908674b..c71a973f9 100644
--- a/core/main/src/main/java/com/rk/filetree/Drawer.kt
+++ b/core/main/src/main/java/com/rk/drawer/Drawer.kt
@@ -1,13 +1,7 @@
-package com.rk.filetree
+package com.rk.drawer
-import android.Manifest
import android.content.Intent
-import android.content.pm.PackageManager
-import android.net.Uri
-import android.os.Build
-import android.os.Environment
-import android.os.storage.StorageManager
-import androidx.activity.compose.ManagedActivityResultLauncher
+import androidx.activity.compose.LocalActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.Crossfade
@@ -30,12 +24,10 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailDefaults
import androidx.compose.material3.NavigationRailItem
@@ -48,7 +40,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
-import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -61,200 +52,35 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
-import androidx.core.content.ContextCompat
-import androidx.lifecycle.lifecycleScope
-import com.rk.DefaultScope
import com.rk.activities.main.MainActivity
-import com.rk.activities.main.fileTreeViewModel
import com.rk.activities.main.gitViewModel
-import com.rk.components.AddDialogItem
import com.rk.components.DoubleInputDialog
-import com.rk.file.FileObject
-import com.rk.file.FileWrapper
-import com.rk.file.child
-import com.rk.file.sandboxHomeDir
import com.rk.file.toFileObject
-import com.rk.git.GitTab
+import com.rk.filetree.ProjectCloseConfirmationDialog
import com.rk.git.ProgressCoordinator
-import com.rk.icons.Icon
import com.rk.icons.XedIcon
import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-import com.rk.settings.Settings
-import com.rk.settings.app.InbuiltFeatures
-import com.rk.utils.application
-import com.rk.utils.dialog
-import com.rk.utils.readObject
-import com.rk.utils.toast
-import com.rk.utils.writeObject
+import com.rk.utils.dialogRes
import java.io.File
-import kotlinx.coroutines.DelicateCoroutinesApi
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import kotlinx.coroutines.withContext
-
-object DrawerPersistence {
- private val saveMutex = Mutex()
-
- private const val DRAWER_TABS = "drawerTabs"
- private const val CURRENT_DRAWER_TAB = "currentDrawerTab"
- private const val EXPANDED_FILE_TREE_NODES = "expandedFileTree"
-
- suspend fun saveState() {
- saveMutex.withLock {
- val file = FileWrapper(application!!.filesDir.child(DRAWER_TABS))
- val serializableList = ArrayList(drawerTabs)
- file.writeObject(serializableList)
-
- val currentTabFile = FileWrapper(application!!.filesDir.child(CURRENT_DRAWER_TAB))
- if (currentDrawerTab != null) {
- currentTabFile.writeObject(currentDrawerTab!!)
- } else {
- currentTabFile.delete()
- }
-
- val expandedNodeFile = FileWrapper(application!!.filesDir.child(EXPANDED_FILE_TREE_NODES))
- fileTreeViewModel.get()?.getExpandedNodes()?.let { expandedNodeFile.writeObject(it) }
- }
- }
-
- suspend fun restoreState() {
- saveMutex.withLock {
- runCatching {
- val loadedTabs =
- withContext(Dispatchers.IO) {
- val file = FileWrapper(application!!.filesDir.child(DRAWER_TABS))
-
- if (file.exists() && file.canRead()) {
- file.readObject() as? ArrayList ?: emptyList()
- } else {
- emptyList()
- }
- }
-
- // Update the existing state list on Main thread
- withContext(Dispatchers.Main) {
- drawerTabs.clear()
- drawerTabs.addAll(loadedTabs)
- }
-
- val currentTabFile = FileWrapper(application!!.filesDir.child(CURRENT_DRAWER_TAB))
- if (currentTabFile.exists() && currentTabFile.canRead()) {
- selectTab(currentTabFile.readObject() as DrawerTab)
- }
-
- val expandedNodeFile = FileWrapper(application!!.filesDir.child(EXPANDED_FILE_TREE_NODES))
- if (expandedNodeFile.exists() && expandedNodeFile.canRead()) {
- fileTreeViewModel
- .get()
- ?.setExpandedNodes(expandedNodeFile.readObject() as Map)
- }
- }
- .onFailure {
- it.printStackTrace()
- toast(strings.project_restore_failed)
- }
- }
- }
-}
-
-fun createServices() {
- serviceTabs.clear()
- serviceTabs.add(GitTab(gitViewModel.get()!!))
-}
-
-var drawerTabs = mutableStateListOf()
-var serviceTabs = mutableStateListOf()
-var currentDrawerTab by mutableStateOf(null)
-var currentServiceTab by mutableStateOf(null)
-
-@OptIn(DelicateCoroutinesApi::class)
-fun addProject(fileObject: FileObject, save: Boolean = false) {
- val alreadyExistingProject = drawerTabs.find { it is FileTreeTab && it.root == fileObject }
- if (alreadyExistingProject != null) {
- selectTab(alreadyExistingProject)
- return
- }
- val tab = FileTreeTab(fileObject)
- tab.onAdded()
- drawerTabs.add(tab)
- selectTab(tab)
- if (save) {
- GlobalScope.launch(Dispatchers.IO) { DrawerPersistence.saveState() }
- }
-}
-@OptIn(DelicateCoroutinesApi::class)
-fun addProject(tab: DrawerTab, save: Boolean = false) {
- tab.onAdded()
- drawerTabs.add(tab)
- selectTab(tab)
- if (save) {
- GlobalScope.launch(Dispatchers.IO) { DrawerPersistence.saveState() }
- }
-}
-
-@OptIn(DelicateCoroutinesApi::class)
-fun removeProject(fileObject: FileObject, save: Boolean = false) {
- val index = drawerTabs.indexOfFirst { it is FileTreeTab && it.root == fileObject }
- if (index == -1) return
-
- if (currentDrawerTab == drawerTabs[index]) {
- val tabBefore = drawerTabs.getOrNull(index - 1)
- val tabAfter = drawerTabs.getOrNull(index + 1)
- selectTab(tabBefore ?: tabAfter)
- }
-
- drawerTabs[index].onRemoved()
- drawerTabs.removeAt(index)
-
- if (save) {
- GlobalScope.launch(Dispatchers.IO) { DrawerPersistence.saveState() }
- }
-}
-
-@OptIn(DelicateCoroutinesApi::class)
-fun removeProject(tab: DrawerTab, save: Boolean = false) {
- val index = drawerTabs.indexOf(tab)
- if (index == -1) return
-
- if (currentDrawerTab == drawerTabs[index]) {
- val tabBefore = drawerTabs.getOrNull(index - 1)
- val tabAfter = drawerTabs.getOrNull(index + 1)
- selectTab(tabBefore ?: tabAfter)
- }
-
- drawerTabs[index].onRemoved()
- drawerTabs.removeAt(index)
-
- if (save) {
- GlobalScope.launch(Dispatchers.IO) { DrawerPersistence.saveState() }
- }
-}
-
-fun validateValue(value: String): String? {
+private fun validateValue(value: String): String? {
return when {
value.isBlank() -> strings.value_empty_err.getString()
else -> null
}
}
-fun selectTab(tab: DrawerTab?) {
- currentDrawerTab = tab
- currentServiceTab = null
-}
-
-var isLoading by mutableStateOf(true)
-
@Composable
fun DrawerContent(fullscreen: Boolean) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
+ val mainActivity = LocalActivity.current as MainActivity
+ val viewModel = mainActivity.drawerViewModel
+
val openFolder =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocumentTree(),
@@ -269,13 +95,13 @@ fun DrawerContent(fullscreen: Boolean) {
}
.onFailure { it.printStackTrace() }
- scope.launch { addProject(it.toFileObject(expectedIsFile = false)) }
+ scope.launch { viewModel.addFileTreeTab(it.toFileObject(expectedIsFile = false)) }
}
},
)
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
- if (isLoading) {
+ if (viewModel.isLoading) {
CircularProgressIndicator()
} else {
Row(horizontalArrangement = Arrangement.Start, modifier = Modifier.fillMaxSize()) {
@@ -369,7 +195,7 @@ fun DrawerContent(fullscreen: Boolean) {
repoURLError = null
repoBranchError = null
if (success) {
- addProject(fileObject)
+ viewModel.addFileTreeTab(fileObject)
}
},
)
@@ -387,22 +213,22 @@ fun DrawerContent(fullscreen: Boolean) {
) {
Column(modifier = Modifier.fillMaxHeight()) {
LazyColumn(modifier = Modifier.weight(1f, fill = true), state = lazyListState) {
- items(drawerTabs) { tab ->
+ items(items = viewModel.drawerTabs) { tab ->
if (!tab.isSupported()) return@items
NavigationRailItem(
- selected = currentDrawerTab == tab,
+ selected = viewModel.currentDrawerTab == tab,
icon = { XedIcon(tab.getIcon()) },
onClick = {
- if (currentDrawerTab == tab && currentServiceTab == null) {
+ if (viewModel.currentDrawerTab == tab && viewModel.currentServiceTab == null) {
closeProjectDialog = true
} else {
- selectTab(tab)
+ viewModel.selectDrawerTab(tab)
}
},
label = { Text(tab.getName(), maxLines = 1, overflow = TextOverflow.Ellipsis) },
colors =
NavigationRailItemDefaults.colors().let {
- if (currentServiceTab == null) it
+ if (viewModel.currentServiceTab == null) it
else
it.copy(
selectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -428,16 +254,16 @@ fun DrawerContent(fullscreen: Boolean) {
if (showHorizontalDivider) HorizontalDivider()
Column(modifier = Modifier.wrapContentHeight().padding(vertical = 8.dp)) {
- serviceTabs.forEach { tab ->
+ viewModel.serviceTabs.forEach { tab ->
if (!tab.isSupported()) return@forEach
NavigationRailItem(
- selected = currentServiceTab == tab,
+ selected = viewModel.currentServiceTab == tab,
icon = { XedIcon(icon = tab.getIcon()) },
onClick = {
- if (currentServiceTab == tab) {
- currentServiceTab = null
+ if (viewModel.currentServiceTab == tab) {
+ viewModel.unselectServiceTab()
} else {
- currentServiceTab = tab
+ viewModel.selectServiceTab(tab)
}
},
label = { Text(tab.getName(), maxLines = 1, overflow = TextOverflow.Ellipsis) },
@@ -451,8 +277,8 @@ fun DrawerContent(fullscreen: Boolean) {
VerticalDivider()
Surface {
- Crossfade(targetState = currentDrawerTab, label = "file tree") { tab ->
- if (currentServiceTab == null) {
+ Crossfade(targetState = viewModel.currentDrawerTab, label = "file tree") { tab ->
+ if (viewModel.currentServiceTab == null) {
if (tab != null) {
tab.Content(modifier = Modifier.fillMaxSize())
} else {
@@ -476,18 +302,18 @@ fun DrawerContent(fullscreen: Boolean) {
}
}
- Crossfade(targetState = currentServiceTab) { tab ->
+ Crossfade(targetState = viewModel.currentServiceTab) { tab ->
tab?.Content(modifier = Modifier.fillMaxSize())
}
}
if (showAddDialog) {
- AddProjectDialog(
+ AddProjectSheet(
onDismiss = { showAddDialog = false },
openFolder = openFolder,
- onAddProject = { fileObject -> scope.launch { addProject(fileObject, true) } },
+ onAddProject = { fileObject -> scope.launch { viewModel.addFileTreeTab(fileObject, true) } },
showPrivateFileWarning = { callback ->
- dialog(
+ dialogRes(
title = strings.attention.getString(),
msg = strings.warning_private_dir.getString(),
onOk = { callback.invoke() },
@@ -553,12 +379,13 @@ fun DrawerContent(fullscreen: Boolean) {
)
}
+ val currentDrawerTab = viewModel.currentDrawerTab
if (closeProjectDialog && currentDrawerTab != null) {
ProjectCloseConfirmationDialog(
- projectName = currentDrawerTab!!.getName(),
+ projectName = currentDrawerTab.getName(),
onConfirm = {
closeProjectDialog = false
- removeProject(currentDrawerTab!!)
+ viewModel.removeDrawerTab(currentDrawerTab)
},
onDismiss = { closeProjectDialog = false },
)
@@ -567,127 +394,3 @@ fun DrawerContent(fullscreen: Boolean) {
}
}
}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-private fun AddProjectDialog(
- onDismiss: () -> Unit,
- onAddProject: (FileObject) -> Unit,
- openFolder: ManagedActivityResultLauncher,
- showPrivateFileWarning: (onOK: () -> Unit) -> Unit,
- showGitCloneDialog: () -> Unit,
-) {
- val context = LocalContext.current
- val activity = context as? MainActivity
- val lifecycleScope = remember { activity?.lifecycleScope ?: DefaultScope }
-
- ModalBottomSheet(onDismissRequest = onDismiss) {
- Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp, top = 0.dp)) {
- AddDialogItem(
- icon = Icon.DrawableRes(drawables.file_symlink),
- title = stringResource(strings.open_directory),
- description = stringResource(strings.open_dir_desc),
- onClick = {
- openFolder.launch(null)
- onDismiss()
- },
- )
-
- // Open Path option
- val is11Plus = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
- val isManager = is11Plus && Environment.isExternalStorageManager()
- val legacyPermission =
- ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) !=
- PackageManager.PERMISSION_GRANTED ||
- ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE) !=
- PackageManager.PERMISSION_GRANTED
-
- val storage = Environment.getExternalStorageDirectory()
- if ((isManager || (!is11Plus && legacyPermission)) && storage.canWrite() && storage.canRead()) {
- AddDialogItem(
- icon = Icon.DrawableRes(drawables.android),
- title = stringResource(strings.internal_storage),
- description = stringResource(strings.open_internal_storage),
- onClick = {
- addProject(FileWrapper(storage))
- onDismiss()
- },
- )
- }
-
- if (isManager) {
- val storageManager = context.getSystemService(StorageManager::class.java)
- val volumes = storageManager.storageVolumes
-
- volumes.forEach { volume ->
- val root = volume.directory ?: return@forEach
- if (root == storage) return@forEach
- if (!root.canRead() || !root.canWrite() || root.listFiles() == null) return@forEach
-
- val name = volume.getDescription(context)
- val removable = volume.isRemovable
- val description = if (removable) strings.open_removable_storage else strings.open_internal_storage
-
- AddDialogItem(
- icon = Icon.DrawableRes(drawables.sd_card),
- title = name,
- description = stringResource(description),
- ) {
- addProject(FileWrapper(root))
- onDismiss()
- }
- }
- }
-
- if (InbuiltFeatures.debugMode.state.value) {
- AddDialogItem(
- icon = Icon.DrawableRes(drawables.build),
- title = stringResource(strings.private_files),
- description = stringResource(strings.private_files_desc),
- onClick = {
- if (!Settings.has_shown_private_data_dir_warning) {
- showPrivateFileWarning {
- Settings.has_shown_private_data_dir_warning = true
- lifecycleScope.launch { onAddProject(FileWrapper(activity!!.filesDir.parentFile!!)) }
- }
- } else {
- lifecycleScope.launch { onAddProject(FileWrapper(activity!!.filesDir.parentFile!!)) }
- }
- onDismiss()
- },
- )
- }
-
- // Clone repository option
- if (InbuiltFeatures.git.state.value) {
- AddDialogItem(
- icon = Icon.DrawableRes(drawables.git),
- title = stringResource(strings.clone_repo),
- description = stringResource(strings.clone_repo_desc),
- onClick = {
- showGitCloneDialog()
- onDismiss()
- },
- )
- }
-
- // Terminal Home option
- AddDialogItem(
- icon = Icon.DrawableRes(drawables.terminal),
- title = stringResource(strings.terminal_home),
- description = stringResource(strings.terminal_home_desc),
- onClick = {
- if (!Settings.has_shown_terminal_dir_warning) {
- showPrivateFileWarning {
- Settings.has_shown_terminal_dir_warning = true
- lifecycleScope.launch { onAddProject(FileWrapper(sandboxHomeDir())) }
- }
- } else {
- lifecycleScope.launch { onAddProject(FileWrapper(sandboxHomeDir())) }
- }
- onDismiss()
- },
- )
- }
- }
-}
diff --git a/core/main/src/main/java/com/rk/drawer/DrawerPersistence.kt b/core/main/src/main/java/com/rk/drawer/DrawerPersistence.kt
new file mode 100644
index 000000000..616224152
--- /dev/null
+++ b/core/main/src/main/java/com/rk/drawer/DrawerPersistence.kt
@@ -0,0 +1,77 @@
+package com.rk.drawer
+
+import com.rk.activities.main.fileTreeViewModel
+import com.rk.file.FileObject
+import com.rk.file.FileWrapper
+import com.rk.file.child
+import com.rk.resources.strings
+import com.rk.utils.application
+import com.rk.utils.readObject
+import com.rk.utils.toast
+import com.rk.utils.writeObject
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+
+object DrawerPersistence {
+ private val saveMutex = Mutex()
+
+ private const val DRAWER_TABS = "drawerTabs"
+ private const val CURRENT_DRAWER_TAB = "currentDrawerTab"
+ private const val EXPANDED_FILE_TREE_NODES = "expandedFileTree"
+
+ suspend fun saveState(viewModel: DrawerViewModel) {
+ saveMutex.withLock {
+ val file = FileWrapper(application!!.filesDir.child(DRAWER_TABS))
+ val serializableList = ArrayList(viewModel.drawerTabs)
+ file.writeObject(serializableList)
+
+ val currentTabFile = FileWrapper(application!!.filesDir.child(CURRENT_DRAWER_TAB))
+ if (viewModel.currentDrawerTab != null) {
+ currentTabFile.writeObject(viewModel.currentDrawerTab!!)
+ } else {
+ currentTabFile.delete()
+ }
+
+ val expandedNodeFile = FileWrapper(application!!.filesDir.child(EXPANDED_FILE_TREE_NODES))
+ fileTreeViewModel.get()?.getExpandedNodes()?.let { expandedNodeFile.writeObject(it) }
+ }
+ }
+
+ suspend fun restoreState(viewModel: DrawerViewModel) {
+ saveMutex.withLock {
+ runCatching {
+ val loadedTabs =
+ withContext(Dispatchers.IO) {
+ val file = FileWrapper(application!!.filesDir.child(DRAWER_TABS))
+
+ if (file.exists() && file.canRead()) {
+ file.readObject() as? ArrayList ?: emptyList()
+ } else {
+ emptyList()
+ }
+ }
+
+ // Update the existing state list on Main thread
+ withContext(Dispatchers.Main) { viewModel.forcePushDrawerTabs(loadedTabs) }
+
+ val currentTabFile = FileWrapper(application!!.filesDir.child(CURRENT_DRAWER_TAB))
+ if (currentTabFile.exists() && currentTabFile.canRead()) {
+ viewModel.selectDrawerTab(currentTabFile.readObject() as DrawerTab)
+ }
+
+ val expandedNodeFile = FileWrapper(application!!.filesDir.child(EXPANDED_FILE_TREE_NODES))
+ if (expandedNodeFile.exists() && expandedNodeFile.canRead()) {
+ fileTreeViewModel
+ .get()
+ ?.setExpandedNodes(expandedNodeFile.readObject() as Map>)
+ }
+ }
+ .onFailure {
+ it.printStackTrace()
+ toast(strings.project_restore_failed)
+ }
+ }
+ }
+}
diff --git a/core/main/src/main/java/com/rk/filetree/DrawerTab.kt b/core/main/src/main/java/com/rk/drawer/DrawerTab.kt
similarity index 94%
rename from core/main/src/main/java/com/rk/filetree/DrawerTab.kt
rename to core/main/src/main/java/com/rk/drawer/DrawerTab.kt
index 88de7ff1d..48a7207f5 100644
--- a/core/main/src/main/java/com/rk/filetree/DrawerTab.kt
+++ b/core/main/src/main/java/com/rk/drawer/DrawerTab.kt
@@ -1,4 +1,4 @@
-package com.rk.filetree
+package com.rk.drawer
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
diff --git a/core/main/src/main/java/com/rk/drawer/DrawerViewModel.kt b/core/main/src/main/java/com/rk/drawer/DrawerViewModel.kt
new file mode 100644
index 000000000..aee82663f
--- /dev/null
+++ b/core/main/src/main/java/com/rk/drawer/DrawerViewModel.kt
@@ -0,0 +1,149 @@
+package com.rk.drawer
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.rk.file.FileObject
+import com.rk.filetree.FileTreeTab
+import com.rk.git.GitTab
+import com.rk.git.GitViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+
+class DrawerViewModel : ViewModel() {
+ var isLoading by mutableStateOf(true)
+
+ private val _drawerTabs = mutableStateListOf()
+ private val _serviceTabs = mutableStateListOf()
+
+ val drawerTabs: List
+ get() = _drawerTabs.toList()
+
+ val serviceTabs: List
+ get() = _serviceTabs.toList()
+
+ var currentDrawerTabIndex by mutableIntStateOf(0)
+ private set
+
+ val currentDrawerTab: DrawerTab?
+ get() = _drawerTabs.getOrNull(currentDrawerTabIndex)
+
+ var currentServiceTabIndex by mutableIntStateOf(0)
+ private set
+
+ val currentServiceTab: DrawerTab?
+ get() = _serviceTabs.getOrNull(currentServiceTabIndex)
+
+ internal fun setupBuiltinServices(gitViewModel: GitViewModel) {
+ _serviceTabs.clear()
+ _serviceTabs.add(GitTab(gitViewModel))
+ currentServiceTabIndex = -1
+ }
+
+ fun addFileTreeTab(fileObject: FileObject, save: Boolean = false) {
+ val existingIndex = _drawerTabs.indexOfFirst { it is FileTreeTab && it.root == fileObject }
+
+ if (existingIndex != -1) {
+ selectDrawerTab(existingIndex)
+ return
+ }
+
+ val tab = FileTreeTab(fileObject)
+ addDrawerTab(tab, save)
+ }
+
+ fun addDrawerTab(tab: DrawerTab, save: Boolean = false) {
+ tab.onAdded()
+
+ _drawerTabs.add(tab)
+ selectDrawerTab(_drawerTabs.lastIndex)
+
+ if (save) persistAsync()
+ }
+
+ fun removeFileTreeTab(fileObject: FileObject, save: Boolean = false) {
+ val index = _drawerTabs.indexOfFirst { it is FileTreeTab && it.root == fileObject }
+ if (index == -1) return
+
+ removeDrawerTab(index, save)
+ }
+
+ fun removeDrawerTab(drawerTab: DrawerTab, save: Boolean = false) {
+ val index = _drawerTabs.indexOf(drawerTab)
+ if (index == -1) return
+
+ removeDrawerTab(index, save)
+ }
+
+ fun removeDrawerTab(index: Int, save: Boolean = false) {
+ if (index !in _drawerTabs.indices) return
+
+ val isActive = currentDrawerTabIndex == index
+
+ _drawerTabs[index].onRemoved()
+ _drawerTabs.removeAt(index)
+
+ if (_drawerTabs.isEmpty()) {
+ unselectDrawerTab()
+ } else if (isActive) {
+ val newIndex =
+ when {
+ index - 1 >= 0 -> index - 1
+ index <= _drawerTabs.lastIndex -> index
+ else -> _drawerTabs.lastIndex
+ }
+ selectDrawerTab(newIndex)
+ } else {
+ if (currentDrawerTabIndex > index) {
+ currentDrawerTabIndex -= 1
+ }
+ }
+
+ if (save) persistAsync()
+ }
+
+ fun selectDrawerTab(drawerTab: DrawerTab) {
+ val index = _drawerTabs.indexOf(drawerTab)
+ if (index != -1) selectDrawerTab(index)
+ }
+
+ fun selectDrawerTab(index: Int) {
+ if (index !in _drawerTabs.indices) return
+
+ currentDrawerTabIndex = index
+ currentServiceTabIndex = -1
+ }
+
+ fun unselectDrawerTab() {
+ currentDrawerTabIndex = -1
+ currentServiceTabIndex = -1
+ }
+
+ fun selectServiceTab(serviceTab: DrawerTab) {
+ val index = _serviceTabs.indexOf(serviceTab)
+ if (index != -1) selectServiceTab(index)
+ }
+
+ fun selectServiceTab(index: Int) {
+ if (index !in _serviceTabs.indices) return
+
+ currentServiceTabIndex = index
+ }
+
+ fun unselectServiceTab() {
+ currentServiceTabIndex = -1
+ }
+
+ fun forcePushDrawerTabs(drawerTabs: List) {
+ _drawerTabs.clear()
+ _drawerTabs.addAll(drawerTabs)
+ }
+
+ private fun persistAsync() {
+ viewModelScope.launch(Dispatchers.IO) { DrawerPersistence.saveState(this@DrawerViewModel) }
+ }
+}
diff --git a/core/main/src/main/java/com/rk/editor/Editor.kt b/core/main/src/main/java/com/rk/editor/Editor.kt
index b3589fe0a..8bdc73ff9 100644
--- a/core/main/src/main/java/com/rk/editor/Editor.kt
+++ b/core/main/src/main/java/com/rk/editor/Editor.kt
@@ -14,6 +14,7 @@ import com.rk.settings.editor.DEFAULT_EDITOR_FONT_PATH
import com.rk.settings.editor.LineEnding
import com.rk.utils.errorDialog
import io.github.rosemoe.sora.langs.textmate.TextMateLanguage
+import io.github.rosemoe.sora.lsp.editor.LspLanguage
import io.github.rosemoe.sora.text.CharPosition
import io.github.rosemoe.sora.text.TextRange
import io.github.rosemoe.sora.widget.CodeEditor
@@ -46,6 +47,7 @@ class Editor : CodeEditor {
val editorSurface: Int,
val surfaceContainer: Int,
val highSurfaceContainer: Int,
+ val highestSurfaceContainer: Int,
val onSurface: Int,
val colorPrimary: Int,
val selectionBg: Int,
@@ -67,6 +69,7 @@ class Editor : CodeEditor {
val surfaceColor = if (isDarkMode) colorScheme.surfaceDim else colorScheme.surface
val surfaceContainer = colorScheme.surfaceContainer
val highSurfaceContainer = colorScheme.surfaceContainerHigh
+ val highestSurfaceContainer = colorScheme.surfaceContainerHighest
val onSurfaceColor = colorScheme.onSurface
val colorPrimary = colorScheme.primary
val divider = colorScheme.outlineVariant
@@ -83,6 +86,7 @@ class Editor : CodeEditor {
editorSurface = surfaceColor.toArgb(),
surfaceContainer = surfaceContainer.toArgb(),
highSurfaceContainer = highSurfaceContainer.toArgb(),
+ highestSurfaceContainer = highestSurfaceContainer.toArgb(),
onSurface = onSurfaceColor.toArgb(),
colorPrimary = colorPrimary.toArgb(),
selectionBg = selectionBackground.toArgb(),
@@ -99,6 +103,7 @@ class Editor : CodeEditor {
editorSurface: Int,
surfaceContainer: Int,
highSurfaceContainer: Int,
+ highestSurfaceContainer: Int,
onSurface: Int,
colorPrimary: Int,
selectionBg: Int,
@@ -114,6 +119,7 @@ class Editor : CodeEditor {
editorSurface,
surfaceContainer,
highSurfaceContainer,
+ highestSurfaceContainer,
onSurface,
colorPrimary,
selectionBg,
@@ -205,13 +211,14 @@ class Editor : CodeEditor {
indentStyle?.let {
val useTabs = it == PropertyType.IndentStyleValue.tab
- (editorLanguage as? TextMateLanguage)?.useTab(useTabs)
+ getTextMateLanguage()?.useTab(useTabs)
}
val actualTabSize = indentSize ?: tabSize
actualTabSize?.let {
props.deleteMultiSpaces = it
tabWidth = it
+ getTextMateLanguage()?.tabSize = it
}
val endOfLine: PropertyType.EndOfLineValue? = resourceProperties.getValue(PropertyType.end_of_line, null, false)
@@ -229,6 +236,21 @@ class Editor : CodeEditor {
resourceProperties.getValue(PropertyType.trim_trailing_whitespace, trimTrailingWhitespace, false)
}
+ /** Gets the current [TextMateLanguage], unwrapping it from [LspLanguage] if necessary. */
+ fun getTextMateLanguage(): TextMateLanguage? {
+ val language = editorLanguage
+
+ if (language is TextMateLanguage) {
+ return language
+ }
+
+ if (language is LspLanguage) {
+ return language.wrapperLanguage as? TextMateLanguage
+ }
+
+ return null
+ }
+
fun applyFont() {
runCatching {
val fontPath = Settings.editor_font_path
@@ -243,7 +265,7 @@ class Editor : CodeEditor {
typefaceText = font ?: Typeface.DEFAULT
typefaceLineNumber = font ?: Typeface.DEFAULT
}
- .onFailure { errorDialog(it) }
+ .onFailure { errorDialog(throwable = it) }
}
fun showSuggestions(yes: Boolean) {
@@ -255,9 +277,10 @@ class Editor : CodeEditor {
}
}
- suspend fun setLanguage(textmateScope: String) {
+ suspend fun configureLanguage(textmateScope: String) {
val language = LanguageManager.createLanguage(textmateScope)
language.useTab(Settings.actual_tabs)
+ language.tabSize = Settings.tab_size
if (Settings.textmate_suggestions) {
val keywords = KeywordManager.getKeywords(textmateScope)
diff --git a/core/main/src/main/java/com/rk/editor/Formatters.kt b/core/main/src/main/java/com/rk/editor/Formatters.kt
new file mode 100644
index 000000000..92855c91c
--- /dev/null
+++ b/core/main/src/main/java/com/rk/editor/Formatters.kt
@@ -0,0 +1,98 @@
+package com.rk.editor
+
+import com.rk.extension.api.XedExtensionPoint
+import com.rk.file.FileObject
+import com.rk.icons.Icon
+import com.rk.settings.Preference
+import com.rk.settings.Settings
+import com.rk.settings.app.InbuiltFeatures
+import io.github.rosemoe.sora.lang.format.Formatter
+
+abstract class FormatterProvider {
+ abstract val id: String
+ abstract val label: String
+ abstract val supportedExtensions: List
+ abstract val icon: Icon?
+
+ abstract fun getFormatter(): Formatter
+}
+
+sealed class FormatterSource {
+ data object LSP : FormatterSource()
+
+ data class EXTENSION(val formatter: FormatterProvider) : FormatterSource()
+}
+
+object Formatters {
+ const val LSP_FORMATTER_ID = "lsp"
+
+ private val _providers = mutableListOf()
+ val providers: List
+ get() = _providers.toList()
+
+ @XedExtensionPoint
+ fun registerFormatter(provider: FormatterProvider) {
+ if (!_providers.contains(provider)) {
+ _providers.add(provider)
+ }
+ }
+
+ @XedExtensionPoint
+ fun unregisterFormatter(provider: FormatterProvider) {
+ _providers.remove(provider)
+ }
+
+ fun getSourceForId(id: String): FormatterSource? {
+ if (id == LSP_FORMATTER_ID) return FormatterSource.LSP
+
+ providers
+ .find { it.id == id }
+ ?.also {
+ return FormatterSource.EXTENSION(it)
+ }
+ return null
+ }
+
+ fun getIdOf(source: FormatterSource): String {
+ return when (source) {
+ is FormatterSource.LSP -> "lsp"
+ is FormatterSource.EXTENSION -> source.formatter.id
+ }
+ }
+
+ fun isProviderEnabled(provider: FormatterProvider): Boolean {
+ return Preference.getBoolean("formatter_${provider.id}", true)
+ }
+
+ fun setProviderEnabled(formatter: FormatterProvider, enabled: Boolean) {
+ Preference.setBoolean("formatter_${formatter.id}", enabled)
+ }
+
+ fun getPreferredSourceForFile(file: FileObject): FormatterSource? {
+ val formatterIds = Settings.formatters.split("|").toTypedArray()
+ val formatters = formatterIds.mapNotNull { id -> getSourceForId(id) }
+
+ for (formatterType in formatters) {
+ when (formatterType) {
+ is FormatterSource.LSP -> return formatterType
+ is FormatterSource.EXTENSION -> {
+ val formatter = formatterType.formatter
+ val fileExt = file.getExtension()
+ val isSupported = formatter.supportedExtensions.contains(fileExt)
+
+ if (isSupported) return formatterType
+ }
+ }
+ }
+
+ return null
+ }
+
+ fun isLspFormatterEnabled(): Boolean {
+ return Preference.getBoolean("formatter_$LSP_FORMATTER_ID", true) && InbuiltFeatures.terminal.state.value
+ }
+
+ fun setLspFormatterEnabled(enabled: Boolean) {
+ Preference.setBoolean("formatter_$LSP_FORMATTER_ID", enabled)
+ }
+}
diff --git a/core/main/src/main/java/com/rk/editor/XedColorScheme.kt b/core/main/src/main/java/com/rk/editor/XedColorScheme.kt
index 8d044b598..10fad4bc7 100644
--- a/core/main/src/main/java/com/rk/editor/XedColorScheme.kt
+++ b/core/main/src/main/java/com/rk/editor/XedColorScheme.kt
@@ -44,6 +44,7 @@ class XedColorScheme(
editorSurface,
surfaceContainer,
highSurfaceContainer,
+ highestSurfaceContainer,
onSurface,
colorPrimary,
selectionBg,
@@ -70,12 +71,17 @@ class XedColorScheme(
SIGNATURE_BACKGROUND,
HOVER_BACKGROUND,
LINE_NUMBER_PANEL,
+ TEXT_INLAY_HINT_BACKGROUND,
)
setColors(setAlpha(surfaceContainer, 0.4f), MINIMAP_BACKGROUND)
setColors(setAlpha(highSurfaceContainer, 0.6f), MINIMAP_VIEWPORT, MINIMAP_VIEWPORT_BORDER)
- setColors(highSurfaceContainer, COMPLETION_WND_ITEM_CURRENT)
+ setColors(highSurfaceContainer, COMPLETION_WND_ITEM_CURRENT, TEXT_HIGHLIGHT_BACKGROUND)
+
+ setColors(highestSurfaceContainer, SNIPPET_BACKGROUND_EDITING, TEXT_HIGHLIGHT_STRONG_BACKGROUND)
+ setColors(setAlpha(highestSurfaceContainer, 0.8f), SNIPPET_BACKGROUND_RELATED)
+ setColors(setAlpha(highestSurfaceContainer, 0.6f), SNIPPET_BACKGROUND_INACTIVE)
setColors(
onSurface,
@@ -89,6 +95,7 @@ class XedColorScheme(
LINE_NUMBER,
LINE_NUMBER_CURRENT,
LINE_NUMBER_PANEL_TEXT,
+ TEXT_INLAY_HINT_FOREGROUND,
)
setColors(handleColor, SELECTION_HANDLE)
diff --git a/core/main/src/main/java/com/rk/editor/intelligent/IntelligentFeature.kt b/core/main/src/main/java/com/rk/editor/intelligent/IntelligentFeature.kt
index 77d0f8450..a0f140d7d 100644
--- a/core/main/src/main/java/com/rk/editor/intelligent/IntelligentFeature.kt
+++ b/core/main/src/main/java/com/rk/editor/intelligent/IntelligentFeature.kt
@@ -2,6 +2,7 @@ package com.rk.editor.intelligent
import androidx.compose.runtime.mutableStateListOf
import com.rk.editor.Editor
+import com.rk.extension.api.XedExtensionPoint
import io.github.rosemoe.sora.event.EditorKeyEvent
import io.github.rosemoe.sora.event.KeyBindingEvent
@@ -15,12 +16,14 @@ object IntelligentFeatureRegistry {
val allFeatures: List
get() = builtInFeatures + mutableFeatures
+ @XedExtensionPoint
fun registerFeature(feature: IntelligentFeature) {
if (!mutableFeatures.contains(feature)) {
mutableFeatures.add(feature)
}
}
+ @XedExtensionPoint
fun unregisterFeature(feature: IntelligentFeature) {
mutableFeatures.remove(feature)
}
diff --git a/core/main/src/main/java/com/rk/exec/NpmUtils.kt b/core/main/src/main/java/com/rk/exec/NpmUtils.kt
index 67c0463de..7943e25b8 100644
--- a/core/main/src/main/java/com/rk/exec/NpmUtils.kt
+++ b/core/main/src/main/java/com/rk/exec/NpmUtils.kt
@@ -1,5 +1,6 @@
package com.rk.exec
+import io.github.z4kn4fein.semver.toVersionOrNull
import org.json.JSONObject
object NpmUtils {
@@ -26,8 +27,8 @@ object NpmUtils {
}
suspend fun hasUpdate(packageName: String): Boolean {
- val currentVersion = getInstalledVersion(packageName) ?: return false
- val latestVersion = getLatestVersion(packageName) ?: return false
- return currentVersion != latestVersion
+ val currentVersion = getInstalledVersion(packageName)?.toVersionOrNull(false) ?: return false
+ val latestVersion = getLatestVersion(packageName)?.toVersionOrNull(false) ?: return false
+ return currentVersion < latestVersion
}
}
diff --git a/core/main/src/main/java/com/rk/exec/PipxUtils.kt b/core/main/src/main/java/com/rk/exec/PipxUtils.kt
index 274ca3bf9..f9119c085 100644
--- a/core/main/src/main/java/com/rk/exec/PipxUtils.kt
+++ b/core/main/src/main/java/com/rk/exec/PipxUtils.kt
@@ -1,5 +1,6 @@
package com.rk.exec
+import io.github.z4kn4fein.semver.toVersionOrNull
import org.json.JSONObject
object PipxUtils {
@@ -45,7 +46,10 @@ object PipxUtils {
val obj = JSONObject(result.output)
val latest = obj.getString("latest")
val installed = obj.getString("installed_version")
- installed != latest
+
+ val latestVersion = latest.toVersionOrNull(false) ?: return false
+ val installedVersion = installed.toVersionOrNull(false) ?: return false
+ installedVersion < latestVersion
}
.getOrDefault(false)
}
diff --git a/core/main/src/main/java/com/rk/exec/TerminalUtils.kt b/core/main/src/main/java/com/rk/exec/TerminalUtils.kt
index 73b9383b1..64a5f1d89 100644
--- a/core/main/src/main/java/com/rk/exec/TerminalUtils.kt
+++ b/core/main/src/main/java/com/rk/exec/TerminalUtils.kt
@@ -1,14 +1,12 @@
package com.rk.exec
-import android.content.Context
+import android.app.Activity
import android.content.Intent
-import com.rk.activities.main.MainActivity
import com.rk.activities.terminal.Terminal
import com.rk.file.child
import com.rk.file.localDir
import com.rk.file.sandboxDir
import com.rk.file.sandboxHomeDir
-import com.rk.utils.showTerminalNotice
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -28,9 +26,7 @@ suspend fun isTerminalWorking(): Boolean =
return@withContext process.waitFor() == 0
}
-fun launchTerminal(context: Context, terminalCommand: TerminalCommand) {
- showTerminalNotice(activity = MainActivity.instance!!) {
- pendingCommand = terminalCommand
- context.startActivity(Intent(context, Terminal::class.java))
- }
+fun launchTerminal(activity: Activity, terminalCommand: TerminalCommand) {
+ pendingCommand = terminalCommand
+ activity.startActivity(Intent(activity, Terminal::class.java))
}
diff --git a/core/main/src/main/java/com/rk/exec/ubuntuProcess.kt b/core/main/src/main/java/com/rk/exec/ubuntuProcess.kt
index 2cce1c93f..07790b7fa 100644
--- a/core/main/src/main/java/com/rk/exec/ubuntuProcess.kt
+++ b/core/main/src/main/java/com/rk/exec/ubuntuProcess.kt
@@ -1,7 +1,6 @@
package com.rk.exec
import android.annotation.SuppressLint
-import android.os.Build
import android.util.Log
import com.rk.file.child
import com.rk.file.localBinDir
@@ -13,7 +12,6 @@ import com.rk.settings.Settings
import com.rk.utils.application
import com.rk.utils.getSourceDirOfPackage
import com.rk.utils.getTempDir
-import com.rk.utils.isFDroid
import com.rk.xededitor.BuildConfig
import java.io.File
import java.io.IOException
@@ -93,7 +91,7 @@ suspend fun ubuntuProcess(
val args =
mutableListOf().apply {
- add(localBinDir().child("proot").absolutePath)
+ add("${application!!.applicationInfo.nativeLibraryDir}/libproot.so")
add("--kill-on-exit")
if (workingDir != null) {
@@ -145,7 +143,8 @@ suspend fun ubuntuProcess(
"/system/bin/linker"
}
env["NATIVE_LIB_DIR"] = application!!.applicationInfo.nativeLibraryDir
- env["FDROID"] = isFDroid.toString()
+ env["PROOT"] = "${application!!.applicationInfo.nativeLibraryDir}/libproot.so"
+ env["PROOT_LOADER"] = "${application!!.applicationInfo.nativeLibraryDir}/libloader.so"
env["SANDBOX"] = Settings.sandbox.toString()
env["TMP_DIR"] = getTempDir().absolutePath
env["TMPDIR"] = getTempDir().absolutePath
@@ -170,18 +169,14 @@ suspend fun ubuntuProcess(
env["PATH"] =
"/bin:/sbin:/usr/bin:/usr/sbin:/usr/games:/usr/local/bin:/usr/local/sbin:${localBinDir()}:${System.getenv("PATH")}"
- if (!isFDroid) {
- env["PROOT_LOADER"] = "${application!!.applicationInfo.nativeLibraryDir}/libproot-loader.so"
- if (
- Build.SUPPORTED_32_BIT_ABIS.isNotEmpty() &&
- File(application!!.applicationInfo.nativeLibraryDir).child("libproot-loader32.so").exists()
- ) {
- env["PROOT_LOADER32"] = "${application!!.applicationInfo.nativeLibraryDir}/libproot-loader32.so"
- }
+ val loader32 = "${application!!.applicationInfo.nativeLibraryDir}/libloader32.so"
+ if (File(loader32).exists()) {
+ env["PROOT_LOADER_32"] = loader32
}
- if (Settings.seccomp) {
- env["SECCOMP"] = "1"
+ when (Settings.seccomp_mode) {
+ "yes" -> env["SECCOMP"] = "1"
+ "no" -> env["PROOT_NO_SECCOMP"] = "1"
}
}
diff --git a/core/main/src/main/java/com/rk/extension/ExtensionAPI.kt b/core/main/src/main/java/com/rk/extension/ExtensionAPI.kt
deleted file mode 100644
index a75baeada..000000000
--- a/core/main/src/main/java/com/rk/extension/ExtensionAPI.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package com.rk.extension
-
-import android.app.Application
-
-abstract class ExtensionAPI : Application.ActivityLifecycleCallbacks {
- abstract fun onExtensionLoaded(extension: Extension)
-
- abstract fun onUninstalled(extension: Extension)
-}
diff --git a/core/main/src/main/java/com/rk/extension/ExtensionAPIManager.kt b/core/main/src/main/java/com/rk/extension/ExtensionAPIManager.kt
deleted file mode 100644
index 28f6b90c9..000000000
--- a/core/main/src/main/java/com/rk/extension/ExtensionAPIManager.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-package com.rk.extension
-
-import android.app.Activity
-import android.os.Bundle
-import com.rk.DefaultScope
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-
-object ExtensionAPIManager : ExtensionAPI(), CoroutineScope by DefaultScope {
- private val mutex = Mutex()
-
- override fun onExtensionLoaded(extension: Extension) {
- throw IllegalStateException("This function not be called from here")
- }
-
- override fun onUninstalled(extension: Extension) {
- throw IllegalStateException("This function not be called from here")
- }
-
- override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
- launch {
- mutex.withLock {
- launch { loadedExtensions.values.forEach { it?.onActivityCreated(activity, savedInstanceState) } }
- }
- }
- }
-
- override fun onActivityDestroyed(activity: Activity) {
- launch { mutex.withLock { launch { loadedExtensions.values.forEach { it?.onActivityDestroyed(activity) } } } }
- }
-
- override fun onActivityPaused(activity: Activity) {
- launch { mutex.withLock { launch { loadedExtensions.values.forEach { it?.onActivityPaused(activity) } } } }
- }
-
- override fun onActivityResumed(activity: Activity) {
- launch { mutex.withLock { launch { loadedExtensions.values.forEach { it?.onActivityResumed(activity) } } } }
- }
-
- override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {
- launch {
- mutex.withLock {
- launch { loadedExtensions.values.forEach { it?.onActivitySaveInstanceState(activity, outState) } }
- }
- }
- }
-
- override fun onActivityStarted(activity: Activity) {
- launch { mutex.withLock { launch { loadedExtensions.values.forEach { it?.onActivityStarted(activity) } } } }
- }
-
- override fun onActivityStopped(activity: Activity) {
- launch { mutex.withLock { launch { loadedExtensions.values.forEach { it?.onActivityStopped(activity) } } } }
- }
-}
diff --git a/core/main/src/main/java/com/rk/extension/ExtensionRegistry.kt b/core/main/src/main/java/com/rk/extension/ExtensionRegistry.kt
deleted file mode 100644
index 12a8b021e..000000000
--- a/core/main/src/main/java/com/rk/extension/ExtensionRegistry.kt
+++ /dev/null
@@ -1,90 +0,0 @@
-package com.rk.extension
-
-import android.util.Log
-import com.rk.utils.errorDialog
-import java.io.File
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import kotlinx.serialization.Serializable
-import kotlinx.serialization.json.Json
-import okhttp3.OkHttpClient
-import okhttp3.Request
-
-@Serializable private data class ExtensionListResponse(val extensions: List)
-
-@Serializable private data class ExtensionEntry(val manifest: ExtensionManifest)
-
-@Serializable private data class ExtensionDetail(val downloads: Int? = null, val download: DownloadUrls)
-
-@Serializable private data class DownloadUrls(val icon: String? = null, val readme: String? = null, val zip: String)
-
-object ExtensionRegistry {
- private const val TAG = "ExtensionRegistry"
- private const val BASE_URL = "https://xed-editor.app/api/extensions"
-
- private val client: OkHttpClient = OkHttpClient()
- private val json = Json { ignoreUnknownKeys = true }
-
- suspend fun fetchExtensions(): List =
- withContext(Dispatchers.IO) {
- runCatching {
- val jsonString = requestJson(BASE_URL)
- val response = json.decodeFromString(jsonString)
- response.extensions.map { it.manifest }
- }
- .onFailure {
- it.printStackTrace()
- throw it
- }
- .getOrElse { emptyList() }
- }
-
- fun getIconUrl(id: String): String = "$BASE_URL/$id/icon.png"
-
- fun getReadmeUrl(id: String): String = "$BASE_URL/$id/README.md"
-
- fun getChangelogUrl(id: String): String = "$BASE_URL/$id/CHANGELOG.md"
-
- suspend fun getDownloadCount(id: String): Int? = getDetails(id)?.downloads
-
- private suspend fun getDetails(id: String): ExtensionDetail? =
- runCatching {
- withContext(Dispatchers.IO) {
- val jsonString = requestJson("$BASE_URL/$id")
- json.decodeFromString(jsonString)
- }
- }
- .getOrNull()
-
- private fun requestJson(url: String): String {
- val req = Request.Builder().url(url).build()
- return client.newCall(req).execute().use { res ->
- if (!res.isSuccessful) error("HTTP ${res.code}")
- val body = res.body.string()
- Log.d(TAG, body)
- body
- }
- }
-
- suspend fun downloadZip(manifest: ExtensionManifest, destFile: File): Boolean =
- withContext(Dispatchers.IO) {
- runCatching {
- val zipUrl = getDetails(manifest.id)?.download?.zip ?: error("Extension ZIP file was not found.")
-
- val request = Request.Builder().url(zipUrl).build()
- client.newCall(request).execute().use { response ->
- if (!response.isSuccessful) error("HTTP ${response.code}")
- destFile.parentFile?.mkdirs()
- response.body.byteStream().use { input ->
- destFile.outputStream().use { output -> input.copyTo(output) }
- }
- }
- true
- }
- .onFailure {
- it.printStackTrace()
- errorDialog(it)
- }
- .getOrElse { false }
- }
-}
diff --git a/core/main/src/main/java/com/rk/extension/ExtensionUtils.kt b/core/main/src/main/java/com/rk/extension/ExtensionUtils.kt
deleted file mode 100644
index b33568f37..000000000
--- a/core/main/src/main/java/com/rk/extension/ExtensionUtils.kt
+++ /dev/null
@@ -1,120 +0,0 @@
-package com.rk.extension
-
-import android.app.Application
-import androidx.core.content.pm.PackageInfoCompat
-import com.rk.file.FileObject
-import com.rk.file.copyToTempDir
-import com.rk.utils.application
-import com.rk.utils.errorDialog
-import com.rk.utils.isMainThread
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
-
-fun LocalExtension.load(application: Application) = run {
- val classLoader =
- try {
- classLoader(application.classLoader)
- } catch (err: Exception) {
- return@run Result.failure(
- RuntimeException(
- "Failed to create ClassLoader for extension '${manifest.name}'. Details: ${err.message}",
- err,
- )
- )
- }
-
- if (isMainThread()) {
- return@run Result.failure(
- RuntimeException(
- "Attempted to load extension '${manifest.name}' on the main thread. Extension loading must be performed on a background thread."
- )
- )
- }
-
- val minAppVersion = manifest.minAppVersion
- val maxAppVersion = manifest.targetAppVersion
-
- val xedVersionCode =
- PackageInfoCompat.getLongVersionCode(application.packageManager.getPackageInfo(application.packageName, 0))
-
- if (!(minAppVersion <= xedVersionCode && maxAppVersion <= xedVersionCode)) {
- return@run Result.failure(
- RuntimeException(
- "Extension '${manifest.name}' (${manifest.version}) is not compatible with this version of Xed-Editor (min: $minAppVersion, max: $maxAppVersion, Xed-Editor: $xedVersionCode)"
- )
- )
- }
-
- val mainClassInstance =
- try {
- classLoader.loadClass(manifest.mainClass)
- } catch (err: Exception) {
- return@run Result.failure(
- RuntimeException(
- "Failed to load main class '${manifest.mainClass}' for extension '${manifest.name}'. Details: ${err.message}",
- err,
- )
- )
- }
-
- if (ExtensionAPI::class.java.isAssignableFrom(mainClassInstance)) {
- val instance =
- mainClassInstance.getDeclaredConstructor().newInstance() as? ExtensionAPI
- ?: return@run Result.failure(
- RuntimeException(
- "Failed to instantiate main class '${mainClassInstance.name}' for extension '${manifest.name}'. The class could not be cast to ExtensionAPI. Ensure it implements the ExtensionAPI interface and has a public no-argument constructor."
- )
- )
-
- try {
- instance.onExtensionLoaded(this)
- } catch (err: ClassNotFoundException) {
- return@run Result.failure(
- RuntimeException(
- "Failed to initialize extension '${manifest.name}': A required class was not found. This might indicate a missing dependency or an issue with the extension's packaging. Details: ${err.message}",
- err,
- )
- )
- } catch (err: NoClassDefFoundError) {
- return@run Result.failure(
- RuntimeException(
- "Failed to initialize extension '${manifest.name}': A class definition was not found. This usually means a class was available at compile time but is missing at runtime. Check the extension's dependencies. Details: ${err.message}",
- err,
- )
- )
- } catch (err: Exception) {
- return@run Result.failure(
- RuntimeException(
- "Failed to initialize extension '${manifest.name}': An unexpected error occurred during the onExtensionLoaded call. Details: ${err.message}",
- err,
- )
- )
- }
-
- loadedExtensions[this] = instance
- Result.success(instance)
- } else {
- Result.failure(
- RuntimeException(
- "The main class '${manifest.mainClass}' of extension '${manifest.name}' does not implement the ExtensionAPI interface. Please ensure the main class correctly implements this interface."
- )
- )
- }
-}
-
-suspend fun ExtensionManager.installExtensionFromZip(fileObject: FileObject) = run {
- val file = fileObject.copyToTempDir()
- installExtensionFromZip(file).also { file.delete() }
-}
-
-suspend fun ExtensionManager.loadAllExtensions() =
- withContext(Dispatchers.IO) {
- for ((_, extension) in localExtensions) {
- launch(Dispatchers.IO) {
- extension.load(application!!).onFailure {
- errorDialog(it.message ?: "Failed to load extension '${extension.name}'")
- }
- }
- }
- }
diff --git a/core/main/src/main/java/com/rk/extension/api/ActivityProvider.kt b/core/main/src/main/java/com/rk/extension/api/ActivityProvider.kt
new file mode 100644
index 000000000..83e5ad969
--- /dev/null
+++ b/core/main/src/main/java/com/rk/extension/api/ActivityProvider.kt
@@ -0,0 +1,39 @@
+
+
+
+
+//DO NOT UPDATE PACKAGE NAME OTHERWISE EXTENSIONS WILL BREAK
+package com.rk.extension
+
+import android.app.Activity
+import android.app.Application
+import android.os.Bundle
+import java.lang.ref.WeakReference
+
+object ActivityProvider : Application.ActivityLifecycleCallbacks {
+
+ private var currentActivityRef: WeakReference? = null
+
+ val currentActivity: Activity?
+ get() = currentActivityRef?.get()
+
+ override fun onActivityResumed(activity: Activity) {
+ currentActivityRef = WeakReference(activity)
+ }
+
+ override fun onActivityPaused(activity: Activity) {
+ if (currentActivityRef?.get() == activity) {
+ currentActivityRef = null
+ }
+ }
+
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
+
+ override fun onActivityStarted(activity: Activity) = Unit
+
+ override fun onActivityStopped(activity: Activity) = Unit
+
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
+
+ override fun onActivityDestroyed(activity: Activity) = Unit
+}
diff --git a/core/main/src/main/java/com/rk/extension/api/AppResources.kt b/core/main/src/main/java/com/rk/extension/api/AppResources.kt
new file mode 100644
index 000000000..06a36c128
--- /dev/null
+++ b/core/main/src/main/java/com/rk/extension/api/AppResources.kt
@@ -0,0 +1,139 @@
+
+
+
+//DO NOT UPDATE PACKAGE NAME OTHERWISE EXTENSIONS WILL BREAK
+package com.rk.extension
+
+import android.content.Context
+import android.content.res.Resources
+import android.graphics.Typeface
+import android.graphics.drawable.Drawable
+import androidx.core.content.res.ResourcesCompat
+import java.io.InputStream
+
+class AppResources(private val context: Context, private val resources: Resources, private val packageName: String) {
+
+ private fun id(name: String, type: String): Int {
+ return resources.getIdentifier(name, type, packageName)
+ }
+
+ fun getString(name: String): String? {
+ val resId = id(name, "string")
+ return if (resId != 0) resources.getString(resId) else null
+ }
+
+ fun getStringId(name: String): Int? {
+ val resId = id(name, "string")
+ return if (resId != 0) resId else null
+ }
+
+ fun getText(name: String): CharSequence? {
+ val resId = id(name, "string")
+ return if (resId != 0) resources.getText(resId) else null
+ }
+
+ fun getStringArray(name: String): Array? {
+ val resId = id(name, "array")
+ return if (resId != 0) resources.getStringArray(resId) else null
+ }
+
+ fun getTextArray(name: String): Array? {
+ val resId = id(name, "array")
+ return if (resId != 0) resources.getTextArray(resId) else null
+ }
+
+ fun getIntArray(name: String): IntArray? {
+ val resId = id(name, "array")
+ return if (resId != 0) resources.getIntArray(resId) else null
+ }
+
+ fun getArrayId(name: String): Int? {
+ val resId = id(name, "array")
+ return if (resId != 0) resId else null
+ }
+
+ fun getQuantityString(name: String, quantity: Int): String? {
+ val resId = id(name, "plurals")
+ return if (resId != 0) resources.getQuantityString(resId, quantity) else null
+ }
+
+ fun getQuantityText(name: String, quantity: Int): CharSequence? {
+ val resId = id(name, "plurals")
+ return if (resId != 0) resources.getQuantityText(resId, quantity) else null
+ }
+
+ fun getPluralId(name: String): Int? {
+ val resId = id(name, "plurals")
+ return if (resId != 0) resId else null
+ }
+
+ fun getDrawable(name: String): Drawable? {
+ val resId = id(name, "drawable")
+ return if (resId != 0) ResourcesCompat.getDrawable(resources, resId, context.theme) else null
+ }
+
+ fun getDrawableId(name: String): Int? {
+ val resId = id(name, "drawable")
+ return if (resId != 0) resId else null
+ }
+
+ fun getDimension(name: String): Float? {
+ val resId = id(name, "dimen")
+ return if (resId != 0) resources.getDimension(resId) else null
+ }
+
+ fun getDimensionId(name: String): Int? {
+ val resId = id(name, "dimen")
+ return if (resId != 0) resId else null
+ }
+
+ fun getInteger(name: String): Int? {
+ val resId = id(name, "integer")
+ return if (resId != 0) resources.getInteger(resId) else null
+ }
+
+ fun getIntegerId(name: String): Int? {
+ val resId = id(name, "integer")
+ return if (resId != 0) resId else null
+ }
+
+ fun getBoolean(name: String): Boolean? {
+ val resId = id(name, "bool")
+ return if (resId != 0) resources.getBoolean(resId) else null
+ }
+
+ fun getBooleanId(name: String): Int? {
+ val resId = id(name, "bool")
+ return if (resId != 0) resId else null
+ }
+
+ fun openRaw(name: String): InputStream? {
+ val resId = id(name, "raw")
+ return if (resId != 0) resources.openRawResource(resId) else null
+ }
+
+ fun getRawId(name: String): Int? {
+ val resId = id(name, "raw")
+ return if (resId != 0) resId else null
+ }
+
+ fun getFont(name: String): Typeface? {
+ val resId = id(name, "font")
+ return if (resId != 0) ResourcesCompat.getFont(context, resId) else null
+ }
+
+ fun getFontId(name: String): Int? {
+ val resId = id(name, "font")
+ return if (resId != 0) resId else null
+ }
+
+ fun getAnimationId(name: String): Int? {
+ val resId = id(name, "anim")
+ return if (resId != 0) resId else null
+ }
+
+ fun getAnimatorId(name: String): Int? {
+ val resId = id(name, "animator")
+ return if (resId != 0) resId else null
+ }
+}
diff --git a/core/main/src/main/java/com/rk/extension/api/ExtensionAPI.kt b/core/main/src/main/java/com/rk/extension/api/ExtensionAPI.kt
new file mode 100644
index 000000000..d9bffc56f
--- /dev/null
+++ b/core/main/src/main/java/com/rk/extension/api/ExtensionAPI.kt
@@ -0,0 +1,24 @@
+
+
+
+//DO NOT UPDATE PACKAGE NAME OTHERWISE EXTENSIONS WILL BREAK
+package com.rk.extension
+
+import android.app.Application
+import androidx.compose.runtime.Composable
+
+abstract class ExtensionAPI(protected val context: ExtensionContext) : Application.ActivityLifecycleCallbacks {
+ /** Called only once when the extension is installed for the first time. */
+ abstract fun onInstalled()
+
+ /** Called every time the extension is loaded (app start or extension installation). */
+ abstract fun onExtensionLoaded()
+
+ /** Called when the extension is updated to a new version. */
+ abstract fun onUpdated()
+
+ /** Called when the extension is uninstalled. */
+ abstract fun onUninstalled()
+
+ @Composable open fun SettingsContent() {}
+}
diff --git a/core/main/src/main/java/com/rk/extension/api/ExtensionContext.kt b/core/main/src/main/java/com/rk/extension/api/ExtensionContext.kt
new file mode 100644
index 000000000..9d8dbb126
--- /dev/null
+++ b/core/main/src/main/java/com/rk/extension/api/ExtensionContext.kt
@@ -0,0 +1,44 @@
+
+
+
+
+//DO NOT UPDATE PACKAGE NAME OTHERWISE EXTENSIONS WILL BREAK
+package com.rk.extension
+
+import android.content.Context
+import android.content.res.AssetManager
+import android.content.res.Resources
+import androidx.annotation.Keep
+import com.rk.extension.api.XedExtensionPoint
+import com.rk.extension.api.logDebug
+import com.rk.extension.api.logError
+import com.rk.extension.api.logInfo
+import com.rk.extension.api.logWarn
+import kotlinx.coroutines.CoroutineScope
+
+@XedExtensionPoint
+@Keep
+class ExtensionContext(val extension: LocalExtension, val appContext: Context, val scope: CoroutineScope) {
+ val settings = SharedPrefExtensionSettings(extension.id)
+
+ val currentActivity
+ get() = ActivityProvider.currentActivity
+
+ val appResources
+ get() = AppResources(appContext, appContext.resources, appContext.packageName)
+
+ val assets: AssetManager by lazy {
+ AssetManager::class.java.getDeclaredConstructor().newInstance().apply {
+ val method = javaClass.getMethod("addAssetPath", String::class.java)
+ method.invoke(this, extension.apkFile.absolutePath)
+ }
+ }
+
+ val resources by lazy { Resources(assets, appContext.resources.displayMetrics, appContext.resources.configuration) }
+
+ //Kept for backward compatibility
+ fun logDebug(msg: String) = extension.id.logDebug(msg)
+ fun logInfo(msg: String) = extension.id.logInfo(msg)
+ fun logWarn(msg: String) = extension.id.logWarn(msg)
+ fun logError(msg: String) = extension.id.logError(msg)
+}
diff --git a/core/main/src/main/java/com/rk/extension/api/ExtensionSettings.kt b/core/main/src/main/java/com/rk/extension/api/ExtensionSettings.kt
new file mode 100644
index 000000000..b8b7853ae
--- /dev/null
+++ b/core/main/src/main/java/com/rk/extension/api/ExtensionSettings.kt
@@ -0,0 +1,35 @@
+
+
+
+//DO NOT UPDATE PACKAGE NAME OTHERWISE EXTENSIONS WILL BREAK
+package com.rk.extension
+
+import com.rk.settings.Preference
+
+interface ExtensionSettings {
+ fun getString(key: String, default: String): String?
+
+ fun getBoolean(key: String, default: Boolean): Boolean
+
+ fun getInt(key: String, default: Int): Int
+
+ fun putString(key: String, value: String)
+
+ fun putBoolean(key: String, value: Boolean)
+
+ fun putInt(key: String, value: Int)
+}
+
+class SharedPrefExtensionSettings(private val id: String) : ExtensionSettings {
+ override fun getString(key: String, default: String) = Preference.getString("$id.$key", default)
+
+ override fun getBoolean(key: String, default: Boolean) = Preference.getBoolean("$id.$key", default)
+
+ override fun getInt(key: String, default: Int) = Preference.getInt("$id.$key", default)
+
+ override fun putString(key: String, value: String) = Preference.setString("$id.$key", value)
+
+ override fun putBoolean(key: String, value: Boolean) = Preference.setBoolean("$id.$key", value)
+
+ override fun putInt(key: String, value: Int) = Preference.setInt("$id.$key", value)
+}
diff --git a/core/main/src/main/java/com/rk/extension/api/Logger.kt b/core/main/src/main/java/com/rk/extension/api/Logger.kt
new file mode 100644
index 000000000..c69cd025d
--- /dev/null
+++ b/core/main/src/main/java/com/rk/extension/api/Logger.kt
@@ -0,0 +1,24 @@
+package com.rk.extension.api
+import android.util.Log
+import com.rk.extension.ExtensionId
+import com.rk.settings.debugOptions.LogCollector
+
+fun ExtensionId.logDebug(msg: String) {
+ Log.d(this, msg)
+ LogCollector.reportDebug("[${this}]$msg")
+}
+
+fun ExtensionId.logInfo(msg: String) {
+ Log.i(this, msg)
+ LogCollector.reportInfo("[${this}]$msg")
+}
+
+fun ExtensionId.logWarn(msg: String) {
+ Log.w(this, msg)
+ LogCollector.reportWarn("[${this}]$msg")
+}
+
+fun ExtensionId.logError(msg: String) {
+ Log.e(this, msg)
+ LogCollector.reportError("[${this}]$msg")
+}
\ No newline at end of file
diff --git a/core/main/src/main/java/com/rk/extension/api/XedExtensionPoint.kt b/core/main/src/main/java/com/rk/extension/api/XedExtensionPoint.kt
new file mode 100644
index 000000000..28865761f
--- /dev/null
+++ b/core/main/src/main/java/com/rk/extension/api/XedExtensionPoint.kt
@@ -0,0 +1,5 @@
+package com.rk.extension.api
+
+@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY)
+@Retention(AnnotationRetention.RUNTIME)
+annotation class XedExtensionPoint
diff --git a/core/main/src/main/java/com/rk/extension/loader/ExtensionLoader.kt b/core/main/src/main/java/com/rk/extension/loader/ExtensionLoader.kt
new file mode 100644
index 000000000..141b87280
--- /dev/null
+++ b/core/main/src/main/java/com/rk/extension/loader/ExtensionLoader.kt
@@ -0,0 +1,183 @@
+package com.rk.extension.loader
+
+import android.app.Application
+import androidx.core.content.pm.PackageInfoCompat
+import com.rk.App
+import com.rk.crashhandler.CrashActivity
+import com.rk.extension.ExtensionAPI
+import com.rk.extension.ExtensionContext
+import com.rk.extension.LocalExtension
+import com.rk.extension.apkFile
+import com.rk.extension.manager.ExtensionManager
+import com.rk.extension.manager.LoadedExtension
+import com.rk.file.FileObject
+import com.rk.file.copyToTempDir
+import com.rk.utils.application
+import com.rk.utils.isMainThread
+import dalvik.system.PathClassLoader
+import kotlinx.coroutines.CoroutineName
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.lang.reflect.InvocationTargetException
+import kotlin.collections.iterator
+
+/**
+ * Loads a locally installed extension.
+ *
+ * This function performs compatibility checks, instantiates the extension's main class,
+ * initializes the extension lifecycle, and caches the result.
+ *
+ * @param application The main Android [Application] instance.
+ * @param initialInstallation True if this is the first time the extension is installed/loaded.
+ * @return A [Result] enclosing the loaded [com.rk.extension.ExtensionAPI] instance, or a failure exception.
+ */
+fun LocalExtension.load(
+ application: Application,
+ initialInstallation: Boolean = false
+): Result {
+ if (isMainThread()) {
+ return Result.failure(
+ IllegalStateException(
+ "Attempted to load extension '${manifest.name}' on the main thread. Extension loading must be performed on a background thread."
+ )
+ )
+ }
+
+ return runCatching {
+ // 1. Verify compatibility with the current app version
+ verifyCompatibility(application)
+
+ // 2. Create the class loader to load the extension's code
+ val classLoader = createClassLoader(application)
+
+ // 3. Load the extension's main class and verify it implements ExtensionAPI
+ val mainClass = loadMainClass(classLoader)
+
+ // 4. Set up the coroutine scope and instantiate the API class
+ val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + CoroutineName("Extension: $id"))
+ val instance = instantiateAPI(mainClass, application, scope)
+
+ // 5. Invoke lifecycle callback methods
+ if (initialInstallation) {
+ instance.onInstalled()
+ }
+ instance.onExtensionLoaded()
+
+ // 6. Cache the loaded extension in the manager
+ App.extensionManager.loadedExtensions[this] = LoadedExtension(instance, scope)
+ instance
+ }
+}
+
+/**
+ * Verifies if the extension is compatible with the running version of the editor.
+ * Throws an [IllegalStateException] if the app version does not satisfy the extension's requirements.
+ */
+private fun LocalExtension.verifyCompatibility(application: Application) {
+ val minAppVersion = manifest.minAppVersion
+ val maxAppVersion = manifest.maxAppVersion
+
+ val xedVersionCode = PackageInfoCompat.getLongVersionCode(
+ application.packageManager.getPackageInfo(application.packageName, 0)
+ )
+
+ val isBelowMin = minAppVersion != null && xedVersionCode < minAppVersion
+ val isAboveMax = maxAppVersion != null && xedVersionCode > maxAppVersion
+
+ if (isBelowMin || isAboveMax) {
+ throw IllegalStateException(
+ "Extension '${manifest.name}' (${manifest.version}) is not compatible with this version of Xed-Editor (min: $minAppVersion, max: $maxAppVersion, Xed-Editor: $xedVersionCode)"
+ )
+ }
+}
+
+/**
+ * Creates a class loader specifically configured for this extension's APK/package file.
+ * Uses a child-first delegation strategy so extension-specific libraries take precedence.
+ */
+private fun LocalExtension.createClassLoader(application: Application): ClassLoader {
+ return try {
+ PathClassLoader(apkFile.absolutePath, application.classLoader)
+ } catch (err: Exception) {
+ throw IllegalStateException(
+ "Failed to create ClassLoader for extension '${manifest.name}'. Details: ${err.message}",
+ err
+ )
+ }
+}
+
+
+
+/**
+ * Loads the main entry point class of the extension and asserts that it implements [ExtensionAPI].
+ */
+private fun LocalExtension.loadMainClass(classLoader: ClassLoader): Class<*> {
+ val mainClass = try {
+ classLoader.loadClass(manifest.mainClass)
+ } catch (err: Throwable) {
+ throw err
+ }
+
+ if (!ExtensionAPI::class.java.isAssignableFrom(mainClass)) {
+ throw IllegalStateException(
+ "The main class '${manifest.mainClass}' of extension '${manifest.name}' does not implement the ExtensionAPI interface. Please ensure the main class correctly implements this interface."
+ )
+ }
+
+ return mainClass
+}
+
+/**
+ * Instantiates the extension's main [ExtensionAPI] class by calling its public constructor
+ * that accepts an [com.rk.extension.ExtensionContext].
+ */
+private fun LocalExtension.instantiateAPI(
+ mainClassInstance: Class<*>,
+ application: Application,
+ scope: CoroutineScope
+): ExtensionAPI {
+ val extContext = ExtensionContext(extension = this, appContext = application, scope = scope)
+ return try {
+ val constructor = mainClassInstance.getDeclaredConstructor(ExtensionContext::class.java)
+ (constructor.newInstance(extContext) as? ExtensionAPI)
+ ?: throw IllegalStateException(
+ "Failed to instantiate main class '${mainClassInstance.name}' for extension '${manifest.name}'. Ensure the class implements the ExtensionAPI interface and declares a public constructor accepting ExtensionContext."
+ )
+ } catch (err: Throwable) {
+ // Unpack Java reflection wrapping to show the real root exception if available
+ val realError = if (err is InvocationTargetException) err.cause ?: err else err
+ throw realError
+ }
+}
+
+/**
+ * Installs an extension directly from a file object by copying it to a temporary directory first.
+ */
+suspend fun ExtensionManager.installExtensionFromZip(fileObject: FileObject) = run {
+ val file = fileObject.copyToTempDir()
+ installExtensionFromZip(file).also { file.delete() }
+}
+
+/**
+ * Scans all local extensions and loads any that are not disabled.
+ * If an extension fails to load, it is marked as disabled and a crash screen is shown.
+ */
+suspend fun ExtensionManager.loadAllExtensions() =
+ withContext(Dispatchers.IO) {
+ for ((_, extension) in localExtensions) {
+ if (isExtensionDisabled(extension.id)) {
+ continue
+ }
+ launch(Dispatchers.IO) {
+ extension.load(application!!).onFailure { error ->
+ setExtensionDisabled(extension.id, true)
+ withContext(Dispatchers.Main) {
+ CrashActivity.start(application!!, extension, error)
+ }
+ }
+ }
+ }
+ }
diff --git a/core/main/src/main/java/com/rk/extension/manager/ExtensionAPIManager.kt b/core/main/src/main/java/com/rk/extension/manager/ExtensionAPIManager.kt
new file mode 100644
index 000000000..a304c54a1
--- /dev/null
+++ b/core/main/src/main/java/com/rk/extension/manager/ExtensionAPIManager.kt
@@ -0,0 +1,49 @@
+package com.rk.extension.manager
+
+import android.app.Activity
+import android.app.Application
+import android.os.Bundle
+import com.rk.App
+import com.rk.DefaultScope
+import com.rk.extension.ExtensionAPI
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+object ExtensionAPIManager : Application.ActivityLifecycleCallbacks, CoroutineScope by DefaultScope {
+
+ private fun runForAll(block: ExtensionAPI.() -> Unit) {
+ App.extensionManager.loadedExtensions.values.forEach { loaded -> loaded?.api?.block() }
+ }
+
+ private fun runForAllAsync(block: ExtensionAPI.() -> Unit) {
+ launch { runForAll(block) }
+ }
+
+ override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
+ runForAllAsync { onActivityCreated(activity, savedInstanceState) }
+ }
+
+ override fun onActivityStarted(activity: Activity) {
+ runForAllAsync { onActivityStarted(activity) }
+ }
+
+ override fun onActivityResumed(activity: Activity) {
+ runForAllAsync { onActivityResumed(activity) }
+ }
+
+ override fun onActivityPaused(activity: Activity) {
+ runForAllAsync { onActivityPaused(activity) }
+ }
+
+ override fun onActivityStopped(activity: Activity) {
+ runForAllAsync { onActivityStopped(activity) }
+ }
+
+ override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {
+ runForAllAsync { onActivitySaveInstanceState(activity, outState) }
+ }
+
+ override fun onActivityDestroyed(activity: Activity) {
+ runForAllAsync { onActivityDestroyed(activity) }
+ }
+}
diff --git a/core/main/src/main/java/com/rk/extension/ExtensionManager.kt b/core/main/src/main/java/com/rk/extension/manager/ExtensionManager.kt
similarity index 56%
rename from core/main/src/main/java/com/rk/extension/ExtensionManager.kt
rename to core/main/src/main/java/com/rk/extension/manager/ExtensionManager.kt
index c5860165d..4d2ba0b37 100644
--- a/core/main/src/main/java/com/rk/extension/ExtensionManager.kt
+++ b/core/main/src/main/java/com/rk/extension/manager/ExtensionManager.kt
@@ -1,21 +1,34 @@
-package com.rk.extension
+package com.rk.extension.manager
import android.app.Application
import android.content.Context
import androidx.compose.runtime.mutableStateMapOf
import androidx.core.content.pm.PackageInfoCompat
import com.rk.file.child
+import com.rk.resources.getString
+import com.rk.resources.strings
import com.rk.utils.errorDialog
-import java.io.File
-import java.util.zip.ZipFile
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
+import java.io.File
+import java.util.zip.ZipFile
+import androidx.core.content.edit
+import com.rk.extension.Extension
+import com.rk.extension.ExtensionAPI
+import com.rk.extension.ExtensionError
+import com.rk.extension.ExtensionId
+import com.rk.extension.ExtensionManifest
+import com.rk.extension.InstallResult
+import com.rk.extension.LocalExtension
+import com.rk.extension.StoreExtension
+import com.rk.extension.UpdatableExtension
private val Context.localDir: File
get() = filesDir.parentFile!!.resolve("local").apply { if (!exists()) mkdirs() }
@@ -25,12 +38,16 @@ val Context.extensionDir: File
internal fun Context.compiledDexDir() = extensionDir.resolve("oat")
+data class LoadedExtension(val api: ExtensionAPI, val scope: CoroutineScope)
+
open class ExtensionManager(private val context: Application) : CoroutineScope by CoroutineScope(Dispatchers.IO) {
private val mutex = Mutex()
val localExtensions = mutableStateMapOf()
val storeExtension = mutableStateMapOf()
val json = Json { ignoreUnknownKeys = true }
+ val loadedExtensions = mutableStateMapOf()
+
init {
launch(Dispatchers.IO) {
runCatching {
@@ -40,30 +57,80 @@ open class ExtensionManager(private val context: Application) : CoroutineScope b
}
}
- suspend fun indexLocalExtensions() =
- mutex.withLock {
- localExtensions.clear()
-
- withContext(Dispatchers.IO) {
- val extensionFolders = context.extensionDir.listFiles()?.filter { it.isDirectory }
- extensionFolders?.forEach { dir ->
- val extensionJson = dir.resolve("manifest.json")
- if (extensionJson.exists()) {
- runCatching {
- val extensionManifest = json.decodeFromString(extensionJson.readText())
- val extension = LocalExtension(manifest = extensionManifest, installPath = dir.absolutePath)
- localExtensions[extensionManifest.id] = extension
- }
+ private val disabledPrefs by lazy {
+ context.getSharedPreferences("disabled_extensions", Context.MODE_PRIVATE)
+ }
+
+ fun isExtensionDisabled(id: ExtensionId): Boolean {
+ return disabledPrefs.getBoolean(id, false)
+ }
+
+ fun setExtensionDisabled(id: ExtensionId, disabled: Boolean) {
+ disabledPrefs.edit { putBoolean(id, disabled) }
+ }
+
+ fun isInstalled(extensionId: ExtensionId) = localExtensions.containsKey(extensionId)
+
+ fun getExtension(extensionId: ExtensionId): Extension? {
+ val local = localExtensions[extensionId]
+ val store = storeExtension[extensionId]
+
+ return when {
+ local != null && store != null -> UpdatableExtension(local, store)
+ local != null -> local
+ store != null -> store
+ else -> null
+ }
+ }
+
+ fun getSyncedExtensions(): List {
+ val allIds = localExtensions.keys + storeExtension.keys
+ return allIds.mapNotNull { id -> getExtension(id) }
+ }
+
+ fun getLocalExtensions(): List {
+ return getSyncedExtensions().filterIsInstance()
+ }
+
+ fun getStoreExtensions(): List {
+ return getSyncedExtensions().filter { it is StoreExtension || it is UpdatableExtension }
+ }
+
+ suspend fun indexLocalExtensions() = mutex.withLock {
+ val newExtensions = withContext(Dispatchers.IO) {
+ val map = mutableMapOf()
+ val extensionFolders = context.extensionDir.listFiles()?.filter { it.isDirectory }
+ extensionFolders?.forEach { dir ->
+ val extensionJson = dir.resolve("manifest.json")
+ if (extensionJson.exists()) {
+ runCatching {
+ val extensionManifest = json.decodeFromString(extensionJson.readText())
+ val extension = LocalExtension(
+ manifest = extensionManifest,
+ installPath = dir.absolutePath
+ )
+ map[extensionManifest.id] = extension
}
}
}
+ map
+ }
+ withContext(Dispatchers.Main) {
+ val toRemove = localExtensions.keys.filter { it !in newExtensions }
+ toRemove.forEach { localExtensions.remove(it) }
+ localExtensions.putAll(newExtensions)
}
+ }
suspend fun indexStoreExtensions() =
withContext(Dispatchers.IO) {
val extensions = ExtensionRegistry.fetchExtensions()
- storeExtension.clear()
- storeExtension.putAll(extensions.associate { it.id to StoreExtension(it) })
+ val newExtensions = extensions.associate { it.id to StoreExtension(it) }
+ withContext(Dispatchers.Main) {
+ val toRemove = storeExtension.keys.filter { it !in newExtensions }
+ toRemove.forEach { storeExtension.remove(it) }
+ storeExtension.putAll(newExtensions)
+ }
}
suspend fun installStoreExtension(context: Context, extension: StoreExtension) = runCatching {
@@ -130,34 +197,46 @@ open class ExtensionManager(private val context: Application) : CoroutineScope b
val extensionInfo = validation.getOrThrow()
val targetDir = context.extensionDir.resolve(extensionInfo.id)
+ var performedUpdate = false
if (targetDir.exists()) {
- uninstallExtension(extensionInfo.id)
+ uninstallExtension(extensionInfo.id, update = true)
+ performedUpdate = true
}
val pm = context.packageManager
val xedVersionCode = PackageInfoCompat.getLongVersionCode(pm.getPackageInfo(context.packageName, 0))
- if (extensionInfo.minAppVersion != -1 && xedVersionCode < extensionInfo.minAppVersion) {
+ if (extensionInfo.minAppVersion != null && xedVersionCode < extensionInfo.minAppVersion) {
return@withContext InstallResult.Error(ExtensionError.OUTDATED_CLIENT)
- } else if (extensionInfo.targetAppVersion != -1 && xedVersionCode > extensionInfo.targetAppVersion) {
+ } else if (extensionInfo.maxAppVersion != null && xedVersionCode > extensionInfo.maxAppVersion) {
return@withContext InstallResult.Error(ExtensionError.OUTDATED_EXTENSION)
}
dir.copyRecursively(targetDir, overwrite = true)
- val extension = LocalExtension(manifest = extensionInfo, installPath = targetDir.absolutePath)
+ val extension =
+ LocalExtension(manifest = extensionInfo, installPath = targetDir.absolutePath)
localExtensions[extensionInfo.id] = extension
- InstallResult.Success(extension)
+ InstallResult.Success(extension, performedUpdate)
}
- suspend fun uninstallExtension(extensionId: ExtensionId) =
+ suspend fun uninstallExtension(extensionId: ExtensionId, update: Boolean = false) =
withContext(Dispatchers.IO) {
try {
val extension =
localExtensions[extensionId] ?: return@withContext Result.failure(Exception("Extension not found"))
- loadedExtensions[extension]?.onUninstalled(extension)
+ val loadedExtension = loadedExtensions[extension]
+ runCatching {
+ if (update) {
+ loadedExtension?.api?.onUpdated()
+ } else {
+ loadedExtension?.api?.onUninstalled()
+ }
+ }
+ .onFailure { errorDialog(title = strings.ext_cleanup_failed.getString(), throwable = it) }
+ loadedExtensions[extension]?.scope?.cancel()
val extensionDir = File(extension.installPath)
if (!extensionDir.exists()) {
@@ -167,6 +246,7 @@ open class ExtensionManager(private val context: Application) : CoroutineScope b
extensionDir.deleteRecursively()
localExtensions.remove(extensionId)
context.compiledDexDir().deleteWithPackageName(extension.manifest.id)
+ disabledPrefs.edit { remove(extensionId) }
Result.success(Unit)
} catch (err: Exception) {
@@ -180,11 +260,4 @@ open class ExtensionManager(private val context: Application) : CoroutineScope b
delete()
} else if (name.startsWith(pkgName)) delete()
}
-
- fun isInstalled(extensionId: ExtensionId) = localExtensions.containsKey(extensionId)
-
- fun getExtension(extensionId: ExtensionId) = localExtensions[extensionId] ?: storeExtension[extensionId]
-
- fun getExtensionManifest(extensionId: ExtensionId) =
- localExtensions[extensionId]?.manifest ?: storeExtension[extensionId]?.manifest
}
diff --git a/core/main/src/main/java/com/rk/extension/manager/ExtensionRegistry.kt b/core/main/src/main/java/com/rk/extension/manager/ExtensionRegistry.kt
new file mode 100644
index 000000000..e9072b171
--- /dev/null
+++ b/core/main/src/main/java/com/rk/extension/manager/ExtensionRegistry.kt
@@ -0,0 +1,213 @@
+package com.rk.extension.manager
+
+import android.util.Log
+import com.rk.XedConstants
+import com.rk.extension.ExtensionManifest
+import com.rk.extension.ExtensionStats
+import com.rk.utils.errorDialog
+import java.io.File
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonObject
+import com.rk.utils.okHttpClient
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import com.google.gson.GsonBuilder
+import com.rk.theme.ThemeConfig
+import com.rk.icons.pack.IconPackManifest
+
+import androidx.compose.runtime.mutableStateMapOf
+import com.rk.settings.extension.InstallState
+
+@Serializable private data class ExtensionListResponse(val extensions: List)
+
+@Serializable private data class ExtensionEntry(val manifest: ExtensionManifest)
+
+@Serializable private data class ExtensionDetail(val downloads: Int? = null, val download: DownloadUrls)
+
+@Serializable
+private data class DownloadUrls(val icon: String? = null, val readme: String? = null, val zip: String, val size: Int)
+
+@Serializable
+data class ThemeStoreEntry(
+ val id: String,
+ val userId: String,
+ val manifest: JsonObject
+)
+
+@Serializable
+data class ThemesResponse(val themes: List)
+
+@Serializable
+data class IconPackStoreEntry(
+ val id: String,
+ val userId: String,
+ val manifest: IconPackManifest
+)
+
+@Serializable
+data class IconPacksResponse(val iconPacks: List)
+
+object ExtensionRegistry {
+ private const val TAG = "ExtensionRegistry"
+ private const val BASE_URL = XedConstants.EXTENSION_API_BASE
+
+ private val client: OkHttpClient = okHttpClient
+ private val json = Json { ignoreUnknownKeys = true }
+
+ val downloadProgress = mutableStateMapOf()
+ val activeInstalls = mutableStateMapOf()
+
+ suspend fun downloadFileWithProgress(
+ url: String,
+ destFile: File,
+ onProgress: (progress: Float) -> Unit
+ ): Boolean = withContext(Dispatchers.IO) {
+ runCatching {
+ val request = Request.Builder().url(url).build()
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) error("HTTP ${response.code}")
+ val totalBytes = response.body.contentLength()
+ destFile.parentFile?.mkdirs()
+ response.body.byteStream().use { input ->
+ destFile.outputStream().use { output ->
+ val buffer = ByteArray(8192)
+ var bytesRead: Int
+ var totalBytesRead = 0L
+ while (input.read(buffer).also { bytesRead = it } != -1) {
+ output.write(buffer, 0, bytesRead)
+ totalBytesRead += bytesRead
+ if (totalBytes > 0) {
+ val progress = totalBytesRead.toFloat() / totalBytes
+ onProgress(progress)
+ } else {
+ onProgress(-1f)
+ }
+ }
+ }
+ }
+ }
+ true
+ }.onFailure {
+ it.printStackTrace()
+ }.getOrElse { false }
+ }
+
+ suspend fun fetchExtensions(): List =
+ withContext(Dispatchers.IO) {
+ runCatching {
+ val jsonString = requestJson(BASE_URL)
+ val response = json.decodeFromString(jsonString)
+ response.extensions.map { it.manifest }
+ }
+ .onFailure {
+ it.printStackTrace()
+ throw it
+ }
+ .getOrElse { emptyList() }
+ }
+
+ fun getIconUrl(id: String): String = "$BASE_URL/$id/icon.png"
+
+ fun getReadmeUrl(id: String): String = "$BASE_URL/$id/README.md"
+
+ fun getChangelogUrl(id: String): String = "$BASE_URL/$id/CHANGELOG.md"
+
+ suspend fun getStats(id: String): ExtensionStats {
+ val details =
+ runCatching {
+ withContext(Dispatchers.IO) {
+ val jsonString = requestJson("$BASE_URL/$id")
+ json.decodeFromString(jsonString)
+ }
+ }
+ .getOrNull()
+
+ return ExtensionStats(
+ downloadCount = details?.downloads,
+ rating = null,
+ size = details?.download?.size?.toLong(),
+ )
+ }
+
+ private fun requestJson(url: String): String {
+ val req = Request.Builder().url(url).build()
+ return client.newCall(req).execute().use { res ->
+ if (!res.isSuccessful) error("HTTP ${res.code}")
+ val body = res.body.string()
+ Log.d(TAG, body)
+ body
+ }
+ }
+
+ suspend fun downloadZip(manifest: ExtensionManifest, destFile: File): Boolean =
+ withContext(Dispatchers.IO) {
+ runCatching {
+ val zipUrl = "$BASE_URL/${manifest.id}/plugin.zip"
+
+ val request = Request.Builder().url(zipUrl).build()
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) error("HTTP ${response.code}")
+ destFile.parentFile?.mkdirs()
+ response.body.byteStream().use { input ->
+ destFile.outputStream().use { output -> input.copyTo(output) }
+ }
+ }
+ true
+ }
+ .onFailure {
+ it.printStackTrace()
+ errorDialog(throwable = it)
+ }
+ .getOrElse { false }
+ }
+
+ suspend fun fetchThemes(): List =
+ withContext(Dispatchers.IO) {
+ runCatching {
+ val jsonString = requestJson(XedConstants.THEMES_API_BASE)
+ val response = json.decodeFromString(jsonString)
+ response.themes
+ }
+ .onFailure {
+ it.printStackTrace()
+ }
+ .getOrElse { emptyList() }
+ }
+
+ suspend fun fetchIconPacks(): List =
+ withContext(Dispatchers.IO) {
+ runCatching {
+ val jsonString = requestJson(XedConstants.ICONPACKS_API_BASE)
+ val response = json.decodeFromString(jsonString)
+ response.iconPacks
+ }
+ .onFailure {
+ it.printStackTrace()
+ }
+ .getOrElse { emptyList() }
+ }
+
+ suspend fun downloadIconPackZip(id: String, destFile: File): Boolean =
+ withContext(Dispatchers.IO) {
+ runCatching {
+ val zipUrl = "${XedConstants.ICONPACKS_API_BASE}/$id/iconpack.zip"
+ val request = Request.Builder().url(zipUrl).build()
+ client.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) error("HTTP ${response.code}")
+ destFile.parentFile?.mkdirs()
+ response.body.byteStream().use { input ->
+ destFile.outputStream().use { output -> input.copyTo(output) }
+ }
+ }
+ true
+ }
+ .onFailure {
+ it.printStackTrace()
+ errorDialog(throwable = it)
+ }
+ .getOrElse { false }
+ }
+}
diff --git a/core/main/src/main/java/com/rk/extension/Extension.kt b/core/main/src/main/java/com/rk/extension/model/Extension.kt
similarity index 51%
rename from core/main/src/main/java/com/rk/extension/Extension.kt
rename to core/main/src/main/java/com/rk/extension/model/Extension.kt
index 559dc6ade..36c4137dc 100644
--- a/core/main/src/main/java/com/rk/extension/Extension.kt
+++ b/core/main/src/main/java/com/rk/extension/model/Extension.kt
@@ -1,14 +1,14 @@
package com.rk.extension
-
-import android.content.Context
-import android.content.pm.PackageManager
-import androidx.compose.runtime.mutableStateMapOf
-import dalvik.system.PathClassLoader
+import com.rk.extension.manager.ExtensionRegistry
+import com.rk.xededitor.BuildConfig
+import io.github.z4kn4fein.semver.toVersionOrNull
import java.io.File
import java.util.Date
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
-val loadedExtensions = mutableStateMapOf()
+data class ExtensionStats(val downloadCount: Int?, val rating: Float?, val size: Long?)
sealed interface Extension {
val id: ExtensionId
@@ -19,19 +19,20 @@ sealed interface Extension {
val tags: List
val repository: String
val license: String?
+ val hasSettings: Boolean
val iconUrl: String
val readmeUrl: String
val changelogUrl: String
- suspend fun calcSize(): Long
+ val minAppVersion: Int?
- suspend fun getRating(): Float?
+ val maxAppVersion: Int?
- suspend fun getReviews(): List
+ suspend fun getStats(): ExtensionStats
- suspend fun getDownloadCount(): Int?
+ suspend fun getReviews(): List
}
data class Review(val rating: Int, val text: String, val author: String, val date: Date, val authorResponse: String?)
@@ -68,7 +69,8 @@ data class StoreExtension(val manifest: ExtensionManifest, val verified: Boolean
override val license
get() = manifest.license
- override suspend fun getRating() = null
+ override val hasSettings: Boolean
+ get() = manifest.hasSettings
override val iconUrl: String
get() = ExtensionRegistry.getIconUrl(manifest.id)
@@ -79,11 +81,15 @@ data class StoreExtension(val manifest: ExtensionManifest, val verified: Boolean
override val changelogUrl
get() = ExtensionRegistry.getChangelogUrl(manifest.id)
- override suspend fun calcSize() = 0L // TODO
+ override val minAppVersion
+ get() = manifest.minAppVersion
- override suspend fun getReviews(): List = emptyList()
+ override val maxAppVersion
+ get() = manifest.maxAppVersion
+
+ override suspend fun getStats() = ExtensionRegistry.getStats(manifest.id)
- override suspend fun getDownloadCount() = ExtensionRegistry.getDownloadCount(manifest.id)
+ override suspend fun getReviews(): List = emptyList()
}
/** Extensions that are installed locally (from disk). */
@@ -132,6 +138,9 @@ data class LocalExtension(
override val license
get() = manifest.license
+ override val hasSettings: Boolean
+ get() = manifest.hasSettings
+
override val iconUrl
get() = "$installPath/icon.png"
@@ -141,91 +150,120 @@ data class LocalExtension(
override val changelogUrl
get() = "$installPath/CHANGELOG.md"
- override suspend fun calcSize(): Long {
- var totalSize = 0L
- val stack = ArrayDeque()
- stack.add(File(installPath))
-
- loop@ while (stack.isNotEmpty()) {
- val current = stack.removeLast()
- runCatching {
- if (current.isDirectory()) {
- val files = current.listFiles() ?: continue@loop
- stack.addAll(files)
- } else {
- totalSize += current.length()
+ override val minAppVersion
+ get() = manifest.minAppVersion
+
+ override val maxAppVersion
+ get() = manifest.maxAppVersion
+
+ override suspend fun getStats(): ExtensionStats {
+ return ExtensionStats(null, null, calcSize())
+ }
+
+ private suspend fun calcSize(): Long {
+ return withContext(Dispatchers.IO) {
+ var totalSize = 0L
+ val stack = ArrayDeque()
+ stack.add(File(installPath))
+
+ loop@ while (stack.isNotEmpty()) {
+ val current = stack.removeLast()
+ runCatching {
+ if (current.isDirectory()) {
+ val files = current.listFiles() ?: continue@loop
+ stack.addAll(files)
+ } else {
+ totalSize += current.length()
+ }
}
}
+ totalSize
}
- return totalSize
}
- override suspend fun getRating() = null
-
override suspend fun getReviews(): List = emptyList()
-
- override suspend fun getDownloadCount() = null
}
data class UpdatableExtension(val installed: LocalExtension, val store: StoreExtension) : Extension {
override val id
- get() = installed.id
+ get() = store.id
override val name
- get() = installed.name
+ get() = store.name
override val version
get() = installed.version
+ val newVersion: String
+ get() = store.version
+
override val author
- get() = installed.author
+ get() = store.author
override val description
- get() = installed.description
+ get() = store.description
override val tags
- get() = installed.tags
+ get() = store.tags
override val repository
- get() = installed.repository
+ get() = store.repository
override val license
- get() = installed.license
+ get() = store.license
+
+ override val hasSettings: Boolean
+ get() = installed.hasSettings
override val iconUrl
- get() = installed.iconUrl
+ get() = if (isUpdatable()) store.iconUrl else installed.iconUrl
override val readmeUrl
- get() = installed.readmeUrl
+ get() = if (isUpdatable()) store.readmeUrl else installed.readmeUrl
override val changelogUrl
- get() = installed.changelogUrl
+ get() = if (isUpdatable()) store.changelogUrl else installed.changelogUrl
+
+ override val minAppVersion
+ get() = store.minAppVersion
- override suspend fun calcSize() = installed.calcSize()
+ override val maxAppVersion
+ get() = store.maxAppVersion
- override suspend fun getRating() = store.getRating()
+ override suspend fun getStats() = store.getStats()
override suspend fun getReviews() = store.getReviews()
- override suspend fun getDownloadCount() = store.getDownloadCount()
+ fun isUpdatable(): Boolean {
+ val installedVersion = installed.version.toVersionOrNull() ?: return false
+ val storeVersion = store.version.toVersionOrNull() ?: return false
+ return installedVersion < storeVersion
+ }
}
-fun LocalExtension.classLoader(parent: ClassLoader?) = PathClassLoader(apkFile.absolutePath, parent)
-val LocalExtension.apkFile
+val LocalExtension.apkFile: File
get() = run {
val dir = File(installPath)
if (!dir.isDirectory) error("Extension [$name, $id] directory not found")
- dir.listFiles { it.extension == "apk" }?.first()?.also { it.setReadOnly() } ?: error("APK not found")
- }
-
-fun LocalExtension.getApkPackageInfo(context: Context) = run {
- val pm = context.packageManager
- pm.getPackageArchiveInfo(apkFile.absolutePath, PackageManager.GET_META_DATA or PackageManager.GET_ACTIVITIES)!!
- .apply {
- applicationInfo!!.sourceDir = apkFile.absolutePath
- applicationInfo!!.publicSourceDir = apkFile.absolutePath
+ val apks = dir.listFiles { it.extension == "apk" } ?: emptyArray()
+ if (apks.isEmpty()) error("APK not found")
+
+ val apk = if (apks.size == 1) {
+ apks.first()
+ } else {
+ val isDebug = BuildConfig.DEBUG
+ if (isDebug) {
+ apks.find { it.name.contains("debug", ignoreCase = true) }
+ ?: apks.find { !it.name.contains("release", ignoreCase = true) }
+ ?: apks.first()
+ } else {
+ apks.find { it.name.contains("release", ignoreCase = true) }
+ ?: apks.find { !it.name.contains("debug", ignoreCase = true) }
+ ?: apks.first()
+ }
}
-}
+ apk.also { it.setReadOnly() }
+ }
diff --git a/core/main/src/main/java/com/rk/extension/ExtensionManifest.kt b/core/main/src/main/java/com/rk/extension/model/ExtensionManifest.kt
similarity index 85%
rename from core/main/src/main/java/com/rk/extension/ExtensionManifest.kt
rename to core/main/src/main/java/com/rk/extension/model/ExtensionManifest.kt
index 0cf308140..a76637d22 100644
--- a/core/main/src/main/java/com/rk/extension/ExtensionManifest.kt
+++ b/core/main/src/main/java/com/rk/extension/model/ExtensionManifest.kt
@@ -13,11 +13,12 @@ data class ExtensionManifest(
val version: String = "1.0.0",
val description: String? = null,
val author: ExtensionAuthor,
- val minAppVersion: Int = -1, // -1 means supports all versions
- val targetAppVersion: Int = -1, // -1 means supports all versions
+ val minAppVersion: Int? = null, // null means no minimum restriction
+ val maxAppVersion: Int? = null, // null means no maximum restriction
val repository: String,
val license: String? = null,
val tags: List = emptyList(),
+ val hasSettings: Boolean = false,
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
diff --git a/core/main/src/main/java/com/rk/extension/InstallResult.kt b/core/main/src/main/java/com/rk/extension/model/InstallResult.kt
similarity index 70%
rename from core/main/src/main/java/com/rk/extension/InstallResult.kt
rename to core/main/src/main/java/com/rk/extension/model/InstallResult.kt
index 1a160a96f..995c43dab 100644
--- a/core/main/src/main/java/com/rk/extension/InstallResult.kt
+++ b/core/main/src/main/java/com/rk/extension/model/InstallResult.kt
@@ -1,12 +1,10 @@
package com.rk.extension
sealed interface InstallResult {
- data class Success(val extension: LocalExtension) : InstallResult
+ data class Success(val extension: LocalExtension, val performedUpdate: Boolean) : InstallResult
data class ValidationFailed(val error: Throwable?) : InstallResult
- class AlreadyInstalled : InstallResult
-
data class Error(val error: ExtensionError) : InstallResult
}
diff --git a/core/main/src/main/java/com/rk/file/BuiltinFileType.kt b/core/main/src/main/java/com/rk/file/BuiltinFileType.kt
index f37bdde67..ea661b6ed 100644
--- a/core/main/src/main/java/com/rk/file/BuiltinFileType.kt
+++ b/core/main/src/main/java/com/rk/file/BuiltinFileType.kt
@@ -1,5 +1,6 @@
package com.rk.file
+import com.rk.extension.api.XedExtensionPoint
import com.rk.icons.Icon
import com.rk.icons.pack.currentIconPack
import com.rk.resources.drawables
@@ -52,6 +53,7 @@ private val diff = drawables.diff
private val cmake = drawables.cmake
private val powershell = drawables.powershell
private val r = drawables.r
+private val nix = drawables.nix
// TODO: Add icon for FileType.EXECUTABLE
// TODO: Add icon for FileType.PASCAL
@@ -81,8 +83,8 @@ interface FileType {
get() = null
val textmateScope: String?
- val icon: Int?
- val iconOverride: Map?
+ val icon: Icon?
+ val iconOverride: Map?
get() = null
val name: String
@@ -104,11 +106,9 @@ interface FileType {
*
* @return An [Icon] representing the file type icon.
*/
- fun getIcon(): Icon {
+ fun getResolvedIcon(): Icon {
val iconPackFile = currentIconPack.value?.getIconFileForFileType(this)
- return iconPackFile?.let { Icon.SvgIcon(it) }
- ?: icon?.let { Icon.DrawableRes(it) }
- ?: Icon.DrawableRes(drawables.file)
+ return iconPackFile?.let { Icon.SvgIcon(it) } ?: icon ?: Icon.ResourceIcon(drawables.file)
}
}
@@ -123,8 +123,17 @@ object FileTypeManager {
private val dynamicRegistry = mutableListOf()
/** Register a new file type dynamically. */
+ @XedExtensionPoint
fun register(fileType: FileType) {
- dynamicRegistry.add(fileType)
+ if (!dynamicRegistry.contains(fileType)) {
+ dynamicRegistry.add(fileType)
+ }
+ }
+
+ /** Unregister a file type. */
+ @XedExtensionPoint
+ fun unregister(fileType: FileType) {
+ dynamicRegistry.remove(fileType)
}
/** Get all dynamically registered file types + built-in file types together */
@@ -158,8 +167,8 @@ enum class BuiltinFileType(
override val extensions: List,
override val names: List? = null,
override val textmateScope: String?,
- override val icon: Int?,
- override val iconOverride: Map? = null,
+ override val icon: Icon?,
+ override val iconOverride: Map? = null,
override val title: String,
override val markdownNames: List = emptyList(),
) : FileType {
@@ -167,46 +176,71 @@ enum class BuiltinFileType(
JAVASCRIPT(
extensions = listOf("js", "mjs", "cjs", "jscsrc", "jshintrc", "mut"),
textmateScope = "source.js",
- icon = js,
+ icon = Icon.ResourceIcon(js),
title = "JavaScript",
markdownNames = listOf("javascript"),
),
TYPESCRIPT(
extensions = listOf("ts", "mts", "cts"),
textmateScope = "source.ts",
- icon = ts,
+ icon = Icon.ResourceIcon(ts),
title = "TypeScript",
markdownNames = listOf("typescript"),
),
- JSX(extensions = listOf("jsx"), textmateScope = "source.js.jsx", icon = react, title = "JavaScript JSX"),
- TSX(extensions = listOf("tsx"), textmateScope = "source.tsx", icon = react, title = "TypeScript JSX"),
+ JSX(
+ extensions = listOf("jsx"),
+ textmateScope = "source.js.jsx",
+ icon = Icon.ResourceIcon(react),
+ title = "JavaScript JSX",
+ ),
+ TSX(
+ extensions = listOf("tsx"),
+ textmateScope = "source.tsx",
+ icon = Icon.ResourceIcon(react),
+ title = "TypeScript JSX",
+ ),
HTML(
extensions = listOf("html", "htm", "xhtml", "xht"),
textmateScope = "text.html.basic",
- icon = html,
+ icon = Icon.ResourceIcon(html),
title = "HTML",
),
- HTMX(extensions = listOf("htmx"), textmateScope = "text.html.htmx", icon = html, title = "HTMX"),
- CSS(extensions = listOf("css"), textmateScope = "source.css", icon = css, title = "CSS"),
- SCSS(extensions = listOf("scss", "sass"), textmateScope = "source.css.scss", icon = sass, title = "SCSS"),
- LESS(extensions = listOf("less"), textmateScope = "source.css.less", icon = less, title = "Less"),
- JSON(extensions = listOf("json", "jsonl", "jsonc"), textmateScope = "source.json", icon = json, title = "JSON"),
+ HTMX(extensions = listOf("htmx"), textmateScope = "text.html.htmx", icon = Icon.ResourceIcon(html), title = "HTMX"),
+ CSS(extensions = listOf("css"), textmateScope = "source.css", icon = Icon.ResourceIcon(css), title = "CSS"),
+ SCSS(
+ extensions = listOf("scss", "sass"),
+ textmateScope = "source.css.scss",
+ icon = Icon.ResourceIcon(sass),
+ title = "SCSS",
+ ),
+ LESS(
+ extensions = listOf("less"),
+ textmateScope = "source.css.less",
+ icon = Icon.ResourceIcon(less),
+ title = "Less",
+ ),
+ JSON(
+ extensions = listOf("json", "jsonl", "jsonc"),
+ textmateScope = "source.json",
+ icon = Icon.ResourceIcon(json),
+ title = "JSON",
+ ),
MARKDOWN(
extensions = listOf("md", "markdown", "mdown", "mkd", "mkdn", "mdoc", "mdtext", "mdtxt", "mdwn"),
textmateScope = "text.html.markdown",
- icon = markdown,
+ icon = Icon.ResourceIcon(markdown),
title = "Markdown",
),
XML(
extensions = listOf("xml", "xaml", "dtd", "plist", "ascx", "csproj", "wxi", "wxl", "wxs", "svg"),
textmateScope = "text.xml",
- icon = xml,
+ icon = Icon.ResourceIcon(xml),
title = "XML",
),
YAML(
extensions = listOf("yaml", "yml", "eyaml", "eyml", "cff"),
textmateScope = "source.yaml",
- icon = yaml,
+ icon = Icon.ResourceIcon(yaml),
title = "YAML",
),
@@ -214,46 +248,51 @@ enum class BuiltinFileType(
PYTHON(
extensions = listOf("py", "pyi"),
textmateScope = "source.python",
- icon = python,
+ icon = Icon.ResourceIcon(python),
title = "Python",
markdownNames = listOf("python"),
),
- JAVA(extensions = listOf("java", "jav", "bsh"), textmateScope = "source.java", icon = java, title = "Java"),
+ JAVA(
+ extensions = listOf("java", "jav", "bsh"),
+ textmateScope = "source.java",
+ icon = Icon.ResourceIcon(java),
+ title = "Java",
+ ),
GROOVY(
extensions = listOf("gsh", "groovy", "gradle", "gvy", "gy"),
textmateScope = "source.groovy",
- icon = groovy,
- iconOverride = mapOf("gradle" to gradle),
+ icon = Icon.ResourceIcon(groovy),
+ iconOverride = mapOf("gradle" to Icon.ResourceIcon(gradle)),
title = "Groovy",
),
- C(extensions = listOf("c"), textmateScope = "source.c", icon = c, title = "C"),
+ C(extensions = listOf("c"), textmateScope = "source.c", icon = Icon.ResourceIcon(c), title = "C"),
CPP(
extensions = listOf("cpp", "cxx", "cc", "c++", "h", "hpp", "hh", "hxx", "h++"),
textmateScope = "source.cpp",
- icon = cpp,
+ icon = Icon.ResourceIcon(cpp),
title = "C++",
),
CSHARP(
extensions = listOf("cs", "csx"),
textmateScope = "source.cs",
- icon = csharp,
+ icon = Icon.ResourceIcon(csharp),
title = "C#",
markdownNames = listOf("csharp"),
),
RUBY(
extensions = listOf("rb", "erb", "gemspec"),
textmateScope = "source.ruby",
- icon = ruby,
+ icon = Icon.ResourceIcon(ruby),
title = "Ruby",
markdownNames = listOf("ruby"),
),
- LUA(extensions = listOf("lua", "luau"), textmateScope = "source.lua", icon = lua, title = "Lua"),
- GO(extensions = listOf("go"), textmateScope = "source.go", icon = go, title = "Go"),
- PHP(extensions = listOf("php"), textmateScope = "source.php", icon = php, title = "PHP"),
+ LUA(extensions = listOf("lua", "luau"), textmateScope = "source.lua", icon = Icon.ResourceIcon(lua), title = "Lua"),
+ GO(extensions = listOf("go"), textmateScope = "source.go", icon = Icon.ResourceIcon(go), title = "Go"),
+ PHP(extensions = listOf("php"), textmateScope = "source.php", icon = Icon.ResourceIcon(php), title = "PHP"),
RUST(
extensions = listOf("rs"),
textmateScope = "source.rust",
- icon = rust,
+ icon = Icon.ResourceIcon(rust),
title = "Rust",
markdownNames = listOf("rust"),
),
@@ -264,19 +303,29 @@ enum class BuiltinFileType(
title = "Pascal",
markdownNames = listOf("pascal"),
),
- ZIG(extensions = listOf("zig"), textmateScope = "source.zig", icon = zig, title = "Zig"),
- NIM(extensions = listOf("nim"), textmateScope = "source.nim", icon = nim, title = "Nim"),
- SWIFT(extensions = listOf("swift"), textmateScope = "source.swift", icon = swift, title = "Swift"),
- DART(extensions = listOf("dart"), textmateScope = "source.dart", icon = dart, title = "Dart"),
+ ZIG(extensions = listOf("zig","zon"), textmateScope = "source.zig", icon = Icon.ResourceIcon(zig), title = "Zig"),
+ NIM(extensions = listOf("nim"), textmateScope = "source.nim", icon = Icon.ResourceIcon(nim), title = "Nim"),
+ SWIFT(
+ extensions = listOf("swift"),
+ textmateScope = "source.swift",
+ icon = Icon.ResourceIcon(swift),
+ title = "Swift",
+ ),
+ DART(extensions = listOf("dart"), textmateScope = "source.dart", icon = Icon.ResourceIcon(dart), title = "Dart"),
ROCQ(extensions = listOf("v", "coq"), textmateScope = "source.coq", icon = null, title = "Rocq (Coq)"),
KOTLIN(
extensions = listOf("kt", "kts"),
textmateScope = "source.kotlin",
- icon = kotlin,
+ icon = Icon.ResourceIcon(kotlin),
title = "Kotlin",
markdownNames = listOf("kotlin"),
),
- LISP(extensions = listOf("lisp", "clisp"), textmateScope = "source.lisp", icon = lisp, title = "Lisp"),
+ LISP(
+ extensions = listOf("lisp", "clisp"),
+ textmateScope = "source.lisp",
+ icon = Icon.ResourceIcon(lisp),
+ title = "Lisp",
+ ),
SHELL(
extensions =
listOf(
@@ -299,15 +348,20 @@ enum class BuiltinFileType(
"ksh",
),
textmateScope = "source.shell",
- icon = shell,
+ icon = Icon.ResourceIcon(shell),
title = "Shell script",
markdownNames = listOf("shell", "console"),
),
- WINDOWS_SHELL(extensions = listOf("cmd", "bat"), textmateScope = "source.batchfile", icon = shell, title = "Batch"),
+ WINDOWS_SHELL(
+ extensions = listOf("cmd", "bat"),
+ textmateScope = "source.batchfile",
+ icon = Icon.ResourceIcon(shell),
+ title = "Batch",
+ ),
POWERSHELL(
extensions = listOf("ps1", "psm1", "psd1"),
textmateScope = "source.powershell",
- icon = powershell,
+ icon = Icon.ResourceIcon(powershell),
title = "PowerShell",
markdownNames = listOf("powershell", "ps"),
),
@@ -317,58 +371,95 @@ enum class BuiltinFileType(
extensions = emptyList(),
names = listOf("cmakelists.txt"),
textmateScope = "source.cmake",
- icon = cmake,
+ icon = Icon.ResourceIcon(cmake),
title = "CMake",
),
- R(extensions = listOf("r"), textmateScope = "source.r", icon = r, title = "R", markdownNames = listOf("r")),
+ R(
+ extensions = listOf("r"),
+ textmateScope = "source.r",
+ icon = Icon.ResourceIcon(r),
+ title = "R",
+ markdownNames = listOf("r"),
+ ),
+ NIX(
+ extensions = listOf("nix"),
+ textmateScope = "source.nix",
+ icon = Icon.ResourceIcon(nix),
+ title = "Nix"
+ ),
// Data Files
- SQL(extensions = listOf("sql", "dsql", "sqllite"), textmateScope = "source.sql", icon = sql, title = "SQL"),
- TOML(extensions = listOf("toml"), textmateScope = "source.toml", icon = toml, title = "TOML"),
- INI(extensions = listOf("ini"), textmateScope = "source.ini", icon = prop, title = "INI"),
+ SQL(
+ extensions = listOf("sql", "dsql", "sqllite"),
+ textmateScope = "source.sql",
+ icon = Icon.ResourceIcon(sql),
+ title = "SQL",
+ ),
+ TOML(extensions = listOf("toml"), textmateScope = "source.toml", icon = Icon.ResourceIcon(toml), title = "TOML"),
+ INI(extensions = listOf("ini"), textmateScope = "source.ini", icon = Icon.ResourceIcon(prop), title = "INI"),
PROPERTIES(
extensions =
listOf("properties", "cfg", "conf", "config", "editorconfig", "gitconfig", "gitmodules", "gitattributes"),
textmateScope = "source.properties",
- icon = prop,
- iconOverride = mapOf("gitmodules" to git, "gitattributes" to git, "gitconfig" to git),
+ icon = Icon.ResourceIcon(prop),
+ iconOverride =
+ mapOf(
+ "gitmodules" to Icon.ResourceIcon(git),
+ "gitattributes" to Icon.ResourceIcon(git),
+ "gitconfig" to Icon.ResourceIcon(git),
+ ),
title = "Properties",
),
IGNORE(
extensions = listOf("gitignore", "gitignore_global", "gitkeep", "git-blame-ignore-revs"),
textmateScope = "source.ignore",
- icon = git,
+ icon = Icon.ResourceIcon(git),
title = "Ignore",
),
- DIFF(extensions = listOf("diff", "patch", "rej"), textmateScope = "source.diff", icon = diff, title = "Diff"),
+ DIFF(
+ extensions = listOf("diff", "patch", "rej"),
+ textmateScope = "source.diff",
+ icon = Icon.ResourceIcon(diff),
+ title = "Diff",
+ ),
// Documents
TEXT(
extensions = listOf("txt"),
textmateScope = null,
- icon = text,
+ icon = Icon.ResourceIcon(text),
title = "Plain text",
markdownNames = listOf("plaintext", "text"),
),
LOG(extensions = listOf("log"), textmateScope = "text.log", icon = null, title = "Log"),
- LATEX(extensions = listOf("latex", "tex", "ltx"), textmateScope = "text.tex.latex", icon = latex, title = "LaTeX"),
+ LATEX(
+ extensions = listOf("latex", "tex", "ltx"),
+ textmateScope = "text.tex.latex",
+ icon = Icon.ResourceIcon(latex),
+ title = "LaTeX",
+ ),
IMAGE(
extensions = listOf("jpg", "jpeg", "png", "gif", "bmp", "tiff", "webp", "ico", "heic", "heif", "avif"),
textmateScope = null,
- icon = image,
+ icon = Icon.ResourceIcon(image),
title = "Image",
),
AUDIO(
extensions = listOf("mp3", "wav", "flac", "ogg", "aac", "m4a", "wma", "opus"),
textmateScope = null,
- icon = audio,
+ icon = Icon.ResourceIcon(audio),
title = "Audio",
),
- VIDEO(extensions = listOf("mp4", "avi", "mov", "mkv", "webm"), textmateScope = null, icon = video, title = "Video"),
+ VIDEO(
+ extensions = listOf("mp4", "avi", "mov", "mkv", "webm"),
+ textmateScope = null,
+ icon = Icon.ResourceIcon(video),
+ title = "Video",
+ ),
ARCHIVE(
extensions = listOf("zip", "rar", "7z", "tar", "gz", "bz2", "xy"),
textmateScope = null,
- icon = archive,
+ icon = Icon.ResourceIcon(archive),
title = "Archive",
),
EXECUTABLE(
@@ -377,6 +468,6 @@ enum class BuiltinFileType(
icon = null,
title = "Executable",
),
- APK(extensions = listOf("apk", "xapk", "apks"), textmateScope = null, icon = apk, title = "APK"),
+ APK(extensions = listOf("apk", "xapk", "apks"), textmateScope = null, icon = Icon.ResourceIcon(apk), title = "APK"),
UNKNOWN(extensions = emptyList(), textmateScope = null, icon = null, title = strings.unknown.getString()),
}
diff --git a/core/main/src/main/java/com/rk/file/FilePermission.kt b/core/main/src/main/java/com/rk/file/FilePermission.kt
index f54dca254..ef15d35ae 100644
--- a/core/main/src/main/java/com/rk/file/FilePermission.kt
+++ b/core/main/src/main/java/com/rk/file/FilePermission.kt
@@ -14,13 +14,15 @@ import androidx.core.net.toUri
import com.rk.resources.getString
import com.rk.resources.strings
import com.rk.settings.Settings
-import com.rk.utils.dialog
-import java.lang.ref.WeakReference
+import com.rk.utils.dialogRes
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
+import java.lang.ref.WeakReference
object FilePermission {
private const val REQUEST_CODE_STORAGE_PERMISSIONS = 1259
+ private var isRequesting = false
+ private var activeActivity = WeakReference(null)
fun onRequestPermissionsResult(
requestCode: Int,
@@ -41,6 +43,11 @@ object FilePermission {
private var dialogRef = WeakReference(null)
fun verifyStoragePermission(activity: Activity) {
+ if (isRequesting && activeActivity.get() == activity) {
+ return
+ }
+ activeActivity = WeakReference(activity)
+
dialogRef.get()?.apply {
if (isShowing) {
dismiss()
@@ -71,17 +78,18 @@ object FilePermission {
}
}
if (shouldAsk) {
- dialog(
- context = activity,
- onDialog = { dialogRef = WeakReference(it) },
+ isRequesting = true
+ dialogRes(
+ activity = activity,
title = strings.manage_storage.getString(),
msg = strings.manage_storage_reason.getString(),
- cancelString = strings.ignore,
- okString = strings.ok,
+ cancelRes = strings.ignore,
+ okRes = strings.ok,
onOk = {
+ isRequesting = false
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
val intent = Intent(ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
- intent.setData("package:${activity.packageName}".toUri())
+ intent.data = "package:${activity.packageName}".toUri()
activity.startActivity(intent)
} else {
// below 11
@@ -94,7 +102,10 @@ object FilePermission {
ActivityCompat.requestPermissions(activity, perms, REQUEST_CODE_STORAGE_PERMISSIONS)
}
},
- onCancel = { Settings.ignore_storage_permission = true },
+ onCancel = {
+ Settings.ignore_storage_permission = true
+ isRequesting = false
+ },
cancelable = false,
)
}
diff --git a/core/main/src/main/java/com/rk/file/UriWrapper.kt b/core/main/src/main/java/com/rk/file/UriWrapper.kt
index 58a0b32d5..cca0ece71 100644
--- a/core/main/src/main/java/com/rk/file/UriWrapper.kt
+++ b/core/main/src/main/java/com/rk/file/UriWrapper.kt
@@ -80,7 +80,7 @@ class UriWrapper : FileObject {
URLDecoder.decode(file.uri.toString(), "UTF-8")
.removePrefix("content://com.termux.documents/tree//data/data/com.termux/files/home/document/")
if (path.startsWith("/data").not()) {
- errorDialog("Converting termux uri into realpath failed: \nURI : ${file.uri}\n\nPATH : $path")
+ errorDialog(msg = "Converting termux uri into realpath failed: \nURI : ${file.uri}\n\nPATH : $path")
}
// dialog(title = "PATH", msg = path, onOk = {})
return File(path)
diff --git a/core/main/src/main/java/com/rk/filetree/FileAction.kt b/core/main/src/main/java/com/rk/filetree/FileAction.kt
index 32d685c31..2c1d134c6 100644
--- a/core/main/src/main/java/com/rk/filetree/FileAction.kt
+++ b/core/main/src/main/java/com/rk/filetree/FileAction.kt
@@ -11,6 +11,7 @@ import androidx.compose.material.icons.outlined.Refresh
import androidx.lifecycle.viewModelScope
import com.rk.activities.main.MainActivity
import com.rk.activities.terminal.Terminal
+import com.rk.drawer.DrawerViewModel
import com.rk.file.FileObject
import com.rk.file.FileOperations
import com.rk.file.FileWrapper
@@ -23,7 +24,6 @@ import com.rk.resources.getString
import com.rk.resources.strings
import com.rk.settings.app.InbuiltFeatures
import com.rk.tabs.editor.EditorTab
-import com.rk.utils.showTerminalNotice
import com.rk.utils.toast
import kotlinx.coroutines.launch
@@ -31,6 +31,7 @@ data class FileActionContext(
val file: FileObject,
val root: FileObject?,
val viewModel: FileTreeViewModel,
+ val drawerViewModel: DrawerViewModel,
val context: Context,
)
@@ -38,6 +39,7 @@ data class MultiFileActionContext(
val files: List,
val root: FileObject?,
val viewModel: FileTreeViewModel,
+ val drawerViewModel: DrawerViewModel,
val context: Context,
)
@@ -103,18 +105,16 @@ object RefreshAction : MultiFileAction() {
}
object TerminalAction : FileAction() {
- override val icon = Icon.DrawableRes(drawables.terminal)
+ override val icon = Icon.ResourceIcon(drawables.terminal)
override val title = strings.open_in_terminal.getString()
override fun action(context: FileActionContext) {
val file = context.file
val context = context.context
- showTerminalNotice(activity = MainActivity.instance!!) {
- val intent = Intent(context, Terminal::class.java)
- intent.putExtra("cwd", file.getAbsolutePath())
- context.startActivity(intent)
- }
+ val intent = Intent(context, Terminal::class.java)
+ intent.putExtra("cwd", file.getAbsolutePath())
+ context.startActivity(intent)
}
override fun isSupported(file: FileObject): Boolean {
@@ -170,7 +170,7 @@ object DeleteAction : MultiFileAction() {
}
object CopyAction : MultiFileAction() {
- override val icon = Icon.DrawableRes(drawables.copy)
+ override val icon = Icon.ResourceIcon(drawables.copy)
override val title = strings.copy.getString()
override fun action(context: MultiFileActionContext) {
@@ -183,7 +183,7 @@ object CopyAction : MultiFileAction() {
}
object CutAction : MultiFileAction() {
- override val icon = Icon.DrawableRes(drawables.cut)
+ override val icon = Icon.ResourceIcon(drawables.cut)
override val title = strings.cut.getString()
override fun action(context: MultiFileActionContext) {
@@ -195,7 +195,7 @@ object CutAction : MultiFileAction() {
}
object PasteAction : FileAction() {
- override val icon = Icon.DrawableRes(drawables.paste)
+ override val icon = Icon.ResourceIcon(drawables.paste)
override val title = strings.paste.getString()
override fun action(context: FileActionContext) {
@@ -240,7 +240,7 @@ object PasteAction : FileAction() {
}
object OpenWithAction : FileAction() {
- override val icon = Icon.DrawableRes(drawables.open_in_new)
+ override val icon = Icon.ResourceIcon(drawables.open_in_new)
override val title = strings.open_with.getString()
override fun action(context: FileActionContext) {
@@ -251,7 +251,7 @@ object OpenWithAction : FileAction() {
}
object SaveAsAction : FileAction() {
- override val icon = Icon.DrawableRes(drawables.file_symlink)
+ override val icon = Icon.ResourceIcon(drawables.file_symlink)
override val title = strings.save_as.getString()
override fun action(context: FileActionContext) {
@@ -262,7 +262,7 @@ object SaveAsAction : FileAction() {
}
object AddFileAction : FileAction() {
- override val icon = Icon.DrawableRes(drawables.arrow_downward)
+ override val icon = Icon.ResourceIcon(drawables.arrow_downward)
override val title = strings.add_file.getString()
override fun action(context: FileActionContext) {
@@ -273,15 +273,16 @@ object AddFileAction : FileAction() {
}
object OpenAsProjectAction : FileAction() {
- override val icon = Icon.DrawableRes(drawables.folder_code)
+ override val icon = Icon.ResourceIcon(drawables.folder_code)
override val title = strings.open_as_project.getString()
override fun action(context: FileActionContext) {
- addProject(context.file, true)
+ context.drawerViewModel.addFileTreeTab(context.file, true)
}
override fun isEnabled(file: FileObject): Boolean {
- return drawerTabs.none { it is FileTreeTab && it.root == file }
+ val drawerViewModel = MainActivity.instance?.drawerViewModel ?: return false
+ return drawerViewModel.drawerTabs.none { it is FileTreeTab && it.root == file }
}
override val type = FileActionType(file = false, folder = true, rootFolder = true)
diff --git a/core/main/src/main/java/com/rk/filetree/FileActionDialogs.kt b/core/main/src/main/java/com/rk/filetree/FileActionDialogs.kt
index 574ac9412..21269f54a 100644
--- a/core/main/src/main/java/com/rk/filetree/FileActionDialogs.kt
+++ b/core/main/src/main/java/com/rk/filetree/FileActionDialogs.kt
@@ -13,6 +13,7 @@ import com.rk.activities.main.MainActivity
import com.rk.activities.main.drawerStateRef
import com.rk.components.PropertiesDialog
import com.rk.components.SingleInputDialog
+import com.rk.drawer.DrawerViewModel
import com.rk.file.FileObject
import com.rk.file.FileOperations
import com.rk.resources.fillPlaceholders
@@ -27,7 +28,12 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Composable
-fun FileActionDialogs(viewModel: FileTreeViewModel, scope: CoroutineScope, context: Context) {
+fun FileActionDialogs(
+ drawerViewModel: DrawerViewModel,
+ viewModel: FileTreeViewModel,
+ scope: CoroutineScope,
+ context: Context,
+) {
if (viewModel.showRenameDialog) {
val file = viewModel.renameFile ?: return
SingleInputDialog(
@@ -91,7 +97,7 @@ fun FileActionDialogs(viewModel: FileTreeViewModel, scope: CoroutineScope, conte
}
if (file == root) {
- removeProject(file, true)
+ drawerViewModel.removeFileTreeTab(file, true)
}
MainActivity.instance?.viewModel?.also { viewModel ->
@@ -180,7 +186,7 @@ fun FileActionDialogs(viewModel: FileTreeViewModel, scope: CoroutineScope, conte
viewModel.updateCache(file)
viewModel.createValue = ""
}
- .onFailure { errorDialog(it) }
+ .onFailure { errorDialog(throwable = it) }
}
},
onFinish = { viewModel.closeCreateDialog() },
@@ -192,7 +198,7 @@ fun FileActionDialogs(viewModel: FileTreeViewModel, scope: CoroutineScope, conte
ProjectCloseConfirmationDialog(
projectName = root.getAppropriateName(),
onConfirm = {
- removeProject(root)
+ drawerViewModel.removeFileTreeTab(root)
viewModel.closeCloseProjectConfirmation()
},
onDismiss = { viewModel.closeCloseProjectConfirmation() },
diff --git a/core/main/src/main/java/com/rk/filetree/FileActionProvider.kt b/core/main/src/main/java/com/rk/filetree/FileActionProvider.kt
new file mode 100644
index 000000000..8b65a5ec9
--- /dev/null
+++ b/core/main/src/main/java/com/rk/filetree/FileActionProvider.kt
@@ -0,0 +1,85 @@
+package com.rk.filetree
+
+import com.rk.extension.api.XedExtensionPoint
+import com.rk.file.FileObject
+
+object FileActionProvider {
+ private val _fileActions =
+ mutableListOf(
+ CloseAction,
+ RefreshAction,
+ TerminalAction,
+ CreateNewFileAction,
+ CreateNewFolderAction,
+ RenameAction,
+ DeleteAction,
+ CopyAction,
+ CutAction,
+ PasteAction,
+ OpenWithAction,
+ SaveAsAction,
+ // AddFileAction,
+ OpenAsProjectAction,
+ PropertiesAction,
+ )
+
+ val fileActions: List
+ get() = _fileActions.toList()
+
+ @XedExtensionPoint
+ fun registerAction(action: BaseFileAction) {
+ if (!_fileActions.contains(action)) {
+ _fileActions.add(action)
+ }
+ }
+
+ @XedExtensionPoint
+ fun unregisterAction(action: BaseFileAction) {
+ _fileActions.remove(action)
+ }
+
+ fun getActions(file: FileObject, root: FileObject?) = getActions(listOf(file), root)
+
+ fun getActions(files: List, root: FileObject?): List {
+ if (files.isEmpty()) return emptyList()
+
+ val suitableActions = mutableListOf()
+
+ fileActions.forEach { action ->
+ val isBulkAction = files.size > 1
+
+ if (action is MultiFileAction) {
+ val isSupported = action.isSupported(files)
+ if (!isSupported) return@forEach
+
+ val hasFolders = files.any { it.isDirectory() }
+ val hasFiles = files.any { it.isFile() }
+ val hasRoot = files.any { it == root }
+
+ val folderValid = if (!hasFolders) true else action.type.folder
+ val fileValid = if (!hasFiles) true else action.type.file
+ val rootValid = if (!hasRoot) true else action.type.rootFolder
+
+ if (folderValid && fileValid && rootValid) {
+ suitableActions.add(action)
+ }
+ } else if (!isBulkAction) {
+ val file = files.first()
+ val action = action as FileAction
+
+ val isSupported = action.isSupported(file)
+ if (!isSupported) return@forEach
+
+ val isRootAction = file == root && action.type.rootFolder
+ val isFileAction = file.isFile() && action.type.file
+ val isFolderAction = file.isDirectory() && action.type.folder
+
+ if (isRootAction || isFileAction || isFolderAction) {
+ suitableActions.add(action)
+ }
+ }
+ }
+
+ return suitableActions
+ }
+}
diff --git a/core/main/src/main/java/com/rk/filetree/FileActionUtils.kt b/core/main/src/main/java/com/rk/filetree/FileActionUtils.kt
deleted file mode 100644
index 202d75842..000000000
--- a/core/main/src/main/java/com/rk/filetree/FileActionUtils.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-package com.rk.filetree
-
-import com.rk.file.FileObject
-
-val fileActions =
- listOf(
- CloseAction,
- RefreshAction,
- TerminalAction,
- CreateNewFileAction,
- CreateNewFolderAction,
- RenameAction,
- DeleteAction,
- CopyAction,
- CutAction,
- PasteAction,
- OpenWithAction,
- SaveAsAction,
- // AddFileAction,
- OpenAsProjectAction,
- PropertiesAction,
- )
-
-fun getActions(file: FileObject, root: FileObject?) = getActions(listOf(file), root)
-
-fun getActions(files: List, root: FileObject?): List {
- if (files.isEmpty()) return emptyList()
-
- val suitableActions = mutableListOf()
-
- fileActions.forEach { action ->
- val isBulkAction = files.size > 1
-
- if (action is MultiFileAction) {
- val isSupported = action.isSupported(files)
- if (!isSupported) return@forEach
-
- val hasFolders = files.any { it.isDirectory() }
- val hasFiles = files.any { it.isFile() }
- val hasRoot = files.any { it == root }
-
- val folderValid = if (!hasFolders) true else action.type.folder
- val fileValid = if (!hasFiles) true else action.type.file
- val rootValid = if (!hasRoot) true else action.type.rootFolder
-
- if (folderValid && fileValid && rootValid) {
- suitableActions.add(action)
- }
- } else if (!isBulkAction) {
- val file = files.first()
- val action = action as FileAction
-
- val isSupported = action.isSupported(file)
- if (!isSupported) return@forEach
-
- val isRootAction = file == root && action.type.rootFolder
- val isFileAction = file.isFile() && action.type.file
- val isFolderAction = file.isDirectory() && action.type.folder
-
- if (isRootAction || isFileAction || isFolderAction) {
- suitableActions.add(action)
- }
- }
- }
-
- return suitableActions
-}
diff --git a/core/main/src/main/java/com/rk/filetree/FileIcon.kt b/core/main/src/main/java/com/rk/filetree/FileIcon.kt
index 4d842cd86..818566836 100644
--- a/core/main/src/main/java/com/rk/filetree/FileIcon.kt
+++ b/core/main/src/main/java/com/rk/filetree/FileIcon.kt
@@ -2,25 +2,22 @@ package com.rk.filetree
import android.content.Context
import android.graphics.drawable.Drawable
-import android.graphics.drawable.PictureDrawable
import androidx.compose.foundation.layout.size
-import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
-import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
-import com.caverock.androidsvg.SVG
import com.rk.file.FileObject
import com.rk.file.FileTypeManager
+import com.rk.icons.Icon
+import com.rk.icons.XedIcon
import com.rk.icons.pack.currentIconPack
import com.rk.icons.rememberSvgImageLoader
import com.rk.resources.drawables
-import com.rk.resources.getDrawable
-import java.io.InputStream
+import com.rk.utils.loadSvg
private val plain_file = drawables.file
private val folder = drawables.folder
@@ -53,18 +50,18 @@ fun FileIcon(file: FileObject, iconTint: Color? = null, isExpanded: Boolean = fa
val icon =
when {
file.isFile() -> getBuiltInFileIcon(file)
- file.isDirectory() -> folder
- file.isSymlink() -> fileSymlink
- else -> unknown
+ file.isDirectory() -> Icon.ResourceIcon(folder)
+ file.isSymlink() -> Icon.ResourceIcon(fileSymlink)
+ else -> Icon.ResourceIcon(unknown)
}
val tint =
iconTint
- ?: if (icon == folder || icon == archive) {
+ ?: if (icon is Icon.ResourceIcon && (icon.drawableRes == folder || icon.drawableRes == archive)) {
MaterialTheme.colorScheme.primary
} else MaterialTheme.colorScheme.secondary
- val useTint = currentIconPack.value?.info?.applyTint == true
+ val useTint = currentIconPack.value?.manifest?.applyTint == true
if (iconPackFile != null) {
AsyncImage(
@@ -75,12 +72,7 @@ fun FileIcon(file: FileObject, iconTint: Color? = null, isExpanded: Boolean = fa
modifier = Modifier.size(20.dp),
)
} else {
- Icon(
- painter = painterResource(id = icon),
- contentDescription = null,
- tint = tint,
- modifier = Modifier.size(20.dp),
- )
+ XedIcon(icon = icon, contentDescription = null, tint = tint, modifier = Modifier.size(20.dp))
}
}
@@ -104,15 +96,15 @@ fun FileNameIcon(fileName: String, isDirectory: Boolean, iconTint: Color? = null
val iconPackFile = currentIconPack.value?.getIconFileForName(fileName, isDirectory, isExpanded)
val imageLoader = rememberSvgImageLoader()
- val icon = if (isDirectory) folder else getBuiltInFileIcon(fileName)
+ val icon = if (isDirectory) Icon.ResourceIcon(folder) else getBuiltInFileIcon(fileName)
val tint =
iconTint
- ?: if (icon == folder || icon == archive) {
+ ?: if (icon is Icon.ResourceIcon && (icon.drawableRes == folder || icon.drawableRes == archive)) {
MaterialTheme.colorScheme.primary
} else MaterialTheme.colorScheme.secondary
- val useTint = currentIconPack.value?.info?.applyTint == true
+ val useTint = currentIconPack.value?.manifest?.applyTint == true
if (iconPackFile != null) {
AsyncImage(
@@ -123,12 +115,7 @@ fun FileNameIcon(fileName: String, isDirectory: Boolean, iconTint: Color? = null
modifier = Modifier.size(20.dp),
)
} else {
- Icon(
- painter = painterResource(id = icon),
- contentDescription = null,
- tint = tint,
- modifier = Modifier.size(20.dp),
- )
+ XedIcon(icon = icon, contentDescription = null, tint = tint, modifier = Modifier.size(20.dp))
}
}
@@ -144,47 +131,30 @@ fun FileNameIcon(fileName: String, isDirectory: Boolean, iconTint: Color? = null
* @param isDirectory Whether the file is a directory.
* @return A [Drawable] representing the file icon.
*/
-fun getDrawableFileIcon(
- context: Context,
- fileName: String,
- isDirectory: Boolean,
- isExpanded: Boolean = false,
-): Drawable? {
- fun loadSvg(inputStream: InputStream): Drawable? {
- val svg =
- try {
- SVG.getFromInputStream(inputStream)
- } catch (_: Exception) {
- return null
- }
-
- val picture = svg.renderToPicture()
- return PictureDrawable(picture)
- }
-
+fun getDrawableFileIcon(fileName: String, isDirectory: Boolean, isExpanded: Boolean = false): Drawable? {
val iconPackFile = currentIconPack.value?.getIconFileForName(fileName, isDirectory, isExpanded)
- val icon = if (isDirectory) folder else getBuiltInFileIcon(fileName)
+ val icon = if (isDirectory) Icon.ResourceIcon(folder) else getBuiltInFileIcon(fileName)
- val builtinIcon = icon.getDrawable(context)
+ val builtinIcon = icon.toDrawable()
val iconPackIcon = iconPackFile?.inputStream()?.let { loadSvg(it) }
return iconPackIcon ?: builtinIcon
}
-private fun getBuiltInFileIcon(fileName: String): Int =
+private fun getBuiltInFileIcon(fileName: String): Icon =
when (fileName) {
"contract.sol",
"LICENSE",
- "NOTICE" -> text
+ "NOTICE" -> Icon.ResourceIcon(text)
"gradlew",
- "gradlew.bat" -> gradle
- "README.md" -> info
+ "gradlew.bat" -> Icon.ResourceIcon(gradle)
+ "README.md" -> Icon.ResourceIcon(info)
else -> {
val ext = fileName.substringAfterLast('.', "")
val type = FileTypeManager.fromExtension(ext)
- type.iconOverride?.get(ext) ?: type.icon ?: plain_file
+ type.iconOverride?.get(ext) ?: type.icon ?: Icon.ResourceIcon(plain_file)
}
}
-private fun getBuiltInFileIcon(file: FileObject): Int = getBuiltInFileIcon(file.getName())
+private fun getBuiltInFileIcon(file: FileObject): Icon = getBuiltInFileIcon(file.getName())
diff --git a/core/main/src/main/java/com/rk/filetree/FileTree.kt b/core/main/src/main/java/com/rk/filetree/FileTree.kt
index 2df55a698..9bc1af37e 100644
--- a/core/main/src/main/java/com/rk/filetree/FileTree.kt
+++ b/core/main/src/main/java/com/rk/filetree/FileTree.kt
@@ -46,6 +46,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewModelScope
import com.rk.activities.main.MainActivity
import com.rk.activities.main.searchViewModel
+import com.rk.drawer.DrawerViewModel
import com.rk.file.FileObject
import com.rk.icons.XedIcon
import com.rk.resources.getString
@@ -67,11 +68,12 @@ fun FileTree(
onFileClick: FileTreeNode.(FileTreeNode) -> Unit,
onSearchClick: () -> Unit = {},
viewModel: FileTreeViewModel,
+ drawerViewModel: DrawerViewModel,
) {
// Auto-expand root node on first composition
LaunchedEffect(rootNode.file) {
- if (!viewModel.isNodeExpanded(rootNode.file)) {
- viewModel.toggleNodeExpansion(rootNode.file)
+ if (!viewModel.isNodeExpanded(rootNode.file, rootNode.file)) {
+ viewModel.toggleNodeExpansion(rootNode.file, rootNode.file)
viewModel.loadChildrenForNode(rootNode)
}
}
@@ -107,7 +109,7 @@ fun FileTree(
modifier = Modifier.weight(1f),
) {
if (viewModel.isAnyFileSelected(rootNode.file)) {
- SelectionActions(viewModel, rootNode)
+ SelectionActions(viewModel, drawerViewModel, rootNode)
} else {
FileTreeActions(viewModel, onSearchClick)
}
@@ -143,9 +145,9 @@ fun FileTree(
}
@Composable
-private fun SelectionActions(viewModel: FileTreeViewModel, rootNode: FileTreeNode) {
+private fun SelectionActions(viewModel: FileTreeViewModel, drawerViewModel: DrawerViewModel, rootNode: FileTreeNode) {
val selectedFiles = viewModel.getSelectedFiles(rootNode.file)
- val actions = remember(selectedFiles, rootNode.file) { getActions(selectedFiles, rootNode.file) }
+ val actions = remember(selectedFiles, rootNode.file) { FileActionProvider.getActions(selectedFiles, rootNode.file) }
var expanded by remember { mutableStateOf(false) }
val context = LocalContext.current
@@ -174,7 +176,8 @@ private fun SelectionActions(viewModel: FileTreeViewModel, rootNode: FileTreeNod
IconButton(
enabled = action.isEnabled(file),
onClick = {
- val context = FileActionContext(file, rootNode.file, viewModel, context)
+ val context =
+ FileActionContext(file, rootNode.file, viewModel, drawerViewModel, context)
action.action(context)
viewModel.unselectAllFiles(rootNode.file)
@@ -187,7 +190,14 @@ private fun SelectionActions(viewModel: FileTreeViewModel, rootNode: FileTreeNod
IconButton(
enabled = action.isEnabled(selectedFiles),
onClick = {
- val context = MultiFileActionContext(selectedFiles, rootNode.file, viewModel, context)
+ val context =
+ MultiFileActionContext(
+ selectedFiles,
+ rootNode.file,
+ viewModel,
+ drawerViewModel,
+ context,
+ )
action.action(context)
viewModel.unselectAllFiles(rootNode.file)
@@ -215,7 +225,14 @@ private fun SelectionActions(viewModel: FileTreeViewModel, rootNode: FileTreeNod
leadingIcon = { XedIcon(action.icon, contentDescription = action.title) },
enabled = action.isEnabled(file),
onClick = {
- val context = FileActionContext(file, rootNode.file, viewModel, context)
+ val context =
+ FileActionContext(
+ file,
+ rootNode.file,
+ viewModel,
+ drawerViewModel,
+ context,
+ )
action.action(context)
viewModel.unselectAllFiles(rootNode.file)
@@ -230,7 +247,13 @@ private fun SelectionActions(viewModel: FileTreeViewModel, rootNode: FileTreeNod
enabled = action.isEnabled(selectedFiles),
onClick = {
val context =
- MultiFileActionContext(selectedFiles, rootNode.file, viewModel, context)
+ MultiFileActionContext(
+ selectedFiles,
+ rootNode.file,
+ viewModel,
+ drawerViewModel,
+ context,
+ )
action.action(context)
viewModel.unselectAllFiles(rootNode.file)
diff --git a/core/main/src/main/java/com/rk/filetree/FileTreeNodeItem.kt b/core/main/src/main/java/com/rk/filetree/FileTreeNodeItem.kt
index 025ecdbc8..8dcc28fda 100644
--- a/core/main/src/main/java/com/rk/filetree/FileTreeNodeItem.kt
+++ b/core/main/src/main/java/com/rk/filetree/FileTreeNodeItem.kt
@@ -55,7 +55,7 @@ fun FileTreeNodeItem(
val isHidden = node.file.getName().startsWith(".")
if (isHidden && !Settings.show_hidden_files_drawer) return
- val isExpanded = viewModel.isNodeExpanded(node.file)
+ val isExpanded = viewModel.isNodeExpanded(root, node.file)
val horizontalPadding = (depth * 16).dp
val isLoading = viewModel.isNodeLoading(node.file)
@@ -101,7 +101,7 @@ fun FileTreeNodeItem(
LaunchedEffect(children, Settings.compact_folders_drawer) {
displayedChildren =
if (Settings.compact_folders_drawer && children.size == 1 && children[0].isDirectory) {
- val collapsedNode = viewModel.collapseNode(node)
+ val collapsedNode = viewModel.collapseNode(root, node)
viewModel.getNodeChildren(collapsedNode)
} else children
displayName =
@@ -123,7 +123,7 @@ fun FileTreeNodeItem(
}
if (node.isDirectory) {
- viewModel.toggleNodeExpansion(node.file)
+ viewModel.toggleNodeExpansion(root, node.file)
return@combinedClickable
}
diff --git a/core/main/src/main/java/com/rk/filetree/FileTreeTab.kt b/core/main/src/main/java/com/rk/filetree/FileTreeTab.kt
index 769cc482f..69b629251 100644
--- a/core/main/src/main/java/com/rk/filetree/FileTreeTab.kt
+++ b/core/main/src/main/java/com/rk/filetree/FileTreeTab.kt
@@ -45,6 +45,7 @@ import com.rk.activities.main.searchViewModel
import com.rk.components.AddDialogItem
import com.rk.components.codeSearchDialog
import com.rk.components.fileSearchDialog
+import com.rk.drawer.DrawerTab
import com.rk.file.FileObject
import com.rk.file.FileWrapper
import com.rk.file.UriWrapper
@@ -78,6 +79,7 @@ class FileTreeTab(val root: FileObject) : DrawerTab() {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val mainViewModel = MainActivity.instance?.viewModel
+ val drawerViewModel = MainActivity.instance?.drawerViewModel
LaunchedEffect(root) {
if (InbuiltFeatures.git.state.value) {
@@ -100,6 +102,7 @@ class FileTreeTab(val root: FileObject) : DrawerTab() {
modifier = Modifier.fillMaxSize().systemBarsPadding(),
rootNode = root.toFileTreeNode(),
viewModel = fileTreeViewModel.get()!!,
+ drawerViewModel = drawerViewModel!!,
onFileClick = { node ->
scope.launch(Dispatchers.IO) {
mainViewModel?.editorManager?.openFile(node.file, projectRoot = root, switchToTab = true)
@@ -249,7 +252,7 @@ class FileTreeTab(val root: FileObject) : DrawerTab() {
drawables.outline_folder
}
- return Icon.DrawableRes(iconId)
+ return Icon.ResourceIcon(iconId)
}
override fun equals(other: Any?): Boolean {
@@ -266,7 +269,8 @@ class FileTreeTab(val root: FileObject) : DrawerTab() {
override fun isSupported(): Boolean {
if (runBlocking { !root.exists() } || !root.isDirectory()) {
- removeProject(this@FileTreeTab, true)
+ val drawerViewModel = MainActivity.instance?.drawerViewModel ?: return false
+ drawerViewModel.removeDrawerTab(this@FileTreeTab, true)
return false
}
return true
diff --git a/core/main/src/main/java/com/rk/filetree/FileTreeViewModel.kt b/core/main/src/main/java/com/rk/filetree/FileTreeViewModel.kt
index a49d3ea6f..4bc6f7199 100644
--- a/core/main/src/main/java/com/rk/filetree/FileTreeViewModel.kt
+++ b/core/main/src/main/java/com/rk/filetree/FileTreeViewModel.kt
@@ -14,6 +14,7 @@ import com.rk.activities.main.searchViewModel
import com.rk.file.FileObject
import com.rk.search.GlobExcluder
import com.rk.settings.Settings
+import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -133,18 +134,19 @@ class FileTreeViewModel : ViewModel() {
private val selectedFiles = mutableStateMapOf>()
private val focusedFile = mutableStateMapOf()
private val fileListCache = mutableStateMapOf>()
- private val expandedNodes = mutableStateMapOf()
+ private val expandedNodes = mutableStateMapOf>()
private val collapsedNameCache = mutableStateMapOf()
private var fileOperationsCount by mutableIntStateOf(0)
private val excluder by derivedStateOf { GlobExcluder(Settings.excluded_files_drawer) }
- fun getExpandedNodes(): Map {
- return mutableMapOf().apply { expandedNodes.forEach { set(it.key, it.value) } }
+ fun getExpandedNodes(): Map> {
+ // Convert to java `Set` to make serialization possible
+ return expandedNodes.mapValues { (_, value) -> HashSet(value) }
}
- fun setExpandedNodes(map: Map) {
- map.forEach { expandedNodes[it.key] = it.value }
+ fun setExpandedNodes(map: Map>) {
+ map.forEach { (key, value) -> expandedNodes[key] = expandedNodes[key]?.plus(value) ?: value }
}
fun toggleSelection(projectRoot: FileObject, fileObject: FileObject) {
@@ -215,7 +217,8 @@ class FileTreeViewModel : ViewModel() {
// Track loading states to avoid showing spinners incorrectly
private val _loadingStates = mutableStateMapOf()
- fun isNodeExpanded(fileObject: FileObject): Boolean = expandedNodes[fileObject] == true
+ fun isNodeExpanded(projectRoot: FileObject, fileObject: FileObject): Boolean =
+ expandedNodes[projectRoot]?.contains(fileObject) ?: false
fun isNodeLoading(fileObject: FileObject): Boolean = _loadingStates[fileObject] == true
@@ -241,12 +244,27 @@ class FileTreeViewModel : ViewModel() {
return diagnosedNodes[fileObject] ?: -1
}
- fun toggleNodeExpansion(fileObject: FileObject) {
- val wasExpanded = expandedNodes[fileObject] == true
- expandedNodes[fileObject] = !wasExpanded
+ fun toggleNodeExpansion(projectRoot: FileObject, fileObject: FileObject) {
+ val wasExpanded = isNodeExpanded(projectRoot, fileObject)
+ if (wasExpanded) {
+ collapseFile(projectRoot, fileObject)
+ } else {
+ expandFile(projectRoot, fileObject)
+ }
+ }
+
+ private fun collapseFile(projectRoot: FileObject, fileObject: FileObject) {
+ expandedNodes[projectRoot] = expandedNodes[projectRoot]?.minus(fileObject) ?: emptySet()
+ if (expandedNodes[projectRoot]?.isEmpty() == true) {
+ expandedNodes.remove(projectRoot)
+ }
+ }
+
+ private fun expandFile(projectRoot: FileObject, fileObject: FileObject) {
+ expandedNodes[projectRoot] = expandedNodes[projectRoot]?.plus(fileObject) ?: setOf(fileObject)
// If we're expanding and haven't loaded yet, trigger a load
- if (!wasExpanded && !fileListCache.containsKey(fileObject)) {
+ if (!fileListCache.containsKey(fileObject)) {
_loadingStates[fileObject] = true
}
}
@@ -255,13 +273,11 @@ class FileTreeViewModel : ViewModel() {
return collapsedNameCache[node.file] ?: node.name
}
- suspend fun collapseNode(node: FileTreeNode): FileTreeNode {
+ suspend fun collapseNode(projectFile: FileObject, node: FileTreeNode): FileTreeNode {
var currentNode = node
var collapsedName = node.name
while (true) {
- if (!isNodeExpanded(currentNode.file)) {
- toggleNodeExpansion(currentNode.file)
- }
+ expandFile(projectFile, currentNode.file)
loadChildrenForNodeSynchronous(currentNode)
val children = getNodeChildren(currentNode)
if (children.size != 1) {
@@ -302,11 +318,8 @@ class FileTreeViewModel : ViewModel() {
fileListCache[file] = sortedFiles
- viewModelScope.launch {
- delay(300)
- _loadingStates[file] = false
- }
- } catch (e: Exception) {
+ viewModelScope.launch { clearLoadingState(file) }
+ } catch (_: Exception) {
_loadingStates[file] = false
}
}
@@ -317,22 +330,23 @@ class FileTreeViewModel : ViewModel() {
suspend fun goToFolder(projectFile: FileObject, fileObject: FileObject) {
focusedFile[projectFile] = fileObject
viewModelScope.launch {
- delay(1000)
+ delay(1000.milliseconds)
focusedFile.remove(projectFile)
}
var currentFile: FileObject? = fileObject
while (currentFile != null && currentFile != projectFile) {
- expandedNodes[currentFile] = true
+ expandFile(projectFile, currentFile)
// If we're expanding and haven't loaded yet, trigger a load
- if (!fileListCache.containsKey(fileObject)) {
+ if (!fileListCache.containsKey(currentFile)) {
_loadingStates[currentFile] = true
}
currentFile = currentFile.getParentFile()
}
- expandedNodes[projectFile] = true
+
+ expandFile(projectFile, projectFile)
}
suspend fun refreshEverything() =
@@ -367,10 +381,7 @@ class FileTreeViewModel : ViewModel() {
val sortedFiles = sortAndFilterFiles(fileList)
fileListCache[node.file] = sortedFiles
- viewModelScope.launch {
- delay(300)
- _loadingStates[node.file] = false
- }
+ viewModelScope.launch { clearLoadingState(node.file) }
} catch (_: Exception) {
_loadingStates[node.file] = false
}
@@ -401,15 +412,18 @@ class FileTreeViewModel : ViewModel() {
val sortedFiles = sortAndFilterFiles(fileList)
fileListCache[node.file] = sortedFiles
- viewModelScope.launch {
- delay(300)
- _loadingStates[node.file] = false
- }
+ viewModelScope.launch { clearLoadingState(node.file) }
} catch (_: Exception) {
_loadingStates[node.file] = false
}
}
+ private suspend fun clearLoadingState(file: FileObject) {
+ // Use delay to avoid flickering
+ delay(300.milliseconds)
+ _loadingStates[file] = false
+ }
+
private suspend fun calculateFileSizes(fileObjects: List): Map {
val fileSizes = mutableMapOf()
if (sortMode != SortMode.SORT_BY_SIZE) return fileSizes
diff --git a/core/main/src/main/java/com/rk/git/GitTab.kt b/core/main/src/main/java/com/rk/git/GitTab.kt
index c02214263..b7c03e51f 100644
--- a/core/main/src/main/java/com/rk/git/GitTab.kt
+++ b/core/main/src/main/java/com/rk/git/GitTab.kt
@@ -66,11 +66,10 @@ import com.rk.activities.main.fileTreeViewModel
import com.rk.components.SingleInputDialog
import com.rk.components.compose.utils.addIf
import com.rk.components.getDrawerWidth
+import com.rk.drawer.DrawerTab
import com.rk.file.toFileWrapper
-import com.rk.filetree.DrawerTab
import com.rk.filetree.FileNameIcon
import com.rk.filetree.FileTreeTab
-import com.rk.filetree.currentDrawerTab
import com.rk.icons.Icon
import com.rk.resources.drawables
import com.rk.resources.getString
@@ -655,12 +654,13 @@ class GitTab(val viewModel: GitViewModel) : DrawerTab() {
}
override fun getIcon(): Icon {
- return Icon.DrawableRes(drawables.git)
+ return Icon.ResourceIcon(drawables.git)
}
override fun isSupported(): Boolean {
if (!InbuiltFeatures.git.state.value) return false
- val tab = currentDrawerTab ?: return false
+ val drawerViewModel = MainActivity.instance?.drawerViewModel ?: return false
+ val tab = drawerViewModel.currentDrawerTab ?: return false
if (tab !is FileTreeTab) return false
val rootDir = File(tab.root.getAbsolutePath())
diff --git a/core/main/src/main/java/com/rk/icons/Icon.kt b/core/main/src/main/java/com/rk/icons/Icon.kt
index a39a4393e..19a9962fd 100644
--- a/core/main/src/main/java/com/rk/icons/Icon.kt
+++ b/core/main/src/main/java/com/rk/icons/Icon.kt
@@ -1,14 +1,34 @@
package com.rk.icons
+import android.content.res.Resources
+import android.graphics.drawable.Drawable
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.core.content.res.ResourcesCompat
+import com.rk.utils.application
+import com.rk.utils.loadSvg
import java.io.File
sealed class Icon {
- class DrawableRes(@androidx.annotation.DrawableRes val drawableRes: Int) : Icon()
+ class ResourceIcon(@androidx.annotation.DrawableRes val drawableRes: Int) : Icon()
+
+ class ExternalResourceIcon(@androidx.annotation.DrawableRes val drawableRes: Int, val resources: Resources) : Icon()
class VectorIcon(val vector: ImageVector) : Icon()
class SvgIcon(val file: File) : Icon()
class TextIcon(val text: String) : Icon()
+
+ fun toDrawable(): Drawable? {
+ return when (this) {
+ is ResourceIcon -> {
+ ResourcesCompat.getDrawable(application!!.resources, drawableRes, null)
+ }
+ is ExternalResourceIcon -> {
+ ResourcesCompat.getDrawable(resources, drawableRes, null)
+ }
+ is SvgIcon -> loadSvg(file.inputStream())
+ else -> null
+ }
+ }
}
diff --git a/core/main/src/main/java/com/rk/icons/QuestionMarkRound.kt b/core/main/src/main/java/com/rk/icons/QuestionMarkRound.kt
new file mode 100644
index 000000000..621a31b1e
--- /dev/null
+++ b/core/main/src/main/java/com/rk/icons/QuestionMarkRound.kt
@@ -0,0 +1,109 @@
+package com.rk.icons
+
+/*
+ISC License
+
+Copyright (c) 2026 Lucide Icons and Contributors
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+---
+
+The following Lucide icons are derived from the Feather project:
+
+airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
+
+The MIT License (MIT) (for the icons listed above)
+
+Copyright (c) 2013-present Cole Bemis
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.StrokeCap
+import androidx.compose.ui.graphics.StrokeJoin
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.path
+import androidx.compose.ui.unit.dp
+
+val LucideCircleQuestionMark: ImageVector
+ get() {
+ if (_LucideCircleQuestionMark != null) return _LucideCircleQuestionMark!!
+
+ _LucideCircleQuestionMark =
+ ImageVector.Builder(
+ name = "circle-question-mark",
+ defaultWidth = 24.dp,
+ defaultHeight = 24.dp,
+ viewportWidth = 24f,
+ viewportHeight = 24f,
+ )
+ .apply {
+ path(
+ fill = SolidColor(Color.Transparent),
+ stroke = SolidColor(Color.Black),
+ strokeLineWidth = 2f,
+ strokeLineCap = StrokeCap.Round,
+ strokeLineJoin = StrokeJoin.Round,
+ ) {
+ moveTo(22f, 12f)
+ arcTo(10f, 10f, 0f, false, true, 12f, 22f)
+ arcTo(10f, 10f, 0f, false, true, 2f, 12f)
+ arcTo(10f, 10f, 0f, false, true, 22f, 12f)
+ close()
+ }
+ path(
+ fill = SolidColor(Color.Transparent),
+ stroke = SolidColor(Color.Black),
+ strokeLineWidth = 2f,
+ strokeLineCap = StrokeCap.Round,
+ strokeLineJoin = StrokeJoin.Round,
+ ) {
+ moveTo(9.09f, 9f)
+ arcToRelative(3f, 3f, 0f, false, true, 5.83f, 1f)
+ curveToRelative(0f, 2f, -3f, 3f, -3f, 3f)
+ }
+ path(
+ fill = SolidColor(Color.Transparent),
+ stroke = SolidColor(Color.Black),
+ strokeLineWidth = 2f,
+ strokeLineCap = StrokeCap.Round,
+ strokeLineJoin = StrokeJoin.Round,
+ ) {
+ moveTo(12f, 17f)
+ horizontalLineToRelative(0.01f)
+ }
+ }
+ .build()
+
+ return _LucideCircleQuestionMark!!
+ }
+
+private var _LucideCircleQuestionMark: ImageVector? = null
diff --git a/core/main/src/main/java/com/rk/icons/XedIcon.kt b/core/main/src/main/java/com/rk/icons/XedIcon.kt
index 54b9393d3..51acfe3f5 100644
--- a/core/main/src/main/java/com/rk/icons/XedIcon.kt
+++ b/core/main/src/main/java/com/rk/icons/XedIcon.kt
@@ -8,8 +8,10 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.sp
import coil.ImageLoader
@@ -24,7 +26,7 @@ fun XedIcon(
tint: Color = LocalContentColor.current,
) {
when (icon) {
- is Icon.DrawableRes -> {
+ is Icon.ResourceIcon -> {
Icon(
painter = painterResource(icon.drawableRes),
contentDescription = contentDescription,
@@ -33,6 +35,17 @@ fun XedIcon(
)
}
+ is Icon.ExternalResourceIcon -> {
+ val theme = LocalContext.current.theme
+
+ Icon(
+ imageVector = ImageVector.vectorResource(theme = theme, resId = icon.drawableRes, res = icon.resources),
+ contentDescription = contentDescription,
+ modifier = modifier,
+ tint = tint,
+ )
+ }
+
is Icon.VectorIcon -> {
Icon(imageVector = icon.vector, contentDescription = contentDescription, modifier = modifier, tint = tint)
}
diff --git a/core/main/src/main/java/com/rk/icons/pack/IconPack.kt b/core/main/src/main/java/com/rk/icons/pack/IconPack.kt
index a7ddba28d..ecdfdd766 100644
--- a/core/main/src/main/java/com/rk/icons/pack/IconPack.kt
+++ b/core/main/src/main/java/com/rk/icons/pack/IconPack.kt
@@ -11,7 +11,13 @@ typealias IconPackId = String
typealias IconPackPath = String
@Serializable
-data class IconPackInfo(val id: IconPackId, val name: String, val applyTint: Boolean = false, val icons: IconPackList)
+data class IconPackManifest(
+ val id: IconPackId,
+ val name: String,
+ val minAppVersion: Int? = null,
+ val applyTint: Boolean = false,
+ val icons: IconPackList,
+)
@Serializable
data class IconPackList(
@@ -25,7 +31,7 @@ data class IconPackList(
val languageNames: Map = emptyMap(),
)
-data class IconPack(val info: IconPackInfo, val installDir: File) {
+data class IconPack(val manifest: IconPackManifest, val installDir: File) {
fun getIconFileForFile(file: FileObject, isExpanded: Boolean = false): File? {
val fileName = file.getName()
val isDirectory = file.isDirectory()
@@ -37,26 +43,27 @@ data class IconPack(val info: IconPackInfo, val installDir: File) {
if (isDirectory) {
if (isExpanded) {
// First use folderNamesExpanded, then defaultFolderExpanded
- info.icons.folderNamesExpanded[fileName.lowercase()]
+ manifest.icons.folderNamesExpanded[fileName.lowercase()]
?.let { installDir.resolve(it) }
- ?.takeIf { it.exists() } ?: installDir.resolve(info.icons.defaultFolderExpanded)
+ ?.takeIf { it.exists() } ?: installDir.resolve(manifest.icons.defaultFolderExpanded)
} else {
// First use folderNames, then defaultFolder
- info.icons.folderNames[fileName.lowercase()]?.let { installDir.resolve(it) }?.takeIf { it.exists() }
- ?: installDir.resolve(info.icons.defaultFolder)
+ manifest.icons.folderNames[fileName.lowercase()]
+ ?.let { installDir.resolve(it) }
+ ?.takeIf { it.exists() } ?: installDir.resolve(manifest.icons.defaultFolder)
}
} else {
// First use fileNames, then fileExtensions, then languageNames, then defaultFile
val ext = fileName.substringAfterLast(".", "")
- info.icons.fileNames[fileName.lowercase()]?.let { installDir.resolve(it) }?.takeIf { it.exists() }
- ?: info.icons.fileExtensions[ext.lowercase()]
+ manifest.icons.fileNames[fileName.lowercase()]?.let { installDir.resolve(it) }?.takeIf { it.exists() }
+ ?: manifest.icons.fileExtensions[ext.lowercase()]
?.let { installDir.resolve(it) }
?.takeIf { it.exists() }
- ?: info.icons.languageNames[FileTypeManager.fromExtension(ext).name.lowercase()]
+ ?: manifest.icons.languageNames[FileTypeManager.fromExtension(ext).name.lowercase()]
?.let { installDir.resolve(it) }
?.takeIf { it.exists() }
- ?: installDir.resolve(info.icons.defaultFile)
+ ?: installDir.resolve(manifest.icons.defaultFile)
}
// If no icon was working (even the fallback ones)
@@ -68,11 +75,13 @@ data class IconPack(val info: IconPackInfo, val installDir: File) {
fun getIconFileForExt(fileExtension: String): File? {
val path =
// First use fileExtensions, then languageNames, then defaultFile
- info.icons.fileExtensions[fileExtension.lowercase()]?.let { installDir.resolve(it) }?.takeIf { it.exists() }
- ?: info.icons.languageNames[FileTypeManager.fromExtension(fileExtension).name.lowercase()]
+ manifest.icons.fileExtensions[fileExtension.lowercase()]
+ ?.let { installDir.resolve(it) }
+ ?.takeIf { it.exists() }
+ ?: manifest.icons.languageNames[FileTypeManager.fromExtension(fileExtension).name.lowercase()]
?.let { installDir.resolve(it) }
?.takeIf { it.exists() }
- ?: installDir.resolve(info.icons.defaultFile)
+ ?: installDir.resolve(manifest.icons.defaultFile)
// If no icon was working (even the fallback ones)
if (!path.exists()) return null
@@ -86,9 +95,9 @@ data class IconPack(val info: IconPackInfo, val installDir: File) {
val path =
// First use fileExtensions, then languageNames, then defaultFile
- extension?.let { info.icons.fileExtensions[it] }?.let { installDir.resolve(it) }?.takeIf { it.exists() }
- ?: info.icons.languageNames[typeName]?.let { installDir.resolve(it) }?.takeIf { it.exists() }
- ?: installDir.resolve(info.icons.defaultFile)
+ extension?.let { manifest.icons.fileExtensions[it] }?.let { installDir.resolve(it) }?.takeIf { it.exists() }
+ ?: manifest.icons.languageNames[typeName]?.let { installDir.resolve(it) }?.takeIf { it.exists() }
+ ?: installDir.resolve(manifest.icons.defaultFile)
// If no icon was working (even the fallback ones)
if (!path.exists()) return null
diff --git a/core/main/src/main/java/com/rk/icons/pack/IconPackManager.kt b/core/main/src/main/java/com/rk/icons/pack/IconPackManager.kt
index 85053441d..000e6e876 100644
--- a/core/main/src/main/java/com/rk/icons/pack/IconPackManager.kt
+++ b/core/main/src/main/java/com/rk/icons/pack/IconPackManager.kt
@@ -3,6 +3,7 @@ package com.rk.icons.pack
import android.app.Application
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
+import androidx.core.content.pm.PackageInfoCompat
import com.rk.activities.settings.SettingsActivity
import com.rk.file.child
import com.rk.file.createDirIfNot
@@ -11,7 +12,8 @@ import com.rk.resources.getFilledString
import com.rk.resources.getString
import com.rk.resources.strings
import com.rk.settings.Settings
-import com.rk.utils.dialog
+import com.rk.utils.application
+import com.rk.utils.dialogRes
import java.io.File
import java.util.zip.ZipFile
import kotlinx.coroutines.Dispatchers
@@ -53,24 +55,43 @@ class IconPackManager(private val context: Application) {
}
private fun installIconPackFromDir(dir: File) {
- val iconPackInfo = validateIconPack(dir) ?: return
+ val iconPackManifest = validateIconPack(dir) ?: return
+
+ val packageName = application!!.packageName
+ val packageManager = application!!.packageManager
+ val currentVersionCode = PackageInfoCompat.getLongVersionCode(packageManager.getPackageInfo(packageName, 0))
+ if (iconPackManifest.minAppVersion != null && iconPackManifest.minAppVersion.toLong() > currentVersionCode) {
+ dialogRes(
+ activity = SettingsActivity.instance,
+ title = strings.warning.getString(),
+ msg = strings.incompatible_theme_warning.getString(),
+ cancelRes = strings.cancel,
+ okRes = strings.continue_action,
+ onOk = { writeIconPackToDisk(iconPackManifest, dir) },
+ )
+ return
+ }
+
+ writeIconPackToDisk(iconPackManifest, dir)
+ }
- val installDir = iconPackDir.child(iconPackInfo.id)
+ private fun writeIconPackToDisk(iconPackManifest: IconPackManifest, dir: File) {
+ val installDir = iconPackDir.child(iconPackManifest.id)
if (installDir.exists()) {
- uninstallIconPack(iconPackInfo.id)
+ uninstallIconPack(iconPackManifest.id)
}
dir.copyRecursively(installDir, overwrite = true)
- val iconPack = IconPack(iconPackInfo, installDir)
- iconPacks[iconPackInfo.id] = iconPack
+ val iconPack = IconPack(iconPackManifest, installDir)
+ iconPacks[iconPackManifest.id] = iconPack
}
@OptIn(ExperimentalSerializationApi::class)
- internal fun validateIconPack(dir: File): IconPackInfo? {
+ internal fun validateIconPack(dir: File): IconPackManifest? {
val iconPackJson = dir.resolve("manifest.json")
if (!iconPackJson.exists()) {
- dialog(
+ dialogRes(
SettingsActivity.instance,
strings.icon_pack_install_failed.getString(),
strings.manifest_missing.getString(),
@@ -79,12 +100,12 @@ class IconPackManager(private val context: Application) {
return null
}
- val iconPackInfo =
- runCatching { json.decodeFromString(iconPackJson.readText()) }
+ val iconPackManifest =
+ runCatching { json.decodeFromString(iconPackJson.readText()) }
.getOrElse { e ->
if (e is MissingFieldException) {
val fields = e.missingFields.joinToString("\n") { "• $it" }
- dialog(
+ dialogRes(
SettingsActivity.instance,
strings.icon_pack_install_failed.getString(),
strings.manifest_missing_fields.getFilledString(fields),
@@ -92,7 +113,7 @@ class IconPackManager(private val context: Application) {
)
return null
}
- dialog(
+ dialogRes(
SettingsActivity.instance,
strings.icon_pack_install_failed.getString(),
e.localizedMessage ?: strings.unknown_err.getString(),
@@ -101,7 +122,7 @@ class IconPackManager(private val context: Application) {
return null
}
- return iconPackInfo
+ return iconPackManifest
}
fun uninstallIconPack(iconPackId: IconPackId) {
@@ -118,10 +139,10 @@ class IconPackManager(private val context: Application) {
val manifestJson = dir.resolve("manifest.json")
if (manifestJson.exists()) {
runCatching {
- val iconPackInfo = json.decodeFromString(manifestJson.readText())
- val installDir = iconPackDir.child(iconPackInfo.id)
- val iconPack = IconPack(iconPackInfo, installDir)
- iconPacks[iconPackInfo.id] = iconPack
+ val iconPackManifest = json.decodeFromString(manifestJson.readText())
+ val installDir = iconPackDir.child(iconPackManifest.id)
+ val iconPack = IconPack(iconPackManifest, installDir)
+ iconPacks[iconPackManifest.id] = iconPack
}
}
}
diff --git a/core/main/src/main/java/com/rk/lsp/AndroidProcessConnection.kt b/core/main/src/main/java/com/rk/lsp/AndroidProcessConnection.kt
new file mode 100644
index 000000000..244b1d685
--- /dev/null
+++ b/core/main/src/main/java/com/rk/lsp/AndroidProcessConnection.kt
@@ -0,0 +1,93 @@
+package com.rk.lsp
+
+import android.util.Log
+import com.rk.file.localBinDir
+import com.rk.file.localLibDir
+import java.io.File
+import java.io.InputStream
+import java.io.OutputStream
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+
+class AndroidProcessConnection(private val cmd: Array, instance: LspServerInstance) :
+ BaseLspConnectionProvider(instance) {
+
+ private var process: Process? = null
+ private var loggingInput: InputStream? = null
+ private var loggingOutput: OutputStream? = null
+
+ private var scope: CoroutineScope? = null
+
+ override val inputStream: InputStream
+ get() = loggingInput ?: throw IllegalStateException("Process not running")
+
+ override val outputStream: OutputStream
+ get() = loggingOutput ?: throw IllegalStateException("Process not running")
+
+ override val isClosed: Boolean
+ get() = process == null || process?.isAlive == false
+
+ override fun start() {
+ if (process != null) return
+ scope = CoroutineScope(Dispatchers.IO)
+
+ val pb = ProcessBuilder(*cmd)
+
+ val rootPath = instance.projectRoot.getAbsolutePath()
+ val rootFile = File(rootPath)
+ if (rootFile.exists() && rootFile.isDirectory) {
+ pb.directory(rootFile)
+ }
+
+ pb.environment().apply {
+ put("ANDROID_ART_ROOT", System.getenv("ANDROID_ART_ROOT").orEmpty())
+ put("ANDROID_DATA", System.getenv("ANDROID_DATA").orEmpty())
+ put("ANDROID_I18N_ROOT", System.getenv("ANDROID_I18N_ROOT").orEmpty())
+ put("ANDROID_ROOT", System.getenv("ANDROID_ROOT").orEmpty())
+ put("ANDROID_RUNTIME_ROOT", System.getenv("ANDROID_RUNTIME_ROOT").orEmpty())
+ put("ANDROID_TZDATA_ROOT", System.getenv("ANDROID_TZDATA_ROOT").orEmpty())
+ put("BOOTCLASSPATH", System.getenv("BOOTCLASSPATH").orEmpty())
+ put("DEX2OATBOOTCLASSPATH", System.getenv("DEX2OATBOOTCLASSPATH").orEmpty())
+ put("EXTERNAL_STORAGE", System.getenv("EXTERNAL_STORAGE").orEmpty())
+ put("PATH", "${System.getenv("PATH")}:${localBinDir().absolutePath}")
+ put("LD_LIBRARY_PATH", localLibDir().absolutePath)
+ }
+
+ process = pb.start()
+
+ loggingInput =
+ LoggingInputStream(process!!.inputStream) { json ->
+ Log.d("AndroidProcessConnection", "[stdout] $json")
+ if (com.rk.settings.Preference.getBoolean("debug_mode", com.rk.xededitor.BuildConfig.DEBUG)) {
+ instance.addLog(LspLogEntry(MessageSource.RPC, null, "→ $json"))
+ }
+ }
+ loggingOutput =
+ LoggingOutputStream(process!!.outputStream) { json ->
+ Log.d("AndroidProcessConnection", "[stdin] $json")
+ if (com.rk.settings.Preference.getBoolean("debug_mode", com.rk.xededitor.BuildConfig.DEBUG)) {
+ instance.addLog(LspLogEntry(MessageSource.RPC, null, "← $json"))
+ }
+ }
+
+ scope!!.launch {
+ runCatching {
+ process!!.errorStream.bufferedReader().forEachLine { line ->
+ Log.e("AndroidProcessConnection", "[stderr] $line")
+ instance.addLog(LspLogEntry(MessageSource.Runtime, null, line))
+ }
+ }
+ }
+ }
+
+ override fun close() {
+ scope?.cancel()
+ scope = null
+ process?.destroy()
+ process = null
+ loggingInput = null
+ loggingOutput = null
+ }
+}
diff --git a/core/main/src/main/java/com/rk/lsp/FileIconProvider.kt b/core/main/src/main/java/com/rk/lsp/FileIconProvider.kt
index d93b51194..2eed053fa 100644
--- a/core/main/src/main/java/com/rk/lsp/FileIconProvider.kt
+++ b/core/main/src/main/java/com/rk/lsp/FileIconProvider.kt
@@ -1,7 +1,6 @@
package com.rk.lsp
import android.graphics.drawable.Drawable
-import com.rk.activities.main.MainActivity
import com.rk.filetree.getDrawableFileIcon
import io.github.rosemoe.sora.lang.completion.SimpleCompletionIconDrawer
@@ -29,7 +28,6 @@ class FileIconProvider : io.github.rosemoe.sora.lang.completion.FileIconProvider
* @return A [Drawable] if successful, or null if no icon can be loaded.
*/
override fun load(src: String, isFolder: Boolean): Drawable? {
- val context = MainActivity.instance ?: return null
- return getDrawableFileIcon(context, src.substringAfterLast('/'), isFolder)
+ return getDrawableFileIcon(src.substringAfterLast('/'), isFolder)
}
}
diff --git a/core/main/src/main/java/com/rk/lsp/LspConnectionConfig.kt b/core/main/src/main/java/com/rk/lsp/LspConnectionConfig.kt
index 624fc7f39..c41a32ebb 100644
--- a/core/main/src/main/java/com/rk/lsp/LspConnectionConfig.kt
+++ b/core/main/src/main/java/com/rk/lsp/LspConnectionConfig.kt
@@ -32,6 +32,23 @@ sealed interface LspConnectionConfig {
}
}
+ data class AndroidProcess(val command: Array) : LspConnectionConfig {
+ override fun providerFactory() = ConnectionProviderFactory { AndroidProcessConnection(command, it) }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as AndroidProcess
+
+ return command.contentEquals(other.command)
+ }
+
+ override fun hashCode(): Int {
+ return command.contentHashCode()
+ }
+ }
+
data class Custom(val provider: ConnectionProviderFactory) : LspConnectionConfig {
override fun providerFactory() = provider
}
diff --git a/core/main/src/main/java/com/rk/lsp/LspConnector.kt b/core/main/src/main/java/com/rk/lsp/LspConnector.kt
index 54130d86c..cd4ae5279 100644
--- a/core/main/src/main/java/com/rk/lsp/LspConnector.kt
+++ b/core/main/src/main/java/com/rk/lsp/LspConnector.kt
@@ -13,9 +13,9 @@ import com.rk.resources.getString
import com.rk.resources.strings
import com.rk.settings.Preference
import com.rk.tabs.editor.EditorTab
-import com.rk.utils.dialog
+import com.rk.utils.dialogRes
import com.rk.utils.errorDialog
-import com.rk.utils.info
+import com.rk.utils.logInfo
import io.github.rosemoe.sora.langs.textmate.TextMateLanguage
import io.github.rosemoe.sora.lsp.client.languageserver.LspFeature
import io.github.rosemoe.sora.lsp.client.languageserver.ServerStatus
@@ -130,7 +130,7 @@ class LspConnector(
suspend fun connect(wrapperLanguage: TextMateLanguage?) =
withContext(Dispatchers.IO) {
if (isConnected()) {
- info("LSP servers already connected skipping...")
+ logInfo("LSP servers already connected skipping...")
return@withContext
}
@@ -233,6 +233,9 @@ class LspConnector(
override val disabledFeatures: Set
get() = buildSet {
+ if (!Preference.getBoolean("lsp_${id}_document_highlight", true)) {
+ add(LspFeature.DocumentHighlight)
+ }
if (!Preference.getBoolean("lsp_${id}_hover", true)) {
add(LspFeature.Hover)
}
@@ -262,24 +265,30 @@ class LspConnector(
get() =
object : EventHandler.EventListener {
override fun onEventException(eventListener: AsyncEventListener, exception: Exception) {
- instance.addLog(LspLogEntry(MessageType.Error, "Event ${eventListener.eventName} failed"))
+ instance.addLog(
+ LspLogEntry(
+ MessageSource.Client,
+ MessageType.Error,
+ "Event ${eventListener.eventName} failed",
+ )
+ )
exception.localizedMessage?.let { message ->
- instance.addLog(LspLogEntry(MessageType.Error, message))
+ instance.addLog(LspLogEntry(MessageSource.Client, MessageType.Error, message))
}
}
override fun onHandlerException(exception: Exception) {
exception.cause?.localizedMessage?.let { message ->
- instance.addLog(LspLogEntry(MessageType.Error, message))
+ instance.addLog(LspLogEntry(MessageSource.Client, MessageType.Error, message))
}
exception.localizedMessage?.let { message ->
- instance.addLog(LspLogEntry(MessageType.Error, message))
+ instance.addLog(LspLogEntry(MessageSource.Client, MessageType.Error, message))
}
}
override fun onLogMessage(messageParams: MessageParams?) {
if (messageParams == null) return
- info(messageParams.message)
+ logInfo(messageParams.message)
instance.addLog(messageParams)
}
@@ -287,14 +296,14 @@ class LspConnector(
if (messageParams == null) return
instance.addLog(messageParams)
when (messageParams.type) {
- MessageType.Error -> errorDialog(messageParams.message)
+ MessageType.Error -> errorDialog(msg = messageParams.message)
MessageType.Warning ->
- dialog(title = strings.warning.getString(), msg = messageParams.message)
+ dialogRes(title = strings.warning.getString(), msg = messageParams.message)
MessageType.Info ->
- dialog(title = strings.info.getString(), msg = messageParams.message)
+ dialogRes(title = strings.info.getString(), msg = messageParams.message)
- MessageType.Log -> info(messageParams.message)
+ MessageType.Log -> logInfo(messageParams.message)
MessageType.Debug -> {}
}
}
@@ -322,7 +331,7 @@ class LspConnector(
is ServerStatus.STOPPED ->
"Disconnected from LSP server (reason: ${newStatus.reason})\n"
}
- instance.addLog(LspLogEntry(MessageType.Info, statusMessage))
+ instance.addLog(LspLogEntry(MessageSource.Client, MessageType.Info, statusMessage))
if (
oldStatus is ServerStatus.STOPPED &&
diff --git a/core/main/src/main/java/com/rk/lsp/LspRegistry.kt b/core/main/src/main/java/com/rk/lsp/LspRegistry.kt
index ebd723482..89709ff72 100644
--- a/core/main/src/main/java/com/rk/lsp/LspRegistry.kt
+++ b/core/main/src/main/java/com/rk/lsp/LspRegistry.kt
@@ -2,12 +2,11 @@ package com.rk.lsp
import android.content.Context
import androidx.compose.runtime.mutableStateListOf
+import com.rk.extension.api.XedExtensionPoint
import com.rk.lsp.servers.Bash
import com.rk.lsp.servers.CSS
import com.rk.lsp.servers.Emmet
import com.rk.lsp.servers.HTML
-import com.rk.lsp.servers.JSON
-import com.rk.lsp.servers.Python
import com.rk.lsp.servers.TypeScript
import com.rk.lsp.servers.XML
@@ -16,7 +15,7 @@ object LspRegistry {
val extensionServers: List
get() = _extensionServers.toList()
- val builtInServer = listOf(Python, HTML, Emmet, CSS, TypeScript, JSON, Bash, XML)
+ val builtInServer = listOf(HTML, Emmet, CSS, TypeScript, Bash, XML)
private val _externalServers = mutableStateListOf()
val externalServers: List
@@ -57,12 +56,14 @@ object LspRegistry {
?: _extensionServers.find { it.id == id }
}
+ @XedExtensionPoint
fun registerServer(server: LspServer) {
if (!_extensionServers.contains(server)) {
_extensionServers.add(server)
}
}
+ @XedExtensionPoint
fun unregisterServer(server: LspServer) {
_extensionServers.remove(server)
}
diff --git a/core/main/src/main/java/com/rk/lsp/LspServer.kt b/core/main/src/main/java/com/rk/lsp/LspServer.kt
index 828b750d2..7db1601e3 100644
--- a/core/main/src/main/java/com/rk/lsp/LspServer.kt
+++ b/core/main/src/main/java/com/rk/lsp/LspServer.kt
@@ -1,5 +1,6 @@
package com.rk.lsp
+import android.app.Activity
import android.content.Context
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@@ -9,6 +10,7 @@ import com.rk.activities.main.MainActivity
import com.rk.exec.TerminalCommand
import com.rk.exec.launchTerminal
import com.rk.file.FileObject
+import com.rk.icons.Icon
import com.rk.tabs.editor.EditorTab
import com.rk.tabs.editor.applyHighlightingAndConnectLSP
import com.rk.theme.greenStatus
@@ -21,15 +23,15 @@ abstract class ScriptedLspServer : LspServer() {
abstract val installScript: File
abstract val installId: String
- override fun install(context: Context) = launchInstaller(context)
+ override fun install(activity: Activity) = launchInstaller(activity)
- override fun uninstall(context: Context) = launchInstaller(context, "--uninstall")
+ override fun uninstall(activity: Activity) = launchInstaller(activity, "--uninstall")
- override fun update(context: Context) = launchInstaller(context, "--update")
+ override fun update(activity: Activity) = launchInstaller(activity, "--update")
- protected fun launchInstaller(context: Context, vararg flags: String) {
+ protected fun launchInstaller(activity: Activity, vararg flags: String) {
launchTerminal(
- context = context,
+ activity = activity,
terminalCommand =
TerminalCommand(
exe = "/bin/bash",
@@ -42,6 +44,16 @@ abstract class ScriptedLspServer : LspServer() {
}
abstract class LspServer {
+ abstract val id: String
+ abstract val languageName: String
+ abstract val serverName: String
+ abstract val supportedExtensions: List
+ abstract val icon: Icon?
+
+ open val canBeUninstalled = true
+
+ open val expectedCapabilities: ServerCapabilities? = null
+
suspend fun startAllInstances(): List {
val connectedEditors = mutableListOf()
_instances.forEach { connectedEditors.addAll(it.start()) }
@@ -84,13 +96,13 @@ abstract class LspServer {
abstract suspend fun isInstalled(context: Context): Boolean
- abstract fun install(context: Context)
+ abstract fun install(activity: Activity)
- abstract fun uninstall(context: Context)
+ abstract fun uninstall(activity: Activity)
abstract suspend fun isUpdatable(context: Context): Boolean
- abstract fun update(context: Context)
+ abstract fun update(activity: Activity)
abstract fun getConnectionConfig(): LspConnectionConfig
@@ -113,16 +125,6 @@ abstract class LspServer {
}
override fun hashCode(): Int = id.hashCode()
-
- open val expectedCapabilities: ServerCapabilities? = null
-
- open val canBeUninstalled = true
-
- abstract val id: String
- abstract val languageName: String
- abstract val serverName: String
- abstract val supportedExtensions: List
- abstract val icon: Int?
}
@Composable
diff --git a/core/main/src/main/java/com/rk/lsp/LspServerInstance.kt b/core/main/src/main/java/com/rk/lsp/LspServerInstance.kt
index bbd79c918..2a61b6560 100644
--- a/core/main/src/main/java/com/rk/lsp/LspServerInstance.kt
+++ b/core/main/src/main/java/com/rk/lsp/LspServerInstance.kt
@@ -21,8 +21,6 @@ import com.rk.file.FileObject
import com.rk.icons.Error
import com.rk.icons.XedIcons
import com.rk.resources.strings
-import com.rk.settings.debugOptions.LogEntry
-import com.rk.settings.debugOptions.LogLevel
import com.rk.tabs.editor.EditorTab
import com.rk.tabs.editor.applyHighlightingAndConnectLSP
import com.rk.theme.greenStatus
@@ -44,21 +42,20 @@ enum class LspConnectionStatus {
TIMEOUT,
}
-private fun MessageType.toLogLevel() =
- when (this) {
- MessageType.Error -> LogLevel.ERROR
- MessageType.Warning -> LogLevel.WARN
- MessageType.Info -> LogLevel.INFO
- MessageType.Log -> LogLevel.DEBUG
- MessageType.Debug -> LogLevel.DEBUG
- }
-
-data class LspLogEntry(val level: MessageType, val message: String, val timestamp: Long = System.currentTimeMillis()) {
- fun toLogEntry(): LogEntry {
- return LogEntry(level = level.toLogLevel(), message = message, timestamp = timestamp)
- }
+enum class MessageSource {
+ RPC,
+ LSP,
+ Runtime,
+ Client,
}
+data class LspLogEntry(
+ val source: MessageSource,
+ val type: MessageType?,
+ val message: String,
+ val timestamp: Long = System.currentTimeMillis(),
+)
+
data class LspServerInstance(val server: LspServer, internal val lspProject: LspProject, val projectRoot: FileObject) {
val id = "${server.id}_${projectRoot.getAbsolutePath().hashCode()}"
@@ -68,25 +65,23 @@ data class LspServerInstance(val server: LspServer, internal val lspProject: Lsp
var hasError by mutableStateOf(false)
fun addLog(messageParams: MessageParams) {
- logs.add(LspLogEntry(level = messageParams.type, message = messageParams.message))
+ logs.add(LspLogEntry(type = messageParams.type, message = messageParams.message, source = MessageSource.LSP))
}
fun addLog(lspLogEntry: LspLogEntry) {
- if (lspLogEntry.level == MessageType.Error) hasError = true
+ if (lspLogEntry.type == MessageType.Error) hasError = true
logs.add(lspLogEntry)
}
fun getLspLogs() = logs.toList()
- fun getLogs() = getLspLogs().map { it.toLogEntry() }
-
fun getWrapper(): LanguageServerWrapper? {
return server.supportedExtensions.firstOrNull()?.let {
lspProject.getLanguageServerWrapper(it, server.serverName)
}
?: run {
hasError = true
- addLog(LspLogEntry(MessageType.Error, "Language server instance not found..."))
+ addLog(LspLogEntry(MessageSource.Client, MessageType.Error, "Language server instance not found..."))
return null
}
}
@@ -94,7 +89,7 @@ data class LspServerInstance(val server: LspServer, internal val lspProject: Lsp
/** Stops this language server instance */
suspend fun stop() {
withContext(Dispatchers.IO) {
- addLog(LspLogEntry(MessageType.Info, "User stopped language server instance..."))
+ addLog(LspLogEntry(MessageSource.Client, MessageType.Info, "User stopped language server instance..."))
val wrapper = getWrapper() ?: return@withContext
DefinitionPrevention.register(lspProject, server)
try {
@@ -108,7 +103,7 @@ data class LspServerInstance(val server: LspServer, internal val lspProject: Lsp
/** Restarts this language server instance */
suspend fun restart() {
withContext(Dispatchers.IO) {
- addLog(LspLogEntry(MessageType.Info, "User restarted language server instance..."))
+ addLog(LspLogEntry(MessageSource.Client, MessageType.Info, "User restarted language server instance..."))
val wrapper = getWrapper() ?: return@withContext
try {
wrapper.restartAndReconnect()
@@ -125,7 +120,7 @@ data class LspServerInstance(val server: LspServer, internal val lspProject: Lsp
*/
suspend fun start(): List {
return withContext(Dispatchers.IO) {
- addLog(LspLogEntry(MessageType.Info, "User started language server instance..."))
+ addLog(LspLogEntry(MessageSource.Client, MessageType.Info, "User started language server instance..."))
val wrapper = getWrapper() ?: return@withContext emptyList()
hasError = false
DefinitionPrevention.unregister(lspProject, server)
diff --git a/core/main/src/main/java/com/rk/lsp/ProcessConnection.kt b/core/main/src/main/java/com/rk/lsp/ProcessConnection.kt
index c304c0b6c..7a1bf95aa 100644
--- a/core/main/src/main/java/com/rk/lsp/ProcessConnection.kt
+++ b/core/main/src/main/java/com/rk/lsp/ProcessConnection.kt
@@ -9,7 +9,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
-import org.eclipse.lsp4j.MessageType
class ProcessConnection(private val cmd: Array, instance: LspServerInstance) :
BaseLspConnectionProvider(instance) {
@@ -37,19 +36,23 @@ class ProcessConnection(private val cmd: Array, instance: LspServerInsta
loggingInput =
LoggingInputStream(process!!.inputStream) { json ->
Log.d("ProcessConnection", "[stdout] $json")
- instance.addLog(LspLogEntry(MessageType.Log, "→ $json"))
+ if (com.rk.settings.Preference.getBoolean("debug_mode", com.rk.xededitor.BuildConfig.DEBUG)) {
+ instance.addLog(LspLogEntry(MessageSource.RPC, null, "→ $json"))
+ }
}
loggingOutput =
LoggingOutputStream(process!!.outputStream) { json ->
Log.d("ProcessConnection", "[stdin] $json")
- instance.addLog(LspLogEntry(MessageType.Log, "← $json"))
+ if (com.rk.settings.Preference.getBoolean("debug_mode", com.rk.xededitor.BuildConfig.DEBUG)) {
+ instance.addLog(LspLogEntry(MessageSource.RPC, null, "← $json"))
+ }
}
scope!!.launch {
runCatching {
process!!.errorStream.bufferedReader().forEachLine { line ->
Log.e("ProcessConnection", "[stderr] $line")
- instance.addLog(LspLogEntry(MessageType.Error, line))
+ instance.addLog(LspLogEntry(MessageSource.Runtime, null, line))
}
}
}
diff --git a/core/main/src/main/java/com/rk/lsp/SocketConnection.kt b/core/main/src/main/java/com/rk/lsp/SocketConnection.kt
index c7a838267..946b284af 100644
--- a/core/main/src/main/java/com/rk/lsp/SocketConnection.kt
+++ b/core/main/src/main/java/com/rk/lsp/SocketConnection.kt
@@ -35,12 +35,16 @@ class SocketConnection(private val port: Int, private val host: String? = null,
loggingInput =
LoggingInputStream(socket!!.getInputStream()) { json ->
Log.d("SocketConnection", "[stdout] $json")
- instance.addLog(LspLogEntry(MessageType.Log, "→ $json"))
+ if (com.rk.settings.Preference.getBoolean("debug_mode", com.rk.xededitor.BuildConfig.DEBUG)) {
+ instance.addLog(LspLogEntry(MessageSource.RPC, null, "→ $json"))
+ }
}
loggingOutput =
LoggingOutputStream(socket!!.getOutputStream()) { json ->
Log.d("SocketConnection", "[stdin] $json")
- instance.addLog(LspLogEntry(MessageType.Log, "← $json"))
+ if (com.rk.settings.Preference.getBoolean("debug_mode", com.rk.xededitor.BuildConfig.DEBUG)) {
+ instance.addLog(LspLogEntry(MessageSource.RPC, null, "← $json"))
+ }
}
}
diff --git a/core/main/src/main/java/com/rk/lsp/servers/ExternalProcessServer.kt b/core/main/src/main/java/com/rk/lsp/servers/ExternalProcessServer.kt
index 50c617554..40468bf0c 100644
--- a/core/main/src/main/java/com/rk/lsp/servers/ExternalProcessServer.kt
+++ b/core/main/src/main/java/com/rk/lsp/servers/ExternalProcessServer.kt
@@ -1,5 +1,6 @@
package com.rk.lsp.servers
+import android.app.Activity
import android.content.Context
import com.rk.file.FileTypeManager
import com.rk.lsp.LspConnectionConfig
@@ -7,7 +8,7 @@ import com.rk.lsp.LspServer
import kotlin.random.Random
// DO not put this in lsp registry
-class ExternalProcessServer(
+data class ExternalProcessServer(
val command: String,
override val supportedExtensions: List,
override val id: String = "${supportedExtensions.firstOrNull()}_${Random.nextInt()}",
@@ -17,19 +18,15 @@ class ExternalProcessServer(
override val icon = supportedExtensions.firstOrNull()?.let { FileTypeManager.fromExtension(it).icon }
override val canBeUninstalled = false
- override suspend fun isInstalled(context: Context): Boolean {
- return true
- }
+ override suspend fun isInstalled(context: Context) = true
- override fun install(context: Context) {}
+ override fun install(activity: Activity) {}
- override fun uninstall(context: Context) {}
+ override fun uninstall(activity: Activity) {}
- override suspend fun isUpdatable(context: Context): Boolean {
- return false
- }
+ override suspend fun isUpdatable(context: Context) = false
- override fun update(context: Context) {}
+ override fun update(activity: Activity) {}
override fun getConnectionConfig(): LspConnectionConfig {
return LspConnectionConfig.Process(
@@ -37,35 +34,5 @@ class ExternalProcessServer(
)
}
- override fun toString(): String {
- return serverName
- }
-
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (javaClass != other?.javaClass) return false
- if (!super.equals(other)) return false
-
- other as ExternalProcessServer
-
- if (icon != other.icon) return false
- if (command != other.command) return false
- if (supportedExtensions != other.supportedExtensions) return false
- if (languageName != other.languageName) return false
- if (id != other.id) return false
- if (serverName != other.serverName) return false
-
- return true
- }
-
- override fun hashCode(): Int {
- var result = super.hashCode()
- result = 31 * result + (icon ?: 0)
- result = 31 * result + command.hashCode()
- result = 31 * result + supportedExtensions.hashCode()
- result = 31 * result + languageName.hashCode()
- result = 31 * result + id.hashCode()
- result = 31 * result + serverName.hashCode()
- return result
- }
+ override fun toString() = serverName
}
diff --git a/core/main/src/main/java/com/rk/lsp/servers/ExternalSocketServer.kt b/core/main/src/main/java/com/rk/lsp/servers/ExternalSocketServer.kt
index a874d9096..462ef2823 100644
--- a/core/main/src/main/java/com/rk/lsp/servers/ExternalSocketServer.kt
+++ b/core/main/src/main/java/com/rk/lsp/servers/ExternalSocketServer.kt
@@ -1,5 +1,6 @@
package com.rk.lsp.servers
+import android.app.Activity
import android.content.Context
import com.rk.file.FileTypeManager
import com.rk.lsp.LspConnectionConfig
@@ -7,7 +8,7 @@ import com.rk.lsp.LspServer
import kotlin.random.Random
// DO not put this in lsp registry
-class ExternalSocketServer(
+data class ExternalSocketServer(
val host: String,
val port: Int,
override val supportedExtensions: List,
@@ -19,55 +20,19 @@ class ExternalSocketServer(
override val icon = supportedExtensions.firstOrNull()?.let { FileTypeManager.fromExtension(it).icon }
override val canBeUninstalled = false
- override suspend fun isInstalled(context: Context): Boolean {
- return true
- }
+ override suspend fun isInstalled(context: Context) = true
- override fun install(context: Context) {}
+ override fun install(activity: Activity) {}
- override fun uninstall(context: Context) {}
+ override fun uninstall(activity: Activity) {}
- override suspend fun isUpdatable(context: Context): Boolean {
- return false
- }
+ override suspend fun isUpdatable(context: Context) = false
- override fun update(context: Context) {}
+ override fun update(activity: Activity) {}
override fun getConnectionConfig(): LspConnectionConfig {
return LspConnectionConfig.Socket(host = host, port = port)
}
- override fun toString(): String {
- return serverName
- }
-
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- if (javaClass != other?.javaClass) return false
- if (!super.equals(other)) return false
-
- other as ExternalSocketServer
-
- if (port != other.port) return false
- if (icon != other.icon) return false
- if (host != other.host) return false
- if (supportedExtensions != other.supportedExtensions) return false
- if (languageName != other.languageName) return false
- if (id != other.id) return false
- if (serverName != other.serverName) return false
-
- return true
- }
-
- override fun hashCode(): Int {
- var result = super.hashCode()
- result = 31 * result + port
- result = 31 * result + (icon ?: 0)
- result = 31 * result + host.hashCode()
- result = 31 * result + supportedExtensions.hashCode()
- result = 31 * result + languageName.hashCode()
- result = 31 * result + id.hashCode()
- result = 31 * result + serverName.hashCode()
- return result
- }
+ override fun toString() = serverName
}
diff --git a/core/main/src/main/java/com/rk/lsp/servers/JSON.kt b/core/main/src/main/java/com/rk/lsp/servers/JSON.kt
deleted file mode 100644
index 5f5e42ea6..000000000
--- a/core/main/src/main/java/com/rk/lsp/servers/JSON.kt
+++ /dev/null
@@ -1,38 +0,0 @@
-package com.rk.lsp.servers
-
-import android.content.Context
-import com.rk.exec.NpmUtils
-import com.rk.exec.isTerminalInstalled
-import com.rk.file.BuiltinFileType
-import com.rk.file.child
-import com.rk.file.localBinDir
-import com.rk.file.sandboxDir
-import com.rk.lsp.LspConnectionConfig
-import com.rk.lsp.ScriptedLspServer
-
-object JSON : ScriptedLspServer() {
- override val id: String = "json"
- override val languageName: String = "JSON"
- override val serverName = "vscode-json-language-server"
- override val supportedExtensions = BuiltinFileType.JSON.extensions
- override val icon = BuiltinFileType.JSON.icon
-
- override val installScript = localBinDir().child("lsp/json")
- override val installId = "JSON language server"
-
- override suspend fun isInstalled(context: Context): Boolean {
- if (!isTerminalInstalled()) {
- return false
- }
-
- return sandboxDir().child("/usr/bin/$serverName").exists()
- }
-
- override suspend fun isUpdatable(context: Context): Boolean {
- return NpmUtils.hasUpdate("vscode-langservers-extracted")
- }
-
- override fun getConnectionConfig(): LspConnectionConfig {
- return LspConnectionConfig.Process(arrayOf("/usr/bin/node", "/usr/bin/$serverName", "--stdio"))
- }
-}
diff --git a/core/main/src/main/java/com/rk/lsp/servers/Python.kt b/core/main/src/main/java/com/rk/lsp/servers/Python.kt
deleted file mode 100644
index 6a3ea83c1..000000000
--- a/core/main/src/main/java/com/rk/lsp/servers/Python.kt
+++ /dev/null
@@ -1,64 +0,0 @@
-package com.rk.lsp.servers
-
-import android.content.Context
-import com.rk.exec.PipxUtils
-import com.rk.exec.isTerminalInstalled
-import com.rk.file.BuiltinFileType
-import com.rk.file.child
-import com.rk.file.localBinDir
-import com.rk.file.sandboxHomeDir
-import com.rk.lsp.LspConnectionConfig
-import com.rk.lsp.LspConnector
-import com.rk.lsp.ScriptedLspServer
-import org.eclipse.lsp4j.DidChangeConfigurationParams
-
-object Python : ScriptedLspServer() {
- override val id: String = "python"
- override val languageName: String = "Python"
- override val serverName = "python-lsp-server"
- override val supportedExtensions = BuiltinFileType.PYTHON.extensions
- override val icon = BuiltinFileType.PYTHON.icon
-
- override val installScript = localBinDir().child("lsp/python")
- override val installId = "Python language server"
-
- override suspend fun isInstalled(context: Context): Boolean {
- if (!isTerminalInstalled()) {
- return false
- }
-
- return sandboxHomeDir().child(".local/share/pipx/venvs/python-lsp-server/bin/pylsp").exists()
- }
-
- override suspend fun isUpdatable(context: Context): Boolean {
- return PipxUtils.hasUpdate(serverName)
- }
-
- override fun getConnectionConfig(): LspConnectionConfig {
- return LspConnectionConfig.Process(arrayOf("/home/.local/share/pipx/venvs/python-lsp-server/bin/pylsp"))
- }
-
- override suspend fun onInitialize(lspConnector: LspConnector) {
- val requestManager = lspConnector.lspEditor!!.requestManager
-
- val params =
- DidChangeConfigurationParams(
- mapOf(
- "pylsp" to
- mapOf(
- "plugins" to
- mapOf(
- "pycodestyle" to
- mapOf(
- "enabled" to true,
- "ignore" to listOf("E501", "W291", "W293"),
- "maxLineLength" to 999,
- )
- )
- )
- )
- )
-
- requestManager.didChangeConfiguration(params)
- }
-}
diff --git a/core/main/src/main/java/com/rk/lsp/servers/XML.kt b/core/main/src/main/java/com/rk/lsp/servers/XML.kt
index 92dd4620d..967660009 100644
--- a/core/main/src/main/java/com/rk/lsp/servers/XML.kt
+++ b/core/main/src/main/java/com/rk/lsp/servers/XML.kt
@@ -8,10 +8,12 @@ import com.rk.file.localBinDir
import com.rk.file.sandboxHomeDir
import com.rk.lsp.LspConnectionConfig
import com.rk.lsp.ScriptedLspServer
+import io.github.z4kn4fein.semver.toVersion
+import io.github.z4kn4fein.semver.toVersionOrNull
object XML : ScriptedLspServer() {
- override val id: String = "xml"
- override val languageName: String = "XML"
+ override val id = "xml"
+ override val languageName = "XML"
override val serverName = "lemminx"
override val supportedExtensions = BuiltinFileType.XML.extensions
override val icon = BuiltinFileType.XML.icon
@@ -32,8 +34,9 @@ object XML : ScriptedLspServer() {
override suspend fun isUpdatable(context: Context): Boolean {
val versionFile = sandboxHomeDir().child(".lsp/lemminx/version.txt")
- val currentVersion = runCatching { versionFile.readText().trim() }.getOrNull()
- return currentVersion != LATEST_VERSION
+ val currentVersionText = runCatching { versionFile.readText().trim() }.getOrNull()
+ val currentVersion = currentVersionText?.toVersionOrNull() ?: return false
+ return currentVersion < LATEST_VERSION.toVersion()
}
override fun getConnectionConfig(): LspConnectionConfig {
diff --git a/core/main/src/main/java/com/rk/runner/Runner.kt b/core/main/src/main/java/com/rk/runner/Runner.kt
deleted file mode 100644
index ef6c51e41..000000000
--- a/core/main/src/main/java/com/rk/runner/Runner.kt
+++ /dev/null
@@ -1,136 +0,0 @@
-package com.rk.runner
-
-import android.content.Context
-import com.rk.file.BuiltinFileType
-import com.rk.file.FileObject
-import com.rk.icons.Icon
-import com.rk.runner.runners.UniversalRunner
-import com.rk.runner.runners.web.html.HtmlRunner
-import com.rk.runner.runners.web.markdown.MarkdownRunner
-import com.rk.settings.Settings
-import com.rk.utils.errorDialog
-import java.lang.ref.WeakReference
-
-abstract class RunnerImpl {
-
- abstract suspend fun run(context: Context, fileObject: FileObject)
-
- abstract fun getName(): String
-
- abstract fun getIcon(context: Context): Icon?
-
- abstract suspend fun isRunning(): Boolean
-
- abstract suspend fun stop()
-}
-
-var currentRunner = WeakReference(null)
-
-abstract class RunnerBuilder(
- val regex: Regex,
- val enabled: () -> Boolean = { true },
- val clazz: Class,
-) {
- fun build(): RunnerImpl {
- return clazz.getDeclaredConstructor().newInstance()
- }
-}
-
-object Runner {
- val runnerBuilders = mutableListOf()
-
- init {
- val htmlExtensions = BuiltinFileType.HTML.extensions.joinToString("|")
- val markdownExtensions = BuiltinFileType.MARKDOWN.extensions.joinToString("|")
-
- runnerBuilders.apply {
- add(
- object :
- RunnerBuilder(
- regex = Regex(".*\\.($htmlExtensions|svg)$"),
- enabled = { Settings.enable_html_runner },
- clazz = HtmlRunner::class.java,
- ) {}
- )
- add(
- object :
- RunnerBuilder(
- regex = Regex(".*\\.($markdownExtensions)$"),
- enabled = { Settings.enable_md_runner },
- clazz = MarkdownRunner::class.java,
- ) {}
- )
- add(
- object :
- RunnerBuilder(
- regex =
- Regex(
- ".*\\.(py|js|ts|java|kt|rs|rb|php|c|cpp|cc|cxx|cs|sh|bash|zsh|fish|pl|lua|r|R|hs|f90|f95|f03|f08|pas|tcl|elm|fsx|fs)$"
- ),
- enabled = { Settings.enable_universal_runner },
- clazz = UniversalRunner::class.java,
- ) {}
- )
- }
- }
-
- fun isRunnable(fileObject: FileObject): Boolean {
- ShellBasedRunners.runners.forEach {
- val name = fileObject.getName()
- val regex = Regex(it.regex)
-
- if (regex.matches(name)) {
- return true
- }
- }
-
- runnerBuilders.forEach {
- if (!it.enabled()) return@forEach
-
- val name = fileObject.getName()
- val regex = it.regex
-
- if (regex.matches(name)) {
- return true
- }
- }
- return false
- }
-
- suspend fun run(context: Context, fileObject: FileObject, onMultipleRunners: (List) -> Unit) {
- val availableRunners = mutableListOf()
-
- ShellBasedRunners.runners.forEach {
- val name = fileObject.getName()
- val regex = Regex(it.regex)
-
- if (regex.matches(name)) {
- availableRunners.add(it)
- }
- }
-
- runnerBuilders.forEach {
- val name = fileObject.getName()
- val regex = it.regex
-
- if (regex.matches(name)) {
- availableRunners.add(it.build())
- }
- }
-
- if (availableRunners.isEmpty()) {
- errorDialog("No runners available")
- return
- }
-
- if (availableRunners.size == 1) {
- availableRunners[0].run(context, fileObject)
- } else {
- onMultipleRunners.invoke(availableRunners)
- }
- }
-
- suspend fun onMainActivityResumed() {
- currentRunner.get()?.stop()
- }
-}
diff --git a/core/main/src/main/java/com/rk/runner/RunnerManager.kt b/core/main/src/main/java/com/rk/runner/RunnerManager.kt
new file mode 100644
index 000000000..cb107a7c1
--- /dev/null
+++ b/core/main/src/main/java/com/rk/runner/RunnerManager.kt
@@ -0,0 +1,92 @@
+package com.rk.runner
+
+import android.app.Activity
+import android.content.Context
+import androidx.compose.runtime.mutableStateListOf
+import com.rk.extension.api.XedExtensionPoint
+import com.rk.file.FileObject
+import com.rk.icons.Icon
+import com.rk.runner.runners.UniversalRunner
+import com.rk.runner.runners.web.html.HtmlRunner
+import com.rk.runner.runners.web.markdown.MarkdownRunner
+import com.rk.settings.Preference
+import com.rk.utils.errorDialog
+
+abstract class Runner {
+ abstract val id: String
+ abstract val label: String
+ open val description: String? = null
+ open val onConfigure: (() -> Unit)? = null
+
+ abstract fun getIcon(context: Context): Icon?
+
+ abstract fun matcher(fileObject: FileObject): Boolean
+
+ abstract suspend fun run(activity: Activity, fileObject: FileObject)
+
+ abstract suspend fun isRunning(): Boolean
+
+ abstract suspend fun stop()
+
+ fun isEnabled(): Boolean {
+ return Preference.getBoolean("runner_$id", true)
+ }
+
+ fun setEnabled(enabled: Boolean) {
+ Preference.setBoolean("runner_$id", enabled)
+ }
+}
+
+object RunnerManager {
+
+ private val _extensionRunners = mutableStateListOf()
+
+ val extensionRunners: List
+ get() = _extensionRunners.toList()
+
+ val builtinRunners = listOf(HtmlRunner, MarkdownRunner, UniversalRunner)
+
+ @XedExtensionPoint
+ fun registerRunner(runner: Runner) {
+ if (!_extensionRunners.contains(runner)) {
+ _extensionRunners.add(runner)
+ }
+ }
+
+ @XedExtensionPoint
+ fun unregisterRunner(runner: Runner) {
+ _extensionRunners.remove(runner)
+ }
+
+ fun isRunnable(fileObject: FileObject): Boolean {
+ return getAvailableRunners(fileObject).isNotEmpty()
+ }
+
+ fun getAvailableRunners(fileObject: FileObject): List {
+ val result = mutableListOf()
+
+ val runners = builtinRunners + extensionRunners + ShellBasedRunners.runners
+ runners.forEach {
+ if (it.isEnabled() && it.matcher(fileObject)) {
+ result.add(it)
+ }
+ }
+
+ return result
+ }
+
+ suspend fun run(activity: Activity, fileObject: FileObject, onMultipleRunners: (List) -> Unit) {
+ val availableRunners = getAvailableRunners(fileObject)
+
+ if (availableRunners.isEmpty()) {
+ errorDialog(activity, msg = "No runners available")
+ return
+ }
+
+ if (availableRunners.size == 1) {
+ availableRunners[0].run(activity, fileObject)
+ } else {
+ onMultipleRunners.invoke(availableRunners)
+ }
+ }
+}
diff --git a/core/main/src/main/java/com/rk/runner/ShellBasedRunners.kt b/core/main/src/main/java/com/rk/runner/ShellBasedRunners.kt
index 5b8f266c4..2e9f66261 100644
--- a/core/main/src/main/java/com/rk/runner/ShellBasedRunners.kt
+++ b/core/main/src/main/java/com/rk/runner/ShellBasedRunners.kt
@@ -1,5 +1,6 @@
package com.rk.runner
+import android.app.Activity
import android.content.Context
import androidx.compose.runtime.mutableStateListOf
import com.google.gson.Gson
@@ -15,6 +16,7 @@ import com.rk.file.runnerDir
import com.rk.icons.Icon
import com.rk.resources.drawables
import java.io.File
+import kotlin.random.Random
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -28,10 +30,10 @@ object ShellBasedRunners {
suspend fun newRunner(runner: ShellBasedRunner): Boolean {
return withContext(Dispatchers.IO) {
- if (runners.find { it.getName() == runner.getName() } == null) {
+ if (runners.find { it.label == runner.label } == null) {
withContext(Dispatchers.Main) { runners.add(runner) }
runnerDir()
- .child("${runner.getName()}.sh")
+ .child("${runner.label}.sh")
.createFileIfNot()
.writeText("echo \"This runner has no implementation. Click the runner and add your own script.\"")
saveRunners()
@@ -47,10 +49,10 @@ object ShellBasedRunners {
localDir().child("runners.json").writeText(json)
}
- suspend fun deleteRunner(runner: ShellBasedRunner, deleteScript: Boolean = true) {
+ suspend fun deleteRunner(runner: ShellBasedRunner) {
runners.remove(runner)
saveRunners()
- runnerDir().child("${runner.getName()}.sh").createFileIfNot().delete()
+ runnerDir().child("${runner.label}.sh").createFileIfNot().delete()
}
suspend fun indexRunners() {
@@ -66,29 +68,32 @@ object ShellBasedRunners {
}
}
-data class ShellBasedRunner(private val name: String, val regex: String) : RunnerImpl() {
- override suspend fun run(context: Context, fileObject: FileObject) {
- val script = runnerDir().child("${name}.sh").createFileIfNot()
+data class ShellBasedRunner(override val label: String, val regex: String) : Runner() {
+
+ override val id = Random.nextInt().toString()
+
+ override fun matcher(fileObject: FileObject): Boolean {
+ return Regex(regex).matches(fileObject.getName())
+ }
+
+ override suspend fun run(activity: Activity, fileObject: FileObject) {
+ val script = runnerDir().child("${label}.sh").createFileIfNot()
launchTerminal(
- context,
+ activity = activity,
TerminalCommand(
exe = "/bin/bash",
args = arrayOf(script.absolutePath, fileObject.getAbsolutePath()),
- id = name,
+ id = label,
),
)
}
- override fun getName(): String {
- return name
- }
-
fun getScript(): File {
- return runnerDir().child("${getName()}.sh").createFileIfNot()
+ return runnerDir().child("$label.sh").createFileIfNot()
}
override fun getIcon(context: Context): Icon {
- return Icon.DrawableRes(drawables.bash)
+ return Icon.ResourceIcon(drawables.bash)
}
override suspend fun isRunning(): Boolean {
diff --git a/core/main/src/main/java/com/rk/runner/runners/Shell.kt b/core/main/src/main/java/com/rk/runner/runners/Shell.kt
deleted file mode 100644
index 373e6f68e..000000000
--- a/core/main/src/main/java/com/rk/runner/runners/Shell.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-package com.rk.runner.runners
-
-import android.content.Context
-import com.rk.exec.TerminalCommand
-import com.rk.exec.launchTerminal
-import com.rk.file.FileObject
-import com.rk.file.FileWrapper
-import com.rk.icons.Icon
-import com.rk.resources.drawables
-import com.rk.resources.strings
-import com.rk.runner.RunnerImpl
-import com.rk.utils.errorDialog
-
-class Shell : RunnerImpl() {
- override suspend fun run(context: Context, fileObject: FileObject) {
- if (fileObject !is FileWrapper) {
- errorDialog(msgRes = strings.native_runner)
- return
- }
-
- launchTerminal(
- context,
- terminalCommand =
- TerminalCommand(
- sandbox = true,
- exe = "/bin/bash",
- args = arrayOf(fileObject.getAbsolutePath()),
- id = "shell-runner",
- terminatePreviousSession = true,
- workingDir = fileObject.getParentFile()?.getAbsolutePath() ?: "/",
- ),
- )
- }
-
- override fun getName(): String {
- return "Shell Runner"
- }
-
- override fun getIcon(context: Context): Icon {
- return Icon.DrawableRes(drawables.bash)
- }
-
- override suspend fun isRunning(): Boolean {
- return false
- }
-
- override suspend fun stop() {}
-}
diff --git a/core/main/src/main/java/com/rk/runner/runners/UniversalRunner.kt b/core/main/src/main/java/com/rk/runner/runners/UniversalRunner.kt
index ecdd9a136..a21432a38 100644
--- a/core/main/src/main/java/com/rk/runner/runners/UniversalRunner.kt
+++ b/core/main/src/main/java/com/rk/runner/runners/UniversalRunner.kt
@@ -1,6 +1,7 @@
package com.rk.runner.runners
import android.annotation.SuppressLint
+import android.app.Activity
import android.content.Context
import android.os.Environment
import com.rk.DefaultScope
@@ -14,18 +15,30 @@ import com.rk.icons.Icon
import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-import com.rk.runner.RunnerImpl
+import com.rk.runner.Runner
import com.rk.terminal.setupAssetFile
-import com.rk.utils.dialog
+import com.rk.utils.dialogRes
import kotlinx.coroutines.launch
-class UniversalRunner : RunnerImpl() {
+object UniversalRunner : Runner() {
+
+ override val id = "universal"
+ override val label = strings.universal_runner.getString()
+ override val description = strings.universal_runner_desc.getString()
+
+ override fun matcher(fileObject: FileObject): Boolean {
+ return Regex(
+ ".*\\.(py|js|ts|java|kt|rs|rb|php|c|cpp|cc|cxx|cs|sh|bash|zsh|fish|pl|lua|r|R|hs|f90|f95|f03|f08|pas|tcl|elm|fsx|fs)$"
+ )
+ .matches(fileObject.getName())
+ }
+
@SuppressLint("SdCardPath")
- override suspend fun run(context: Context, fileObject: FileObject) {
+ override suspend fun run(activity: Activity, fileObject: FileObject) {
setupAssetFile("universal_runner")
if (fileObject !is FileWrapper) {
- dialog(title = strings.attention.getString(), msg = strings.non_native_filetype.getString(), onOk = {})
+ dialogRes(title = strings.attention.getString(), msg = strings.non_native_filetype.getString(), onOk = {})
return
}
@@ -35,40 +48,36 @@ class UniversalRunner : RunnerImpl() {
path.startsWith("/storage/") ||
path.startsWith(Environment.getExternalStorageDirectory().absolutePath)
) {
- dialog(
+ dialogRes(
title = strings.attention.getString(),
msg = strings.sdcard_filetype.getString(),
- okString = strings.continue_action,
+ okRes = strings.continue_action,
onCancel = {},
- onOk = { DefaultScope.launch { launchUniversalRunner(context, fileObject) } },
+ onOk = { DefaultScope.launch { launchUniversalRunner(activity, fileObject) } },
)
return
}
- launchUniversalRunner(context, fileObject)
+ launchUniversalRunner(activity, fileObject)
}
- suspend fun launchUniversalRunner(context: Context, fileObject: FileObject) {
+ suspend fun launchUniversalRunner(activity: Activity, fileObject: FileObject) {
launchTerminal(
- context,
+ activity = activity,
terminalCommand =
TerminalCommand(
sandbox = true,
exe = "/bin/bash",
args = arrayOf(localBinDir().child("universal_runner").absolutePath, fileObject.getAbsolutePath()),
- id = "universal_runner",
+ id = strings.universal_runner.getString(),
terminatePreviousSession = true,
workingDir = fileObject.getParentFile()?.getAbsolutePath() ?: "/",
),
)
}
- override fun getName(): String {
- return strings.universal_runner.getString()
- }
-
override fun getIcon(context: Context): Icon {
- return Icon.DrawableRes(drawables.run)
+ return Icon.ResourceIcon(drawables.run)
}
override suspend fun isRunning(): Boolean {
diff --git a/core/main/src/main/java/com/rk/runner/runners/web/HttpServer.kt b/core/main/src/main/java/com/rk/runner/runners/web/HttpServer.kt
index 87b9c34b6..d35056978 100644
--- a/core/main/src/main/java/com/rk/runner/runners/web/HttpServer.kt
+++ b/core/main/src/main/java/com/rk/runner/runners/web/HttpServer.kt
@@ -22,6 +22,10 @@ class HttpServer(
start()
}
+ override fun useGzipWhenAccepted(r: Response?): Boolean {
+ return false
+ }
+
override fun serve(session: IHTTPSession?): Response {
return runBlocking {
val uri = session!!.uri
@@ -57,6 +61,11 @@ class HttpServer(
private suspend fun serveFileWithEruda(file: FileObject): Response {
return try {
+ val mime = URLConnection.guessContentTypeFromName(file.getName()) ?: "application/octet-stream"
+ if (mime != MIME_HTML) {
+ return serveFileWithoutEruda(file)
+ }
+
val darkTheme = isDarkTheme(context)
val erudaTheme = if (amoled.value && darkTheme) "AMOLED" else if (darkTheme) "Dark" else "Light"
val erudaScript =
@@ -70,17 +79,13 @@ class HttpServer(
val html = file.getInputStream().bufferedReader().use { it.readText() }
val injected =
- if (html.contains("
", ignoreCase = true)) {
- html.replace("", "$erudaScript", ignoreCase = true)
+ if (html.contains("
", ignoreCase = true)) {
+ html.replace("
", "
$erudaScript", ignoreCase = true)
} else {
- html + erudaScript
+ erudaScript + html
}
- newFixedLengthResponse(
- Status.OK,
- URLConnection.guessContentTypeFromName(file.getName()) ?: "text/html",
- injected,
- )
+ newFixedLengthResponse(Status.OK, mime, injected)
} catch (_: SecurityException) {
forbiddenError(file)
} catch (e: Exception) {
diff --git a/core/main/src/main/java/com/rk/runner/runners/web/html/HtmlRunner.kt b/core/main/src/main/java/com/rk/runner/runners/web/html/HtmlRunner.kt
index 1467de082..af334fa52 100644
--- a/core/main/src/main/java/com/rk/runner/runners/web/html/HtmlRunner.kt
+++ b/core/main/src/main/java/com/rk/runner/runners/web/html/HtmlRunner.kt
@@ -1,32 +1,45 @@
package com.rk.runner.runners.web.html
+import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.net.toUri
+import com.rk.activities.settings.SettingsRoutes
+import com.rk.activities.settings.settingsNavController
import com.rk.file.BuiltinFileType
import com.rk.file.FileObject
import com.rk.icons.Icon
import com.rk.resources.getFilledString
import com.rk.resources.getString
import com.rk.resources.strings
-import com.rk.runner.RunnerImpl
+import com.rk.runner.Runner
import com.rk.runner.runners.web.HttpServer
import com.rk.settings.Settings
import com.rk.utils.toast
import java.net.BindException
-class HtmlRunner : RunnerImpl() {
- companion object {
- var httpServer: HttpServer? = null
+object HtmlRunner : Runner() {
+
+ var httpServer: HttpServer? = null
+
+ override val id = "html_preview"
+ override val label = strings.html_preview.getString()
+ override val description = strings.html_preview_desc.getString()
+
+ override fun matcher(fileObject: FileObject): Boolean {
+ val htmlExtensions = BuiltinFileType.HTML.extensions.joinToString("|")
+ return Regex(".*\\.($htmlExtensions|svg)$").matches(fileObject.getName())
}
- override suspend fun run(context: Context, fileObject: FileObject) {
+ override val onConfigure: () -> Unit = { settingsNavController.get()?.navigate(SettingsRoutes.HtmlRunner.route) }
+
+ override suspend fun run(activity: Activity, fileObject: FileObject) {
stop()
val port = Settings.http_server_port
try {
- httpServer = HttpServer(context, port, fileObject.getParentFile() ?: fileObject)
+ httpServer = HttpServer(activity, port, fileObject.getParentFile() ?: fileObject)
} catch (_: BindException) {
toast(strings.http_server_port_error.getFilledString(port))
return
@@ -38,22 +51,18 @@ class HtmlRunner : RunnerImpl() {
val url = "$address/${fileObject.getName()}"
if (Settings.launch_in_browser) {
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
- context.startActivity(intent)
+ activity.startActivity(intent)
return
}
CustomTabsIntent.Builder()
.setShowTitle(true)
.setShareState(CustomTabsIntent.SHARE_STATE_OFF)
.build()
- .launchUrl(context, url.toUri())
- }
-
- override fun getName(): String {
- return strings.html_preview.getString()
+ .launchUrl(activity, url.toUri())
}
- override fun getIcon(context: Context): Icon {
- return Icon.DrawableRes(BuiltinFileType.HTML.icon!!)
+ override fun getIcon(context: Context): Icon? {
+ return BuiltinFileType.HTML.icon
}
override suspend fun isRunning(): Boolean = httpServer?.isAlive == true
diff --git a/core/main/src/main/java/com/rk/runner/runners/web/markdown/MDViewer.kt b/core/main/src/main/java/com/rk/runner/runners/web/markdown/MDViewer.kt
index 6905225b0..d4469b056 100644
--- a/core/main/src/main/java/com/rk/runner/runners/web/markdown/MDViewer.kt
+++ b/core/main/src/main/java/com/rk/runner/runners/web/markdown/MDViewer.kt
@@ -127,12 +127,17 @@ class MDViewer : WebActivity() {
private suspend fun fetchMarkdownFile(url: String): String {
return withContext(Dispatchers.IO) {
- val connection = URL(url).openConnection() as HttpURLConnection
try {
- connection.requestMethod = "GET"
- connection.inputStream.bufferedReader().use { it.readText() }
- } finally {
- connection.disconnect()
+ val connection = URL(url).openConnection() as HttpURLConnection
+ try {
+ connection.requestMethod = "GET"
+ connection.inputStream.bufferedReader().use { it.readText() }
+ } finally {
+ connection.disconnect()
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ ""
}
}
}
diff --git a/core/main/src/main/java/com/rk/runner/runners/web/markdown/MarkdownRunner.kt b/core/main/src/main/java/com/rk/runner/runners/web/markdown/MarkdownRunner.kt
index 0d8db87cd..f439eb3e5 100644
--- a/core/main/src/main/java/com/rk/runner/runners/web/markdown/MarkdownRunner.kt
+++ b/core/main/src/main/java/com/rk/runner/runners/web/markdown/MarkdownRunner.kt
@@ -1,5 +1,6 @@
package com.rk.runner.runners.web.markdown
+import android.app.Activity
import android.content.Context
import android.content.Intent
import com.rk.file.BuiltinFileType
@@ -7,26 +8,32 @@ import com.rk.file.FileObject
import com.rk.icons.Icon
import com.rk.resources.getString
import com.rk.resources.strings
-import com.rk.runner.RunnerImpl
+import com.rk.runner.Runner
import com.rk.runner.runners.web.html.HtmlRunner
import java.lang.ref.WeakReference
var mdViewerRef = WeakReference(null)
var toPreviewFile: FileObject? = null
-class MarkdownRunner : RunnerImpl() {
- override suspend fun run(context: Context, fileObject: FileObject) {
- val intent = Intent(context, MDViewer::class.java)
- toPreviewFile = fileObject
- context.startActivity(intent)
+object MarkdownRunner : Runner() {
+
+ override val id = "markdown_preview"
+ override val label = strings.markdown_preview.getString()
+ override val description = strings.markdown_preview_desc.getString()
+
+ override fun matcher(fileObject: FileObject): Boolean {
+ val markdownExtensions = BuiltinFileType.MARKDOWN.extensions.joinToString("|")
+ return Regex(".*\\.($markdownExtensions)$").matches(fileObject.getName())
}
- override fun getName(): String {
- return strings.markdown_preview.getString()
+ override suspend fun run(activity: Activity, fileObject: FileObject) {
+ val intent = Intent(activity, MDViewer::class.java)
+ toPreviewFile = fileObject
+ activity.startActivity(intent)
}
- override fun getIcon(context: Context): Icon {
- return Icon.DrawableRes(BuiltinFileType.MARKDOWN.icon!!)
+ override fun getIcon(context: Context): Icon? {
+ return BuiltinFileType.MARKDOWN.icon
}
override suspend fun isRunning(): Boolean {
diff --git a/core/main/src/main/java/com/rk/search/CodeSearchDialog.kt b/core/main/src/main/java/com/rk/search/CodeSearchDialog.kt
index 7dacf4a4d..406ef957b 100644
--- a/core/main/src/main/java/com/rk/search/CodeSearchDialog.kt
+++ b/core/main/src/main/java/com/rk/search/CodeSearchDialog.kt
@@ -18,6 +18,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.input.TextFieldLineLimits
+import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.KeyboardArrowDown
import androidx.compose.material.icons.outlined.KeyboardArrowUp
@@ -66,6 +68,7 @@ import com.rk.resources.drawables
import com.rk.resources.fillPlaceholders
import com.rk.resources.strings
import com.rk.settings.Settings
+import com.rk.tabs.editor.EditorTab
import com.rk.utils.rememberNumberFormatter
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.launch
@@ -82,6 +85,18 @@ fun CodeSearchDialog(
val context = LocalContext.current
val viewportHeight = with(LocalDensity.current) { LocalWindowInfo.current.containerSize.height.toDp() }
+ val editorTab = mainViewModel.currentTab as? EditorTab
+ val textFieldSearchState =
+ rememberTextFieldState(
+ editorTab?.editorState?.editor?.get()?.getSelectedText() ?: searchViewModel.codeSearchQuery
+ )
+ LaunchedEffect(textFieldSearchState.text) { searchViewModel.codeSearchQuery = textFieldSearchState.text.toString() }
+
+ val textFieldReplaceState = rememberTextFieldState(searchViewModel.codeReplaceQuery)
+ LaunchedEffect(textFieldReplaceState.text) {
+ searchViewModel.codeReplaceQuery = textFieldReplaceState.text.toString()
+ }
+
LaunchedEffect(
searchViewModel.isIndexing(projectFile),
searchViewModel.codeSearchQuery,
@@ -97,19 +112,15 @@ fun CodeSearchDialog(
fun replace(codeItem: CodeItem) {
searchViewModel.viewModelScope.launch {
- searchViewModel.replaceIn(mainViewModel, codeItem)
+ searchViewModel.replaceIn(context, mainViewModel, projectFile, codeItem)
+ searchViewModel.syncIndex(projectFile)
searchViewModel.launchCodeSearch(context, mainViewModel, projectFile)
}
}
fun replaceAll(codeItems: List) {
- val itemsBackwards =
- codeItems.toList().sortedWith(compareByDescending { it.line }.thenByDescending { it.column })
-
searchViewModel.viewModelScope.launch {
- for (codeItem in itemsBackwards) {
- searchViewModel.replaceIn(mainViewModel, codeItem)
- }
+ searchViewModel.replaceAllIn(context, mainViewModel, projectFile, codeItems)
searchViewModel.launchCodeSearch(context, mainViewModel, projectFile)
}
}
@@ -117,9 +128,8 @@ fun CodeSearchDialog(
XedDialog(onDismissRequest = onFinish, modifier = Modifier.imePadding()) {
Column(modifier = Modifier.animateContentSize().height(viewportHeight * 0.8f)) {
TextField(
- value = searchViewModel.codeSearchQuery,
- onValueChange = { searchViewModel.codeSearchQuery = it },
- maxLines = 1,
+ state = textFieldSearchState,
+ lineLimits = TextFieldLineLimits.SingleLine,
leadingIcon = {
IconButton(modifier = Modifier, onClick = { searchViewModel.toggleReplaceShown() }) {
Icon(
@@ -190,9 +200,8 @@ fun CodeSearchDialog(
if (searchViewModel.isReplaceShown) {
TextField(
- value = searchViewModel.codeReplaceQuery,
- onValueChange = { searchViewModel.codeReplaceQuery = it },
- maxLines = 1,
+ state = textFieldReplaceState,
+ lineLimits = TextFieldLineLimits.SingleLine,
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Search),
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
placeholder = { Text(text = stringResource(strings.replace)) },
diff --git a/core/main/src/main/java/com/rk/search/EditorSearch.kt b/core/main/src/main/java/com/rk/search/EditorSearch.kt
index 88f3977e8..b06411964 100644
--- a/core/main/src/main/java/com/rk/search/EditorSearch.kt
+++ b/core/main/src/main/java/com/rk/search/EditorSearch.kt
@@ -47,6 +47,8 @@ import androidx.compose.ui.unit.dp
import com.rk.components.StyledTextField
import com.rk.resources.strings
import com.rk.tabs.editor.CodeEditorState
+import com.rk.utils.logError
+import com.rk.utils.toast
import io.github.rosemoe.sora.event.PublishSearchResultEvent
import io.github.rosemoe.sora.event.SelectionChangeEvent
import io.github.rosemoe.sora.widget.EditorSearcher
@@ -63,9 +65,15 @@ fun EditorSearchPanel(editorState: CodeEditorState, modifier: Modifier = Modifie
var searchMatchesCount by remember { mutableIntStateOf(0) }
var searchCurrentIndex by remember { mutableIntStateOf(0) }
+ fun stopEditorSearch() {
+ hasSearchError = false
+ isSearchingInternal = false
+ editor.get()?.searcher?.stopSearch()
+ }
+
// Search execution logic
fun tryCommitSearch() {
- val query = editorState.searchKeyword
+ val query = editorState.searchKeyword.text
if (query.isNotEmpty()) {
try {
val searchOptions =
@@ -78,9 +86,7 @@ fun EditorSearchPanel(editorState: CodeEditorState, modifier: Modifier = Modifie
isSearchingInternal = false
}
} else {
- editor.get()?.searcher?.stopSearch()
- hasSearchError = false
- isSearchingInternal = false
+ stopEditorSearch()
}
}
@@ -97,6 +103,7 @@ fun EditorSearchPanel(editorState: CodeEditorState, modifier: Modifier = Modifie
// Execute search when keyword changes
LaunchedEffect(
+ editorState.editor.get(),
editorState.isSearching,
editorState.searchKeyword,
editorState.ignoreCase,
@@ -147,7 +154,6 @@ fun EditorSearchPanel(editorState: CodeEditorState, modifier: Modifier = Modifie
.focusRequester(focusRequester),
shape = RoundedCornerShape(8.dp),
maxLines = 1,
- // Show error state with red text color
textStyle =
LocalTextStyle.current.copy(
color =
@@ -244,7 +250,7 @@ fun EditorSearchPanel(editorState: CodeEditorState, modifier: Modifier = Modifie
IconButton(
onClick = {
editorState.isSearching = false
- editor.get()?.searcher?.stopSearch()
+ stopEditorSearch()
}
) {
Icon(imageVector = Icons.Outlined.Close, null)
@@ -279,27 +285,59 @@ fun EditorSearchPanel(editorState: CodeEditorState, modifier: Modifier = Modifie
horizontalArrangement = Arrangement.Start,
modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()).padding(horizontal = 8.dp),
) {
- TextButton(enabled = isSearchingInternal, onClick = { editor.get()?.searcher?.gotoPrevious() }) {
+ TextButton(
+ enabled = isSearchingInternal,
+ onClick = {
+ runCatching { editor.get()?.searcher?.gotoPrevious() }
+ .onFailure {
+ logError(it)
+ toast(strings.unknown_err)
+ }
+ },
+ ) {
Text(stringResource(strings.go_prev).uppercase())
}
Spacer(Modifier.height(10.dp))
- TextButton(enabled = isSearchingInternal, onClick = { editor.get()?.searcher?.gotoNext() }) {
+ TextButton(
+ enabled = isSearchingInternal,
+ onClick = {
+ runCatching { editor.get()?.searcher?.gotoNext() }
+ .onFailure {
+ logError(it)
+ toast(strings.unknown_err)
+ }
+ },
+ ) {
Text(stringResource(strings.go_next).uppercase())
}
if (editorState.isReplaceShown && editorState.editable) {
TextButton(
enabled = isSearchingInternal,
- onClick = { editor.get()?.searcher?.replaceCurrentMatch(editorState.replaceKeyword) },
+ onClick = {
+ runCatching {
+ editor.get()?.searcher?.replaceCurrentMatch(editorState.replaceKeyword.text)
+ }
+ .onFailure {
+ logError(it)
+ toast(strings.unknown_err)
+ }
+ },
) {
Text(stringResource(strings.replace).uppercase())
}
TextButton(
enabled = isSearchingInternal,
- onClick = { editor.get()?.searcher?.replaceAll(editorState.replaceKeyword) },
+ onClick = {
+ runCatching { editor.get()?.searcher?.replaceAll(editorState.replaceKeyword.text) }
+ .onFailure {
+ logError(it)
+ toast(strings.unknown_err)
+ }
+ },
) {
Text(stringResource(strings.replace_all).uppercase())
}
@@ -315,7 +353,7 @@ fun EditorSearchPanel(editorState: CodeEditorState, modifier: Modifier = Modifie
BackHandler {
editorState.isSearching = false
- editor.get()?.searcher?.stopSearch()
+ stopEditorSearch()
}
}
}
diff --git a/core/main/src/main/java/com/rk/search/FileSearchDialog.kt b/core/main/src/main/java/com/rk/search/FileSearchDialog.kt
index 01dd521be..cdb415dae 100644
--- a/core/main/src/main/java/com/rk/search/FileSearchDialog.kt
+++ b/core/main/src/main/java/com/rk/search/FileSearchDialog.kt
@@ -14,6 +14,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.text.input.TextFieldLineLimits
+import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -37,6 +39,7 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import com.rk.activities.main.MainViewModel
import com.rk.components.XedDialog
import com.rk.components.compose.utils.addIf
import com.rk.file.FileObject
@@ -45,12 +48,14 @@ import com.rk.filetree.FileIcon
import com.rk.filetree.getAppropriateName
import com.rk.resources.fillPlaceholders
import com.rk.resources.strings
+import com.rk.tabs.editor.EditorTab
import com.rk.utils.getGitColor
import com.rk.utils.rememberNumberFormatter
import java.io.File
@Composable
fun FileSearchDialog(
+ mainViewModel: MainViewModel,
searchViewModel: SearchViewModel,
projectFile: FileObject,
onFinish: () -> Unit,
@@ -59,6 +64,14 @@ fun FileSearchDialog(
val focusRequester = remember { FocusRequester() }
val context = LocalContext.current
+ val editorTab = mainViewModel.currentTab as? EditorTab
+ val textFieldState =
+ rememberTextFieldState(
+ editorTab?.editorState?.editor?.get()?.getSelectedText() ?: searchViewModel.fileSearchQuery
+ )
+
+ LaunchedEffect(textFieldState.text) { searchViewModel.fileSearchQuery = textFieldState.text.toString() }
+
LaunchedEffect(searchViewModel.isIndexing(projectFile), searchViewModel.fileSearchQuery) {
searchViewModel.launchFileSearch(context, projectFile)
}
@@ -67,9 +80,8 @@ fun FileSearchDialog(
XedDialog(onDismissRequest = onFinish, modifier = Modifier.imePadding()) {
Column(modifier = Modifier.animateContentSize().height(viewportHeight * 0.8f)) {
TextField(
- value = searchViewModel.fileSearchQuery,
- onValueChange = { searchViewModel.fileSearchQuery = it },
- maxLines = 1,
+ state = textFieldState,
+ lineLimits = TextFieldLineLimits.SingleLine,
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Search),
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester),
placeholder = { Text(text = stringResource(strings.enter_name)) },
diff --git a/core/main/src/main/java/com/rk/search/IndexDatabase.kt b/core/main/src/main/java/com/rk/search/IndexDatabase.kt
index 59ce1064e..b7167116c 100644
--- a/core/main/src/main/java/com/rk/search/IndexDatabase.kt
+++ b/core/main/src/main/java/com/rk/search/IndexDatabase.kt
@@ -68,30 +68,37 @@ abstract class IndexDatabase : RoomDatabase() {
lateinit var projectRoot: FileObject
companion object {
- @Volatile private var INSTANCES = mutableMapOf()
+ private val INSTANCES = java.util.concurrent.ConcurrentHashMap()
fun getDatabase(context: Context, projectRoot: FileObject): IndexDatabase {
return INSTANCES[projectRoot]
?: synchronized(this) {
- val instance =
- Room.databaseBuilder(
+ INSTANCES[projectRoot]
+ ?: Room.databaseBuilder(
context,
IndexDatabase::class.java,
"index_database_${projectRoot.hashCode()}",
)
- .build()
- instance.projectRoot = projectRoot
- INSTANCES[projectRoot] = instance
- instance
+ .build().apply {
+ this.projectRoot = projectRoot
+ INSTANCES[projectRoot] = this
+ }
}
}
fun removeDatabase(context: Context, projectRoot: FileObject) {
- INSTANCES[projectRoot]?.close()
- INSTANCES.remove(projectRoot)
+ closeInstance(projectRoot)
context.deleteDatabase("index_database_${projectRoot.hashCode()}")
}
+ /** Close and remove the in-memory instance for [projectRoot] without deleting the file. */
+ fun closeInstance(projectRoot: FileObject) {
+ synchronized(this) {
+ INSTANCES[projectRoot]?.close()
+ INSTANCES.remove(projectRoot)
+ }
+ }
+
suspend fun findDatabasesFor(file: FileObject): List {
var startFile: FileObject? = file
val databases = mutableListOf()
diff --git a/core/main/src/main/java/com/rk/search/SearchViewModel.kt b/core/main/src/main/java/com/rk/search/SearchViewModel.kt
index 6fb1be69c..0ef3af98d 100644
--- a/core/main/src/main/java/com/rk/search/SearchViewModel.kt
+++ b/core/main/src/main/java/com/rk/search/SearchViewModel.kt
@@ -21,257 +21,294 @@ import com.rk.settings.editor.LineEnding
import com.rk.tabs.editor.EditorTab
import com.rk.utils.hasBinaryChars
import com.rk.utils.isBinaryExtension
+import com.rk.utils.logDebug
+import com.rk.utils.logError
+import com.rk.utils.logWarn
import com.rk.utils.parseExtensions
+import com.rk.utils.toast
import java.io.File
import java.io.InputStreamReader
import java.nio.charset.Charset
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
-import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
-class SearchViewModel : ViewModel() {
- private var isIndexing = mutableStateMapOf()
- private var indexJob: Job? = null
+/** Strategy for searching file names. */
+private interface FileSearchStrategy {
+ suspend fun search(query: String, projectRoot: FileObject): List
+}
- // File search dialog
- var fileSearchQuery by mutableStateOf("")
- var isSearchingFiles by mutableStateOf(false)
- var fileSearchResults by mutableStateOf>(emptyList())
- private var fileSearchJob: Job? = null
+/** File search using indexed database. Fast, but requires index to be built. */
+private class FileSearchIndexed(private val context: Context) : FileSearchStrategy {
+ override suspend fun search(query: String, projectRoot: FileObject): List =
+ withContext(Dispatchers.IO) {
+ try {
+ IndexDatabase.getDatabase(context, projectRoot).fileMetaDao().search(query)
+ } catch (e: Exception) {
+ logError(e, "Error searching file index")
+ emptyList()
+ }
+ }
+}
- // Code search dialog
- var showFileMaskDialog by mutableStateOf(false)
- var fileMaskText by mutableStateOf(Settings.file_mask)
- var fileMask = derivedStateOf { parseExtensions(fileMaskText) }
- private val excluder by derivedStateOf { GlobExcluder(Settings.excluded_files_search) }
+/** File search using filesystem traversal. Slower but doesn't require index. */
+private class FileSearchDirect(private val excluder: GlobExcluder) : FileSearchStrategy {
+ override suspend fun search(query: String, projectRoot: FileObject): List =
+ withContext(Dispatchers.IO) {
+ val results = mutableListOf()
- var isSearchingCode by mutableStateOf(false)
- var totalCodeSearchResults by mutableIntStateOf(0)
- val codeSearchResultsOrder = mutableStateListOf()
- val codeSearchResults = mutableStateMapOf>()
- private var codeSearchJob: Job? = null
+ suspend fun searchRecursively(parent: FileObject) {
+ try {
+ val childFiles = parent.listFiles()
- var codeSearchQuery by mutableStateOf("")
- var codeReplaceQuery by mutableStateOf("")
- var showOptionsMenu by mutableStateOf(false)
- var ignoreCase by mutableStateOf(true)
- var isReplaceShown by mutableStateOf(false)
- private set
+ for (file in childFiles) {
+ currentCoroutineContext().ensureActive()
- companion object {
- // NOTE: Occurrence that are between the borders of two chunks won't be found, this is a known issue
- private const val MAX_CHUNK_SIZE = 1_000_000 // 1 MB limit per column to avoid CursorWindow crash
- const val MAX_CODE_RESULTS = 10_000 // Max amount of code search results to show in UI
- private const val MAX_FILE_SIZE_SEARCH = 10_000_000 // Max size for code search (10 MB)
- private const val CODE_BATCH_SIZE = 5_000 // Insert code mid-traversal in chunks to avoid OOM
- }
+ val path = file.getAbsolutePath()
+ if (excluder.isExcluded(path)) continue
- fun cleanupJobs(projectRoot: FileObject) {
- fileSearchJob?.cancel()
- fileSearchJob = null
+ val isHidden = file.getName().startsWith(".")
+ if (isHidden && !Settings.show_hidden_files_search) continue
- codeSearchJob?.cancel()
- codeSearchJob = null
+ if (file.getName().contains(query, ignoreCase = true)) {
+ results.add(
+ // Last modified and size do not matter here, as they're only used for indexing
+ FileMeta(path = path, fileName = file.getName(), lastModified = 0, size = 0)
+ )
+ }
- indexJob?.cancel()
- indexJob = null
- isIndexing.remove(projectRoot)
- }
+ if (file.isDirectory()) {
+ searchRecursively(file)
+ }
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ logError(e, "Error during file search")
+ }
+ }
- fun matchesFileMask(fileExt: String): Boolean {
- if (fileMask.value.isEmpty()) return true
- return fileMask.value.any { it == fileExt }
- }
+ searchRecursively(projectRoot)
+ results
+ }
+}
- fun cancelFileSearch() {
- fileSearchJob?.cancel()
- fileSearchJob = null
- isSearchingFiles = false
- }
+/** Strategy for searching code content. */
+private interface CodeSearchStrategy {
+ fun search(query: String): Flow
+}
- fun launchFileSearch(context: Context, projectRoot: FileObject) {
- cancelFileSearch()
+/** Code search using indexed database. Returns results as Flow for streaming. */
+private class CodeSearchIndexed(
+ private val context: Context,
+ private val projectRoot: FileObject,
+ private val mainViewModel: MainViewModel,
+ private val fileMaskFilter: (String) -> Boolean,
+ private val ignoreCase: Boolean,
+ private val openPaths: Set,
+) : CodeSearchStrategy {
+ override fun search(query: String): Flow = channelFlow {
+ withContext(Dispatchers.IO) {
+ try {
+ val dao = IndexDatabase.getDatabase(context, projectRoot).codeIndexDao()
+ var resultLimit = 5
+ var offset = 0
- isSearchingFiles = true
- fileSearchJob =
- viewModelScope.launch {
- fileSearchResults =
- searchFileName(
- context = context,
- projectRoot = projectRoot,
- query = fileSearchQuery,
- useIndex =
- Preference.getBoolean(
- "enable_indexing_${projectRoot.hashCode()}",
- Settings.always_index_projects,
- ),
- )
- isSearchingFiles = false
- }
- }
+ while (true) {
+ currentCoroutineContext().ensureActive()
- /** Cancels any running search */
- fun cancelCodeSearch() {
- codeSearchJob?.cancel()
- codeSearchJob = null
+ val results =
+ if (ignoreCase) {
+ dao.search(query, resultLimit, offset)
+ } else {
+ dao.searchCaseSensitive(query, resultLimit, offset)
+ }
+ if (results.isEmpty()) break
- totalCodeSearchResults = 0
- codeSearchResults.clear()
- codeSearchResultsOrder.clear()
- isSearchingCode = false
- }
+ for (result in results) {
+ try {
+ if (result.path in openPaths) continue
+ val file = File(result.path).toFileWrapper()
+ val fileExt = file.getExtension()
- /** Executes a search */
- fun launchCodeSearch(context: Context, mainViewModel: MainViewModel, projectRoot: FileObject) {
- cancelCodeSearch()
+ if (!fileMaskFilter(fileExt)) continue
- if (codeSearchQuery.isBlank()) {
- totalCodeSearchResults = 0
- codeSearchResults.clear()
- return
- }
+ val indices = SearchUtils.findAllIndices(result.content, query, ignoreCase = ignoreCase)
+ for (index in indices) {
+ val absoluteCharIndex = result.chunkStart + index
- codeSearchJob =
- viewModelScope.launch {
- isSearchingCode = true
-
- searchCode(
- context = context,
- projectRoot = projectRoot,
- query = codeSearchQuery,
- mainViewModel = mainViewModel,
- useIndex =
- Preference.getBoolean(
- "enable_indexing_${projectRoot.hashCode()}",
- Settings.always_index_projects,
- ),
- )
- .collect {
- if (totalCodeSearchResults < MAX_CODE_RESULTS) {
- totalCodeSearchResults++
- if (!codeSearchResults.containsKey(it.file)) {
- codeSearchResultsOrder.add(it.file)
+ currentCoroutineContext().ensureActive()
+ send(
+ SearchUtils.createCodeItem(
+ context = context,
+ mainViewModel = mainViewModel,
+ text = result.content,
+ charIndex = absoluteCharIndex,
+ query = query,
+ file = file,
+ projectRoot = projectRoot,
+ lineIndex = result.lineNumber,
+ )
+ )
}
- val fileList = codeSearchResults.getOrPut(it.file) { mutableStateListOf() }
- fileList.add(it)
- } else {
- isSearchingCode = false
- codeSearchJob?.cancel()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ logError(e, "Error processing indexed code result")
}
}
- isSearchingCode = false
- }
- }
-
- fun toggleReplaceShown() {
- isReplaceShown = !isReplaceShown
- }
-
- suspend fun replaceIn(mainViewModel: MainViewModel, codeItem: CodeItem) {
- withContext(Dispatchers.IO) {
- val lineIndex = codeItem.line
- val startCol = codeItem.column
- val diff = codeItem.snippet.highlight.endIndex - codeItem.snippet.highlight.startIndex
- val endCol = codeItem.column + diff
-
- if (codeItem.isOpen) {
- val tab =
- mainViewModel.tabs.filterIsInstance().find { tab -> tab.file == codeItem.file }
- ?: return@withContext
- val editor = tab.editorState.editor.get() ?: return@withContext
-
- withContext(Dispatchers.Main) {
- editor.text.replace(lineIndex, startCol, lineIndex, endCol, codeReplaceQuery)
+ offset += resultLimit
+ resultLimit = 20
}
- } else {
- val content = codeItem.file.readText() ?: return@withContext
- val lines = content.lines().toMutableList()
-
- val line = lines.getOrNull(lineIndex) ?: return@withContext
- val newLine = line.replaceRange(startCol, endCol, codeReplaceQuery)
- lines[lineIndex] = newLine
-
- val charset = Charset.forName(Settings.encoding)
- val lineEnding = LineEnding.detect(content)
-
- val normalizedContent = lines.joinToString(lineEnding.char)
- codeItem.file.writeText(normalizedContent, charset)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ logError(e, "Error searching code index")
}
}
}
+}
- fun isIndexing(projectRoot: FileObject): Boolean {
- return isIndexing[projectRoot] ?: false
- }
-
- data class IndexingStats(val totalFiles: Int, val databaseSize: Long)
-
- suspend fun getStats(context: Context, projectRoot: FileObject): IndexingStats {
- val totalFiles = getDatabase(context, projectRoot).fileMetaDao().getCount()
- val databaseSize = IndexDatabase.getDatabaseSize(context, projectRoot)
- return IndexingStats(totalFiles, databaseSize)
- }
+/** Code search using filesystem traversal. No index required. */
+private class CodeSearchDirect(
+ private val context: Context,
+ private val projectRoot: FileObject,
+ private val mainViewModel: MainViewModel,
+ private val fileMaskFilter: (String) -> Boolean,
+ private val excluder: GlobExcluder,
+ private val ignoreCase: Boolean,
+ private val openPaths: Set,
+) : CodeSearchStrategy {
- private fun getDatabase(context: Context, projectRoot: FileObject): IndexDatabase {
- return IndexDatabase.getDatabase(context, projectRoot)
+ companion object {
+ private const val MAX_CHUNK_SIZE = 1_000_000 // 1 MB limit per column
}
- suspend fun searchFileName(
- context: Context,
- projectRoot: FileObject,
- query: String,
- useIndex: Boolean = true,
- ): List {
- return if (useIndex) {
- searchFileNameWithIndex(context, projectRoot, query)
- } else {
- searchFileNameWithoutIndex(projectRoot, query)
+ override fun search(query: String): Flow = channelFlow {
+ withContext(Dispatchers.IO) {
+ try {
+ searchRecursively(parent = projectRoot, isResultHidden = false, query = query, sendFn = { send(it) })
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ logError(e, "Error during direct code search")
+ }
}
}
- private suspend fun searchFileNameWithIndex(
- context: Context,
- projectRoot: FileObject,
+ private suspend fun searchRecursively(
+ parent: FileObject,
+ isResultHidden: Boolean,
query: String,
- ): List = getDatabase(context, projectRoot).fileMetaDao().search(query)
-
- private suspend fun searchFileNameWithoutIndex(projectRoot: FileObject, query: String): List {
- val results = mutableListOf()
-
- suspend fun searchRecursively(parent: FileObject, results: MutableList) {
+ sendFn: suspend (CodeItem) -> Unit,
+ ) {
+ try {
val childFiles = parent.listFiles()
for (file in childFiles) {
+ currentCoroutineContext().ensureActive()
+
val path = file.getAbsolutePath()
+ if (path in openPaths) continue
+
+ val fileExt = file.getExtension()
+ if (file.isFile() && !fileMaskFilter(fileExt)) continue
+
if (excluder.isExcluded(path)) continue
- val isHidden = file.getName().startsWith(".")
+ val isHidden = file.getName().startsWith(".") || isResultHidden
if (isHidden && !Settings.show_hidden_files_search) continue
- if (file.getName().contains(query, ignoreCase = true)) {
- results.add(
- // Last modified and size do not matter here, as they're only used for indexing
- FileMeta(path = path, fileName = file.getName(), lastModified = 0, size = 0)
- )
+ if (file.isDirectory()) {
+ searchRecursively(file, isHidden, query, sendFn)
+ continue
}
- if (file.isDirectory()) {
- searchRecursively(file, results)
+ if (!SearchUtils.isFileSearchable(file)) continue
+ val charset = Charset.forName(Settings.encoding)
+
+ file.useInputStream { inputStream ->
+ inputStream.bufferedReader(charset).useLines { lineSequence ->
+ lineSequence.forEachIndexed { lineIndex, line ->
+ val chunks = line.chunked(MAX_CHUNK_SIZE)
+ chunks.forEachIndexed { chunkIndex, chunk ->
+ val indices = SearchUtils.findAllIndices(chunk, query, ignoreCase = ignoreCase)
+ for (index in indices) {
+ currentCoroutineContext().ensureActive()
+ val absoluteCharIndex = (chunkIndex * MAX_CHUNK_SIZE) + index
+ currentCoroutineContext().ensureActive()
+ sendFn(
+ SearchUtils.createCodeItem(
+ context = context,
+ mainViewModel = mainViewModel,
+ text = chunk,
+ charIndex = absoluteCharIndex,
+ query = query,
+ file = file,
+ projectRoot = projectRoot,
+ lineIndex = lineIndex,
+ )
+ )
+ }
+ }
+ }
+ }
}
}
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ logError(e, "Error during recursive file search")
}
+ }
+}
+
+object SearchUtils {
+ private const val MAX_FILE_SIZE_SEARCH = 10_000_000 // 10 MB limit
+
+ /**
+ * Reads the file content, returning null if it's unsuitable for searching (e.g. if it's too large or likely
+ * binary).
+ *
+ * @param file The file to read.
+ * @return The file content as a [String], or null.
+ */
+ suspend fun isFileSearchable(file: FileObject): Boolean {
+ // Do not search in file if it's over 10MB
+ if (file.length() > MAX_FILE_SIZE_SEARCH) return false
+
+ // Do not search in file if it's likely to be binary (file extension based detection)
+ val ext = file.getExtension()
+ if (isBinaryExtension(ext)) return false
+
+ val charset = Charset.forName(Settings.encoding)
- searchRecursively(projectRoot, results)
- return results
+ // Do not search in file if it's likely to be binary (character based detection)
+ val isBinary =
+ withContext(Dispatchers.IO) {
+ try {
+ file.useInputStream { stream ->
+ val buffer = CharArray(1024)
+ val charsRead = InputStreamReader(stream, charset).read(buffer, 0, buffer.size)
+ val sample = String(buffer, 0, charsRead)
+ hasBinaryChars(sample)
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ true
+ }
+ }
+ return !isBinary
}
- private fun findAllIndices(text: String, query: String, ignoreCase: Boolean): List {
+ fun findAllIndices(text: String, query: String, ignoreCase: Boolean): List {
val indices = mutableListOf()
var currentIndex = 0
@@ -286,464 +323,806 @@ class SearchViewModel : ViewModel() {
return indices
}
- fun searchCode(
+ suspend fun createCodeItem(
context: Context,
mainViewModel: MainViewModel,
- projectRoot: FileObject,
+ text: String,
+ charIndex: Int,
query: String,
- useIndex: Boolean = true,
- ): Flow =
- channelFlow {
- // Search in opened tabs
- val openedEditorTabs = mainViewModel.tabs.mapNotNull { it as? EditorTab }
- val openPaths = openedEditorTabs.map { it.file.getAbsolutePath() }.toSet()
-
- for (tab in openedEditorTabs) {
- val fileExt = tab.file.getExtension()
- if (!matchesFileMask(fileExt)) continue
-
- val editor = tab.editorState.editor.get()
- val content = editor?.text
- if (content != null) {
- val lineCount = content.lineCount
- for (lineIndex in 0 until lineCount) {
- val line = content.getLine(lineIndex).toString()
- val indices = findAllIndices(line, query, ignoreCase = ignoreCase)
- for (index in indices) {
- currentCoroutineContext().ensureActive()
- send(
- createCodeItem(
- context = context,
- mainViewModel = mainViewModel,
- text = line,
- charIndex = index,
- query = query,
- file = tab.file,
- projectRoot = projectRoot,
- lineIndex = lineIndex,
- isOpen = true,
- )
- )
- }
- }
- }
- }
-
- // Search through other files
- if (!useIndex) {
- searchCodeWithoutIndex(
- context = context,
- mainViewModel = mainViewModel,
- parent = projectRoot,
- projectRoot = projectRoot,
- query = query,
- openPaths = openPaths,
- send = ::send,
- )
- } else {
- searchCodeWithIndex(
- context = context,
- mainViewModel = mainViewModel,
- projectRoot = projectRoot,
- query = query,
- openPaths = openPaths,
- send = ::send,
- )
- }
- }
- .flowOn(Dispatchers.IO)
-
- private suspend fun searchCodeWithIndex(
- context: Context,
- mainViewModel: MainViewModel,
+ file: FileObject,
projectRoot: FileObject,
- query: String,
- openPaths: Set,
- send: suspend (CodeItem) -> Unit,
- ) {
- var resultLimit = 5
- var offset = 0
-
- val dao = getDatabase(context, projectRoot).codeIndexDao()
-
- while (true) {
- val results =
- if (ignoreCase) {
- dao.search(query, resultLimit, offset)
- } else {
- dao.searchCaseSensitive(query, resultLimit, offset)
- }
- if (results.isEmpty()) break
-
- for (result in results) {
- if (result.path in openPaths) continue
-
- val file = File(result.path).toFileWrapper()
- val fileExt = file.getExtension()
- if (!matchesFileMask(fileExt)) continue
-
- val indices = findAllIndices(result.content, query, ignoreCase = ignoreCase)
- for (index in indices) {
- val absoluteCharIndex = result.chunkStart + index
+ lineIndex: Int,
+ isOpen: Boolean = false,
+ ): CodeItem {
+ val snippetResult =
+ SnippetBuilder(context)
+ .generateSnippet(
+ text = text,
+ highlight = Highlight(charIndex, charIndex + query.length),
+ fileExt = file.getExtension(),
+ )
- currentCoroutineContext().ensureActive()
- send(
- createCodeItem(
- context = context,
- mainViewModel = mainViewModel,
- text = result.content,
- charIndex = absoluteCharIndex,
- query = query,
+ val codeItem =
+ CodeItem(
+ snippet = snippetResult,
+ file = file,
+ line = lineIndex,
+ column = charIndex,
+ isOpen = isOpen,
+ onClick = {
+ mainViewModel.viewModelScope.launch {
+ mainViewModel.editorManager.jumpToPosition(
file = file,
projectRoot = projectRoot,
- lineIndex = result.lineNumber,
+ lineStart = lineIndex,
+ charStart = charIndex,
+ lineEnd = lineIndex,
+ charEnd = charIndex + query.length,
)
- )
- }
- }
- offset += resultLimit
- resultLimit = 20
- }
+ }
+ },
+ )
+ return codeItem
}
+}
- private suspend fun searchCodeWithoutIndex(
- context: Context,
- mainViewModel: MainViewModel,
+/** Manages indexing for a single project with proper lifecycle management. */
+private class ProjectIndexer(
+ private val context: Context,
+ private val projectRoot: FileObject,
+ private val excluder: GlobExcluder,
+ private val onIndexingStateChanged: (Boolean) -> Unit,
+ private val onError: (String) -> Unit,
+ private val viewModelScope: kotlinx.coroutines.CoroutineScope,
+) {
+ companion object {
+ private const val CODE_BATCH_SIZE = 5_000
+ private const val MAX_CHUNK_SIZE = 1_000_000
+ private const val MAX_FILE_SIZE_SEARCH = 10_000_000
+ }
+
+ private var indexingJob: Job? = null
+
+ /**
+ * Starts full indexing of the project. Cancels any previous indexing job first. Index stores ALL files and code (no
+ * filtering by file_mask or excluder).
+ */
+ suspend fun startIndexing() {
+ indexingJob?.cancelAndJoin()
+
+ onIndexingStateChanged(true)
+
+ indexingJob =
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ val database =
+ try {
+ IndexDatabase.getDatabase(context, projectRoot)
+ } catch (e: Exception) {
+ logError(e, "Failed to get index database for sync, attempting recovery")
+ attemptDatabaseRecovery()
+ IndexDatabase.getDatabase(context, projectRoot)
+ }
+
+ val codeLineDao = database.codeIndexDao()
+ val fileMetaDao = database.fileMetaDao()
+
+ val indexedFiles = fileMetaDao.getAll().associateBy { it.path }
+ val pathsToKeep = mutableSetOf()
+ val newCodeLines = mutableListOf()
+ val newFileMetas = mutableListOf()
+
+ indexRecursively(projectRoot, indexedFiles, pathsToKeep, newCodeLines, newFileMetas, codeLineDao)
+
+ finalizeIndex(
+ database,
+ indexedFiles,
+ pathsToKeep,
+ codeLineDao,
+ fileMetaDao,
+ newCodeLines,
+ newFileMetas,
+ )
+
+ logDebug("Indexing completed for $projectRoot")
+ } catch (e: CancellationException) {
+ logDebug("Indexing cancelled for $projectRoot")
+ throw e
+ } catch (e: Exception) {
+ logError(e, "Error during indexing")
+ onError("Indexing failed: ${e.message}")
+ } finally {
+ onIndexingStateChanged(false)
+ }
+ }
+ }
+
+ /** Incremental sync of a specific file or directory. Only re-indexes changed files under the given path. */
+ suspend fun syncFile(file: FileObject) {
+ indexingJob?.cancelAndJoin()
+
+ onIndexingStateChanged(true)
+
+ indexingJob =
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ val database =
+ try {
+ IndexDatabase.getDatabase(context, projectRoot)
+ } catch (e: Exception) {
+ logError(e, "Failed to get index database for sync, attempting recovery")
+ attemptDatabaseRecovery()
+ IndexDatabase.getDatabase(context, projectRoot)
+ }
+
+ val codeLineDao = database.codeIndexDao()
+ val fileMetaDao = database.fileMetaDao()
+
+ val allIndexedFiles = fileMetaDao.getAll().associateBy { it.path }
+
+ // Only consider files under the changed path
+ val relevantIndexedFiles =
+ if (file == projectRoot) {
+ allIndexedFiles
+ } else {
+ allIndexedFiles.filter { it.key.startsWith(file.getAbsolutePath()) }
+ }
+
+ val pathsToKeep = mutableSetOf()
+ val newCodeLines = mutableListOf()
+ val newFileMetas = mutableListOf()
+
+ if (file.isDirectory()) {
+ indexRecursively(
+ parent = file,
+ indexedFiles = relevantIndexedFiles,
+ pathsToKeep = pathsToKeep,
+ codeLineResults = newCodeLines,
+ fileMetaResults = newFileMetas,
+ codeLineDao = codeLineDao,
+ )
+ } else {
+ indexFile(
+ file = file,
+ indexedFiles = relevantIndexedFiles,
+ pathsToKeep = pathsToKeep,
+ codeLineResults = newCodeLines,
+ fileMetaResults = newFileMetas,
+ codeLineDao = codeLineDao,
+ )
+ }
+
+ finalizeIndex(
+ database = database,
+ indexedFiles = relevantIndexedFiles,
+ pathsToKeep = pathsToKeep,
+ codeLineDao = codeLineDao,
+ fileMetaDao = fileMetaDao,
+ newCodeLines = newCodeLines,
+ newFileMetas = newFileMetas,
+ )
+
+ logDebug("Sync completed for $file")
+ } catch (e: CancellationException) {
+ logDebug("Sync cancelled for $file")
+ throw e
+ } catch (e: Exception) {
+ logError(e, "Error during file sync")
+ onError("Sync failed: ${e.message}")
+ } finally {
+ onIndexingStateChanged(false)
+ }
+ }
+
+ indexingJob?.join()
+ }
+
+ /** Cancels any ongoing indexing operation and waits for it to complete. */
+ suspend fun cancelIndexing() {
+ indexingJob?.cancelAndJoin()
+ indexingJob = null
+ onIndexingStateChanged(false)
+ }
+
+ /** Closes the database and cleans up resources. Does NOT delete the database file. */
+ fun closeDatabase() {
+ try {
+ IndexDatabase.closeInstance(projectRoot)
+ logDebug("Closed index database for $projectRoot")
+ } catch (e: Exception) {
+ logError(e, "Error closing database")
+ }
+ }
+
+ private suspend fun attemptDatabaseRecovery() {
+ return withContext(Dispatchers.IO) {
+ try {
+ logWarn("Attempting database recovery by deleting corrupt database")
+ IndexDatabase.removeDatabase(context, projectRoot)
+ onError("Index was corrupted and has been rebuilt. Please try your search again.")
+ } catch (e: Exception) {
+ logError(e, "Failed to recover database")
+ }
+ }
+ }
+
+ /** Gets current indexing statistics. */
+ suspend fun getStats(): SearchViewModel.IndexingStats {
+ return withContext(Dispatchers.IO) {
+ try {
+ val database = IndexDatabase.getDatabase(context, projectRoot)
+ val totalFiles = database.fileMetaDao().getCount()
+ val databaseSize = IndexDatabase.getDatabaseSize(context, projectRoot)
+ SearchViewModel.IndexingStats(totalFiles, databaseSize)
+ } catch (e: Exception) {
+ logError(e, "Error getting indexing stats")
+ SearchViewModel.IndexingStats(0, 0)
+ }
+ }
+ }
+
+ private suspend fun indexRecursively(
parent: FileObject,
- projectRoot: FileObject,
- query: String,
- openPaths: Set,
- send: suspend (CodeItem) -> Unit,
+ indexedFiles: Map,
+ pathsToKeep: MutableSet,
+ codeLineResults: MutableList,
+ fileMetaResults: MutableList,
+ codeLineDao: CodeLineDao,
isResultHidden: Boolean = false,
) {
- val childFiles = parent.listFiles()
+ try {
+ val childFiles = parent.listFiles()
+
+ for (file in childFiles) {
+ currentCoroutineContext().ensureActive()
+ indexFile(
+ file = file,
+ indexedFiles = indexedFiles,
+ pathsToKeep = pathsToKeep,
+ codeLineResults = codeLineResults,
+ fileMetaResults = fileMetaResults,
+ codeLineDao = codeLineDao,
+ isResultHidden = isResultHidden,
+ )
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ logError(e, "Error during recursive indexing")
+ }
+ }
+
+ private suspend fun indexFile(
+ file: FileObject,
+ indexedFiles: Map,
+ pathsToKeep: MutableSet,
+ codeLineResults: MutableList,
+ fileMetaResults: MutableList,
+ codeLineDao: CodeLineDao,
+ isResultHidden: Boolean = false,
+ ) {
+ try {
+ val isHidden = file.getName().startsWith(".") || isResultHidden
+ if (isHidden && !Settings.show_hidden_files_search) return
- for (file in childFiles) {
val path = file.getAbsolutePath()
- if (path in openPaths) continue
+ val lastModified = file.lastModified()
- val fileExt = file.getExtension()
- if (file.isFile() && !matchesFileMask(fileExt)) continue
+ if (excluder.isExcluded(path)) return
- if (excluder.isExcluded(path)) continue
+ val indexedFile = indexedFiles[path]
+ val isFileModified =
+ indexedFile == null || indexedFile.lastModified != lastModified || indexedFile.size != file.length()
- val isHidden = file.getName().startsWith(".") || isResultHidden
- if (isHidden && !Settings.show_hidden_files_search) continue
+ if (!isFileModified) {
+ pathsToKeep += path
+ if (!file.isDirectory()) return
+ } else {
+ fileMetaResults.add(
+ FileMeta(path = path, fileName = file.getName(), lastModified = lastModified, size = file.length())
+ )
+ }
if (file.isDirectory()) {
- searchCodeWithoutIndex(
- context = context,
- mainViewModel = mainViewModel,
+ indexRecursively(
parent = file,
- projectRoot = projectRoot,
- query = query,
- openPaths = openPaths,
- send = send,
- isResultHidden = isResultHidden,
+ indexedFiles = indexedFiles,
+ pathsToKeep = pathsToKeep,
+ codeLineResults = codeLineResults,
+ fileMetaResults = fileMetaResults,
+ codeLineDao = codeLineDao,
+ isResultHidden = isHidden,
)
- continue
+ return
}
- if (!isFileSearchable(file)) continue
- val charset = Charset.forName(Settings.encoding)
+ if (!SearchUtils.isFileSearchable(file)) return
+ val charset = Charset.forName(Settings.encoding)
file.useInputStream { inputStream ->
inputStream.bufferedReader(charset).useLines { lineSequence ->
lineSequence.forEachIndexed { lineIndex, line ->
+ currentCoroutineContext().ensureActive()
+
val chunks = line.chunked(MAX_CHUNK_SIZE)
chunks.forEachIndexed { chunkIndex, chunk ->
- val indices = findAllIndices(chunk, query, ignoreCase = ignoreCase)
- for (index in indices) {
- val absoluteCharIndex = (chunkIndex * MAX_CHUNK_SIZE) + index
- currentCoroutineContext().ensureActive()
- send(
- createCodeItem(
- context = context,
- mainViewModel = mainViewModel,
- text = chunk,
- charIndex = absoluteCharIndex,
- query = query,
- file = file,
- projectRoot = projectRoot,
- lineIndex = lineIndex,
- )
+ codeLineResults.add(
+ CodeLine(
+ content = chunk,
+ path = path,
+ lineNumber = lineIndex,
+ chunkStart = chunkIndex * MAX_CHUNK_SIZE,
)
+ )
+
+ // Flush batch to avoid OOM
+ if (codeLineResults.size > CODE_BATCH_SIZE) {
+ codeLineDao.insertAll(codeLineResults)
+ codeLineResults.clear()
}
}
}
}
}
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ logError(e, "Error indexing file: ${file.getAbsolutePath()}")
}
}
- private suspend fun createCodeItem(
- context: Context,
- mainViewModel: MainViewModel,
- text: String,
- charIndex: Int,
- query: String,
- file: FileObject,
- projectRoot: FileObject,
- lineIndex: Int,
- isOpen: Boolean = false,
- ): CodeItem {
- val snippetResult =
- SnippetBuilder(context)
- .generateSnippet(
- text = text,
- highlight = Highlight(charIndex, charIndex + query.length),
- fileExt = file.getExtension(),
- )
+ private suspend fun finalizeIndex(
+ database: IndexDatabase,
+ indexedFiles: Map,
+ pathsToKeep: MutableSet,
+ codeLineDao: CodeLineDao,
+ fileMetaDao: FileMetaDao,
+ newCodeLines: MutableList,
+ newFileMetas: MutableList,
+ ) {
+ return withContext(Dispatchers.IO) {
+ try {
+ currentCoroutineContext().ensureActive()
+
+ database.withTransaction {
+ // Delete files that are no longer present or were modified
+ val deletedPaths = indexedFiles.keys - pathsToKeep
+ for (path in deletedPaths) {
+ codeLineDao.deleteByPath(path)
+ fileMetaDao.deleteByPath(path)
+ }
- val codeItem =
- CodeItem(
- snippet = snippetResult,
- file = file,
- line = lineIndex,
- column = charIndex,
- isOpen = isOpen,
- onClick = {
- viewModelScope.launch {
- mainViewModel.editorManager.jumpToPosition(
- file = file,
- projectRoot = projectRoot,
- lineStart = lineIndex,
- charStart = charIndex,
- lineEnd = lineIndex,
- charEnd = charIndex + query.length,
- )
+ // Insert new/updated entries
+ if (newCodeLines.isNotEmpty()) {
+ codeLineDao.insertAll(newCodeLines)
}
- },
- )
- return codeItem
+ if (newFileMetas.isNotEmpty()) {
+ fileMetaDao.insertAll(newFileMetas)
+ }
+ }
+ } catch (e: Exception) {
+ logError(e, "Error finalizing index")
+ throw e
+ }
+ }
}
+}
- /**
- * Reads the file content, returning null if it's unsuitable for searching (e.g. if it's too large or likely
- * binary).
- *
- * @param file The file to read.
- * @return The file content as a [String], or null.
- */
- private suspend fun isFileSearchable(file: FileObject): Boolean {
- // Do not search in file if it's over 10MB
- if (file.length() > MAX_FILE_SIZE_SEARCH) return false
+class SearchViewModel : ViewModel() {
+ private val projectIndexers = mutableMapOf()
+ private var isIndexing = mutableStateMapOf()
- // Do not search in file if it's likely to be binary (file extension based detection)
- val ext = file.getExtension()
- if (isBinaryExtension(ext)) return false
+ // File search dialog
+ var fileSearchQuery by mutableStateOf("")
+ var isSearchingFiles by mutableStateOf(false)
+ var fileSearchResults by mutableStateOf>(emptyList())
+ private var fileSearchJob: Job? = null
- val charset = Charset.forName(Settings.encoding)
+ // Code search dialog
+ var showFileMaskDialog by mutableStateOf(false)
+ var fileMaskText by mutableStateOf(Settings.file_mask)
+ var fileMask = derivedStateOf { parseExtensions(fileMaskText) }
+ private val excluder by derivedStateOf { GlobExcluder(Settings.excluded_files_search) }
- // Do not search in file if it's likely to be binary (character based detection)
- val isBinary =
- withContext(Dispatchers.IO) {
- try {
- file.useInputStream { stream ->
- val buffer = CharArray(1024)
- val charsRead = InputStreamReader(stream, charset).read(buffer, 0, buffer.size)
- val sample = String(buffer, 0, charsRead)
- hasBinaryChars(sample)
- }
- } catch (e: Exception) {
- e.printStackTrace()
- true
- }
- }
- return !isBinary
+ var isSearchingCode by mutableStateOf(false)
+ var totalCodeSearchResults by mutableIntStateOf(0)
+ val codeSearchResultsOrder = mutableStateListOf()
+ val codeSearchResults = mutableStateMapOf>()
+ private var codeSearchJob: Job? = null
+
+ var codeSearchQuery by mutableStateOf("")
+ var codeReplaceQuery by mutableStateOf("")
+ var showOptionsMenu by mutableStateOf(false)
+ var ignoreCase by mutableStateOf(true)
+ var isReplaceShown by mutableStateOf(false)
+ private set
+
+ companion object {
+ // TODO: Occurrence that are between the borders of two chunks won't be found, this is a known issue
+ const val MAX_CODE_RESULTS = 10_000 // Cap at 10k entries for code search results
}
- suspend fun index(context: Context, projectRoot: FileObject) {
- isIndexing[projectRoot] = true
+ var isReplacing by mutableStateOf(false)
- val database = getDatabase(context, projectRoot)
- val codeLineDao = database.codeIndexDao()
- val fileMetaDao = database.fileMetaDao()
+ fun cancelFileSearch() {
+ fileSearchJob?.cancel()
+ fileSearchJob = null
+ isSearchingFiles = false
+ }
- val indexedFiles = fileMetaDao.getAll().associateBy { it.path }
- val pathsToKeep = mutableSetOf()
+ fun matchesFileMask(fileExt: String): Boolean {
+ if (fileMask.value.isEmpty()) return true
+ return fileMask.value.any { it == fileExt }
+ }
- val newCodeLines = mutableListOf()
- val newFileMetas = mutableListOf()
+ fun launchFileSearch(context: Context, projectRoot: FileObject) {
+ cancelFileSearch()
- try {
- suspend fun flushBatch() = this@SearchViewModel.flushBatch(codeLineDao, newCodeLines)
+ isSearchingFiles = true
+ fileSearchJob =
+ viewModelScope.launch {
+ try {
+ val useIndex =
+ Preference.getBoolean(
+ "enable_indexing_${projectRoot.hashCode()}",
+ Settings.always_index_projects,
+ )
- indexRecursively(projectRoot, indexedFiles, pathsToKeep, newCodeLines, newFileMetas, ::flushBatch)
- finalizeIndex(database, indexedFiles, pathsToKeep, codeLineDao, fileMetaDao, newCodeLines, newFileMetas)
- } finally {
- isIndexing[projectRoot] = false
- }
+ val strategy: FileSearchStrategy =
+ if (useIndex) {
+ FileSearchIndexed(context)
+ } else {
+ FileSearchDirect(excluder)
+ }
+
+ fileSearchResults = strategy.search(fileSearchQuery, projectRoot)
+ } catch (_: CancellationException) {
+ logDebug("File search cancelled")
+ } catch (e: Exception) {
+ logError(e, "Error during file search")
+ fileSearchResults = emptyList()
+ } finally {
+ isSearchingFiles = false
+ }
+ }
}
- fun syncIndex(file: FileObject) {
- indexJob =
- viewModelScope.launch(Dispatchers.IO) {
- val databases = IndexDatabase.findDatabasesFor(file)
- for (database in databases) {
- isIndexing[database.projectRoot] = true
+ /** Cancels any running search */
+ fun cancelCodeSearch() {
+ codeSearchJob?.cancel()
+ codeSearchJob = null
- val codeLineDao = database.codeIndexDao()
- val fileMetaDao = database.fileMetaDao()
+ totalCodeSearchResults = 0
+ codeSearchResults.clear()
+ codeSearchResultsOrder.clear()
+ isSearchingCode = false
+ }
- val indexedFiles = fileMetaDao.getAll().associateBy { it.path }
- val filteredIndexedFiles = indexedFiles.filter { it.key.startsWith(file.getAbsolutePath()) }
- val pathsToKeep = mutableSetOf()
+ fun launchCodeSearch(context: Context, mainViewModel: MainViewModel, projectRoot: FileObject) {
+ cancelCodeSearch()
- val newCodeLines = mutableListOf()
- val newFileMetas = mutableListOf()
+ if (codeSearchQuery.isBlank()) {
+ totalCodeSearchResults = 0
+ codeSearchResults.clear()
+ return
+ }
- try {
- suspend fun flushBatch() = this@SearchViewModel.flushBatch(codeLineDao, newCodeLines)
+ isSearchingCode = true
+ codeSearchJob =
+ viewModelScope.launch {
+ try {
+ val useIndex =
+ Preference.getBoolean(
+ "enable_indexing_${projectRoot.hashCode()}",
+ Settings.always_index_projects,
+ )
- if (file == database.projectRoot) {
- indexRecursively(file, indexedFiles, pathsToKeep, newCodeLines, newFileMetas, ::flushBatch)
+ val openedEditorTabs = mainViewModel.tabs.mapNotNull { it as? EditorTab }
+ val openPaths = openedEditorTabs.map { it.file.getAbsolutePath() }.toSet()
+
+ // Emit results from open editor tabs first
+ scanOpenTabs(openedEditorTabs, context, mainViewModel, projectRoot)
+
+ // Search in remaining files
+ val strategy: CodeSearchStrategy =
+ if (useIndex) {
+ CodeSearchIndexed(
+ context = context,
+ projectRoot = projectRoot,
+ mainViewModel = mainViewModel,
+ fileMaskFilter = ::matchesFileMask,
+ ignoreCase = ignoreCase,
+ openPaths = openPaths,
+ )
} else {
- indexFile(file, indexedFiles, pathsToKeep, newCodeLines, newFileMetas, ::flushBatch)
+ CodeSearchDirect(
+ context = context,
+ projectRoot = projectRoot,
+ mainViewModel = mainViewModel,
+ fileMaskFilter = ::matchesFileMask,
+ excluder = excluder,
+ ignoreCase = ignoreCase,
+ openPaths = openPaths,
+ )
}
- finalizeIndex(
- database,
- filteredIndexedFiles,
- pathsToKeep,
- codeLineDao,
- fileMetaDao,
- newCodeLines,
- newFileMetas,
- )
- } finally {
- isIndexing[database.projectRoot] = false
+ strategy.search(codeSearchQuery).collect { codeItem ->
+ if (totalCodeSearchResults < MAX_CODE_RESULTS) {
+ addCodeResult(codeItem)
+ totalCodeSearchResults++
+ } else {
+ isSearchingCode = false
+ codeSearchJob?.cancel()
+ }
}
+ } catch (_: CancellationException) {
+ logDebug("Code search cancelled")
+ } catch (e: Exception) {
+ logError(e, "Error during code search")
+ } finally {
+ isSearchingCode = false
}
}
}
- fun deleteIndex(context: Context, projectRoot: FileObject) {
- cleanupJobs(projectRoot)
- IndexDatabase.removeDatabase(context, projectRoot)
+ private suspend fun scanOpenTabs(
+ openedEditorTabs: List,
+ context: Context,
+ mainViewModel: MainViewModel,
+ projectRoot: FileObject,
+ ) {
+ for (tab in openedEditorTabs) {
+ val fileExt = tab.file.getExtension()
+ if (!matchesFileMask(fileExt)) continue
+
+ val editor = tab.editorState.editor.get()
+ val content = editor?.text
+ if (content != null) {
+ val lineCount = content.lineCount
+ for (lineIndex in 0 until lineCount) {
+
+ val line = content.getLine(lineIndex).toString()
+ val indices = SearchUtils.findAllIndices(line, codeSearchQuery, ignoreCase)
+ for (index in indices) {
+ currentCoroutineContext().ensureActive()
+
+ val codeItem =
+ SearchUtils.createCodeItem(
+ context = context,
+ mainViewModel = mainViewModel,
+ text = line,
+ charIndex = index,
+ query = codeSearchQuery,
+ file = tab.file,
+ projectRoot = projectRoot,
+ lineIndex = lineIndex,
+ isOpen = true,
+ )
+
+ addCodeResult(codeItem)
+ totalCodeSearchResults++
+ }
+ }
+ }
+ }
}
- // Called mid-traversal (to reduce memory allocation size of newCodeLines and newFileMetas)
- private suspend fun flushBatch(codeLineDao: CodeLineDao, newCodeLines: MutableList) {
- if (newCodeLines.size > CODE_BATCH_SIZE) {
- codeLineDao.insertAll(newCodeLines)
- newCodeLines.clear()
+ private fun addCodeResult(codeItem: CodeItem) {
+ if (!codeSearchResults.containsKey(codeItem.file)) {
+ codeSearchResultsOrder.add(codeItem.file)
}
+ val fileList = codeSearchResults.getOrPut(codeItem.file) { mutableStateListOf() }
+ fileList.add(codeItem)
}
- // Only called once at the end of indexing and sync (handles deletions + remaining inserts)
- private suspend fun finalizeIndex(
- database: IndexDatabase,
- indexedFiles: Map,
- pathsToKeep: MutableSet,
- codeLineDao: CodeLineDao,
- fileMetaDao: FileMetaDao,
- newCodeLines: MutableList,
- newFileMetas: MutableList,
- ) {
- currentCoroutineContext().ensureActive()
+ fun toggleReplaceShown() {
+ isReplaceShown = !isReplaceShown
+ }
+
+ suspend fun replaceIn(context: Context, mainViewModel: MainViewModel, projectRoot: FileObject, codeItem: CodeItem) {
+ // Pause searches while replacing
+ cancelCodeSearch()
+ isReplacing = true
- database.withTransaction {
- val deletedPaths = indexedFiles.keys - pathsToKeep
- deletedPaths.forEach { path ->
- codeLineDao.deleteByPath(path)
- fileMetaDao.deleteByPath(path)
+ try {
+ withContext(Dispatchers.IO) {
+ val lineIndex = codeItem.line
+ val startCol = codeItem.column
+ val diff = codeItem.snippet.highlight.endIndex - codeItem.snippet.highlight.startIndex
+ val endCol = codeItem.column + diff
+
+ if (codeItem.isOpen) {
+ val tab =
+ mainViewModel.tabs.filterIsInstance().find { tab -> tab.file == codeItem.file }
+ ?: return@withContext
+ val editor = tab.editorState.editor.get() ?: return@withContext
+
+ withContext(Dispatchers.Main) {
+ editor.text.replace(lineIndex, startCol, lineIndex, endCol, codeReplaceQuery)
+ }
+ } else {
+ val content = codeItem.file.readText() ?: return@withContext
+ val lines = content.lines().toMutableList()
+
+ val line = lines.getOrNull(lineIndex) ?: return@withContext
+ val newLine = line.replaceRange(startCol, endCol, codeReplaceQuery)
+ lines[lineIndex] = newLine
+
+ val charset = Charset.forName(Settings.encoding)
+ val lineEnding = LineEnding.detect(content)
+
+ val normalizedContent = lines.joinToString(lineEnding.char)
+ codeItem.file.writeText(normalizedContent, charset)
+ }
}
- codeLineDao.insertAll(newCodeLines)
- fileMetaDao.insertAll(newFileMetas)
+ // After replacing, update index for this file if indexing enabled for project
+ try {
+ val useIndex =
+ Preference.getBoolean("enable_indexing_${projectRoot.hashCode()}", Settings.always_index_projects)
+ if (useIndex) {
+ val indexer = getOrCreateIndexer(context, projectRoot)
+ indexer.syncFile(codeItem.file)
+ }
+ } catch (e: Exception) {
+ logWarn("Index sync after replace failed: ${e.message}")
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ logError(e, "Error replacing text")
+ } finally {
+ isReplacing = false
}
}
- private suspend fun indexRecursively(
- parent: FileObject,
- indexedFiles: Map,
- pathsToKeep: MutableSet,
- codeLineResults: MutableList,
- fileMetaResults: MutableList,
- flushBatch: suspend () -> Unit,
- isResultHidden: Boolean = false,
- ) {
- val childFiles = parent.listFiles()
-
- for (file in childFiles) {
- currentCoroutineContext().ensureActive()
- indexFile(
- file,
- indexedFiles,
- pathsToKeep,
- codeLineResults,
- fileMetaResults,
- flushBatch = flushBatch,
- isResultHidden = isResultHidden,
- )
+ suspend fun replaceAllIn(context: Context, mainViewModel: MainViewModel, projectRoot: FileObject, codeItems: List) {
+ if (codeItems.isEmpty()) return
+ cancelCodeSearch()
+ isReplacing = true
+
+ try {
+ val groupedItems = codeItems.groupBy { it.file }
+
+ withContext(Dispatchers.IO) {
+ for ((file, items) in groupedItems) {
+ val itemsSorted = items.sortedWith(compareByDescending { it.line }.thenByDescending { it.column })
+ val firstItem = itemsSorted.first()
+ if (firstItem.isOpen) {
+ val tab = mainViewModel.tabs.filterIsInstance().find { tab -> tab.file == file }
+ val editor = tab?.editorState?.editor?.get()
+ if (editor != null) {
+ withContext(Dispatchers.Main) {
+ for (codeItem in itemsSorted) {
+ val lineIndex = codeItem.line
+ val startCol = codeItem.column
+ val diff = codeItem.snippet.highlight.endIndex - codeItem.snippet.highlight.startIndex
+ val endCol = codeItem.column + diff
+ editor.text.replace(lineIndex, startCol, lineIndex, endCol, codeReplaceQuery)
+ }
+ }
+ }
+ } else {
+ val content = file.readText() ?: continue
+ val lines = content.lines().toMutableList()
+
+ for (codeItem in itemsSorted) {
+ val lineIndex = codeItem.line
+ val startCol = codeItem.column
+ val diff = codeItem.snippet.highlight.endIndex - codeItem.snippet.highlight.startIndex
+ val endCol = codeItem.column + diff
+
+ val line = lines.getOrNull(lineIndex) ?: continue
+ val newLine = line.replaceRange(startCol, endCol, codeReplaceQuery)
+ lines[lineIndex] = newLine
+ }
+
+ val charset = Charset.forName(Settings.encoding)
+ val lineEnding = LineEnding.detect(content)
+ val normalizedContent = lines.joinToString(lineEnding.char)
+ file.writeText(normalizedContent, charset)
+ }
+
+ syncIndex(file)
+ }
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ logError(e, "Error replacing all text")
+ } finally {
+ isReplacing = false
}
}
- private suspend fun indexFile(
- file: FileObject,
- indexedFiles: Map,
- pathsToKeep: MutableSet,
- codeLineResults: MutableList,
- fileMetaResults: MutableList,
- flushBatch: suspend () -> Unit,
- isResultHidden: Boolean = false,
- ) {
- val isHidden = file.getName().startsWith(".") || isResultHidden
- if (isHidden && !Settings.show_hidden_files_search) return
-
- val path = file.getAbsolutePath()
- val lastModified = file.lastModified()
-
- if (excluder.isExcluded(path)) return
-
- val indexedFile = indexedFiles[path]
- val isFileModified =
- indexedFile == null || indexedFile.lastModified != lastModified || indexedFile.size != file.length()
- if (!isFileModified) {
- pathsToKeep += path
- if (!file.isDirectory()) return
- } else {
- fileMetaResults.add(
- FileMeta(path = path, fileName = file.getName(), lastModified = lastModified, size = file.length())
- )
- flushBatch()
+ fun isIndexing(projectRoot: FileObject): Boolean {
+ return isIndexing[projectRoot] ?: false
+ }
+
+ suspend fun index(context: Context, projectRoot: FileObject) {
+ val indexer = getOrCreateIndexer(context, projectRoot)
+ indexer.startIndexing()
+ }
+
+ fun deleteIndex(context: Context, projectRoot: FileObject) {
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ val indexer = projectIndexers[projectRoot]
+ if (indexer != null) {
+ indexer.cancelIndexing()
+ projectIndexers.remove(projectRoot)
+ }
+ IndexDatabase.removeDatabase(context, projectRoot)
+ isIndexing.remove(projectRoot)
+ } catch (e: Exception) {
+ logError(e, "Error deleting index")
+ }
}
+ }
- if (file.isDirectory()) {
- indexRecursively(
- parent = file,
- indexedFiles = indexedFiles,
- pathsToKeep = pathsToKeep,
- codeLineResults = codeLineResults,
- fileMetaResults = fileMetaResults,
- flushBatch = flushBatch,
- isResultHidden = isHidden,
- )
- return
+ fun syncIndex(file: FileObject) {
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ val databases = IndexDatabase.findDatabasesFor(file)
+ for (database in databases) {
+ val indexer = projectIndexers[database.projectRoot]
+ indexer?.syncFile(file)
+ }
+ } catch (e: Exception) {
+ logError(e, "Error syncing index")
+ }
}
+ }
- if (!isFileSearchable(file)) return
- val charset = Charset.forName(Settings.encoding)
+ data class IndexingStats(val totalFiles: Int, val databaseSize: Long)
- file.useInputStream { inputStream ->
- inputStream.bufferedReader(charset).useLines { lineSequence ->
- lineSequence.forEachIndexed { lineIndex, line ->
- val chunks = line.chunked(MAX_CHUNK_SIZE)
- chunks.forEachIndexed { chunkIndex, chunk ->
- currentCoroutineContext().ensureActive()
- codeLineResults.add(
- CodeLine(
- content = chunk,
- path = path,
- lineNumber = lineIndex,
- chunkStart = chunkIndex * MAX_CHUNK_SIZE,
- )
- )
- flushBatch()
- }
+ suspend fun getStats(context: Context, projectRoot: FileObject): IndexingStats {
+ return withContext(Dispatchers.IO) {
+ try {
+ val indexer = getOrCreateIndexer(context, projectRoot)
+ indexer.getStats()
+ } catch (e: Exception) {
+ logError(e, "Error getting stats")
+ IndexingStats(0, 0)
+ }
+ }
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+
+ fileSearchJob?.cancel()
+ fileSearchJob = null
+ codeSearchJob?.cancel()
+ codeSearchJob = null
+
+ viewModelScope.launch {
+ for ((_, indexer) in projectIndexers) {
+ try {
+ indexer.cancelIndexing()
+ indexer.closeDatabase()
+ } catch (e: Exception) {
+ logError(e, "Error cleaning up indexer")
}
}
+ projectIndexers.clear()
+ isIndexing.clear()
+ }
+ }
+
+ private fun getOrCreateIndexer(context: Context, projectRoot: FileObject): ProjectIndexer {
+ return projectIndexers.getOrPut(projectRoot) {
+ ProjectIndexer(
+ context = context,
+ projectRoot = projectRoot,
+ onIndexingStateChanged = { isIndexing -> this.isIndexing[projectRoot] = isIndexing },
+ onError = { errorMessage ->
+ logError("Indexer error: $errorMessage")
+ toast("Indexer error: $errorMessage")
+ },
+ viewModelScope = viewModelScope,
+ excluder = excluder,
+ )
}
}
}
diff --git a/core/main/src/main/java/com/rk/settings/Settings.kt b/core/main/src/main/java/com/rk/settings/Settings.kt
index 5df50dc94..13231a7d4 100644
--- a/core/main/src/main/java/com/rk/settings/Settings.kt
+++ b/core/main/src/main/java/com/rk/settings/Settings.kt
@@ -7,8 +7,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.edit
+import com.rk.commands.ToolbarConfiguration
import com.rk.filetree.SortMode
-import com.rk.settings.editor.DEFAULT_ACTION_ITEMS
import com.rk.settings.editor.DEFAULT_EXCLUDED_FILES_DRAWER
import com.rk.settings.editor.DEFAULT_EXCLUDED_FILES_SEARCH
import com.rk.settings.editor.DEFAULT_EXTRA_KEYS_COMMANDS
@@ -19,6 +19,8 @@ import com.rk.utils.application
import com.rk.utils.hasHardwareKeyboard
import com.rk.xededitor.BuildConfig
import com.termux.terminal.TerminalEmulator
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
import java.lang.ref.WeakReference
import java.nio.charset.Charset
import kotlin.properties.ReadWriteProperty
@@ -26,8 +28,6 @@ import kotlin.reflect.KClass
import kotlin.reflect.KProperty
import kotlin.reflect.full.declaredMemberProperties
import kotlin.reflect.jvm.isAccessible
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
// NOTE: USE snake_case FOR KEYS!
object Settings {
@@ -36,6 +36,7 @@ object Settings {
var read_only_default by CachedPreference("read_only_default", false)
var shown_disclaimer by CachedPreference("shown_disclaimer", false)
+ var warn_extensions by CachedPreference("warn_extensions", true)
var amoled by CachedPreference("amoled", false)
var monet by CachedPreference("monet", false)
var pin_line_number by CachedPreference("pin_line_number", false)
@@ -72,7 +73,7 @@ object Settings {
var sandbox by CachedPreference("sandbox", true)
var terminal_virus_notice by CachedPreference("terminal_virus_notice", false)
var textmate_suggestions by CachedPreference("textmate_suggestions", true)
- var seccomp by CachedPreference("seccomp", false)
+ var seccomp_mode by CachedPreference("seccomp_mode", "unspecified")
var desktop_mode by CachedPreference("desktop_mode", false)
var theme_flipper by CachedPreference("theme_flipper", false)
var format_on_save by CachedPreference("format_on_save", false)
@@ -84,9 +85,6 @@ object Settings {
var extra_keys_bg by CachedPreference("extra_keys_bg", false)
var auto_open_new_files by CachedPreference("auto_open_new_files", true)
var complete_on_enter by CachedPreference("complete_on_enter", true)
- var enable_html_runner by CachedPreference("enable_html_runner", true)
- var enable_md_runner by CachedPreference("enable_md_runner", true)
- var enable_universal_runner by CachedPreference("enable_universal_runner", true)
var http_server_port by CachedPreference("http_server_port", 8357)
var launch_in_browser by CachedPreference("launch_in_browser", false)
var inject_eruda by CachedPreference("inject_eruda", true)
@@ -104,6 +102,7 @@ object Settings {
var show_minimap by CachedPreference("show_minimap", false)
var auto_closing_bracket by CachedPreference("auto_closing_bracket", true)
var confirm_exit by CachedPreference("confirm_exit", true)
+ var terminal_clipboard_keybindings by CachedPreference("terminal_clipboard_keybindings", true)
// Int settings
var tab_size by CachedPreference("tab_size", 4)
@@ -124,7 +123,6 @@ object Settings {
var sort_mode by CachedPreference("sort_mode", SortMode.SORT_BY_NAME.ordinal)
// String settings
- var selected_project by CachedPreference("selected_project", "")
var font_gson by CachedPreference("selected_font", "")
var theme by CachedPreference("theme", blueberry.id)
var icon_pack: String by CachedPreference("icon_pack", "")
@@ -148,6 +146,7 @@ object Settings {
var excluded_files_drawer by
CachedPreference("excluded_files_drawer", DEFAULT_EXCLUDED_FILES_DRAWER.joinToString("\n"))
var file_mask by CachedPreference("file_mask", "")
+ var formatters by CachedPreference("formatters", "")
// Long settings
var last_update_check_timestamp by CachedPreference("last_update", 0L)
@@ -157,7 +156,23 @@ object Settings {
var line_spacing by CachedPreference("line_spacing", 1f)
var last_used_command by CachedPreference("last_used_command", "")
- var action_items by CachedPreference("action_items", DEFAULT_ACTION_ITEMS)
+ var action_items by CachedPreference("action_items", ToolbarConfiguration.DEFAULT_EDITOR_TOOLBAR_COMMANDS)
+
+ init {
+ if (Preference.getAll().containsKey("seccomp")) {
+ try {
+ val legacySeccomp = Preference.getBoolean("seccomp", false)
+ if (legacySeccomp) {
+ Preference.setString("seccomp_mode", "yes")
+ } else {
+ Preference.setString("seccomp_mode", "unspecified")
+ }
+ Preference.removeKey("seccomp")
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+ }
}
object Preference {
diff --git a/core/main/src/main/java/com/rk/settings/SettingsScreen.kt b/core/main/src/main/java/com/rk/settings/SettingsScreen.kt
index b210ae249..d7968dd44 100644
--- a/core/main/src/main/java/com/rk/settings/SettingsScreen.kt
+++ b/core/main/src/main/java/com/rk/settings/SettingsScreen.kt
@@ -101,9 +101,9 @@ private fun Categories(navController: NavController) {
if (InbuiltFeatures.extensions.state.value) {
PreferenceCategory(
- label = stringResource(strings.ext),
- description = stringResource(strings.ext_desc),
- iconResource = drawables.extension,
+ label = stringResource(strings.store),
+ description = stringResource(strings.store_desc),
+ iconResource = drawables.store,
onNavigate = { navController.navigate(SettingsRoutes.Extensions.route) },
)
}
diff --git a/core/main/src/main/java/com/rk/settings/about/AboutScreen.kt b/core/main/src/main/java/com/rk/settings/about/AboutScreen.kt
index cbbecaefc..bfec3ae6d 100644
--- a/core/main/src/main/java/com/rk/settings/about/AboutScreen.kt
+++ b/core/main/src/main/java/com/rk/settings/about/AboutScreen.kt
@@ -24,7 +24,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.content.pm.PackageInfoCompat
import androidx.core.net.toUri
import coil.compose.AsyncImage
-import com.rk.components.SettingsToggle
+import com.rk.components.SettingsItem
import com.rk.components.compose.preferences.base.PreferenceGroup
import com.rk.components.compose.preferences.base.PreferenceLayout
import com.rk.components.compose.preferences.base.PreferenceTemplate
@@ -35,6 +35,7 @@ import com.rk.utils.copyToClipboard
import com.rk.xededitor.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import com.rk.utils.okHttpClient
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
@@ -54,18 +55,18 @@ fun AboutScreen() {
AsyncImage(
model = appIcon,
contentDescription = null,
- modifier = Modifier.size(64.dp).padding(bottom = 8.dp),
+ modifier = Modifier.size(96.dp).padding(bottom = 8.dp),
)
Text(
text = stringResource(strings.app_name),
- style = MaterialTheme.typography.titleLarge,
+ style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
)
Text(
text = versionName.toString().uppercase(),
- style = MaterialTheme.typography.bodyMedium,
+ style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
}
@@ -117,7 +118,7 @@ fun AboutScreen() {
val stars = remember { mutableStateOf(strings.loading.getString()) }
LaunchedEffect(Unit) {
- val client = OkHttpClient()
+ val client = okHttpClient
val url = "https://api.github.com/repos/Xed-Editor/Xed-Editor"
val request = Request.Builder().url(url).build()
@@ -125,7 +126,7 @@ fun AboutScreen() {
try {
client.newCall(request).execute().use { response ->
if (response.isSuccessful) {
- val jsonBody = response.body?.string() ?: throw RuntimeException("Empty response body")
+ val jsonBody = response.body.string()
val json = JSONObject(jsonBody)
val count = json.getInt("stargazers_count")
@@ -147,7 +148,7 @@ fun AboutScreen() {
description = { Text(text = stars.value, style = MaterialTheme.typography.titleSmall) },
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(id = strings.github),
description = stringResource(id = strings.github_desc),
isEnabled = true,
@@ -167,7 +168,7 @@ fun AboutScreen() {
},
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(id = strings.telegram),
description = stringResource(id = strings.telegram_desc),
isEnabled = true,
@@ -187,7 +188,7 @@ fun AboutScreen() {
},
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(id = strings.discord),
description = stringResource(id = strings.telegram_desc),
isEnabled = true,
diff --git a/core/main/src/main/java/com/rk/settings/app/SettingsAppScreen.kt b/core/main/src/main/java/com/rk/settings/app/SettingsAppScreen.kt
index 0b50a06ad..ee6a28d63 100644
--- a/core/main/src/main/java/com/rk/settings/app/SettingsAppScreen.kt
+++ b/core/main/src/main/java/com/rk/settings/app/SettingsAppScreen.kt
@@ -25,7 +25,7 @@ import com.rk.activities.settings.SettingsActivity
import com.rk.activities.settings.SettingsRoutes
import com.rk.components.BasicToggle
import com.rk.components.NextScreenCard
-import com.rk.components.SettingsToggle
+import com.rk.components.SettingsItem
import com.rk.components.compose.preferences.base.PreferenceGroup
import com.rk.components.compose.preferences.base.PreferenceLayout
import com.rk.file.toFileObject
@@ -38,7 +38,7 @@ import com.rk.settings.editor.refreshEditors
import com.rk.theme.amoled
import com.rk.theme.currentTheme
import com.rk.theme.dynamicTheme
-import com.rk.utils.dialog
+import com.rk.utils.dialogRes
import com.rk.utils.toast
import com.rk.xededitor.BuildConfig
import kotlinx.coroutines.Dispatchers
@@ -77,7 +77,7 @@ fun SettingsAppScreen(activity: SettingsActivity, navController: NavController)
val gson = remember { GsonBuilder().setPrettyPrinting().create() }
PreferenceGroup {
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.lang),
description = stringResource(strings.lang_desc),
showSwitch = false,
@@ -92,28 +92,28 @@ fun SettingsAppScreen(activity: SettingsActivity, navController: NavController)
sideEffect = { navController.navigate(SettingsRoutes.LanguageScreen.route) },
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.check_for_updates),
description = stringResource(strings.check_for_updates_desc),
default = Settings.check_for_update,
sideEffect = { Settings.check_for_update = it },
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.fullscreen),
description = stringResource(strings.fullscreen_desc),
default = Settings.fullscreen,
sideEffect = { Settings.fullscreen = it },
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.smart_toolbar),
description = stringResource(strings.smart_toolbar_desc),
default = Settings.smart_toolbar,
sideEffect = { Settings.smart_toolbar = it },
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.confirm_exit_dialog),
description = stringResource(strings.confirm_exit_dialog_desc),
default = Settings.confirm_exit,
@@ -121,7 +121,7 @@ fun SettingsAppScreen(activity: SettingsActivity, navController: NavController)
)
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.manage_storage),
description = stringResource(strings.manage_storage_desc),
showSwitch = false,
@@ -156,8 +156,8 @@ fun SettingsAppScreen(activity: SettingsActivity, navController: NavController)
checked = InbuiltFeatures.debugMode.state.value,
onSwitch = {
if (it) {
- dialog(
- context = activity,
+ dialogRes(
+ activity = activity,
title = strings.attention.getString(),
msg = strings.debug_mode_warn.getString(),
onCancel = { InbuiltFeatures.debugMode.setEnable(false) },
@@ -177,7 +177,7 @@ fun SettingsAppScreen(activity: SettingsActivity, navController: NavController)
enabled = InbuiltFeatures.debugMode.supported,
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(InbuiltFeatures.terminal.nameRes),
default = InbuiltFeatures.terminal.state.value,
sideEffect = { InbuiltFeatures.terminal.setEnable(it) },
@@ -191,7 +191,7 @@ fun SettingsAppScreen(activity: SettingsActivity, navController: NavController)
isEnabled = InbuiltFeatures.terminal.supported,
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(InbuiltFeatures.extensions.nameRes),
default = InbuiltFeatures.extensions.state.value,
sideEffect = { InbuiltFeatures.extensions.setEnable(it) },
@@ -205,7 +205,7 @@ fun SettingsAppScreen(activity: SettingsActivity, navController: NavController)
isEnabled = InbuiltFeatures.extensions.supported,
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(InbuiltFeatures.git.nameRes),
default = InbuiltFeatures.git.state.value,
sideEffect = { InbuiltFeatures.git.setEnable(it) },
@@ -221,7 +221,7 @@ fun SettingsAppScreen(activity: SettingsActivity, navController: NavController)
}
PreferenceGroup(heading = stringResource(strings.backup)) {
- SettingsToggle(
+ SettingsItem(
label = stringResource(id = strings.backup),
description = stringResource(id = strings.settings_backup_desc),
showSwitch = false,
@@ -244,7 +244,7 @@ fun SettingsAppScreen(activity: SettingsActivity, navController: NavController)
}
},
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(id = strings.restore),
description = stringResource(id = strings.settings_restore_desc),
showSwitch = false,
diff --git a/core/main/src/main/java/com/rk/settings/debugOptions/AppLogs.kt b/core/main/src/main/java/com/rk/settings/debugOptions/AppLogs.kt
index 48a0cc559..e8938a94c 100644
--- a/core/main/src/main/java/com/rk/settings/debugOptions/AppLogs.kt
+++ b/core/main/src/main/java/com/rk/settings/debugOptions/AppLogs.kt
@@ -1,11 +1,25 @@
package com.rk.settings.debugOptions
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuAnchorType
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
import androidx.core.content.pm.PackageInfoCompat
+import com.rk.components.StyledTextField
import com.rk.utils.application
import com.rk.xededitor.BuildConfig
@@ -14,13 +28,13 @@ fun AppLogs() {
var logLevel by remember { mutableStateOf(LogLevel.INFO) }
val logText = buildLogs(logLevel)
- LogScreen(logText, "App Logs Report", "app_logs", logLevel, { logLevel = it })
+ LogScreen(logText, "App Logs Report", "app_logs") { LogLevelDropdown(logLevel, { logLevel = it }) }
}
private fun buildLogs(logLevel: LogLevel): String {
val entries =
LogCollector.logs
- .filter { it.level.ordinal >= logLevel.ordinal }
+ .filter { it.level.ordinal <= logLevel.ordinal }
.joinToString("\n") { "[${it.level.name.uppercase()}] ${it.message}" }
val packageInfo = with(application!!) { packageManager.getPackageInfo(packageName, 0) }
@@ -28,12 +42,47 @@ private fun buildLogs(logLevel: LogLevel): String {
val versionCode = PackageInfoCompat.getLongVersionCode(packageInfo)
return buildString {
- append("[DEBUG] App version: ").append(versionName).appendLine()
- append("[DEBUG] Version code: ").append(versionCode).appendLine()
- append("[DEBUG] Commit hash: ").append(BuildConfig.GIT_COMMIT_HASH.substring(0, 8)).appendLine()
+ append("[REPORT] App version: ").append(versionName).appendLine()
+ append("[REPORT] Version code: ").append(versionCode).appendLine()
+ append("[REPORT] Commit hash: ").append(BuildConfig.GIT_COMMIT_HASH.substring(0, 8)).appendLine()
appendLine()
append(entries)
}
}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RowScope.LogLevelDropdown(logLevel: LogLevel, onLogLevelChange: (LogLevel) -> Unit) {
+ var dropdownMenuExpanded by remember { mutableStateOf(false) }
+
+ ExposedDropdownMenuBox(
+ expanded = dropdownMenuExpanded,
+ onExpandedChange = { dropdownMenuExpanded = !dropdownMenuExpanded },
+ modifier = Modifier.weight(1f).padding(horizontal = 8.dp),
+ ) {
+ StyledTextField(
+ value = logLevel.label,
+ onValueChange = {},
+ shape = RoundedCornerShape(8.dp),
+ maxLines = 1,
+ readOnly = true,
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownMenuExpanded) },
+ modifier =
+ Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable).fillMaxWidth().height(42.dp),
+ )
+
+ ExposedDropdownMenu(expanded = dropdownMenuExpanded, onDismissRequest = { dropdownMenuExpanded = false }) {
+ LogLevel.entries.forEach { level ->
+ DropdownMenuItem(
+ text = { Text(text = level.label) },
+ onClick = {
+ onLogLevelChange(level)
+ dropdownMenuExpanded = false
+ },
+ )
+ }
+ }
+ }
+}
diff --git a/core/main/src/main/java/com/rk/settings/debugOptions/DeveloperOptions.kt b/core/main/src/main/java/com/rk/settings/debugOptions/DeveloperOptions.kt
index 598becc46..a72038434 100644
--- a/core/main/src/main/java/com/rk/settings/debugOptions/DeveloperOptions.kt
+++ b/core/main/src/main/java/com/rk/settings/debugOptions/DeveloperOptions.kt
@@ -5,20 +5,22 @@ package com.rk.settings.debugOptions
import androidx.activity.compose.LocalActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.*
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavController
import com.rk.activities.settings.SettingsRoutes
-import com.rk.components.SettingsToggle
+import com.rk.components.SettingsItem
import com.rk.components.compose.preferences.base.PreferenceGroup
import com.rk.components.compose.preferences.base.PreferenceLayout
+import com.rk.file.child
import com.rk.resources.getString
import com.rk.resources.strings
import com.rk.settings.Settings
-import com.rk.utils.dialog
+import com.rk.utils.application
+import com.rk.utils.dialogRes
import com.rk.utils.toast
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
@@ -28,6 +30,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import java.io.File
private var flipperJob: Job? = null
@@ -52,14 +55,14 @@ fun DeveloperOptions(modifier: Modifier = Modifier, navController: NavController
PreferenceLayout(label = stringResource(strings.debug_options)) {
PreferenceGroup {
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.force_crash),
description = stringResource(strings.force_crash_desc),
showSwitch = false,
default = false,
sideEffect = {
- dialog(
- context = activity,
+ dialogRes(
+ activity = activity,
title = strings.force_crash.getString(),
msg = strings.force_crash_confirm.getString(),
onCancel = {},
@@ -68,14 +71,14 @@ fun DeveloperOptions(modifier: Modifier = Modifier, navController: NavController
},
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.memory_usage),
description = memoryUsage.value,
showSwitch = false,
default = false,
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.strict_mode),
description = stringResource(strings.strict_mode_desc),
showSwitch = true,
@@ -83,14 +86,14 @@ fun DeveloperOptions(modifier: Modifier = Modifier, navController: NavController
sideEffect = { Settings.strict_mode = it },
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.anr_watchdog),
description = stringResource(strings.anr_watchdog_desc),
default = Settings.anr_watchdog,
sideEffect = { Settings.anr_watchdog = it },
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.verbose_errors),
description = stringResource(strings.verbose_errors_desc),
showSwitch = true,
@@ -98,7 +101,7 @@ fun DeveloperOptions(modifier: Modifier = Modifier, navController: NavController
sideEffect = { Settings.verbose_error = it },
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.desktop_mode),
description = stringResource(strings.desktop_mode_desc),
showSwitch = true,
@@ -106,7 +109,7 @@ fun DeveloperOptions(modifier: Modifier = Modifier, navController: NavController
sideEffect = { Settings.desktop_mode = it },
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.theme_flipper),
description = stringResource(strings.theme_flipper_desc),
showSwitch = true,
@@ -119,7 +122,7 @@ fun DeveloperOptions(modifier: Modifier = Modifier, navController: NavController
},
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.reset_consent),
description = stringResource(strings.reset_consent_desc),
showSwitch = false,
@@ -130,7 +133,7 @@ fun DeveloperOptions(modifier: Modifier = Modifier, navController: NavController
},
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.view_logs),
description = stringResource(strings.view_app_logs),
default = false,
diff --git a/core/main/src/main/java/com/rk/settings/debugOptions/LogCollector.kt b/core/main/src/main/java/com/rk/settings/debugOptions/LogCollector.kt
index 325940c11..4553fad91 100644
--- a/core/main/src/main/java/com/rk/settings/debugOptions/LogCollector.kt
+++ b/core/main/src/main/java/com/rk/settings/debugOptions/LogCollector.kt
@@ -4,11 +4,11 @@ import androidx.compose.runtime.mutableStateListOf
import com.rk.resources.getString
import com.rk.resources.strings
-enum class LogLevel(val label: String) {
- DEBUG(strings.debug.getString()),
- INFO(strings.info.getString()),
- WARN(strings.warning.getString()),
- ERROR(strings.error.getString()),
+enum class LogLevel(val label: String, val value: Int) {
+ ERROR(strings.error.getString(), 1),
+ WARN(strings.warning.getString(), 2),
+ INFO(strings.info.getString(), 3),
+ DEBUG(strings.debug.getString(), 5),
}
data class LogEntry(val level: LogLevel, val message: String, val timestamp: Long = System.currentTimeMillis())
diff --git a/core/main/src/main/java/com/rk/settings/debugOptions/LogScreen.kt b/core/main/src/main/java/com/rk/settings/debugOptions/LogScreen.kt
index 144556a12..6311091b6 100644
--- a/core/main/src/main/java/com/rk/settings/debugOptions/LogScreen.kt
+++ b/core/main/src/main/java/com/rk/settings/debugOptions/LogScreen.kt
@@ -5,19 +5,14 @@ import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
-import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.ExposedDropdownMenuBox
-import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -28,11 +23,8 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
@@ -41,7 +33,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.net.toUri
import com.rk.activities.settings.SettingsActivity
-import com.rk.components.StyledTextField
import com.rk.crashhandler.CrashHandler.logErrorOrExit
import com.rk.editor.Editor
import com.rk.file.BuiltinFileType
@@ -52,7 +43,7 @@ import com.rk.search.EditorSearchPanel
import com.rk.tabs.editor.CodeEditorState
import com.rk.theme.XedTheme
import com.rk.utils.copyToClipboard
-import com.rk.utils.dialog
+import com.rk.utils.dialogRes
import java.lang.ref.WeakReference
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
@@ -60,19 +51,11 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun LogScreen(
- logText: String,
- issueTitle: String,
- copyLabel: String,
- logLevel: LogLevel,
- onLogLevelChange: (LogLevel) -> Unit,
- actionButtons: @Composable (() -> Unit)? = null,
-) {
+fun LogScreen(logText: String, issueTitle: String, copyLabel: String, toolbarButtons: @Composable RowScope.() -> Unit) {
val scope = rememberCoroutineScope()
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
val editorState = remember { CodeEditorState() }
- var dropdownMenuExpanded by remember { mutableStateOf(false) }
XedTheme {
Scaffold(
@@ -118,40 +101,7 @@ fun LogScreen(
)
}
- ExposedDropdownMenuBox(
- expanded = dropdownMenuExpanded,
- onExpandedChange = { dropdownMenuExpanded = !dropdownMenuExpanded },
- modifier = Modifier.weight(1f).padding(horizontal = 8.dp),
- ) {
- StyledTextField(
- value = logLevel.label,
- onValueChange = {},
- shape = RoundedCornerShape(8.dp),
- maxLines = 1,
- readOnly = true,
- trailingIcon = {
- ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownMenuExpanded)
- },
- modifier = Modifier.menuAnchor().fillMaxWidth().height(42.dp),
- )
-
- ExposedDropdownMenu(
- expanded = dropdownMenuExpanded,
- onDismissRequest = { dropdownMenuExpanded = false },
- ) {
- LogLevel.entries.forEach { level ->
- DropdownMenuItem(
- text = { Text(text = level.label) },
- onClick = {
- onLogLevelChange(level)
- dropdownMenuExpanded = false
- },
- )
- }
- }
- }
-
- actionButtons?.invoke()
+ toolbarButtons()
}
HorizontalDivider()
@@ -182,7 +132,7 @@ fun LogScreen(
colorScheme = colorScheme,
)
- scope.launch { setLanguage(BuiltinFileType.LOG.textmateScope!!) }
+ scope.launch { configureLanguage(BuiltinFileType.LOG.textmateScope!!) }
}
},
update = { editor ->
@@ -210,11 +160,11 @@ private fun reportLogs(logText: String, issueTitle: String, copyLabel: String) {
if (url.length > 2048) {
val trimmedUrl =
urlStart + URLEncoder.encode("```log \nPaste the logs here\n ```", StandardCharsets.UTF_8.toString())
- dialog(
- context = context,
+ dialogRes(
+ activity = context,
title = strings.logs_too_long.getString(),
msg = strings.logs_too_long_desc.getString(),
- okString = strings.continue_action,
+ okRes = strings.continue_action,
onOk = {
copyToClipboard(copyLabel, logText, true)
val browserIntent = Intent(Intent.ACTION_VIEW, trimmedUrl.toUri())
diff --git a/core/main/src/main/java/com/rk/settings/editor/CommandSettings.kt b/core/main/src/main/java/com/rk/settings/editor/CommandSettings.kt
index 3a4ff5ec7..de85d557c 100644
--- a/core/main/src/main/java/com/rk/settings/editor/CommandSettings.kt
+++ b/core/main/src/main/java/com/rk/settings/editor/CommandSettings.kt
@@ -42,14 +42,14 @@ fun CommandSelectionDialog(
CommandPalette(progress = 1f, commands = dialogCommands, lastUsedCommand = null) { onDismiss() }
}
-fun buildAddActions(
+private fun buildAddActions(
command: Command,
commandIds: SnapshotStateList,
existingCommands: List,
saveOrder: (SnapshotStateList) -> Unit,
): List = buildList {
add(
- object : Command(command.commandContext) {
+ object : Command() {
override val id: String = command.id
override fun getLabel(): String = strings.add_parent_command.getString()
@@ -63,7 +63,7 @@ fun buildAddActions(
override fun isEnabled(): Boolean = !commandIds.contains(command.id)
- override fun getIcon(): Icon = Icon.DrawableRes(drawables.arrow_outward)
+ override fun getIcon(): Icon = Icon.ResourceIcon(drawables.arrow_outward)
}
)
addAll(
diff --git a/core/main/src/main/java/com/rk/settings/editor/EditExtraKeys.kt b/core/main/src/main/java/com/rk/settings/editor/EditExtraKeys.kt
index 8bef40313..5c781c278 100644
--- a/core/main/src/main/java/com/rk/settings/editor/EditExtraKeys.kt
+++ b/core/main/src/main/java/com/rk/settings/editor/EditExtraKeys.kt
@@ -33,7 +33,7 @@ import com.mohamedrejeb.compose.dnd.reorder.ReorderContainer
import com.mohamedrejeb.compose.dnd.reorder.ReorderableItem
import com.mohamedrejeb.compose.dnd.reorder.rememberReorderState
import com.rk.commands.CommandProvider
-import com.rk.components.EditorSettingsToggle
+import com.rk.components.EditorSettingsItem
import com.rk.components.InfoBlock
import com.rk.components.ResetButton
import com.rk.components.SingleInputDialog
@@ -156,7 +156,7 @@ fun EditExtraKeys(modifier: Modifier = Modifier) {
item {
PreferenceGroup(heading = stringResource(strings.symbols), modifier = Modifier.animateItem()) {
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(id = strings.change_extra_symbols),
description = stringResource(id = strings.change_extra_symbols_desc),
showSwitch = false,
diff --git a/core/main/src/main/java/com/rk/settings/editor/EditToolbarActions.kt b/core/main/src/main/java/com/rk/settings/editor/EditToolbarActions.kt
index be4c6f62f..2b7c37cc7 100644
--- a/core/main/src/main/java/com/rk/settings/editor/EditToolbarActions.kt
+++ b/core/main/src/main/java/com/rk/settings/editor/EditToolbarActions.kt
@@ -33,6 +33,7 @@ import com.mohamedrejeb.compose.dnd.reorder.ReorderContainer
import com.mohamedrejeb.compose.dnd.reorder.ReorderableItem
import com.mohamedrejeb.compose.dnd.reorder.rememberReorderState
import com.rk.commands.CommandProvider
+import com.rk.commands.ToolbarConfiguration
import com.rk.components.InfoBlock
import com.rk.components.ResetButton
import com.rk.components.compose.preferences.base.LocalIsExpandedScreen
@@ -44,9 +45,6 @@ import com.rk.settings.Settings
import com.rk.utils.handleLazyListScroll
import kotlinx.coroutines.launch
-const val DEFAULT_ACTION_ITEMS =
- "editor.undo|editor.redo|editor.save|editor.run|global.new_file|editor.editable|editor.search|editor.refresh|global.terminal|global.settings"
-
@Composable
fun EditToolbarActions(modifier: Modifier = Modifier) {
var showCommandSelectionDialog by remember { mutableStateOf(false) }
@@ -145,5 +143,5 @@ private fun saveOrder(commandIds: SnapshotStateList) {
private fun resetOrder(commandIds: SnapshotStateList) {
Preference.removeKey("action_items")
commandIds.clear()
- commandIds.addAll(DEFAULT_ACTION_ITEMS.split("|"))
+ commandIds.addAll(ToolbarConfiguration.DEFAULT_EDITOR_TOOLBAR_COMMANDS.split("|"))
}
diff --git a/core/main/src/main/java/com/rk/settings/editor/ExcludeFiles.kt b/core/main/src/main/java/com/rk/settings/editor/ExcludeFiles.kt
index 7728b9c19..3dad4893d 100644
--- a/core/main/src/main/java/com/rk/settings/editor/ExcludeFiles.kt
+++ b/core/main/src/main/java/com/rk/settings/editor/ExcludeFiles.kt
@@ -174,7 +174,7 @@ fun ExcludeFiles(isDrawer: Boolean) {
colorScheme = colorScheme,
)
- scope.launch { setLanguage(BuiltinFileType.IGNORE.textmateScope!!) }
+ scope.launch { configureLanguage(BuiltinFileType.IGNORE.textmateScope!!) }
}
},
)
diff --git a/core/main/src/main/java/com/rk/settings/editor/FontScreen.kt b/core/main/src/main/java/com/rk/settings/editor/FontScreen.kt
index 8c49c2472..ba2df2303 100644
--- a/core/main/src/main/java/com/rk/settings/editor/FontScreen.kt
+++ b/core/main/src/main/java/com/rk/settings/editor/FontScreen.kt
@@ -181,7 +181,7 @@ fun FontScreen(
}
.onFailure {
if (it.message?.isNotBlank() == true) {
- errorDialog(it)
+ errorDialog(throwable = it)
}
}
},
diff --git a/core/main/src/main/java/com/rk/settings/editor/FormatterSettings.kt b/core/main/src/main/java/com/rk/settings/editor/FormatterSettings.kt
new file mode 100644
index 000000000..a240d0137
--- /dev/null
+++ b/core/main/src/main/java/com/rk/settings/editor/FormatterSettings.kt
@@ -0,0 +1,308 @@
+package com.rk.settings.editor
+
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.calculateEndPadding
+import androidx.compose.foundation.layout.calculateStartPadding
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Info
+import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshots.SnapshotStateList
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.navigation.NavController
+import com.mohamedrejeb.compose.dnd.reorder.ReorderContainer
+import com.mohamedrejeb.compose.dnd.reorder.ReorderableItem
+import com.mohamedrejeb.compose.dnd.reorder.rememberReorderState
+import com.rk.activities.settings.SettingsRoutes
+import com.rk.components.InfoBlock
+import com.rk.components.compose.preferences.base.LocalIsExpandedScreen
+import com.rk.components.compose.preferences.base.NestedScrollStretch
+import com.rk.components.compose.preferences.base.PreferenceScaffold
+import com.rk.components.compose.preferences.base.PreferenceTemplate
+import com.rk.editor.FormatterProvider
+import com.rk.editor.FormatterSource
+import com.rk.editor.Formatters
+import com.rk.icons.Icon
+import com.rk.icons.XedIcon
+import com.rk.resources.drawables
+import com.rk.resources.strings
+import com.rk.settings.Settings
+import com.rk.utils.handleLazyListScroll
+import kotlinx.coroutines.launch
+
+@Composable
+fun FormatterSettings(navController: NavController, modifier: Modifier = Modifier) {
+ val scope = rememberCoroutineScope()
+ val reorderState = rememberReorderState(dragAfterLongPress = true)
+ val lazyListState = rememberLazyListState()
+
+ val formatterIds = remember { mutableStateListOf(*Settings.formatters.split("|").toTypedArray()) }
+ val formatterSources by remember {
+ derivedStateOf {
+ formatterIds
+ .mapNotNull { id -> Formatters.getSourceForId(id) }
+ .toSet()
+ .let { it + Formatters.providers.map { provider -> FormatterSource.EXTENSION(provider) } }
+ .let { it + FormatterSource.LSP }
+ }
+ }
+
+ PreferenceScaffold(
+ label = stringResource(strings.manage_formatters),
+ backArrowVisible = true,
+ isExpandedScreen = LocalIsExpandedScreen.current,
+ fab = {
+ ExtendedFloatingActionButton(
+ onClick = { navController.navigate(SettingsRoutes.Extensions.route) },
+ icon = { Icon(painter = painterResource(drawables.extension), contentDescription = null) },
+ text = { Text(stringResource(strings.browse_extensions)) },
+ )
+ },
+ ) { paddingValues ->
+ ReorderContainer(state = reorderState, modifier = modifier) {
+ NestedScrollStretch {
+ LazyColumn(
+ contentPadding =
+ PaddingValues(
+ top = paddingValues.calculateTopPadding(),
+ bottom =
+ paddingValues.calculateBottomPadding() +
+ 88.dp, // Add extra space so that FAB doesn't cover content
+ start = paddingValues.calculateStartPadding(LayoutDirection.Ltr),
+ end = paddingValues.calculateEndPadding(LayoutDirection.Ltr),
+ ),
+ state = lazyListState,
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxHeight(),
+ ) {
+ item {
+ InfoBlock(
+ icon = { Icon(imageVector = Icons.Outlined.Info, contentDescription = null) },
+ text = stringResource(strings.info_formatters),
+ modifier = Modifier.padding(bottom = 8.dp),
+ )
+ }
+
+ items(items = formatterSources.toList(), key = { Formatters.getIdOf(it) }) { formatterSource ->
+ when (formatterSource) {
+ is FormatterSource.LSP -> {
+ ReorderableItem(
+ state = reorderState,
+ key = Formatters.LSP_FORMATTER_ID,
+ data = Formatters.LSP_FORMATTER_ID,
+ onDragEnter = { state ->
+ val index = formatterIds.indexOf(Formatters.LSP_FORMATTER_ID)
+ if (index == -1) return@ReorderableItem
+
+ val oldIndex = formatterIds.indexOf(state.data)
+
+ formatterIds.removeAt(oldIndex)
+ formatterIds.add(index, state.data)
+ saveOrder(formatterIds)
+
+ scope.launch {
+ handleLazyListScroll(lazyListState = lazyListState, dropIndex = index)
+ }
+ },
+ modifier = Modifier.animateItem(),
+ ) {
+ DraggableLspFormatter(
+ modifier =
+ Modifier.padding(horizontal = 16.dp).graphicsLayer {
+ alpha = if (isDragging) 0f else 1f
+ }
+ )
+ }
+ }
+ is FormatterSource.EXTENSION -> {
+ val formatter = formatterSource.formatter
+ ReorderableItem(
+ state = reorderState,
+ key = formatter.id,
+ data = formatter.id,
+ onDragEnter = { state ->
+ val index = formatterIds.indexOf(formatter.id)
+ if (index == -1) return@ReorderableItem
+
+ val oldIndex = formatterIds.indexOf(state.data)
+
+ formatterIds.removeAt(oldIndex)
+ formatterIds.add(index, state.data)
+ saveOrder(formatterIds)
+
+ scope.launch {
+ handleLazyListScroll(lazyListState = lazyListState, dropIndex = index)
+ }
+ },
+ modifier = Modifier.animateItem(),
+ ) {
+ DraggableFormatter(
+ modifier =
+ Modifier.padding(horizontal = 16.dp).graphicsLayer {
+ alpha = if (isDragging) 0f else 1f
+ },
+ formatter = formatter,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun DraggableFormatter(modifier: Modifier = Modifier, formatter: FormatterProvider) {
+ val interactionSource = remember { MutableInteractionSource() }
+ var checked by remember { mutableStateOf(Formatters.isProviderEnabled(formatter)) }
+ val onCheckedChange: (Boolean) -> Unit = {
+ Formatters.setProviderEnabled(formatter, it)
+ checked = it
+ }
+
+ Surface(shape = MaterialTheme.shapes.large, tonalElevation = 1.dp, modifier = modifier) {
+ PreferenceTemplate(
+ modifier =
+ Modifier.combinedClickable(
+ interactionSource = interactionSource,
+ onClick = { onCheckedChange(!checked) },
+ ),
+ verticalPadding = 8.dp,
+ title = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = ImageVector.vectorResource(drawables.drag_indicator),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
+ modifier = Modifier.padding(end = 12.dp).size(20.dp),
+ )
+
+ XedIcon(
+ icon = formatter.icon ?: Icon.ResourceIcon(drawables.auto_fix),
+ modifier = Modifier.padding(end = 8.dp).size(20.dp),
+ contentDescription = formatter.label,
+ )
+
+ Column {
+ Row { Text(text = formatter.label, style = MaterialTheme.typography.bodyLarge) }
+ Text(
+ text = formatter.supportedExtensions.joinToString(", ") { ".$it" },
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ fontSize = 12.sp,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
+ )
+ }
+ }
+ },
+ endWidget = {
+ Switch(interactionSource = interactionSource, checked = checked, onCheckedChange = onCheckedChange)
+ },
+ )
+ }
+}
+
+@Composable
+fun DraggableLspFormatter(modifier: Modifier = Modifier) {
+ val interactionSource = remember { MutableInteractionSource() }
+ var checked by remember { mutableStateOf(Formatters.isLspFormatterEnabled()) }
+ val onCheckedChange: (Boolean) -> Unit = {
+ Formatters.setLspFormatterEnabled(it)
+ checked = it
+ }
+
+ val enabled = false // -> set to InbuiltFeatures.terminal.state.value
+
+ Surface(shape = MaterialTheme.shapes.large, tonalElevation = 1.dp, modifier = modifier) {
+ PreferenceTemplate(
+ modifier =
+ Modifier.combinedClickable(
+ enabled = enabled,
+ interactionSource = interactionSource,
+ onClick = { onCheckedChange(!checked) },
+ ),
+ verticalPadding = 8.dp,
+ title = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = ImageVector.vectorResource(drawables.drag_indicator),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f),
+ modifier = Modifier.padding(end = 12.dp).size(20.dp),
+ )
+
+ XedIcon(
+ icon = Icon.ResourceIcon(drawables.server),
+ modifier = Modifier.padding(end = 8.dp).size(20.dp),
+ contentDescription = stringResource(strings.language_server),
+ )
+
+ Column {
+ Row {
+ Text(
+ text = stringResource(strings.language_server),
+ style = MaterialTheme.typography.bodyLarge,
+ )
+ }
+ Text(
+ text = stringResource(strings.formatter_lsp_desc),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ fontSize = 12.sp,
+ color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
+ )
+ }
+ }
+ },
+ endWidget = {
+ Switch(
+ enabled = enabled,
+ interactionSource = interactionSource,
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ )
+ },
+ )
+ }
+}
+
+/** Save order of formatters in settings */
+private fun saveOrder(formatterIds: SnapshotStateList) {
+ val formatters = formatterIds.joinToString("|")
+ Settings.formatters = formatters
+}
diff --git a/core/main/src/main/java/com/rk/settings/editor/SettingsEditorScreen.kt b/core/main/src/main/java/com/rk/settings/editor/SettingsEditorScreen.kt
index c1870f6d1..1cdc6eb9f 100644
--- a/core/main/src/main/java/com/rk/settings/editor/SettingsEditorScreen.kt
+++ b/core/main/src/main/java/com/rk/settings/editor/SettingsEditorScreen.kt
@@ -24,9 +24,9 @@ import com.rk.activities.main.MainActivity
import com.rk.activities.main.fileTreeViewModel
import com.rk.activities.settings.SettingsRoutes
import com.rk.activities.settings.settingsNavController
-import com.rk.components.EditorSettingsToggle
+import com.rk.components.EditorSettingsItem
import com.rk.components.NextScreenCard
-import com.rk.components.SettingsToggle
+import com.rk.components.SettingsItem
import com.rk.components.SingleInputDialog
import com.rk.components.ValueSlider
import com.rk.components.compose.preferences.base.PreferenceGroup
@@ -38,7 +38,6 @@ import com.rk.resources.strings
import com.rk.settings.Settings
import com.rk.settings.app.InbuiltFeatures
import com.rk.tabs.editor.EditorTab
-import io.github.rosemoe.sora.langs.textmate.TextMateLanguage
import kotlinx.coroutines.launch
@Composable
@@ -67,21 +66,14 @@ fun SettingsEditorScreen(navController: NavController) {
route = SettingsRoutes.LspSettings,
)
- EditorSettingsToggle(
- label = stringResource(strings.format_on_save),
- description = stringResource(strings.format_on_save_desc),
- default = Settings.format_on_save,
- sideEffect = { Settings.format_on_save = it },
- )
-
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(strings.insert_final_newline),
description = stringResource(strings.insert_final_newline_desc),
default = Settings.insert_final_newline,
sideEffect = { Settings.insert_final_newline = it },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(strings.trim_trailing_whitespace),
description = stringResource(strings.trim_trailing_whitespace_desc),
default = Settings.trim_trailing_whitespace,
@@ -90,8 +82,23 @@ fun SettingsEditorScreen(navController: NavController) {
}
}
+ PreferenceGroup(heading = stringResource(strings.formatting)) {
+ NextScreenCard(
+ label = stringResource(strings.manage_formatters),
+ description = stringResource(strings.manage_formatters_desc),
+ onClick = { navController.navigate(SettingsRoutes.Formatters.route) },
+ )
+
+ EditorSettingsItem(
+ label = stringResource(strings.format_on_save),
+ description = stringResource(strings.format_on_save_desc),
+ default = Settings.format_on_save,
+ sideEffect = { Settings.format_on_save = it },
+ )
+ }
+
PreferenceGroup(heading = stringResource(strings.intelligent_features)) {
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(strings.auto_close_tags),
description = stringResource(strings.auto_close_tags_desc),
default = Settings.auto_close_tags,
@@ -101,7 +108,7 @@ fun SettingsEditorScreen(navController: NavController) {
},
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(strings.bullet_continuation),
description = stringResource(strings.bullet_continuation_desc),
default = Settings.bullet_continuation,
@@ -116,7 +123,7 @@ fun SettingsEditorScreen(navController: NavController) {
val wordWrap = remember { mutableStateOf(Settings.word_wrap) }
val wordWrapTxt = remember { mutableStateOf(Settings.word_wrap_text || Settings.word_wrap) }
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(id = strings.word_wrap),
description = stringResource(id = strings.word_wrap_desc),
state = wordWrap,
@@ -129,7 +136,7 @@ fun SettingsEditorScreen(navController: NavController) {
},
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(strings.txt_word_wrap),
description = stringResource(strings.txt_word_wrap_desc),
isEnabled = !wordWrap.value,
@@ -140,7 +147,7 @@ fun SettingsEditorScreen(navController: NavController) {
},
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(strings.read_mode),
description = stringResource(strings.read_mode_desc),
default = Settings.read_only_default,
@@ -149,14 +156,14 @@ fun SettingsEditorScreen(navController: NavController) {
}
PreferenceGroup(heading = stringResource(id = strings.editor)) {
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(strings.disable_virtual_kbd),
description = stringResource(strings.disable_virtual_kbd_desc),
default = Settings.hide_soft_keyboard_if_hardware,
sideEffect = { Settings.hide_soft_keyboard_if_hardware = it },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(id = strings.line_spacing),
description = stringResource(id = strings.line_spacing_desc),
showSwitch = false,
@@ -164,56 +171,56 @@ fun SettingsEditorScreen(navController: NavController) {
sideEffect = { showLineSpacingDialog = true },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(id = strings.cursor_anim),
description = stringResource(id = strings.cursor_anim_desc),
default = Settings.cursor_animation,
sideEffect = { Settings.cursor_animation = it },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(strings.show_minimap),
description = stringResource(strings.show_minimap_desc),
default = Settings.show_minimap,
sideEffect = { Settings.show_minimap = it },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(id = strings.show_line_number),
description = stringResource(id = strings.show_line_number),
default = Settings.show_line_numbers,
sideEffect = { Settings.show_line_numbers = it },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(id = strings.pin_line_number),
description = stringResource(id = strings.pin_line_number),
default = Settings.pin_line_number,
sideEffect = { Settings.pin_line_number = it },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(id = strings.render_whitespace),
description = stringResource(id = strings.render_whitespace_desc),
default = Settings.render_whitespace,
sideEffect = { Settings.render_whitespace = it },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(id = strings.show_suggestions),
description = stringResource(id = strings.show_suggestions),
default = Settings.show_suggestions,
sideEffect = { Settings.show_suggestions = it },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(id = strings.enable_sticky_scroll),
description = stringResource(id = strings.enable_sticky_scroll_desc),
default = Settings.sticky_scroll,
sideEffect = { Settings.sticky_scroll = it },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(id = strings.enable_quick_deletion),
description = stringResource(id = strings.enable_quick_deletion_desc),
default = Settings.quick_deletion,
@@ -238,21 +245,21 @@ fun SettingsEditorScreen(navController: NavController) {
scope.launch { refreshEditorSettings() }
}
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(strings.auto_closing_bracket),
description = stringResource(strings.auto_closing_bracket_desc),
default = Settings.auto_closing_bracket,
sideEffect = { Settings.auto_closing_bracket = it },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(strings.complete_on_enter),
description = stringResource(strings.complete_on_enter_desc),
default = Settings.complete_on_enter,
sideEffect = { Settings.complete_on_enter = it },
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.text_mate_suggestion),
description = stringResource(strings.text_mate_suggestion_desc),
default = Settings.textmate_suggestions,
@@ -263,13 +270,13 @@ fun SettingsEditorScreen(navController: NavController) {
MainActivity.instance?.apply {
viewModel.tabs.filterIsInstance().forEach { tab ->
val scope = tab.editorState.textmateScope ?: return@forEach
- val language = tab.editorState.editor.get()?.editorLanguage as? TextMateLanguage
+ val textMateLanguage = tab.editorState.editor.get()?.getTextMateLanguage()
if (newValue) {
val keywords = KeywordManager.getKeywords(scope)
- keywords?.let { language?.setCompleterKeywords(it.toTypedArray()) }
+ keywords?.let { textMateLanguage?.setCompleterKeywords(it.toTypedArray()) }
} else {
- language?.setCompleterKeywords(null)
+ textMateLanguage?.setCompleterKeywords(null)
}
}
}
@@ -285,10 +292,18 @@ fun SettingsEditorScreen(navController: NavController) {
max = 16,
) {
Settings.tab_size = it
+
+ MainActivity.instance?.apply {
+ viewModel.tabs.filterIsInstance().forEach { tab ->
+ val textMateLanguage = tab.editorState.editor.get()?.getTextMateLanguage()
+ textMateLanguage?.tabSize = it
+ }
+ }
+
scope.launch { refreshEditorSettings() }
}
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(strings.use_tabs),
description = stringResource(strings.use_tabs_desc),
default = Settings.actual_tabs,
@@ -297,10 +312,12 @@ fun SettingsEditorScreen(navController: NavController) {
MainActivity.instance?.apply {
viewModel.tabs.filterIsInstance().forEach { tab ->
- val language = tab.editorState.editor.get()?.editorLanguage as? TextMateLanguage
- language?.useTab(it)
+ val textMateLanguage = tab.editorState.editor.get()?.getTextMateLanguage()
+ textMateLanguage?.useTab(it)
}
}
+
+ scope.launch { refreshEditorSettings() }
},
)
}
@@ -312,14 +329,14 @@ fun SettingsEditorScreen(navController: NavController) {
route = SettingsRoutes.ToolbarActions,
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(id = strings.extra_keys),
description = stringResource(id = strings.extra_keys_desc),
default = Settings.show_extra_keys,
sideEffect = { Settings.show_extra_keys = it },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(id = strings.extra_key_bg),
description = stringResource(id = strings.extra_key_bg_desc),
isEnabled = Settings.show_extra_keys,
@@ -327,7 +344,7 @@ fun SettingsEditorScreen(navController: NavController) {
sideEffect = { Settings.extra_keys_bg = it },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(id = strings.split_extra_keys),
description = stringResource(id = strings.split_extra_keys_desc),
isEnabled = Settings.show_extra_keys,
@@ -344,21 +361,21 @@ fun SettingsEditorScreen(navController: NavController) {
}
PreferenceGroup(heading = stringResource(strings.drawer)) {
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(id = strings.keep_drawer_locked),
description = stringResource(id = strings.drawer_lock_desc),
default = Settings.keep_drawer_locked,
sideEffect = { Settings.keep_drawer_locked = it },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(id = strings.sort_mode),
description = stringResource(id = strings.sort_mode_desc),
showSwitch = false,
sideEffect = { showSortingModeDialog = true },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(id = strings.show_hidden_files_drawer),
description = stringResource(id = strings.show_hidden_files_drawer_desc),
default = Settings.show_hidden_files_drawer,
@@ -371,21 +388,21 @@ fun SettingsEditorScreen(navController: NavController) {
onClick = { settingsNavController.get()!!.navigate("${SettingsRoutes.ExcludeFiles.route}/true") },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(id = strings.compact_folders_drawer),
description = stringResource(id = strings.compact_folders_drawer_desc),
default = Settings.compact_folders_drawer,
sideEffect = { Settings.compact_folders_drawer = it },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(id = strings.show_hidden_files_search),
description = stringResource(id = strings.show_hidden_files_search_desc),
default = Settings.show_hidden_files_search,
sideEffect = { Settings.show_hidden_files_search = it },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(strings.always_index_projects),
description = stringResource(strings.always_index_projects_desc),
default = Settings.always_index_projects,
@@ -398,7 +415,7 @@ fun SettingsEditorScreen(navController: NavController) {
onClick = { settingsNavController.get()!!.navigate("${SettingsRoutes.ExcludeFiles.route}/false") },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(strings.auto_open_new_files),
description = stringResource(strings.auto_open_new_files_desc),
default = Settings.auto_open_new_files,
@@ -407,35 +424,35 @@ fun SettingsEditorScreen(navController: NavController) {
}
PreferenceGroup(heading = stringResource(strings.other)) {
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(strings.detect_bin_files),
description = stringResource(strings.detect_bin_files_desc),
default = Settings.detect_bin_files,
sideEffect = { Settings.detect_bin_files = it },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(strings.oom_prediction),
description = stringResource(strings.oom_prediction_desc),
default = Settings.oom_prediction,
sideEffect = { Settings.oom_prediction = it },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(id = strings.restore_sessions),
description = stringResource(id = strings.restore_sessions_desc),
default = Settings.restore_sessions,
sideEffect = { Settings.restore_sessions = it },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(id = strings.smooth_tabs),
description = stringResource(id = strings.smooth_tab_desc),
default = Settings.smooth_tabs,
sideEffect = { Settings.smooth_tabs = it },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(id = strings.show_tab_icons),
description = stringResource(id = strings.show_tab_icons_desc),
default = Settings.show_tab_icons,
@@ -454,21 +471,21 @@ fun SettingsEditorScreen(navController: NavController) {
route = SettingsRoutes.DefaultLineEnding,
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(id = strings.auto_save),
description = stringResource(id = strings.auto_save_desc),
default = Settings.auto_save,
sideEffect = { Settings.auto_save = it },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(id = strings.auto_save_delay),
description = stringResource(id = strings.auto_save_delay_desc),
showSwitch = false,
sideEffect = { showAutoSaveDialog = true },
)
- EditorSettingsToggle(
+ EditorSettingsItem(
label = stringResource(id = strings.enable_editorconfig),
description = stringResource(id = strings.enable_editorconfig_desc),
default = Settings.enable_editorconfig,
diff --git a/core/main/src/main/java/com/rk/settings/extension/ExtensionActionButton.kt b/core/main/src/main/java/com/rk/settings/extension/ExtensionActionButton.kt
deleted file mode 100644
index ef0fc7b8f..000000000
--- a/core/main/src/main/java/com/rk/settings/extension/ExtensionActionButton.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-package com.rk.settings.extension
-
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.Delete
-import androidx.compose.material3.Button
-import androidx.compose.material3.ButtonDefaults
-import androidx.compose.material3.CircularProgressIndicator
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import com.rk.extension.Extension
-import com.rk.icons.Download
-import com.rk.icons.XedIcons
-import com.rk.resources.strings
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
-
-enum class InstallState {
- Idle,
- Installing,
- Installed,
-}
-
-@Composable
-fun ExtensionActionButton(
- extension: Extension,
- installState: InstallState,
- scope: CoroutineScope,
- onInstallClick: suspend (Extension) -> Unit,
- onUninstallClick: suspend (Extension) -> Unit,
-) {
- when (installState) {
- InstallState.Idle -> {
- Button(
- onClick = { scope.launch { onInstallClick(extension) } },
- shape = RoundedCornerShape(10.dp),
- elevation = ButtonDefaults.buttonElevation(defaultElevation = 4.dp),
- ) {
- Icon(XedIcons.Download, contentDescription = null, Modifier.size(18.dp))
- Spacer(Modifier.width(6.dp))
- Text(stringResource(strings.install))
- }
- }
-
- InstallState.Installing -> {
- Button(
- onClick = {},
- enabled = false,
- shape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp),
- colors = ButtonDefaults.buttonColors(disabledContentColor = MaterialTheme.colorScheme.onSurface),
- ) {
- CircularProgressIndicator(
- modifier = Modifier.size(16.dp),
- strokeWidth = 2.dp,
- color = MaterialTheme.colorScheme.onSurface,
- )
- Spacer(Modifier.width(6.dp))
- Text(stringResource(strings.installing))
- }
- }
-
- InstallState.Installed -> {
- Button(
- onClick = { scope.launch { onUninstallClick(extension) } },
- shape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp),
- elevation = ButtonDefaults.buttonElevation(defaultElevation = 4.dp),
- colors =
- ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.error,
- contentColor = MaterialTheme.colorScheme.onError,
- ),
- ) {
- Icon(Icons.Outlined.Delete, contentDescription = stringResource(strings.delete), Modifier.size(18.dp))
- Spacer(Modifier.width(6.dp))
- Text(stringResource(strings.uninstall))
- }
- }
- }
-}
diff --git a/core/main/src/main/java/com/rk/settings/extension/ExtensionActionButtons.kt b/core/main/src/main/java/com/rk/settings/extension/ExtensionActionButtons.kt
new file mode 100644
index 000000000..11cc2ac5e
--- /dev/null
+++ b/core/main/src/main/java/com/rk/settings/extension/ExtensionActionButtons.kt
@@ -0,0 +1,234 @@
+package com.rk.settings.extension
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Delete
+import androidx.compose.material.icons.rounded.Warning
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.rk.icons.Download
+import com.rk.icons.XedIcons
+import com.rk.resources.drawables
+import com.rk.resources.strings
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+
+enum class InstallState {
+ Idle,
+ Installing,
+ Installed,
+ Updatable,
+ Updating,
+}
+
+@Composable
+fun SmallExtensionActionButton(
+ installState: InstallState,
+ scope: CoroutineScope,
+ onInstallClick: suspend () -> Unit,
+ onUninstallClick: suspend () -> Unit,
+ onUpdateClick: suspend () -> Unit,
+ modifier: Modifier = Modifier,
+ outdatedWarning: Boolean = false,
+) {
+ if (outdatedWarning) {
+ Icon(
+ imageVector = Icons.Rounded.Warning,
+ contentDescription = stringResource(strings.warning),
+ tint = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(12.dp),
+ )
+ return
+ }
+
+ when (installState) {
+ InstallState.Idle -> {
+ IconButton(modifier = modifier, onClick = { scope.launch { onInstallClick() } }) {
+ Icon(XedIcons.Download, contentDescription = null)
+ }
+ }
+
+ InstallState.Installing -> {
+ IconButton(modifier = modifier, onClick = {}, enabled = false) {
+ Icon(XedIcons.Download, contentDescription = null)
+ }
+ }
+
+ InstallState.Installed -> {
+ IconButton(
+ modifier = modifier,
+ onClick = { scope.launch { onUninstallClick() } },
+ colors = IconButtonDefaults.iconButtonColors(contentColor = MaterialTheme.colorScheme.error),
+ ) {
+ Icon(Icons.Outlined.Delete, contentDescription = stringResource(strings.delete))
+ }
+ }
+
+ InstallState.Updatable -> {
+ IconButton(
+ modifier = modifier,
+ onClick = { scope.launch { onUpdateClick() } },
+ colors = IconButtonDefaults.iconButtonColors(contentColor = MaterialTheme.colorScheme.primary),
+ ) {
+ Icon(painterResource(drawables.update), contentDescription = stringResource(strings.update))
+ }
+ }
+
+ InstallState.Updating -> {
+ IconButton(modifier = modifier, onClick = {}, enabled = false) {
+ Icon(painterResource(drawables.update), contentDescription = stringResource(strings.update))
+ }
+ }
+ }
+}
+
+@Composable
+fun ExtensionActionButtons(
+ installState: InstallState,
+ scope: CoroutineScope,
+ onInstallClick: suspend () -> Unit,
+ onUninstallClick: suspend () -> Unit,
+ onUpdateClick: suspend () -> Unit,
+ modifier: Modifier = Modifier,
+ outdatedWarning: Boolean = false,
+ progress: Float? = null,
+) {
+ when (installState) {
+ InstallState.Idle -> {
+ InstallButton(scope, onInstallClick, modifier, outdatedWarning)
+ }
+
+ InstallState.Installing -> {
+ InstallingButton(modifier, progress)
+ }
+
+ InstallState.Installed -> {
+ UninstallButton(scope, onUninstallClick, modifier)
+ }
+
+ InstallState.Updatable -> {
+ Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ UpdateButton(scope, onUpdateClick, Modifier.weight(1f), outdatedWarning)
+ UninstallButton(scope, onUninstallClick, Modifier.weight(1f))
+ }
+ }
+
+ InstallState.Updating -> {
+ UpdatingButton(modifier, progress)
+ }
+ }
+}
+
+@Composable
+private fun InstallButton(
+ scope: CoroutineScope,
+ onInstallClick: suspend () -> Unit,
+ modifier: Modifier = Modifier,
+ outdatedWarning: Boolean = false,
+) {
+ Button(modifier = modifier, enabled = !outdatedWarning, onClick = { scope.launch { onInstallClick() } }) {
+ Icon(XedIcons.Download, contentDescription = null, Modifier.size(18.dp))
+ Spacer(Modifier.width(6.dp))
+ Text(stringResource(strings.install))
+ }
+}
+
+@Composable
+private fun InstallingButton(modifier: Modifier = Modifier, progress: Float? = null) {
+ Button(modifier = modifier, onClick = {}, enabled = false) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Spacer(Modifier.width(6.dp))
+ val text = if (progress != null && progress >= 0f) {
+ "${stringResource(strings.installing)} (${(progress * 100).toInt()}%)"
+ } else {
+ stringResource(strings.installing)
+ }
+ Text(text)
+ }
+}
+
+@Composable
+private fun UninstallButton(
+ scope: CoroutineScope,
+ onUninstallClick: suspend () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Button(
+ modifier = modifier,
+ onClick = { scope.launch { onUninstallClick() } },
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.error,
+ contentColor = MaterialTheme.colorScheme.onError,
+ ),
+ ) {
+ Icon(Icons.Outlined.Delete, contentDescription = stringResource(strings.delete), Modifier.size(18.dp))
+ Spacer(Modifier.width(6.dp))
+ Text(stringResource(strings.uninstall))
+ }
+}
+
+@Composable
+private fun UpdateButton(
+ scope: CoroutineScope,
+ onUpdateClick: suspend () -> Unit,
+ modifier: Modifier = Modifier,
+ outdatedWarning: Boolean = false,
+) {
+ Button(
+ modifier = modifier,
+ enabled = !outdatedWarning,
+ onClick = { scope.launch { onUpdateClick() } },
+ colors =
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.secondary,
+ contentColor = MaterialTheme.colorScheme.onSecondary,
+ ),
+ ) {
+ Icon(
+ painterResource(drawables.update),
+ contentDescription = stringResource(strings.update),
+ Modifier.size(18.dp),
+ )
+ Spacer(Modifier.width(6.dp))
+ Text(stringResource(strings.update))
+ }
+}
+
+@Composable
+private fun UpdatingButton(modifier: Modifier = Modifier, progress: Float? = null) {
+ Button(modifier = modifier, onClick = {}, enabled = false) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 2.dp,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Spacer(Modifier.width(6.dp))
+ val text = if (progress != null && progress >= 0f) {
+ "${stringResource(strings.updating)} (${(progress * 100).toInt()}%)"
+ } else {
+ stringResource(strings.updating)
+ }
+ Text(text)
+ }
+}
diff --git a/core/main/src/main/java/com/rk/settings/extension/ExtensionAuthorIcon.kt b/core/main/src/main/java/com/rk/settings/extension/ExtensionAuthorIcon.kt
index cd5356dc3..73245f86e 100644
--- a/core/main/src/main/java/com/rk/settings/extension/ExtensionAuthorIcon.kt
+++ b/core/main/src/main/java/com/rk/settings/extension/ExtensionAuthorIcon.kt
@@ -20,6 +20,8 @@ fun ExtensionAuthorIcon(author: ExtensionAuthor, modifier: Modifier = Modifier)
ImageRequest.Builder(context)
.data(author.github?.let { "https://github.com/$it.png" })
.fallback(drawables.person)
+ .placeholder(drawables.person)
+ .error(drawables.person)
.crossfade(true)
.diskCachePolicy(CachePolicy.ENABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
diff --git a/core/main/src/main/java/com/rk/settings/extension/ExtensionCard.kt b/core/main/src/main/java/com/rk/settings/extension/ExtensionCard.kt
index 6200d70fc..9114239d6 100644
--- a/core/main/src/main/java/com/rk/settings/extension/ExtensionCard.kt
+++ b/core/main/src/main/java/com/rk/settings/extension/ExtensionCard.kt
@@ -1,19 +1,15 @@
package com.rk.settings.extension
import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.combinedClickable
-import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.material3.Card
-import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
-import androidx.compose.material3.contentColorFor
-import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
@@ -26,7 +22,12 @@ import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.CachePolicy
import coil.request.ImageRequest
+import com.rk.components.compose.preferences.base.PreferenceTemplate
import com.rk.extension.Extension
+import com.rk.extension.UpdatableExtension
+import androidx.compose.ui.res.stringResource
+import com.rk.App.Companion.extensionManager
+import com.rk.resources.strings
import com.rk.resources.drawables
import com.rk.theme.Typography
@@ -36,44 +37,46 @@ fun ExtensionCard(
extension: Extension,
modifier: Modifier = Modifier,
installState: InstallState = InstallState.Idle,
- onInstallClick: suspend (Extension) -> Unit,
- onUninstallClick: suspend (Extension) -> Unit,
+ onInstallClick: suspend () -> Unit,
+ onUninstallClick: suspend () -> Unit,
+ onUpdateClick: suspend () -> Unit,
onClick: (Extension) -> Unit = {},
) {
val scope = rememberCoroutineScope()
- val cardColor = MaterialTheme.colorScheme.surfaceColorAtElevation(1.dp)
+ val context = LocalContext.current
- Card(
- modifier =
- modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).combinedClickable(onClick = { onClick(extension) }),
- elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
- colors = CardDefaults.cardColors(containerColor = cardColor, contentColor = contentColorFor(cardColor)),
- ) {
- Row(modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
+ val minAppVersion = extension.minAppVersion
+ val maxAppVersion = extension.maxAppVersion
+
+ val xedVersionCode = com.rk.App.versionCode
+
+ val outdatedClient = minAppVersion != null && xedVersionCode < minAppVersion
+ val outdatedExtension = maxAppVersion != null && xedVersionCode > maxAppVersion
+
+ PreferenceTemplate(
+ modifier = modifier.fillMaxWidth().clickable(onClick = { onClick(extension) }),
+ startWidget = {
AsyncImage(
model =
ImageRequest.Builder(LocalContext.current)
.data(extension.iconUrl)
- .fallback(drawables.extension)
+ .placeholder(drawables.extension)
+ .error(drawables.extension)
.crossfade(true)
.diskCachePolicy(CachePolicy.ENABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.build(),
- modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8.dp)).padding(end = 16.dp),
+ modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)),
contentDescription = null,
)
-
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = extension.name,
- style = Typography.titleMedium,
- fontWeight = FontWeight.SemiBold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- )
-
+ },
+ title = {
+ Text(text = extension.name, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ },
+ description = {
+ androidx.compose.foundation.layout.Column {
Row(verticalAlignment = Alignment.CenterVertically) {
- ExtensionAuthorIcon(extension.author, Modifier.size(16.dp).padding(end = 4.dp))
+ ExtensionAuthorIcon(extension.author, Modifier.size(20.dp).padding(end = 4.dp))
Text(
text = "${extension.author} • v${extension.version}",
maxLines = 1,
@@ -81,10 +84,56 @@ fun ExtensionCard(
style = Typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
+
+ val isUpdatable = extension is UpdatableExtension && extension.isUpdatable()
+ if (isUpdatable) {
+ Text(
+ text = " → v${extension.newVersion}",
+ style = Typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+
+ if (extensionManager.isExtensionDisabled(extension.id)) {
+ Text(
+ text = " • ${stringResource(strings.disabled_crashed)}",
+ style = Typography.labelMedium,
+ color = MaterialTheme.colorScheme.error,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
}
- }
- ExtensionActionButton(extension, installState, scope, onInstallClick, onUninstallClick)
- }
- }
+ val progress = com.rk.extension.manager.ExtensionRegistry.downloadProgress[extension.id]
+ if (progress != null) {
+ androidx.compose.foundation.layout.Spacer(Modifier.height(4.dp))
+ if (progress >= 0f) {
+ androidx.compose.material3.LinearProgressIndicator(
+ progress = { progress },
+ modifier = Modifier.fillMaxWidth().height(4.dp),
+ color = MaterialTheme.colorScheme.primary,
+ )
+ } else {
+ androidx.compose.material3.LinearProgressIndicator(
+ modifier = Modifier.fillMaxWidth().height(4.dp),
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+ },
+ endWidget = {
+ SmallExtensionActionButton(
+ installState = installState,
+ scope = scope,
+ onInstallClick = onInstallClick,
+ onUninstallClick = onUninstallClick,
+ onUpdateClick = onUpdateClick,
+ outdatedWarning = outdatedClient || outdatedExtension,
+ )
+ },
+ )
}
diff --git a/core/main/src/main/java/com/rk/settings/extension/ExtensionDetail.kt b/core/main/src/main/java/com/rk/settings/extension/ExtensionDetail.kt
index 145bc6988..3aa0109c3 100644
--- a/core/main/src/main/java/com/rk/settings/extension/ExtensionDetail.kt
+++ b/core/main/src/main/java/com/rk/settings/extension/ExtensionDetail.kt
@@ -1,7 +1,10 @@
package com.rk.settings.extension
+import com.rk.extension.manager.ExtensionRegistry
+import android.content.Intent
import androidx.activity.compose.LocalActivity
import androidx.appcompat.app.AppCompatActivity
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -14,6 +17,7 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
+import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
@@ -25,10 +29,10 @@ import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -42,12 +46,16 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
+import androidx.navigation.NavController
import coil.compose.AsyncImage
import coil.request.CachePolicy
import coil.request.ImageRequest
import com.rk.App.Companion.extensionManager
+import com.rk.activities.settings.SettingsRoutes
import com.rk.components.compose.preferences.base.RefreshablePreferenceLayout
import com.rk.extension.Extension
+import com.rk.extension.UpdatableExtension
import com.rk.icons.Icon
import com.rk.icons.XedIcon
import com.rk.resources.drawables
@@ -60,16 +68,34 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Composable
-fun ExtensionDetail(extension: Extension?) {
+fun ExtensionDetail(extension: Extension?, navController: NavController) {
val scope = rememberCoroutineScope()
var isRefreshing by remember { mutableStateOf(false) }
var refreshKey by remember { mutableIntStateOf(0) }
+ var showSourceCodeSheet by remember { mutableStateOf(false) }
RefreshablePreferenceLayout(
label = extension?.name ?: stringResource(strings.ext_not_found),
backArrowVisible = true,
isExpandedScreen = true,
+ actions = {
+ IconButton(onClick = { showSourceCodeSheet = true }) {
+ Icon(painter = painterResource(drawables.xml), contentDescription = null)
+ }
+
+ if (extension?.hasSettings == true) {
+ IconButton(
+ enabled = extensionManager.isInstalled(extension.id),
+ onClick = { navController.navigate("${SettingsRoutes.ExtensionSettings.route}/{extensionId}") },
+ ) {
+ Icon(
+ painter = painterResource(drawables.settings),
+ contentDescription = stringResource(strings.settings),
+ )
+ }
+ }
+ },
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
@@ -79,20 +105,48 @@ fun ExtensionDetail(extension: Extension?) {
if (extension == null) {
Text(stringResource(strings.ext_not_found_desc), modifier = Modifier.padding(horizontal = 16.dp))
} else {
- var installState by remember {
+ var localInstallState by remember {
mutableStateOf(
if (extensionManager.isInstalled(extension.id)) {
- InstallState.Installed
+ if (extension is UpdatableExtension && extension.isUpdatable()) {
+ InstallState.Updatable
+ } else {
+ InstallState.Installed
+ }
} else {
InstallState.Idle
}
)
}
- Column(modifier = Modifier.padding(horizontal = 16.dp)) {
- AboutSection(extension, installState, { installState = it }, scope)
+ val installState = remember(extension, localInstallState, ExtensionRegistry.activeInstalls[extension.id]) {
+ val active = ExtensionRegistry.activeInstalls[extension.id]
+ if (active != null) {
+ active
+ } else {
+ localInstallState
+ }
+ }
+
+ Column(modifier = Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ AboutSection(
+ extension = extension,
+ refreshKey = refreshKey,
+ installState = installState,
+ updateInstallState = {
+ localInstallState = it
+ if (it == InstallState.Idle && extensionManager.storeExtension[extension.id] == null) {
+ navController.popBackStack()
+ }
+ },
+ scope = scope,
+ )
}
TabSection(extension, scope, refreshKey, onLoaded = { isRefreshing = false })
+
+ if (showSourceCodeSheet) {
+ SourceCodeSheet(extension) { showSourceCodeSheet = false }
+ }
}
}
}
@@ -100,6 +154,7 @@ fun ExtensionDetail(extension: Extension?) {
@Composable
private fun AboutSection(
extension: Extension,
+ refreshKey: Int,
installState: InstallState,
updateInstallState: (InstallState) -> Unit,
scope: CoroutineScope,
@@ -107,75 +162,160 @@ private fun AboutSection(
val context = LocalContext.current
val activity = LocalActivity.current as? AppCompatActivity
- var showSourceCodeSheet by remember { mutableStateOf(false) }
-
- Row(verticalAlignment = Alignment.CenterVertically) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
AsyncImage(
model =
ImageRequest.Builder(LocalContext.current)
.data(extension.iconUrl)
- .fallback(drawables.extension)
+ .placeholder(drawables.extension)
+ .error(drawables.extension)
.crossfade(true)
.diskCachePolicy(CachePolicy.ENABLED)
.memoryCachePolicy(CachePolicy.ENABLED)
.build(),
- modifier = Modifier.size(64.dp).clip(RoundedCornerShape(8.dp)).padding(end = 16.dp),
+ modifier = Modifier.size(70.dp).clip(RoundedCornerShape(8.dp)),
contentDescription = null,
)
- Column(modifier = Modifier.weight(1f)) {
+ Column {
Text(
text = extension.name,
- style = Typography.titleLarge,
+ style = Typography.headlineSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Row(verticalAlignment = Alignment.CenterVertically) {
- ExtensionAuthorIcon(extension.author, Modifier.size(16.dp).padding(end = 4.dp))
+ Row(
+ modifier =
+ Modifier.clickable(
+ enabled = extension.author.github != null,
+ onClick = {
+ val githubProfileUrl = extension.author.github.let { "https://github.com/$it" }
+ val intent = Intent(Intent.ACTION_VIEW, githubProfileUrl.toUri())
+ context.startActivity(intent)
+ },
+ ),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ ExtensionAuthorIcon(extension.author, Modifier.size(24.dp).padding(end = 4.dp))
+ Text(
+ text = "${extension.author}",
+ style = Typography.labelLarge,
+ color =
+ if (extension.author.github != null) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+
Text(
- text = "${extension.author} • v${extension.version}",
- style = Typography.labelMedium,
+ text = " • v${extension.version}",
+ style = Typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
+
+ val isUpdatable = extension is UpdatableExtension && extension.isUpdatable()
+ if (isUpdatable) {
+ Text(
+ text = " → v${extension.newVersion}",
+ style = Typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
}
}
+ }
+
+ var size by remember { mutableStateOf("---") }
+ var rating by remember { mutableStateOf("---") }
+ var downloadCount by remember { mutableStateOf("---") }
+ var showStar by remember { mutableStateOf(false) }
- IconButton(onClick = { showSourceCodeSheet = true }) {
- Icon(painter = painterResource(drawables.xml), contentDescription = null)
+ LaunchedEffect(refreshKey) {
+ val stats = extension.getStats()
+ stats.size?.let { size = formatFileSize(it) }
+ stats.rating?.let {
+ rating = it.toString()
+ showStar = true
}
+ stats.downloadCount?.let { downloadCount = formatNumberCompact(it) }
+ }
- ExtensionActionButton(
- extension = extension,
- installState = installState,
- scope = scope,
- onInstallClick = { runExtensionInstallAction(extension, updateInstallState, scope, context, activity) },
- onUninstallClick = { runExtensionUninstallAction(extension, updateInstallState, activity) },
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ ExtensionStats(Modifier.weight(1f), stringResource(strings.downloads).uppercase(), downloadCount)
+ ExtensionStats(
+ Modifier.weight(1f),
+ stringResource(strings.rating).uppercase(),
+ rating,
+ if (showStar) Icons.Default.Star else null,
)
+ ExtensionStats(Modifier.weight(1f), stringResource(strings.size).uppercase(), size)
}
- val size by produceState("---") { value = formatFileSize(extension.calcSize()) }
- val rating by
- produceState>("---" to null) {
- val rating = extension.getRating() ?: return@produceState
- value = rating.toString() to Icons.Default.Star
- }
- val downloadCount by
- produceState("---") {
- val count = extension.getDownloadCount() ?: return@produceState
- value = formatNumberCompact(count)
+ val minAppVersion = extension.minAppVersion
+ val maxAppVersion = extension.maxAppVersion
+
+ val xedVersionCode = com.rk.App.versionCode
+
+ val outdatedClient = minAppVersion != null && xedVersionCode < minAppVersion
+ val outdatedExtension = maxAppVersion != null && xedVersionCode > maxAppVersion
+
+ val progress = ExtensionRegistry.downloadProgress[extension.id]
+ if (progress != null) {
+ if (progress >= 0f) {
+ androidx.compose.material3.LinearProgressIndicator(
+ progress = { progress },
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.primary
+ )
+ } else {
+ androidx.compose.material3.LinearProgressIndicator(
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.primary
+ )
}
- Row(modifier = Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- ExtensionStats(Modifier.weight(1f), stringResource(strings.downloads).uppercase(), downloadCount)
- ExtensionStats(Modifier.weight(1f), stringResource(strings.rating).uppercase(), rating.first, rating.second)
- ExtensionStats(Modifier.weight(1f), stringResource(strings.size).uppercase(), size)
}
- if (showSourceCodeSheet) {
- SourceCodeSheet(extension) { showSourceCodeSheet = false }
+ ExtensionActionButtons(
+ outdatedWarning = outdatedClient || outdatedExtension,
+ modifier = Modifier.fillMaxWidth(),
+ installState = installState,
+ scope = scope,
+ progress = progress,
+ onInstallClick = {
+ checkExtensionWarningAndRun(activity) {
+ runExtensionInstallAction(extension, updateInstallState, context, activity)
+ }
+ },
+ onUninstallClick = { runExtensionUninstallAction(extension, updateInstallState, scope, activity) },
+ onUpdateClick = {
+ if (extension !is UpdatableExtension) return@ExtensionActionButtons
+ runExtensionUpdateAction(extension, updateInstallState, context, activity)
+ },
+ )
+
+ if (outdatedClient || outdatedExtension) {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = Icons.Rounded.Warning,
+ contentDescription = stringResource(strings.warning),
+ tint = MaterialTheme.colorScheme.error,
+ )
+ Text(
+ stringResource(if (outdatedClient) strings.outdated_client else strings.outdated_extension),
+ style = MaterialTheme.typography.labelMedium,
+ )
+ }
}
}
@@ -205,9 +345,9 @@ fun ExtensionStats(modifier: Modifier = Modifier, title: String, value: String,
}
enum class ExtensionRoutes(val icon: Icon, val label: String, val route: String) {
- OVERVIEW(Icon.DrawableRes(drawables.file), strings.overview.getString(), "overview"),
- REVIEWS(Icon.DrawableRes(drawables.comment), strings.reviews.getString(), "reviews"),
- CHANGELOG(Icon.DrawableRes(drawables.update), strings.changelog.getString(), "changelog"),
+ OVERVIEW(Icon.ResourceIcon(drawables.file), strings.overview.getString(), "overview"),
+ REVIEWS(Icon.ResourceIcon(drawables.comment), strings.reviews.getString(), "reviews"),
+ CHANGELOG(Icon.ResourceIcon(drawables.update), strings.changelog.getString(), "changelog"),
}
@Composable
diff --git a/core/main/src/main/java/com/rk/settings/extension/ExtensionScreen.kt b/core/main/src/main/java/com/rk/settings/extension/ExtensionScreen.kt
index 0eb2e3436..c6515500e 100644
--- a/core/main/src/main/java/com/rk/settings/extension/ExtensionScreen.kt
+++ b/core/main/src/main/java/com/rk/settings/extension/ExtensionScreen.kt
@@ -11,15 +11,17 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
-import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Info
+import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Search
+import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
@@ -28,19 +30,20 @@ import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.RadioButton
import androidx.compose.material3.SearchBarDefaults
-import androidx.compose.material3.SegmentedButton
-import androidx.compose.material3.SegmentedButtonDefaults
-import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Text
+import androidx.compose.material3.Tab
+import androidx.compose.material3.PrimaryScrollableTabRow
+import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -50,32 +53,55 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.rk.App.Companion.extensionManager
+import com.rk.App.Companion.iconPackManager
import com.rk.activities.settings.SettingsRoutes
import com.rk.components.InfoBlock
import com.rk.components.compose.preferences.base.PreferenceGroup
import com.rk.components.compose.preferences.base.RefreshablePreferenceLayoutLazyColumn
+import com.rk.components.compose.preferences.base.PreferenceTemplate
import com.rk.extension.Extension
import com.rk.extension.StoreExtension
+import com.rk.extension.UpdatableExtension
import com.rk.resources.drawables
import com.rk.resources.strings
import com.rk.theme.Typography
-import com.rk.utils.openUrl
+import com.rk.utils.openDocs
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
-
-private enum class ExtensionCategories(val drawableRes: Int, val stringRes: Int) {
- ALL(drawables.widgets, strings.all),
- LOCAL(drawables.sd_card, strings.local),
- STORE(drawables.store, strings.store),
-}
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import androidx.compose.runtime.mutableStateMapOf
+import com.rk.extension.ExtensionId
+import com.rk.extension.ExtensionStats
+import com.rk.extension.manager.ExtensionRegistry
+import com.rk.extension.manager.ThemeStoreEntry
+import com.rk.extension.manager.IconPackStoreEntry
+import com.rk.utils.LoadingPopup
+import com.rk.file.toFileObject
+import com.rk.file.copyToTempDir
+import com.rk.theme.ThemeConfig
+import com.rk.theme.installTheme
+import com.rk.theme.updateThemes
+import com.rk.settings.theme.themes
+import com.rk.file.themeDir
+import com.rk.file.child
+import kotlinx.serialization.json.jsonPrimitive
+import com.google.gson.GsonBuilder
+import com.rk.icons.XedIcons
+import com.rk.icons.Download
+import java.io.ObjectOutputStream
+import java.io.FileOutputStream
+import java.io.File
private enum class ExtensionSortOptions(val stringRes: Int) {
NAME(strings.name),
RATING(strings.rating),
- DATE_ADDED(strings.date_added),
+ DOWNLOAD_COUNT(strings.download_count)
}
private enum class ExtensionFilterOptions(val stringRes: Int) {
@@ -93,15 +119,84 @@ fun ExtensionScreen(navController: NavController) {
var isRefreshing by remember { mutableStateOf(false) }
var refreshKey by remember { mutableIntStateOf(0) }
- var currentSortOption by remember { mutableStateOf(ExtensionSortOptions.NAME) }
+ var selectedTab by remember { mutableIntStateOf(0) } // 0 = Extensions, 1 = Themes, 2 = Icon Packs
+
+ var currentSortOption by remember { mutableStateOf(ExtensionSortOptions.DOWNLOAD_COUNT) }
var currentFilterOption by remember { mutableStateOf(ExtensionFilterOptions.ALL) }
val searchQuery = rememberTextFieldState("")
- LaunchedEffect(Unit) {
- launch(Dispatchers.IO) {
- runCatching {
- extensionManager.indexLocalExtensions()
- extensionManager.indexStoreExtensions()
+ var isIndexing by remember { mutableStateOf(false) }
+ var isFetching by remember { mutableStateOf(false) }
+
+ val statsMap = remember { mutableStateMapOf() }
+
+ var storeThemes by remember { mutableStateOf>(emptyList()) }
+ var storeIconPacks by remember { mutableStateOf>(emptyList()) }
+
+ LaunchedEffect(refreshKey) {
+ if (refreshKey > 0) {
+ statsMap.clear()
+ }
+ val shouldLoad = refreshKey > 0 ||
+ extensionManager.localExtensions.isEmpty() ||
+ extensionManager.storeExtension.isEmpty() ||
+ storeThemes.isEmpty() ||
+ storeIconPacks.isEmpty()
+
+ if (shouldLoad) {
+ isIndexing = true
+ isFetching = true
+
+ val localJob = launch(Dispatchers.IO) {
+ runCatching { extensionManager.indexLocalExtensions() }
+ isIndexing = false
+ }
+ val storeJob = launch(Dispatchers.IO) {
+ runCatching { extensionManager.indexStoreExtensions() }
+ isFetching = false
+ }
+ val themesJob = launch(Dispatchers.IO) {
+ runCatching {
+ val list = ExtensionRegistry.fetchThemes()
+ withContext(Dispatchers.Main) {
+ storeThemes = list
+ }
+ }
+ }
+ val iconPacksJob = launch(Dispatchers.IO) {
+ runCatching {
+ val list = ExtensionRegistry.fetchIconPacks()
+ withContext(Dispatchers.Main) {
+ storeIconPacks = list
+ }
+ }
+ }
+
+ localJob.join()
+ storeJob.join()
+ themesJob.join()
+ iconPacksJob.join()
+ isRefreshing = false
+ }
+ }
+
+ LaunchedEffect(refreshKey, isFetching) {
+ if (!isFetching) {
+ val rawList = extensionManager.getSyncedExtensions()
+ launch(Dispatchers.IO) {
+ val deferreds = rawList.filter { !statsMap.containsKey(it.id) }.map { ext ->
+ async {
+ ext.id to runCatching { ext.getStats() }.getOrNull()
+ }
+ }
+ val results = deferreds.awaitAll()
+ withContext(Dispatchers.Main) {
+ results.forEach { (id, stats) ->
+ if (stats != null) {
+ statsMap[id] = stats
+ }
+ }
+ }
}
}
}
@@ -111,51 +206,81 @@ fun ExtensionScreen(navController: NavController) {
installExtensionFromUri(scope, uri, activity)
}
- var selectedCategory by remember { mutableStateOf(ExtensionCategories.ALL) }
- val extensions by
- remember(selectedCategory) {
- derivedStateOf {
- when (selectedCategory) {
- ExtensionCategories.ALL -> extensionManager.localExtensions + extensionManager.storeExtension
- ExtensionCategories.LOCAL -> extensionManager.localExtensions
- ExtensionCategories.STORE -> extensionManager.storeExtension
- }.map { it.value }
+ val themeFilePickerLauncher =
+ rememberLauncherForActivityResult(contract = ActivityResultContracts.OpenDocument()) { uri ->
+ if (uri != null) {
+ scope.launch {
+ val loading = LoadingPopup(activity, null)
+ loading.show()
+ runCatching {
+ com.rk.theme.installFromFile(uri.toFileObject(expectedIsFile = true))
+ }
+ loading.hide()
+ }
}
}
- val filteredExtensions by
- remember(searchQuery.text, extensions, currentFilterOption) {
- derivedStateOf { applyFilter(searchQuery, extensions, currentFilterOption) }
- }
- val sortedExtension by
- produceState(filteredExtensions, filteredExtensions, currentSortOption) {
- value = applySort(currentSortOption, filteredExtensions)
- }
- var isIndexing by remember { mutableStateOf(false) }
- var isFetching by remember { mutableStateOf(false) }
+ val iconPackFilePickerLauncher =
+ rememberLauncherForActivityResult(contract = ActivityResultContracts.OpenDocument()) { uri ->
+ if (uri != null) {
+ scope.launch {
+ val loading = LoadingPopup(activity, null)
+ loading.show()
+ runCatching {
+ val file = uri.toFileObject(expectedIsFile = true).copyToTempDir()
+ iconPackManager.installIconPack(file)
+ }
+ loading.hide()
+ }
+ }
+ }
- LaunchedEffect(refreshKey, selectedCategory) {
- if (selectedCategory == ExtensionCategories.STORE) return@LaunchedEffect
- isIndexing = true
- extensionManager.indexLocalExtensions()
- isIndexing = false
- isRefreshing = false
+ val sortedExtension by remember {
+ derivedStateOf {
+ val rawList = extensionManager.getSyncedExtensions()
+ val filtered = applyFilter(searchQuery, rawList, currentFilterOption)
+ when (currentSortOption) {
+ ExtensionSortOptions.NAME -> filtered.sortedBy { it.name }
+ ExtensionSortOptions.RATING -> filtered.sortedByDescending { statsMap[it.id]?.rating ?: 0f }
+ ExtensionSortOptions.DOWNLOAD_COUNT -> filtered.sortedByDescending { statsMap[it.id]?.downloadCount ?: 0 }
+ }
+ }
}
- LaunchedEffect(refreshKey, selectedCategory) {
- if (selectedCategory == ExtensionCategories.LOCAL) return@LaunchedEffect
- runCatching {
- isFetching = true
- extensionManager.indexStoreExtensions()
+ val sortedThemes by remember {
+ derivedStateOf {
+ val query = searchQuery.text
+ val filtered = if (query.isEmpty()) {
+ storeThemes
+ } else {
+ storeThemes.filter { theme ->
+ theme.manifest.get("name")?.jsonPrimitive?.content?.contains(query, ignoreCase = true) == true ||
+ theme.id.contains(query, ignoreCase = true)
+ }
}
- .also {
- isFetching = false
- isRefreshing = false
+ filtered.sortedBy { theme ->
+ theme.manifest.get("name")?.jsonPrimitive?.content ?: theme.id
+ }
+ }
+ }
+
+ val sortedIconPacks by remember {
+ derivedStateOf {
+ val query = searchQuery.text
+ val filtered = if (query.isEmpty()) {
+ storeIconPacks
+ } else {
+ storeIconPacks.filter { pack ->
+ pack.manifest.name.contains(query, ignoreCase = true) ||
+ pack.manifest.id.contains(query, ignoreCase = true)
+ }
}
+ filtered.sortedBy { it.manifest.name }
+ }
}
RefreshablePreferenceLayoutLazyColumn(
- label = stringResource(strings.ext),
+ label = stringResource(strings.store),
isExpandedScreen = false,
backArrowVisible = true,
isRefreshing = isRefreshing,
@@ -165,92 +290,383 @@ fun ExtensionScreen(navController: NavController) {
},
fab = {
ExtendedFloatingActionButton(
- onClick = { filePickerLauncher.launch(arrayOf("application/zip")) },
+ onClick = {
+ when (selectedTab) {
+ 0 -> {
+ checkExtensionWarningAndRun(activity) {
+ filePickerLauncher.launch(arrayOf("application/zip"))
+ }
+ }
+ 1 -> themeFilePickerLauncher.launch(arrayOf("application/json"))
+ 2 -> iconPackFilePickerLauncher.launch(arrayOf("application/zip"))
+ }
+ },
icon = { Icon(imageVector = Icons.Outlined.Add, contentDescription = null) },
text = { Text(stringResource(strings.install_from_storage)) },
)
},
) {
item {
- InfoBlock(
- modifier =
- Modifier.clickable { activity?.openUrl("https://xed-editor.github.io/Xed-Docs/docs/extensions/") }
- .padding(bottom = 16.dp),
- icon = { Icon(imageVector = Icons.Outlined.Info, contentDescription = null) },
- text = stringResource(strings.info_ext),
- )
+ PrimaryScrollableTabRow(
+ selectedTabIndex = selectedTab,
+ modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
+ edgePadding = 16.dp
+ ) {
+ Tab(
+ selected = selectedTab == 0,
+ onClick = { selectedTab = 0 },
+ text = { Text(stringResource(strings.ext)) }
+ )
+ Tab(
+ selected = selectedTab == 1,
+ onClick = { selectedTab = 1 },
+ text = { Text(stringResource(strings.themes)) }
+ )
+ Tab(
+ selected = selectedTab == 2,
+ onClick = { selectedTab = 2 },
+ text = { Text(stringResource(strings.icon_packs)) }
+ )
+ }
}
- item {
- ExtensionSearchBar(
- searchQuery = searchQuery,
- currentSortOption = currentSortOption,
- currentFilterOption = currentFilterOption,
- onSortOptionChange = { currentSortOption = it },
- onFilterOptionChange = { currentFilterOption = it },
- )
- }
+ if (selectedTab == 0) {
+ item {
+ InfoBlock(
+ onClick = { activity?.openDocs("extensions") },
+ modifier = Modifier.padding(bottom = 16.dp),
+ icon = { Icon(imageVector = Icons.Outlined.Info, contentDescription = null) },
+ text = stringResource(strings.info_ext),
+ )
+ }
- item {
- SingleChoiceSegmentedButtonRow(modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)) {
- ExtensionCategories.entries.forEach { category ->
- SegmentedButton(
- selected = selectedCategory == category,
- onClick = { selectedCategory = category },
- label = { Text(stringResource(category.stringRes)) },
- icon = { Icon(painterResource(category.drawableRes), null) },
- shape =
- SegmentedButtonDefaults.itemShape(
- index = category.ordinal,
- count = ExtensionCategories.entries.size,
- ),
- )
- }
+ item {
+ ExtensionSearchBar(
+ searchQuery = searchQuery,
+ currentSortOption = currentSortOption,
+ currentFilterOption = currentFilterOption,
+ onSortOptionChange = { currentSortOption = it },
+ onFilterOptionChange = { currentFilterOption = it },
+ )
}
- }
- if (sortedExtension.isNotEmpty() || isIndexing || isFetching) {
- items(sortedExtension, key = { it.id }) { extension ->
- var installState by remember {
- mutableStateOf(
- if (extensionManager.isInstalled(extension.id)) {
- InstallState.Installed
- } else {
- InstallState.Idle
+ if (sortedExtension.isNotEmpty() || isIndexing || isFetching) {
+ item {
+ PreferenceGroup {
+ sortedExtension.forEach { extension ->
+ key(extension.id) {
+ val installState = remember(extension, ExtensionRegistry.activeInstalls[extension.id]) {
+ val active = ExtensionRegistry.activeInstalls[extension.id]
+ if (active != null) {
+ active
+ } else if (extensionManager.isInstalled(extension.id)) {
+ if (extension is UpdatableExtension && extension.isUpdatable()) {
+ InstallState.Updatable
+ } else {
+ InstallState.Installed
+ }
+ } else {
+ InstallState.Idle
+ }
+ }
+
+ ExtensionCard(
+ extension = extension,
+ installState = installState,
+ onInstallClick = {
+ checkExtensionWarningAndRun(activity) {
+ runExtensionInstallAction(extension, {}, context, activity)
+ }
+ },
+ onUninstallClick = {
+ runExtensionUninstallAction(extension, {}, scope, activity)
+ },
+ onUpdateClick = {
+ if (extension !is UpdatableExtension) return@ExtensionCard
+ runExtensionUpdateAction(extension, {}, context, activity)
+ },
+ onClick = { navController.navigate("${SettingsRoutes.ExtensionDetail.route}/${it.id}") },
+ )
+ }
}
- )
+ }
}
- ExtensionCard(
- modifier = Modifier.padding(top = 8.dp, start = 16.dp, end = 16.dp),
- extension = extension,
- installState = installState,
- onInstallClick = {
- runExtensionInstallAction(extension, { installState = it }, scope, context, activity)
- },
- onUninstallClick = { runExtensionUninstallAction(extension, { installState = it }, activity) },
- onClick = { navController.navigate("${SettingsRoutes.ExtensionDetail.route}/${it.id}") },
+ if (isIndexing || isFetching) {
+ item {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.CenterHorizontally),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ CircularProgressIndicator(modifier = Modifier.size(24.dp))
+ Text(text = stringResource(strings.loading))
+ }
+ }
+ }
+ } else {
+ item { PreferenceGroup { Text(text = stringResource(strings.no_ext), modifier = Modifier.padding(16.dp)) } }
+ }
+ } else if (selectedTab == 1) {
+ item {
+ StoreSearchBar(
+ searchQuery = searchQuery,
+ placeholderText = stringResource(strings.search_themes)
)
}
- if (isIndexing || isFetching) {
+ if (sortedThemes.isNotEmpty() || isRefreshing) {
item {
- Row(
- modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
- horizontalArrangement = Arrangement.spacedBy(16.dp, alignment = Alignment.CenterHorizontally),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- CircularProgressIndicator(modifier = Modifier.size(24.dp))
- Text(text = stringResource(strings.loading))
+ PreferenceGroup {
+ sortedThemes.forEach { themeEntry ->
+ key(themeEntry.id) {
+ val isInstalled = themes.any { it.id == themeEntry.id }
+ ThemeStoreCard(
+ themeEntry = themeEntry,
+ isInstalled = isInstalled,
+ onInstallClick = {
+ val manifestJsonString = themeEntry.manifest.toString()
+ val gson = GsonBuilder().excludeFieldsWithModifiers(java.lang.reflect.Modifier.STATIC).create()
+ val themeConfig = gson.fromJson(manifestJsonString, ThemeConfig::class.java)
+ com.rk.DefaultScope.launch(Dispatchers.IO) {
+ runCatching {
+ themeConfig.installTheme()
+ }.onSuccess {
+ withContext(Dispatchers.Main) {
+ com.rk.utils.toast(strings.installed)
+ }
+ }.onFailure { err ->
+ withContext(Dispatchers.Main) {
+ com.rk.utils.errorDialog(activity, err)
+ }
+ }
+ }
+ },
+ onUninstallClick = {
+ val installedTheme = themes.find { it.id == themeEntry.id }
+ if (installedTheme != null) {
+ com.rk.DefaultScope.launch(Dispatchers.IO) {
+ runCatching {
+ themeDir().child(installedTheme.name).delete()
+ withContext(Dispatchers.Main) {
+ themes.remove(installedTheme)
+ }
+ }
+ }
+ }
+ }
+ )
+ }
+ }
}
}
+ } else {
+ item { PreferenceGroup { Text(text = stringResource(strings.no_themes), modifier = Modifier.padding(16.dp)) } }
+ }
+ } else if (selectedTab == 2) {
+ item {
+ StoreSearchBar(
+ searchQuery = searchQuery,
+ placeholderText = stringResource(strings.search_icon_packs)
+ )
+ }
+
+ if (sortedIconPacks.isNotEmpty() || isRefreshing) {
+ item {
+ PreferenceGroup {
+ sortedIconPacks.forEach { iconPackEntry ->
+ key(iconPackEntry.id) {
+ val isInstalled = iconPackManager.iconPacks.containsKey(iconPackEntry.id)
+ IconPackStoreCard(
+ iconPackEntry = iconPackEntry,
+ isInstalled = isInstalled,
+ onInstallClick = {
+ runIconPackInstallAction(
+ iconPackEntry.id,
+ iconPackEntry.manifest.name,
+ context,
+ activity
+ )
+ },
+ onUninstallClick = {
+ com.rk.DefaultScope.launch(Dispatchers.IO) {
+ runCatching {
+ iconPackManager.uninstallIconPack(iconPackEntry.id)
+ }
+ }
+ }
+ )
+ }
+ }
+ }
+ }
+ } else {
+ item { PreferenceGroup { Text(text = stringResource(strings.no_icon_packs), modifier = Modifier.padding(16.dp)) } }
}
- } else {
- item { PreferenceGroup { Text(text = stringResource(strings.no_ext), modifier = Modifier.padding(16.dp)) } }
+ }
+ item {
+ androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(80.dp))
}
}
}
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+private fun StoreSearchBar(
+ searchQuery: TextFieldState,
+ placeholderText: String,
+) {
+ SearchBarDefaults.InputField(
+ modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
+ state = searchQuery,
+ leadingIcon = { Icon(Icons.Rounded.Search, null) },
+ trailingIcon = {
+ IconButton({ searchQuery.clearText() }) {
+ Icon(imageVector = Icons.Rounded.Close, contentDescription = stringResource(strings.close))
+ }
+ },
+ onSearch = {},
+ expanded = false,
+ onExpandedChange = {},
+ placeholder = { Text(placeholderText) },
+ )
+}
+
+@Composable
+fun ThemeStoreCard(
+ themeEntry: ThemeStoreEntry,
+ isInstalled: Boolean,
+ onInstallClick: () -> Unit,
+ onUninstallClick: () -> Unit,
+) {
+ val name = themeEntry.manifest.get("name")?.jsonPrimitive?.content ?: themeEntry.id
+ val progress = ExtensionRegistry.downloadProgress[themeEntry.id]
+ PreferenceTemplate(
+ modifier = Modifier.fillMaxWidth(),
+ startWidget = {
+ Icon(
+ painter = painterResource(drawables.palette),
+ contentDescription = null,
+ modifier = Modifier.size(48.dp).padding(8.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ title = {
+ Text(text = name, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ },
+ description = {
+ androidx.compose.foundation.layout.Column {
+ Text(
+ text = themeEntry.id,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = Typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ if (progress != null) {
+ androidx.compose.foundation.layout.Spacer(Modifier.height(4.dp))
+ if (progress >= 0f) {
+ androidx.compose.material3.LinearProgressIndicator(
+ progress = { progress },
+ modifier = Modifier.fillMaxWidth().height(4.dp),
+ color = MaterialTheme.colorScheme.primary
+ )
+ } else {
+ androidx.compose.material3.LinearProgressIndicator(
+ modifier = Modifier.fillMaxWidth().height(4.dp),
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ }
+ },
+ endWidget = {
+ if (isInstalled) {
+ IconButton(
+ onClick = onUninstallClick,
+ colors = IconButtonDefaults.iconButtonColors(contentColor = MaterialTheme.colorScheme.error),
+ ) {
+ Icon(Icons.Outlined.Delete, contentDescription = stringResource(strings.delete))
+ }
+ } else {
+ IconButton(
+ onClick = onInstallClick
+ ) {
+ Icon(XedIcons.Download, contentDescription = null)
+ }
+ }
+ }
+ )
+}
+
+@Composable
+fun IconPackStoreCard(
+ iconPackEntry: IconPackStoreEntry,
+ isInstalled: Boolean,
+ onInstallClick: () -> Unit,
+ onUninstallClick: () -> Unit,
+) {
+ val name = iconPackEntry.manifest.name
+ val id = iconPackEntry.manifest.id
+ val progress = ExtensionRegistry.downloadProgress[id]
+ PreferenceTemplate(
+ modifier = Modifier.fillMaxWidth(),
+ startWidget = {
+ Icon(
+ painter = painterResource(drawables.widgets),
+ contentDescription = null,
+ modifier = Modifier.size(48.dp).padding(8.dp),
+ tint = MaterialTheme.colorScheme.secondary
+ )
+ },
+ title = {
+ Text(text = name, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ },
+ description = {
+ androidx.compose.foundation.layout.Column {
+ Text(
+ text = id,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = Typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ if (progress != null) {
+ androidx.compose.foundation.layout.Spacer(Modifier.height(4.dp))
+ if (progress >= 0f) {
+ androidx.compose.material3.LinearProgressIndicator(
+ progress = { progress },
+ modifier = Modifier.fillMaxWidth().height(4.dp),
+ color = MaterialTheme.colorScheme.primary
+ )
+ } else {
+ androidx.compose.material3.LinearProgressIndicator(
+ modifier = Modifier.fillMaxWidth().height(4.dp),
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+ }
+ }
+ },
+ endWidget = {
+ if (isInstalled) {
+ IconButton(
+ onClick = onUninstallClick,
+ colors = IconButtonDefaults.iconButtonColors(contentColor = MaterialTheme.colorScheme.error),
+ ) {
+ Icon(Icons.Outlined.Delete, contentDescription = stringResource(strings.delete))
+ }
+ } else {
+ IconButton(
+ onClick = onInstallClick
+ ) {
+ Icon(XedIcons.Download, contentDescription = null)
+ }
+ }
+ }
+ )
+}
+
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun ExtensionSearchBar(
@@ -324,17 +740,6 @@ private fun ExtensionSearchBar(
)
}
-private suspend fun applySort(
- currentSortOption: ExtensionSortOptions,
- filteredExtensions: List,
-): List =
- when (currentSortOption) {
- ExtensionSortOptions.NAME -> filteredExtensions.sortedBy { it.name }
- ExtensionSortOptions.RATING -> filteredExtensions.sortedBy { it.id } // TODO: RATING
-
- ExtensionSortOptions.DATE_ADDED -> filteredExtensions.sortedBy { it.id } // TODO: DATE_ADDED
- }
-
private fun applyFilter(
searchQuery: TextFieldState,
extensions: List,
diff --git a/core/main/src/main/java/com/rk/settings/extension/ExtensionSettings.kt b/core/main/src/main/java/com/rk/settings/extension/ExtensionSettings.kt
new file mode 100644
index 000000000..c129e06c7
--- /dev/null
+++ b/core/main/src/main/java/com/rk/settings/extension/ExtensionSettings.kt
@@ -0,0 +1,25 @@
+package com.rk.settings.extension
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.rk.App
+import com.rk.components.compose.preferences.base.PreferenceLayout
+import com.rk.extension.Extension
+import com.rk.resources.strings
+
+@Composable
+fun ExtensionSettings(extension: Extension?) {
+ val api = App.extensionManager.loadedExtensions[extension]?.api
+
+ PreferenceLayout(label = extension?.name ?: stringResource(strings.ext_not_found)) {
+ if (extension == null || api == null) {
+ Text(stringResource(strings.ext_not_found_desc), modifier = Modifier.padding(horizontal = 16.dp))
+ } else {
+ api.SettingsContent()
+ }
+ }
+}
diff --git a/core/main/src/main/java/com/rk/settings/extension/MarkdownViewer.kt b/core/main/src/main/java/com/rk/settings/extension/MarkdownViewer.kt
index 4149c02bb..2be93a5ec 100644
--- a/core/main/src/main/java/com/rk/settings/extension/MarkdownViewer.kt
+++ b/core/main/src/main/java/com/rk/settings/extension/MarkdownViewer.kt
@@ -2,11 +2,13 @@ package com.rk.settings.extension
import android.graphics.Typeface
import android.text.Spanned
+import android.text.method.LinkMovementMethod
import android.widget.TextView
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
@@ -26,9 +28,11 @@ import com.rk.components.StateScreen
import com.rk.resources.drawables
import com.rk.resources.strings
import io.github.rosemoe.sora.lsp.editor.text.SimpleMarkdownRenderer
+import com.rk.utils.okHttpClient
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import okhttp3.CacheControl
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -50,11 +54,12 @@ sealed interface MarkdownStatus {
fun MarkdownViewer(url: String?, refreshKey: Int, onLoaded: () -> Unit, modifier: Modifier = Modifier) {
var state by remember(url) { mutableStateOf(MarkdownStatus.Loading) }
val primaryColor = MaterialTheme.colorScheme.primary
- val client = remember { OkHttpClient() }
+ val client = remember { okHttpClient }
LaunchedEffect(url, refreshKey) {
state = MarkdownStatus.Loading
- state = loadMarkdown(url, primaryColor.toArgb(), client)
+ val forceRefresh = refreshKey > 0
+ state = loadMarkdown(url, primaryColor.toArgb(), client, forceRefresh)
onLoaded()
}
AnimatedContent(targetState = state, modifier = modifier.fillMaxWidth()) { state ->
@@ -79,9 +84,16 @@ fun MarkdownViewer(url: String?, refreshKey: Int, onLoaded: () -> Unit, modifier
}
is MarkdownStatus.Success -> {
+ val selectionColors = LocalTextSelectionColors.current
+ val selectionBackground = selectionColors.backgroundColor
AndroidView(
factory = { ctx -> TextView(ctx) },
- update = { it.text = state.spanned },
+ update = {
+ it.text = state.spanned
+ it.setTextIsSelectable(true)
+ it.movementMethod = LinkMovementMethod.getInstance()
+ it.highlightColor = selectionBackground.toArgb()
+ },
modifier = Modifier.fillMaxWidth(),
)
}
@@ -89,7 +101,12 @@ fun MarkdownViewer(url: String?, refreshKey: Int, onLoaded: () -> Unit, modifier
}
}
-private suspend fun loadMarkdown(url: String?, primaryColor: Int, client: OkHttpClient): MarkdownStatus {
+private suspend fun loadMarkdown(
+ url: String?,
+ primaryColor: Int,
+ client: OkHttpClient,
+ forceRefresh: Boolean = false
+): MarkdownStatus {
return withContext(Dispatchers.IO) {
if (url == null) {
return@withContext MarkdownStatus.Error.Empty
@@ -98,7 +115,11 @@ private suspend fun loadMarkdown(url: String?, primaryColor: Int, client: OkHttp
runCatching {
val markdown =
if (url.startsWith("http://") || url.startsWith("https://")) {
- val request = Request.Builder().url(url).build()
+ val requestBuilder = Request.Builder().url(url)
+ if (forceRefresh) {
+ requestBuilder.cacheControl(CacheControl.FORCE_NETWORK)
+ }
+ val request = requestBuilder.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
return@withContext when (response.code) {
diff --git a/core/main/src/main/java/com/rk/settings/extension/SourceCodeSheet.kt b/core/main/src/main/java/com/rk/settings/extension/SourceCodeSheet.kt
index 1444fe681..5254fb8b7 100644
--- a/core/main/src/main/java/com/rk/settings/extension/SourceCodeSheet.kt
+++ b/core/main/src/main/java/com/rk/settings/extension/SourceCodeSheet.kt
@@ -20,7 +20,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
-import com.rk.components.SettingsToggle
+import com.rk.components.SettingsItem
import com.rk.components.compose.preferences.base.PreferenceGroup
import com.rk.extension.Extension
import com.rk.resources.drawables
@@ -81,7 +81,7 @@ fun SourceCodeSheet(extension: Extension, onDismissRequest: () -> Unit) {
Text(sheetDescription, modifier = Modifier.padding(horizontal = 16.dp))
PreferenceGroup {
- SettingsToggle(
+ SettingsItem(
label = stringResource(sourceCodeProvider.viewStringRes),
description = extension.repository,
isEnabled = true,
diff --git a/core/main/src/main/java/com/rk/settings/extension/extensionActions.kt b/core/main/src/main/java/com/rk/settings/extension/extensionActions.kt
index c2121db22..c4ad5f296 100644
--- a/core/main/src/main/java/com/rk/settings/extension/extensionActions.kt
+++ b/core/main/src/main/java/com/rk/settings/extension/extensionActions.kt
@@ -1,25 +1,35 @@
package com.rk.settings.extension
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import androidx.core.app.NotificationCompat
+import com.rk.DefaultScope
+import com.rk.XedConstants
+import com.rk.extension.manager.ExtensionRegistry
+import java.io.File
+import com.rk.resources.drawables
import android.app.Activity
import android.content.Context
import android.net.Uri
import androidx.appcompat.app.AppCompatActivity
-import com.rk.App
+import com.rk.App.Companion.extensionManager
import com.rk.activities.settings.SettingsActivity
+import com.rk.crashhandler.CrashActivity
import com.rk.extension.Extension
import com.rk.extension.ExtensionError
import com.rk.extension.InstallResult
import com.rk.extension.LocalExtension
import com.rk.extension.StoreExtension
-import com.rk.extension.installExtensionFromZip
-import com.rk.extension.load
+import com.rk.extension.UpdatableExtension
+import com.rk.extension.loader.installExtensionFromZip
+import com.rk.extension.loader.load
import com.rk.file.toFileObject
import com.rk.resources.getFilledString
import com.rk.resources.getString
import com.rk.resources.strings
import com.rk.utils.LoadingPopup
import com.rk.utils.application
-import com.rk.utils.dialog
+import com.rk.utils.dialogRes
import com.rk.utils.errorDialog
import com.rk.utils.toast
import kotlinx.coroutines.CoroutineScope
@@ -29,58 +39,363 @@ import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.MissingFieldException
-suspend fun runExtensionUninstallAction(
+fun checkExtensionWarningAndRun(activity: AppCompatActivity?, onApproved: () -> Unit) {
+ if (com.rk.settings.Settings.warn_extensions) {
+ dialogRes(
+ activity = activity,
+ title = strings.attention.getString(),
+ msg = strings.extension_warning_msg.getString(),
+ okRes = strings.continue_action,
+ cancelRes = strings.cancel,
+ onOk = {
+ com.rk.settings.Settings.warn_extensions = false
+ onApproved()
+ },
+ onCancel = {}
+ )
+ } else {
+ onApproved()
+ }
+}
+
+fun runExtensionUninstallAction(
extension: Extension,
updateInstallState: (InstallState) -> Unit,
+ scope: CoroutineScope,
activity: AppCompatActivity?,
) {
- App.extensionManager.uninstallExtension(extension.id).onFailure {
- errorDialog(it, activity)
- return
+ dialogRes(
+ activity = activity,
+ title = strings.uninstall_ext_dialog.getString(),
+ msg = strings.uninstall_ext_dialog_desc.getFilledString(extension.name),
+ okRes = strings.uninstall,
+ onOk = {
+ scope.launch(Dispatchers.IO) {
+ extensionManager.uninstallExtension(extension.id).onFailure { error ->
+ withContext(Dispatchers.Main) {
+ errorDialog(activity, error)
+ }
+ return@launch
+ }
+ withContext(Dispatchers.Main) {
+ updateInstallState(InstallState.Idle)
+ }
+ }
+ },
+ onCancel = {},
+ )
+}
+
+
+private fun showDownloadNotification(
+ context: Context,
+ id: String,
+ title: String,
+ progress: Float,
+ isFinished: Boolean = false,
+ errorMessage: String? = null
+) {
+ val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channelId = "store_downloads"
+
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
+ val channel = NotificationChannel(
+ channelId,
+ "Store Downloads",
+ NotificationManager.IMPORTANCE_LOW
+ ).apply {
+ description = "Notifications for store downloads and installations"
+ }
+ notificationManager.createNotificationChannel(channel)
+ }
+
+ val builder = NotificationCompat.Builder(context, channelId)
+ .setSmallIcon(drawables.extension)
+ .setOnlyAlertOnce(true)
+
+ if (isFinished) {
+ if (errorMessage != null) {
+ builder.setContentTitle(strings.install_failed.getString(context))
+ .setContentText(errorMessage)
+ .setOngoing(false)
+ .setAutoCancel(true)
+ } else {
+ builder.setContentTitle(strings.installed.getString(context))
+ .setContentText(title)
+ .setOngoing(false)
+ .setAutoCancel(true)
+ }
+ } else {
+ builder.setContentTitle(title)
+ .setOngoing(true)
+ if (progress >= 0f) {
+ val percent = (progress * 100).toInt()
+ builder.setContentText("$percent%")
+ .setProgress(100, percent, false)
+ } else {
+ builder.setContentText(strings.installing.getString(context))
+ .setProgress(100, 0, true)
+ }
+ }
+
+ runCatching {
+ notificationManager.notify(id.hashCode(), builder.build())
}
- updateInstallState(InstallState.Idle)
}
-suspend fun runExtensionInstallAction(
+fun runExtensionInstallAction(
extension: Extension,
updateInstallState: (InstallState) -> Unit,
- scope: CoroutineScope,
context: Context,
activity: AppCompatActivity?,
) {
+ val storeExt = extension as? StoreExtension ?: return
+ val id = storeExt.id
+ val name = storeExt.name
+
+ ExtensionRegistry.activeInstalls[id] = InstallState.Installing
+ ExtensionRegistry.downloadProgress[id] = 0f
updateInstallState(InstallState.Installing)
- var loading: LoadingPopup? = null
- runCatching {
- val extension = extension as? StoreExtension ?: return
+ showDownloadNotification(context, id, name, 0f)
+
+ DefaultScope.launch(Dispatchers.IO) {
+ var success = false
+ var errorMsg: String? = null
+ val tempFile = File(context.cacheDir, "ext_download_${id}.zip")
+
+ try {
+ var lastNotificationTime = 0L
+
+ val downloadSuccess = ExtensionRegistry.downloadFileWithProgress(
+ url = "${XedConstants.EXTENSION_API_BASE}/$id/plugin.zip",
+ destFile = tempFile,
+ onProgress = { progress ->
+ DefaultScope.launch(Dispatchers.Main) {
+ ExtensionRegistry.downloadProgress[id] = progress
+ }
+ val now = System.currentTimeMillis()
+ if (now - lastNotificationTime > 300) {
+ lastNotificationTime = now
+ showDownloadNotification(context, id, name, progress)
+ }
+ }
+ )
- loading = LoadingPopup(activity).show()
- loading.setMessage(strings.installing.getString())
+ if (downloadSuccess) {
+ showDownloadNotification(context, id, name, 1f)
- val result =
- App.extensionManager.installStoreExtension(context, extension).getOrElse {
- loading.hide()
- errorDialog(it.message ?: strings.unknown_error.getString(), activity)
+ val result = extensionManager.installExtensionFromZip(tempFile)
+ if (result is InstallResult.Success) {
+ extensionManager.setExtensionDisabled(result.extension.id, false)
+ result.extension.load(application!!, true).onFailure { error ->
+ extensionManager.setExtensionDisabled(result.extension.id, true)
+ errorMsg = error.message ?: "Failed to load extension"
+ withContext(Dispatchers.Main) {
+ activity?.let {
+ CrashActivity.start(it, result.extension, error)
+ } ?: run {
+ errorDialog(activity, msg = errorMsg)
+ }
+ }
+ }
+ success = errorMsg == null
+ } else if (result is InstallResult.Error) {
+ errorMsg = when (result.error) {
+ ExtensionError.OUTDATED_CLIENT -> strings.outdated_client.getString(context)
+ ExtensionError.OUTDATED_EXTENSION -> strings.outdated_extension.getString(context)
+ }
+ } else if (result is InstallResult.ValidationFailed) {
+ errorMsg = result.error?.message ?: "Validation failed"
+ }
+ } else {
+ errorMsg = "Download failed"
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ errorMsg = e.message ?: "Unknown error"
+ } finally {
+ if (tempFile.exists()) {
+ tempFile.delete()
+ }
+
+ withContext(Dispatchers.Main) {
+ ExtensionRegistry.activeInstalls.remove(id)
+ ExtensionRegistry.downloadProgress.remove(id)
+
+ if (success) {
+ showDownloadNotification(context, id, name, 1f, isFinished = true)
+ updateInstallState(InstallState.Installed)
+ } else {
+ showDownloadNotification(context, id, name, 0f, isFinished = true, errorMessage = errorMsg)
updateInstallState(InstallState.Idle)
- return@runCatching
+ errorDialog(activity, msg = errorMsg ?: "Unknown error")
+ }
+ }
+ }
+ }
+}
+
+fun runExtensionUpdateAction(
+ extension: UpdatableExtension,
+ updateInstallState: (InstallState) -> Unit,
+ context: Context,
+ activity: AppCompatActivity?,
+) {
+ val store = extension.store
+ val id = store.id
+ val name = store.name
+
+ ExtensionRegistry.activeInstalls[id] = InstallState.Updating
+ ExtensionRegistry.downloadProgress[id] = 0f
+ updateInstallState(InstallState.Updating)
+
+ showDownloadNotification(context, id, name, 0f)
+
+ DefaultScope.launch(Dispatchers.IO) {
+ var success = false
+ var errorMsg: String? = null
+ val tempFile = File(context.cacheDir, "ext_download_${id}.zip")
+
+ try {
+ var lastNotificationTime = 0L
+
+ val downloadSuccess = ExtensionRegistry.downloadFileWithProgress(
+ url = "${XedConstants.EXTENSION_API_BASE}/$id/plugin.zip",
+ destFile = tempFile,
+ onProgress = { progress ->
+ DefaultScope.launch(Dispatchers.Main) {
+ ExtensionRegistry.downloadProgress[id] = progress
+ }
+ val now = System.currentTimeMillis()
+ if (now - lastNotificationTime > 300) {
+ lastNotificationTime = now
+ showDownloadNotification(context, id, name, progress)
+ }
}
+ )
- handleInstallResult(result, activity, { updateInstallState(InstallState.Idle) }) { ext ->
- updateInstallState(InstallState.Installed)
+ if (downloadSuccess) {
+ showDownloadNotification(context, id, name, 1f)
- scope.launch(Dispatchers.Default) {
- ext.load(application!!).onFailure {
- errorDialog(it.message ?: strings.unknown_error.getString(), activity)
+ val result = extensionManager.installExtensionFromZip(tempFile)
+ if (result is InstallResult.Success) {
+ extensionManager.setExtensionDisabled(result.extension.id, false)
+ result.extension.load(application!!).onFailure { error ->
+ extensionManager.setExtensionDisabled(result.extension.id, true)
+ errorMsg = error.message ?: "Failed to load extension"
+ withContext(Dispatchers.Main) {
+ activity?.let {
+ CrashActivity.start(it, result.extension, error)
+ } ?: run {
+ errorDialog(activity, msg = errorMsg)
+ }
+ }
}
+ success = errorMsg == null
+ } else if (result is InstallResult.Error) {
+ errorMsg = when (result.error) {
+ ExtensionError.OUTDATED_CLIENT -> strings.outdated_client.getString(context)
+ ExtensionError.OUTDATED_EXTENSION -> strings.outdated_extension.getString(context)
+ }
+ } else if (result is InstallResult.ValidationFailed) {
+ errorMsg = result.error?.message ?: "Validation failed"
+ }
+ } else {
+ errorMsg = "Download failed"
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ errorMsg = e.message ?: "Unknown error"
+ } finally {
+ if (tempFile.exists()) {
+ tempFile.delete()
+ }
+
+ withContext(Dispatchers.Main) {
+ ExtensionRegistry.activeInstalls.remove(id)
+ ExtensionRegistry.downloadProgress.remove(id)
+
+ if (success) {
+ showDownloadNotification(context, id, name, 1f, isFinished = true)
+ updateInstallState(InstallState.Installed)
+ } else {
+ showDownloadNotification(context, id, name, 0f, isFinished = true, errorMessage = errorMsg)
+ updateInstallState(InstallState.Idle)
+ errorDialog(activity, msg = errorMsg ?: "Unknown error")
}
}
- loading.hide()
}
- .onFailure {
- loading?.hide()
- errorDialog(it, activity)
- updateInstallState(InstallState.Idle)
+ }
+}
+
+fun runIconPackInstallAction(
+ id: String,
+ name: String,
+ context: Context,
+ activity: AppCompatActivity?,
+) {
+ ExtensionRegistry.activeInstalls[id] = InstallState.Installing
+ ExtensionRegistry.downloadProgress[id] = 0f
+
+ showDownloadNotification(context, id, name, 0f)
+
+ DefaultScope.launch(Dispatchers.IO) {
+ var success = false
+ var errorMsg: String? = null
+ val tempFile = File(context.cacheDir, "iconpack_${id}.zip")
+
+ try {
+ var lastNotificationTime = 0L
+
+ val downloadSuccess = ExtensionRegistry.downloadFileWithProgress(
+ url = "${XedConstants.ICONPACKS_API_BASE}/$id/iconpack.zip",
+ destFile = tempFile,
+ onProgress = { progress ->
+ DefaultScope.launch(Dispatchers.Main) {
+ ExtensionRegistry.downloadProgress[id] = progress
+ }
+ val now = System.currentTimeMillis()
+ if (now - lastNotificationTime > 300) {
+ lastNotificationTime = now
+ showDownloadNotification(context, id, name, progress)
+ }
+ }
+ )
+
+ if (downloadSuccess) {
+ showDownloadNotification(context, id, name, 1f)
+ runCatching {
+ com.rk.App.Companion.iconPackManager.installIconPack(tempFile)
+ }.onSuccess {
+ success = true
+ }.onFailure {
+ errorMsg = it.message ?: "Failed to install icon pack"
+ }
+ } else {
+ errorMsg = "Download failed"
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ errorMsg = e.message ?: "Unknown error"
+ } finally {
+ if (tempFile.exists()) {
+ tempFile.delete()
+ }
+
+ withContext(Dispatchers.Main) {
+ ExtensionRegistry.activeInstalls.remove(id)
+ ExtensionRegistry.downloadProgress.remove(id)
+
+ if (success) {
+ showDownloadNotification(context, id, name, 1f, isFinished = true)
+ } else {
+ showDownloadNotification(context, id, name, 0f, isFinished = true, errorMessage = errorMsg)
+ errorDialog(activity, msg = errorMsg ?: "Unknown error")
+ }
+ }
}
+ }
}
fun installExtensionFromUri(scope: CoroutineScope, uri: Uri?, activity: AppCompatActivity?) {
@@ -88,43 +403,54 @@ fun installExtensionFromUri(scope: CoroutineScope, uri: Uri?, activity: AppCompa
scope.launch(Dispatchers.IO) {
runCatching {
- if (uri == null) return@runCatching
+ if (uri == null) return@runCatching
- val fileObject = uri.toFileObject(expectedIsFile = true)
- val exists = fileObject.exists()
- val canRead = fileObject.canRead()
- val isZip = fileObject.getName().endsWith(".zip")
+ val fileObject = uri.toFileObject(expectedIsFile = true)
+ val exists = fileObject.exists()
+ val canRead = fileObject.canRead()
+ val isZip = fileObject.getName().endsWith(".zip")
- if (exists && canRead && isZip) {
- withContext(Dispatchers.Main) {
- loading = LoadingPopup(activity).show()
- loading.setMessage(strings.installing.getString())
- }
+ if (exists && canRead && isZip) {
+ withContext(Dispatchers.Main) {
+ loading = LoadingPopup(activity).show()
+ loading.setMessage(strings.installing.getString())
+ }
- val result = App.extensionManager.installExtensionFromZip(fileObject)
+ val result = extensionManager.installExtensionFromZip(fileObject)
- withContext(Dispatchers.Main) {
- handleInstallResult(result, activity) { ext ->
- scope.launch(Dispatchers.Default) {
- ext.load(application!!).onFailure {
- errorDialog(it.message ?: strings.unknown_error.getString(), activity)
- }
+ if (result is InstallResult.Success) {
+ extensionManager.setExtensionDisabled(result.extension.id, false)
+ val initialInstallation = !result.performedUpdate
+ result.extension.load(application!!, initialInstallation).onFailure { error ->
+ extensionManager.setExtensionDisabled(result.extension.id, true)
+ withContext(Dispatchers.Main) {
+ activity?.let {
+ CrashActivity.start(it, result.extension, error)
+ } ?: run {
+ errorDialog(activity, msg = error.message ?: strings.unknown_error.getString())
}
}
-
- loading?.hide()
}
- } else {
+ }
+
+ withContext(Dispatchers.Main) {
+ handleInstallResult(result, activity)
+ loading?.hide()
+ }
+ } else {
+ withContext(Dispatchers.Main) {
errorDialog(
- "Install criteria failed \nis_zip = $isZip\ncan_read = $canRead\n exists = $exists\nuri = ${fileObject.getAbsolutePath()}",
activity,
+ msg = "Install criteria failed \nis_zip = $isZip\ncan_read = $canRead\n exists = $exists\nuri = ${fileObject.getAbsolutePath()}",
)
}
}
- .onFailure {
+ }.onFailure { error ->
+ withContext(Dispatchers.Main) {
loading?.hide()
- errorDialog(it, activity)
+ errorDialog(activity, error)
}
+ }
}
}
@@ -136,16 +462,12 @@ private fun handleInstallResult(
onSuccess: (LocalExtension) -> Unit = {},
) =
when (result) {
- is InstallResult.AlreadyInstalled -> {
- // errorDialog("Extension already installed", activity)
- }
-
is InstallResult.Error -> {
when (result.error) {
ExtensionError.OUTDATED_CLIENT ->
- errorDialog(strings.outdated_client.getString(), activity, strings.install_failed.getString())
+ errorDialog(activity, strings.install_failed.getString(), strings.outdated_client.getString())
ExtensionError.OUTDATED_EXTENSION ->
- errorDialog(strings.outdated_extension.getString(), activity, strings.install_failed.getString())
+ errorDialog(activity, strings.install_failed.getString(), strings.outdated_extension.getString())
}
onError()
}
@@ -159,7 +481,7 @@ private fun handleInstallResult(
val e = result.error
if (e is MissingFieldException) {
val fields = e.missingFields.joinToString("\n") { "• $it" }
- dialog(
+ dialogRes(
SettingsActivity.instance,
strings.extension_validation_failed.getString(),
strings.manifest_missing_fields.getFilledString(fields),
@@ -168,8 +490,8 @@ private fun handleInstallResult(
onError()
} else {
errorDialog(
- e?.localizedMessage ?: strings.unknown_error.getString(),
activity,
+ e?.localizedMessage ?: strings.unknown_error.getString(),
strings.extension_validation_failed.getString(),
)
onError()
diff --git a/core/main/src/main/java/com/rk/settings/git/GitSettings.kt b/core/main/src/main/java/com/rk/settings/git/GitSettings.kt
index 47631ee64..6b91b6cef 100644
--- a/core/main/src/main/java/com/rk/settings/git/GitSettings.kt
+++ b/core/main/src/main/java/com/rk/settings/git/GitSettings.kt
@@ -7,7 +7,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import com.rk.components.DoubleInputDialog
-import com.rk.components.SettingsToggle
+import com.rk.components.SettingsItem
import com.rk.components.compose.preferences.base.PreferenceGroup
import com.rk.components.compose.preferences.base.PreferenceLayout
import com.rk.resources.strings
@@ -25,7 +25,7 @@ fun GitSettings() {
var email by remember { mutableStateOf(Settings.git_email) }
PreferenceGroup(heading = stringResource(strings.general)) {
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.git_colorize_files),
description = stringResource(strings.git_colorize_files_desc),
default = Settings.git_colorize_names,
@@ -34,7 +34,7 @@ fun GitSettings() {
}
PreferenceGroup(heading = stringResource(strings.account)) {
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.credentials),
description = stringResource(strings.credentials_desc),
showSwitch = false,
@@ -42,7 +42,7 @@ fun GitSettings() {
sideEffect = { showCredentialsDialog = true },
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.user_data),
description = stringResource(strings.user_data_desc),
showSwitch = false,
@@ -52,14 +52,14 @@ fun GitSettings() {
}
PreferenceGroup(heading = stringResource(strings.repository)) {
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.submodules),
description = stringResource(strings.submodules_desc),
default = Settings.git_submodules,
sideEffect = { Settings.git_submodules = it },
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.recursive_submodules),
description = stringResource(strings.recursive_submodules_desc),
default = Settings.git_recursive_submodules,
diff --git a/core/main/src/main/java/com/rk/settings/keybinds/KeyUtils.kt b/core/main/src/main/java/com/rk/settings/keybinds/KeyUtils.kt
index 1c1041f0d..7683273a3 100644
--- a/core/main/src/main/java/com/rk/settings/keybinds/KeyUtils.kt
+++ b/core/main/src/main/java/com/rk/settings/keybinds/KeyUtils.kt
@@ -85,7 +85,7 @@ data object KeyUtils {
}
fun getKeyIcon(keyCode: Int): Icon {
- return Icon.DrawableRes(
+ return Icon.ResourceIcon(
when (keyCode) {
KeyEvent.KEYCODE_SHIFT_LEFT -> drawables.shift
KeyEvent.KEYCODE_DPAD_DOWN -> drawables.chevron_down
diff --git a/core/main/src/main/java/com/rk/settings/keybinds/KeybindingsScreen.kt b/core/main/src/main/java/com/rk/settings/keybinds/KeybindingsScreen.kt
index 2861676a6..2f07434a4 100644
--- a/core/main/src/main/java/com/rk/settings/keybinds/KeybindingsScreen.kt
+++ b/core/main/src/main/java/com/rk/settings/keybinds/KeybindingsScreen.kt
@@ -78,21 +78,22 @@ fun KeybindingsScreen() {
val commands = CommandProvider.commandList
val filteredCommands =
if (searchQuery.text.isEmpty()) {
- commands
- } else {
- val query = searchQuery.text
+ commands
+ } else {
+ val query = searchQuery.text
- commands.filter { command ->
- val labelMatch = command.getLabel().contains(query, ignoreCase = true)
- val prefixMatch = command.prefix?.contains(query, ignoreCase = true) == true
- val keybindMatch =
- KeybindingsManager.getKeyCombinationForCommand(command.id)
- ?.getDisplayName()
- ?.contains(query, ignoreCase = true) == true
+ commands.filter { command ->
+ val labelMatch = command.getLabel().contains(query, ignoreCase = true)
+ val prefixMatch = command.prefix?.contains(query, ignoreCase = true) == true
+ val keybindMatch =
+ KeybindingsManager.getKeyCombinationForCommand(command.id)
+ ?.getDisplayName()
+ ?.contains(query, ignoreCase = true) == true
- labelMatch || prefixMatch || keybindMatch
+ labelMatch || prefixMatch || keybindMatch
+ }
}
- }
+ .sortedBy { it.getLabel() }
PreferenceLayoutLazyColumn(
label = stringResource(id = strings.keybindings),
diff --git a/core/main/src/main/java/com/rk/settings/language/Language.kt b/core/main/src/main/java/com/rk/settings/language/Language.kt
index 76588cabc..40ac19cb8 100644
--- a/core/main/src/main/java/com/rk/settings/language/Language.kt
+++ b/core/main/src/main/java/com/rk/settings/language/Language.kt
@@ -27,7 +27,7 @@ import androidx.core.os.LocaleListCompat
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.rk.components.InfoBlock
-import com.rk.components.SettingsToggle
+import com.rk.components.SettingsItem
import com.rk.components.compose.preferences.base.PreferenceGroup
import com.rk.components.compose.preferences.base.PreferenceLayout
import com.rk.resources.strings
@@ -104,7 +104,7 @@ fun LanguageScreen(modifier: Modifier = Modifier) {
if (locales != null) {
locales.forEach { localeInfo ->
- SettingsToggle(
+ SettingsItem(
modifier = Modifier,
label = localeInfo.displayName,
default = false,
@@ -120,7 +120,7 @@ fun LanguageScreen(modifier: Modifier = Modifier) {
)
}
} else {
- SettingsToggle(
+ SettingsItem(
modifier = Modifier,
label = stringResource(strings.loading),
default = false,
diff --git a/core/main/src/main/java/com/rk/settings/lsp/LspServerDetail.kt b/core/main/src/main/java/com/rk/settings/lsp/LspServerDetail.kt
index 4864ba361..1ebf76868 100644
--- a/core/main/src/main/java/com/rk/settings/lsp/LspServerDetail.kt
+++ b/core/main/src/main/java/com/rk/settings/lsp/LspServerDetail.kt
@@ -1,5 +1,6 @@
package com.rk.settings.lsp
+import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -44,10 +45,11 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.currentStateAsState
import androidx.navigation.NavHostController
import com.rk.activities.settings.snackbarHostStateRef
-import com.rk.components.SettingsToggle
+import com.rk.components.SettingsItem
import com.rk.components.compose.preferences.base.PreferenceGroup
import com.rk.components.compose.preferences.base.PreferenceGroupHeading
import com.rk.components.compose.preferences.base.PreferenceLayout
+import com.rk.icons.XedIcon
import com.rk.lsp.DefinitionPrevention
import com.rk.lsp.LspConnectionStatus
import com.rk.lsp.LspServer
@@ -72,6 +74,7 @@ enum class LspInstallationAction {
fun LspServerDetail(navController: NavHostController, server: LspServer) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
+ val activity = LocalActivity.current
var refreshKey by remember { mutableIntStateOf(0) }
val lifecycleOwner = LocalLifecycleOwner.current
@@ -97,7 +100,7 @@ fun LspServerDetail(navController: NavHostController, server: LspServer) {
if (!server.canBeUninstalled) return
FilledTonalButton(
- onClick = { server.uninstall(context) },
+ onClick = { activity?.let { server.uninstall(it) } },
colors =
ButtonDefaults.filledTonalButtonColors()
.copy(
@@ -113,7 +116,7 @@ fun LspServerDetail(navController: NavHostController, server: LspServer) {
@Composable
fun UpdateButton() {
- FilledTonalButton(onClick = { server.update(context) }) {
+ FilledTonalButton(onClick = { activity?.let { server.update(it) } }) {
Icon(painter = painterResource(drawables.update), contentDescription = stringResource(strings.update))
Spacer(Modifier.width(12.dp))
Text(stringResource(strings.update))
@@ -122,7 +125,7 @@ fun LspServerDetail(navController: NavHostController, server: LspServer) {
@Composable
fun DownloadButton() {
- FilledTonalButton(onClick = { server.install(context) }) {
+ FilledTonalButton(onClick = { activity?.let { server.install(it) } }) {
Icon(painter = painterResource(drawables.download), contentDescription = stringResource(strings.download))
Spacer(Modifier.width(12.dp))
Text(stringResource(strings.install))
@@ -131,7 +134,7 @@ fun LspServerDetail(navController: NavHostController, server: LspServer) {
@Composable
fun LspFeatureToggle(label: String, description: String? = null, preferenceId: String, server: LspServer) {
- SettingsToggle(
+ SettingsItem(
label = label,
description = description,
default = Preference.getBoolean(preferenceId, true),
@@ -154,8 +157,8 @@ fun LspServerDetail(navController: NavHostController, server: LspServer) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
server.icon?.let {
- Icon(
- painter = painterResource(it),
+ XedIcon(
+ icon = it,
contentDescription = null,
modifier = Modifier.size(42.dp).padding(end = 8.dp),
)
@@ -221,7 +224,7 @@ fun LspServerDetail(navController: NavHostController, server: LspServer) {
shape = MaterialTheme.shapes.large,
tonalElevation = 1.dp,
) {
- SettingsToggle(
+ SettingsItem(
modifier = Modifier,
label = stringResource(strings.no_instances),
default = false,
@@ -234,6 +237,13 @@ fun LspServerDetail(navController: NavHostController, server: LspServer) {
}
PreferenceGroup(heading = stringResource(strings.features)) {
+ LspFeatureToggle(
+ label = stringResource(strings.document_highlight),
+ description = stringResource(strings.document_highlight_desc),
+ preferenceId = "lsp_${server.id}_document_highlight",
+ server = server,
+ )
+
LspFeatureToggle(
label = stringResource(strings.hover_information),
description = stringResource(strings.hover_information_desc),
diff --git a/core/main/src/main/java/com/rk/settings/lsp/LspServerLogs.kt b/core/main/src/main/java/com/rk/settings/lsp/LspServerLogs.kt
index 573f952f0..bb9212eef 100644
--- a/core/main/src/main/java/com/rk/settings/lsp/LspServerLogs.kt
+++ b/core/main/src/main/java/com/rk/settings/lsp/LspServerLogs.kt
@@ -1,40 +1,114 @@
package com.rk.settings.lsp
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuAnchorType
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
+import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
import androidx.core.content.pm.PackageInfoCompat
+import com.rk.components.StyledTextField
import com.rk.lsp.LspConnectionStatus
import com.rk.lsp.LspRegistry
import com.rk.lsp.LspServer
import com.rk.lsp.LspServerInstance
+import com.rk.lsp.MessageSource
import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-import com.rk.settings.debugOptions.LogLevel
import com.rk.settings.debugOptions.LogScreen
import com.rk.utils.application
import com.rk.xededitor.BuildConfig
import kotlinx.coroutines.launch
+import org.eclipse.lsp4j.MessageType
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LspServerLogs(server: LspServer, id: String) {
val scope = rememberCoroutineScope()
- var logLevel by remember { mutableStateOf(LogLevel.INFO) }
+ var messageType by remember { mutableStateOf(MessageType.Info) }
+
+ val messageSources = remember { mutableStateSetOf(MessageSource.LSP, MessageSource.Runtime, MessageSource.Client, MessageSource.RPC) }
+ var sourceDropdownExpanded by remember { mutableStateOf(false) }
val instance = server.instances.find { it.id == id }
- val logText = instance?.let { buildLogs(server, it, logLevel) } ?: strings.instance_not_found.getString()
+ val logText =
+ instance?.let { buildLogs(server, it, messageType, messageSources) } ?: strings.instance_not_found.getString()
+
+ LogScreen(logText, "Language Server Error Report", "server_logs") {
+ var dropdownMenuExpanded by remember { mutableStateOf(false) }
+
+ ExposedDropdownMenuBox(
+ expanded = dropdownMenuExpanded,
+ onExpandedChange = { dropdownMenuExpanded = !dropdownMenuExpanded },
+ modifier = Modifier.weight(1f).padding(horizontal = 8.dp),
+ ) {
+ StyledTextField(
+ value = messageType.name + " (LSP)",
+ onValueChange = {},
+ shape = RoundedCornerShape(8.dp),
+ maxLines = 1,
+ readOnly = true,
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = dropdownMenuExpanded) },
+ modifier =
+ Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable).fillMaxWidth().height(42.dp),
+ )
+
+ ExposedDropdownMenu(expanded = dropdownMenuExpanded, onDismissRequest = { dropdownMenuExpanded = false }) {
+ MessageType.entries.forEach { type ->
+ DropdownMenuItem(
+ text = { Text(text = type.name) },
+ onClick = {
+ messageType = type
+ dropdownMenuExpanded = false
+ },
+ )
+ }
+ }
+ }
+
+ IconButton(onClick = { sourceDropdownExpanded = !sourceDropdownExpanded }) {
+ Icon(
+ painter = painterResource(drawables.filter),
+ contentDescription = stringResource(strings.filter_options),
+ )
+
+ DropdownMenu(expanded = sourceDropdownExpanded, onDismissRequest = { sourceDropdownExpanded = false }) {
+ MessageSource.entries.forEach { source ->
+ DropdownMenuItem(
+ text = { Text(text = source.name) },
+ leadingIcon = { Checkbox(checked = messageSources.contains(source), onCheckedChange = null) },
+ onClick = {
+ if (messageSources.contains(source)) {
+ messageSources.remove(source)
+ } else {
+ messageSources.add(source)
+ }
+ },
+ )
+ }
+ }
+ }
- LogScreen(logText, "Language Server Error Report", "server_logs", logLevel, { logLevel = it }) {
val isRunning =
instance?.status != LspConnectionStatus.NOT_RUNNING &&
instance?.status != LspConnectionStatus.CRASHED &&
@@ -55,29 +129,39 @@ fun LspServerLogs(server: LspServer, id: String) {
}
}
-private fun buildLogs(server: LspServer, instance: LspServerInstance, logLevel: LogLevel): String {
+private fun buildLogs(
+ server: LspServer,
+ instance: LspServerInstance,
+ messageType: MessageType,
+ messageSources: Set,
+): String {
val entries =
instance
- .getLogs()
- .filter { it.level.ordinal >= logLevel.ordinal }
- .joinToString("\n") { "[${it.level.name.uppercase()}] ${it.message}" }
+ .getLspLogs()
+ .filter { messageSources.contains(it.source) }
+ .filter { it.source == MessageSource.RPC || it.type == null || it.type.value <= messageType.value }
+ .joinToString("\n") {
+ val sourceString = it.source.name.uppercase().let { source -> "[$source]" }
+ val levelString = it.type?.name?.uppercase() ?: ""
+ "$sourceString $levelString ${it.message}"
+ }
val packageInfo = with(application!!) { packageManager.getPackageInfo(packageName, 0) }
val versionName = packageInfo.versionName
val versionCode = PackageInfoCompat.getLongVersionCode(packageInfo)
return buildString {
- append("[DEBUG] App version: ").append(versionName).appendLine()
- append("[DEBUG] Version code: ").append(versionCode).appendLine()
- append("[DEBUG] Commit hash: ").append(BuildConfig.GIT_COMMIT_HASH.substring(0, 8)).appendLine()
+ append("[REPORT] App version: ").append(versionName).appendLine()
+ append("[REPORT] Version code: ").append(versionCode).appendLine()
+ append("[REPORT] Commit hash: ").append(BuildConfig.GIT_COMMIT_HASH.substring(0, 8)).appendLine()
appendLine()
- append("[DEBUG] Server name: ").append(server.serverName).appendLine()
- append("[DEBUG] Server id: ").append(server.id).appendLine()
- append("[DEBUG] Server status: ").append(instance.status.name).appendLine()
- append("[DEBUG] Supported extensions: ").append(server.supportedExtensions).appendLine()
- append("[DEBUG] Is built-in: ").append(LspRegistry.builtInServer.contains(server)).appendLine()
+ append("[REPORT] Server name: ").append(server.serverName).appendLine()
+ append("[REPORT] Server id: ").append(server.id).appendLine()
+ append("[REPORT] Server status: ").append(instance.status.name).appendLine()
+ append("[REPORT] Supported extensions: ").append(server.supportedExtensions).appendLine()
+ append("[REPORT] Is built-in: ").append(LspRegistry.builtInServer.contains(server)).appendLine()
appendLine()
diff --git a/core/main/src/main/java/com/rk/settings/lsp/LspSettings.kt b/core/main/src/main/java/com/rk/settings/lsp/LspSettings.kt
index 026a959cd..362c697e9 100644
--- a/core/main/src/main/java/com/rk/settings/lsp/LspSettings.kt
+++ b/core/main/src/main/java/com/rk/settings/lsp/LspSettings.kt
@@ -1,6 +1,7 @@
package com.rk.settings.lsp
import android.content.Context
+import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -48,9 +49,11 @@ import androidx.lifecycle.compose.currentStateAsState
import androidx.navigation.NavController
import com.rk.activities.settings.SettingsRoutes
import com.rk.components.InfoBlock
-import com.rk.components.SettingsToggle
+import com.rk.components.SettingsItem
import com.rk.components.compose.preferences.base.PreferenceGroup
import com.rk.components.compose.preferences.base.PreferenceLayout
+import com.rk.icons.Icon
+import com.rk.icons.XedIcon
import com.rk.lsp.LspPersistence
import com.rk.lsp.LspRegistry
import com.rk.lsp.LspServer
@@ -61,6 +64,7 @@ import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
import com.rk.settings.Preference
+import com.rk.utils.openDocs
import com.rk.utils.parseExtensions
import com.rk.utils.toast
import kotlinx.coroutines.CoroutineScope
@@ -108,8 +112,8 @@ fun LspSettings(navController: NavController) {
PreferenceGroup(heading = stringResource(strings.external)) {
LspRegistry.externalServers.forEachIndexed { index, server ->
key(server.id) {
- val icon = server.icon ?: drawables.unknown_document
- SettingsToggle(
+ val icon = server.icon ?: Icon.ResourceIcon(drawables.unknown_document)
+ SettingsItem(
label = server.languageName,
default = true,
description = server.serverName,
@@ -159,6 +163,7 @@ fun LspSettings(navController: NavController) {
},
) {
InfoBlock(
+ onClick = { context.openDocs("lsp") },
icon = { Icon(imageVector = Icons.Outlined.Info, contentDescription = null) },
text = stringResource(strings.info_lsp),
)
@@ -205,8 +210,8 @@ private fun LspServerItem(
navController: NavController,
refreshKey: Int,
) {
- val icon = server.icon ?: drawables.unknown_document
- SettingsToggle(
+ val icon = server.icon ?: Icon.ResourceIcon(drawables.unknown_document)
+ SettingsItem(
label = server.languageName,
default = Preference.getBoolean("lsp_${server.id}", true),
description = server.serverName,
@@ -224,10 +229,11 @@ private fun LspServerItem(
},
endWidget = {
val status by rememberLspInstallStatus(context, server, refreshKey)
+ val activity = LocalActivity.current
when (status) {
LspInstallationAction.INSTALL -> {
- IconButton(onClick = { server.install(context) }) {
+ IconButton(onClick = { activity?.let { server.install(it) } }) {
Icon(
painter = painterResource(drawables.download),
contentDescription = stringResource(strings.download),
@@ -236,7 +242,7 @@ private fun LspServerItem(
}
LspInstallationAction.UPDATE -> {
- IconButton(onClick = { server.update(context) }) {
+ IconButton(onClick = { activity?.let { server.update(it) } }) {
Icon(
painter = painterResource(drawables.update),
contentDescription = stringResource(strings.update),
@@ -274,9 +280,9 @@ fun rememberLspInstallStatus(context: Context, server: LspServer, refreshKey: In
}
@Composable
-private fun LspServerIcon(server: LspServer, i: Int) {
+private fun LspServerIcon(server: LspServer, icon: Icon) {
BadgedBox(badge = { server.getDominantStatusColor()?.let { color -> Badge(containerColor = color) } }) {
- Icon(modifier = Modifier.padding(start = 16.dp), painter = painterResource(i), contentDescription = null)
+ XedIcon(icon = icon, contentDescription = null, modifier = Modifier.padding(start = 16.dp))
}
}
diff --git a/core/main/src/main/java/com/rk/settings/runners/HtmlRunnerSettings.kt b/core/main/src/main/java/com/rk/settings/runners/HtmlRunnerSettings.kt
index 71f6dbbf5..45a0b8c36 100644
--- a/core/main/src/main/java/com/rk/settings/runners/HtmlRunnerSettings.kt
+++ b/core/main/src/main/java/com/rk/settings/runners/HtmlRunnerSettings.kt
@@ -10,7 +10,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
-import com.rk.components.SettingsToggle
+import com.rk.components.SettingsItem
import com.rk.components.SingleInputDialog
import com.rk.components.compose.preferences.base.PreferenceGroup
import com.rk.components.compose.preferences.base.PreferenceLayout
@@ -26,21 +26,21 @@ fun HtmlRunnerSettings(modifier: Modifier = Modifier) {
PreferenceLayout(label = stringResource(strings.html_preview)) {
PreferenceGroup {
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.launch_in_browser),
description = stringResource(strings.launch_in_browser_desc),
default = Settings.launch_in_browser,
sideEffect = { Settings.launch_in_browser = it },
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.inject_eruda),
description = stringResource(strings.inject_eruda_desc),
default = Settings.inject_eruda,
sideEffect = { Settings.inject_eruda = it },
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.server_port),
description = stringResource(strings.server_port_desc),
default = false,
diff --git a/core/main/src/main/java/com/rk/settings/runners/RunnerSettings.kt b/core/main/src/main/java/com/rk/settings/runners/RunnerSettings.kt
index 2e734f9d4..6263c7ec2 100644
--- a/core/main/src/main/java/com/rk/settings/runners/RunnerSettings.kt
+++ b/core/main/src/main/java/com/rk/settings/runners/RunnerSettings.kt
@@ -29,6 +29,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
@@ -36,19 +37,22 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavController
import com.rk.activities.main.MainActivity
-import com.rk.activities.settings.SettingsRoutes
import com.rk.components.InfoBlock
-import com.rk.components.SettingsToggle
+import com.rk.components.SettingsItem
import com.rk.components.compose.preferences.base.PreferenceGroup
import com.rk.components.compose.preferences.base.PreferenceLayout
import com.rk.file.FileWrapper
import com.rk.icons.Error
+import com.rk.icons.Icon
+import com.rk.icons.XedIcon
import com.rk.icons.XedIcons
+import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
+import com.rk.runner.RunnerManager
import com.rk.runner.ShellBasedRunner
import com.rk.runner.ShellBasedRunners
-import com.rk.settings.Settings
+import com.rk.utils.openDocs
import com.rk.utils.toast
import kotlinx.coroutines.launch
@@ -63,7 +67,9 @@ fun RunnerSettings(modifier: Modifier = Modifier, navController: NavController)
val nameFocusRequester = remember { FocusRequester() }
val regexFocusRequester = remember { FocusRequester() }
+
val scope = rememberCoroutineScope()
+ val context = LocalContext.current
var regexFieldValue by remember { mutableStateOf(TextFieldValue("", selection = TextRange(runnerName.length))) }
@@ -126,38 +132,48 @@ fun RunnerSettings(modifier: Modifier = Modifier, navController: NavController)
},
) {
InfoBlock(
+ onClick = { context.openDocs("runners") },
icon = { Icon(imageVector = Icons.Outlined.Info, contentDescription = null) },
text = stringResource(strings.info_runners),
)
PreferenceGroup(heading = stringResource(strings.built_in)) {
- SettingsToggle(
- label = stringResource(strings.html_preview),
- description = stringResource(strings.html_preview_desc),
- default = Settings.enable_html_runner,
- sideEffect = { Settings.enable_html_runner = it },
- onClick = { navController.navigate(SettingsRoutes.HtmlRunner.route) },
- )
-
- SettingsToggle(
- label = stringResource(strings.markdown_preview),
- description = stringResource(strings.markdown_preview_desc),
- default = Settings.enable_md_runner,
- sideEffect = { Settings.enable_md_runner = it },
- )
+ RunnerManager.builtinRunners.forEach { runner ->
+ SettingsItem(
+ label = runner.label,
+ description = runner.description,
+ default = runner.isEnabled(),
+ sideEffect = { runner.setEnabled(it) },
+ onClick = runner.onConfigure,
+ )
+ }
+ }
- SettingsToggle(
- label = stringResource(strings.universal_runner),
- description = stringResource(strings.universal_runner_desc),
- default = Settings.enable_universal_runner,
- sideEffect = { Settings.enable_universal_runner = it },
- )
+ if (RunnerManager.extensionRunners.isNotEmpty()) {
+ PreferenceGroup(heading = stringResource(strings.ext)) {
+ RunnerManager.extensionRunners.forEach { runner ->
+ SettingsItem(
+ label = runner.label,
+ description = runner.description,
+ startWidget = {
+ XedIcon(
+ icon = runner.getIcon(context) ?: Icon.ResourceIcon(drawableRes = drawables.run),
+ contentDescription = null,
+ modifier = Modifier.padding(start = 16.dp),
+ )
+ },
+ default = runner.isEnabled(),
+ sideEffect = { runner.setEnabled(it) },
+ onClick = runner.onConfigure,
+ )
+ }
+ }
}
PreferenceGroup(heading = stringResource(strings.external)) {
val scope = rememberCoroutineScope()
if (isLoading) {
- SettingsToggle(
+ SettingsItem(
modifier = Modifier,
label = stringResource(strings.loading),
default = false,
@@ -167,7 +183,7 @@ fun RunnerSettings(modifier: Modifier = Modifier, navController: NavController)
)
} else {
if (ShellBasedRunners.runners.isEmpty()) {
- SettingsToggle(
+ SettingsItem(
modifier = Modifier,
label = stringResource(strings.no_runners),
default = false,
@@ -177,12 +193,12 @@ fun RunnerSettings(modifier: Modifier = Modifier, navController: NavController)
)
} else {
ShellBasedRunners.runners.forEach { runner ->
- SettingsToggle(
+ SettingsItem(
modifier = Modifier,
- label = runner.getName(),
+ label = runner.label,
description = null,
- default = false,
- sideEffect = { _ ->
+ default = runner.isEnabled(),
+ onClick = {
MainActivity.instance?.let {
it.lifecycleScope.launch {
it.viewModel.editorManager.openFile(FileWrapper(runner.getScript()), null, true)
@@ -190,7 +206,7 @@ fun RunnerSettings(modifier: Modifier = Modifier, navController: NavController)
}
}
},
- showSwitch = false,
+ sideEffect = { runner.setEnabled(it) },
endWidget = {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -199,7 +215,7 @@ fun RunnerSettings(modifier: Modifier = Modifier, navController: NavController)
IconButton(
onClick = {
isEditingExisting = runner
- runnerName = runner.getName()
+ runnerName = runner.label
regexFieldValue =
regexFieldValue.copy(
text = runner.regex,
@@ -325,7 +341,7 @@ fun RunnerSettings(modifier: Modifier = Modifier, navController: NavController)
onClick = {
// Check for duplicate names only when creating new runner
if (isEditingExisting == null) {
- if (ShellBasedRunners.runners.any { it.getName() == runnerName }) {
+ if (ShellBasedRunners.runners.any { it.label == runnerName }) {
nameError = strings.runner_name_exists.getString()
return@TextButton
}
@@ -334,7 +350,7 @@ fun RunnerSettings(modifier: Modifier = Modifier, navController: NavController)
scope.launch {
if (isEditingExisting == null) {
// Create new runner
- val runner = ShellBasedRunner(name = runnerName, regex = regexFieldValue.text)
+ val runner = ShellBasedRunner(label = runnerName, regex = regexFieldValue.text)
val created = ShellBasedRunners.newRunner(runner)
if (created) {
// Refresh list
@@ -350,10 +366,10 @@ fun RunnerSettings(modifier: Modifier = Modifier, navController: NavController)
// Update existing runner
val updatedRunner =
ShellBasedRunner(
- name = runnerName, // Name remains the same
+ label = runnerName, // Name remains the same
regex = regexFieldValue.text,
)
- ShellBasedRunners.deleteRunner(isEditingExisting!!, deleteScript = false)
+ ShellBasedRunners.deleteRunner(isEditingExisting!!)
val updated = ShellBasedRunners.newRunner(updatedRunner)
if (updated) {
// Refresh list
diff --git a/core/main/src/main/java/com/rk/settings/support/Support.kt b/core/main/src/main/java/com/rk/settings/support/Support.kt
index eeaa55db1..f83d2ece3 100644
--- a/core/main/src/main/java/com/rk/settings/support/Support.kt
+++ b/core/main/src/main/java/com/rk/settings/support/Support.kt
@@ -17,7 +17,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import com.rk.activities.settings.SettingsActivity
import com.rk.activities.settings.SettingsRoutes
-import com.rk.components.SettingsToggle
+import com.rk.components.SettingsItem
import com.rk.components.compose.preferences.base.PreferenceGroup
import com.rk.components.compose.preferences.base.PreferenceLayout
import com.rk.resources.drawables
@@ -25,7 +25,7 @@ import com.rk.resources.getFilledString
import com.rk.resources.getString
import com.rk.resources.strings
import com.rk.settings.Settings
-import com.rk.utils.dialog
+import com.rk.utils.dialogRes
import com.rk.utils.isDialogShowing
import com.rk.utils.toast
@@ -62,9 +62,9 @@ fun Support(modifier: Modifier = Modifier) {
val context = LocalContext.current
PreferenceGroup {
- SettingsToggle(
+ SettingsItem(
label = "GitHub Sponsors",
- description = stringResource(strings.sponsor_desc),
+
isEnabled = true,
showSwitch = false,
default = false,
@@ -89,9 +89,9 @@ fun Support(modifier: Modifier = Modifier) {
Settings.donated = true
},
)
- SettingsToggle(
+ SettingsItem(
label = "Buy Me a Coffee",
- description = stringResource(strings.coffee_desc),
+
isEnabled = true,
showSwitch = false,
default = false,
@@ -118,9 +118,9 @@ fun Support(modifier: Modifier = Modifier) {
)
val upiAvailable = remember { isUPISupported(context) }
if (upiAvailable) {
- SettingsToggle(
+ SettingsItem(
label = "UPI",
- description = stringResource(strings.upi_desc),
+
isEnabled = true,
showSwitch = false,
default = false,
@@ -143,7 +143,7 @@ fun Support(modifier: Modifier = Modifier) {
"upi://pay"
.toUri()
.buildUpon()
- .appendQueryParameter("pa", "rohitkushvaha01@axl")
+ .appendQueryParameter("pa", "rohitkushwaha01x@axl")
.appendQueryParameter("pn", "Rohit Kushwaha")
.appendQueryParameter("tn", "Xed-Editor")
.appendQueryParameter("cu", "INR")
@@ -204,12 +204,12 @@ fun Activity.handleSupport() {
}
private fun Activity.showCombinedDonationDialog() {
- dialog(
- context = this,
+ dialogRes(
+ activity = this,
title = strings.enjoying_xed.getString(),
msg = strings.support_message.getFilledString(Settings.saves.toString(), Settings.runs.toString()),
- okString = strings.yes_support,
- cancelString = strings.not_for_me,
+ okRes = strings.yes_support,
+ cancelRes = strings.not_for_me,
cancelable = false,
onCancel = {
// User doesn't find value - stop asking
diff --git a/core/main/src/main/java/com/rk/settings/terminal/ExtraKeys.kt b/core/main/src/main/java/com/rk/settings/terminal/ExtraKeys.kt
index d4db8cf52..637d5e888 100644
--- a/core/main/src/main/java/com/rk/settings/terminal/ExtraKeys.kt
+++ b/core/main/src/main/java/com/rk/settings/terminal/ExtraKeys.kt
@@ -1,6 +1,5 @@
package com.rk.settings.terminal
-import android.content.Intent
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@@ -27,7 +26,6 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.viewinterop.AndroidView
-import androidx.core.net.toUri
import com.rk.components.ResetButton
import com.rk.editor.Editor
import com.rk.file.BuiltinFileType
@@ -37,6 +35,7 @@ import com.rk.settings.Preference
import com.rk.settings.Settings
import com.rk.tabs.editor.EditorNotice
import com.rk.utils.isSystemInDarkTheme
+import com.rk.utils.openUrl
import io.github.rosemoe.sora.event.ContentChangeEvent
import java.lang.ref.WeakReference
import kotlinx.coroutines.launch
@@ -114,8 +113,7 @@ fun TerminalExtraKeys() {
IconButton(
onClick = {
val url = "https://wiki.termux.com/wiki/Touch_Keyboard#Extra_Keys_Row"
- val intent = Intent(Intent.ACTION_VIEW, url.toUri())
- context.startActivity(intent)
+ context.openUrl(url)
}
) {
Icon(
@@ -146,7 +144,7 @@ fun TerminalExtraKeys() {
colorScheme = colorScheme,
)
- scope.launch { setLanguage(BuiltinFileType.JSON.textmateScope!!) }
+ scope.launch { configureLanguage(BuiltinFileType.JSON.textmateScope!!) }
}
},
)
diff --git a/core/main/src/main/java/com/rk/settings/terminal/SettingsTerminalScreen.kt b/core/main/src/main/java/com/rk/settings/terminal/SettingsTerminalScreen.kt
index 06b59ecb2..163b370fd 100644
--- a/core/main/src/main/java/com/rk/settings/terminal/SettingsTerminalScreen.kt
+++ b/core/main/src/main/java/com/rk/settings/terminal/SettingsTerminalScreen.kt
@@ -5,7 +5,10 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
@@ -27,7 +30,7 @@ import com.rk.activities.settings.SettingsActivity
import com.rk.activities.settings.SettingsRoutes
import com.rk.activities.settings.settingsNavController
import com.rk.components.NextScreenCard
-import com.rk.components.SettingsToggle
+import com.rk.components.SettingsItem
import com.rk.components.ValueSlider
import com.rk.components.compose.preferences.base.PreferenceGroup
import com.rk.components.compose.preferences.base.PreferenceLayout
@@ -46,19 +49,19 @@ import com.rk.settings.Settings
import com.rk.settings.app.InbuiltFeatures
import com.rk.terminal.terminalView
import com.rk.utils.LoadingPopup
-import com.rk.utils.dialog
+import com.rk.utils.dialogRes
import com.rk.utils.dpToPx
import com.rk.utils.getTempDir
import com.rk.utils.toast
import com.termux.terminal.TerminalEmulator
-import java.io.File
-import java.io.FileOutputStream
-import java.lang.Runtime.getRuntime
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.FileOutputStream
+import java.lang.Runtime.getRuntime
enum class TerminalCursorStyle(val value: String, val stringRes: Int) {
BLOCK("block", strings.block),
@@ -79,6 +82,81 @@ fun SettingsTerminalScreen(overrideNavController: NavController? = null) {
val context = LocalContext.current
val activity = LocalActivity.current as? AppCompatActivity
+ PreferenceGroup(heading = stringResource(strings.advanced)) {
+ if (InbuiltFeatures.debugMode.state.value) {
+ SettingsItem(
+ label = stringResource(strings.failsafe_mode),
+ description = stringResource(strings.failsafe_mode_desc),
+ default = !Settings.sandbox,
+ sideEffect = { Settings.sandbox = !it },
+ )
+ }
+
+ var showSeccompDialog by remember { mutableStateOf(false) }
+ var seccompMode by remember { mutableStateOf(Settings.seccomp_mode) }
+
+ SettingsItem(
+ label = "SECCOMP",
+ description = stringResource(strings.seccomp_desc),
+ default = false,
+ showSwitch = false,
+ onClick = { showSeccompDialog = true },
+ )
+
+ if (showSeccompDialog) {
+ var tempSeccompMode by remember { mutableStateOf(seccompMode) }
+ AlertDialog(
+ onDismissRequest = {
+ showSeccompDialog = false
+ },
+ title = { Text("SECCOMP") },
+ text = {
+ Column {
+ listOf(
+ "unspecified" to strings.seccomp_unspecified,
+ "no" to strings.seccomp_no_seccomp,
+ "yes" to strings.seccomp_seccomp
+ ).forEach { (mode, stringRes) ->
+ PreferenceTemplate(
+ modifier =
+ Modifier.clip(MaterialTheme.shapes.large).clickable { tempSeccompMode = mode },
+ title = { Text(stringResource(stringRes)) },
+ startWidget = { RadioButton(selected = tempSeccompMode == mode, onClick = null) },
+ )
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ showSeccompDialog = false
+ Settings.seccomp_mode = tempSeccompMode
+ seccompMode = tempSeccompMode
+ }
+ ) {
+ Text(stringResource(strings.apply))
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = {
+ showSeccompDialog = false
+ }
+ ) {
+ Text(stringResource(strings.cancel))
+ }
+ },
+ )
+ }
+
+ NextScreenCard(
+ label = stringResource(strings.terminal_health),
+ description = stringResource(strings.terminal_health_desc),
+ navController = overrideNavController ?: settingsNavController.get(),
+ route = SettingsRoutes.TerminalCheck,
+ )
+ }
+
var showCursorStyleDialog by remember { mutableStateOf(false) }
var cursorStyleValue by remember {
mutableStateOf(TerminalCursorStyle.fromString(Settings.terminal_cursor_style))
@@ -103,7 +181,7 @@ fun SettingsTerminalScreen(overrideNavController: NavController? = null) {
route = SettingsRoutes.TerminalFontScreen,
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.cursor_style),
description = stringResource(strings.cursor_style_desc),
default = false,
@@ -198,7 +276,7 @@ fun SettingsTerminalScreen(overrideNavController: NavController? = null) {
}
}
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.backup),
description = stringResource(strings.terminal_backup),
showSwitch = false,
@@ -277,7 +355,7 @@ fun SettingsTerminalScreen(overrideNavController: NavController? = null) {
},
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.restore),
description = stringResource(strings.restore_terminal),
showSwitch = false,
@@ -285,18 +363,18 @@ fun SettingsTerminalScreen(overrideNavController: NavController? = null) {
sideEffect = { restore.launch("application/gzip") },
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.uninstall),
default = false,
description = stringResource(strings.uninstall_terminal),
showSwitch = false,
sideEffect = {
- dialog(
- context = activity,
+ dialogRes(
+ activity = activity,
title = strings.attention.getString(),
msg = strings.uninstall_terminal_warning.getString(),
onCancel = {},
- okString = strings.delete,
+ okRes = strings.delete,
onOk = {
GlobalScope.launch(Dispatchers.IO) {
val loading = LoadingPopup(activity, null)
@@ -315,30 +393,6 @@ fun SettingsTerminalScreen(overrideNavController: NavController? = null) {
)
}
- PreferenceGroup(heading = stringResource(strings.advanced)) {
- if (InbuiltFeatures.debugMode.state.value) {
- SettingsToggle(
- label = stringResource(strings.failsafe_mode),
- description = stringResource(strings.failsafe_mode_desc),
- default = !Settings.sandbox,
- sideEffect = { Settings.sandbox = !it },
- )
- }
-
- var seccomp by remember { mutableStateOf(Settings.seccomp) }
-
- SettingsToggle(
- label = "SECCOMP",
- default = seccomp,
- description = stringResource(strings.seccomp_desc),
- sideEffect = {
- Settings.seccomp = it
- seccomp = it
- },
- showSwitch = true,
- )
- }
-
PreferenceGroup(heading = stringResource(strings.other)) {
NextScreenCard(
label = stringResource(strings.change_extra_keys),
@@ -347,6 +401,13 @@ fun SettingsTerminalScreen(overrideNavController: NavController? = null) {
route = SettingsRoutes.TerminalExtraKeys,
)
+ SettingsItem(
+ label = stringResource(strings.clipboard_keybindings),
+ description = stringResource(strings.clipboard_keybindings_desc),
+ default = Settings.terminal_clipboard_keybindings,
+ sideEffect = { Settings.terminal_clipboard_keybindings = it },
+ )
+
ValueSlider(
label = stringResource(strings.scrollback_buffer),
description = stringResource(strings.scrollback_buffer_desc),
@@ -359,19 +420,18 @@ fun SettingsTerminalScreen(overrideNavController: NavController? = null) {
toast(strings.restart_required)
}
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.terminate_all_sessions),
description = stringResource(strings.terminate_all_sessions_desc),
default = Settings.terminate_sessions_on_exit,
sideEffect = { Settings.terminate_sessions_on_exit = it },
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.project_as_wk),
description = stringResource(strings.project_as_wk_desc),
default = Settings.project_as_pwd,
sideEffect = { Settings.project_as_pwd = it },
- showSwitch = true,
)
var exposeHomeDirState by remember { mutableStateOf(Settings.expose_home_dir) }
@@ -379,11 +439,11 @@ fun SettingsTerminalScreen(overrideNavController: NavController? = null) {
checked = exposeHomeDirState,
onCheckedChange = {
if (it) {
- dialog(
- context = activity,
+ dialogRes(
+ activity = activity,
title = strings.attention.getString(),
msg = strings.saf_expose_warning.getString(),
- okString = strings.continue_action,
+ okRes = strings.continue_action,
onCancel = {},
onOk = {
Settings.expose_home_dir = true
diff --git a/core/main/src/main/java/com/rk/settings/terminal/TerminalCheckScreen.kt b/core/main/src/main/java/com/rk/settings/terminal/TerminalCheckScreen.kt
new file mode 100644
index 000000000..43809ed47
--- /dev/null
+++ b/core/main/src/main/java/com/rk/settings/terminal/TerminalCheckScreen.kt
@@ -0,0 +1,211 @@
+package com.rk.settings.terminal
+
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.outlined.Warning
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.rk.components.InfoBlock
+import com.rk.components.compose.preferences.base.PreferenceGroup
+import com.rk.components.compose.preferences.base.PreferenceLayout
+import com.rk.components.compose.preferences.base.PreferenceTemplate
+import com.rk.icons.LucideCircleQuestionMark
+import com.rk.resources.strings
+import com.rk.theme.greenStatus
+import com.rk.utils.openUrl
+
+enum class CheckStatus {
+ PENDING,
+ LOADING,
+ SUCCESS,
+ FAILED,
+}
+
+data class Check(
+ val label: String,
+ val status: CheckStatus = CheckStatus.PENDING,
+ val logs: List = emptyList(),
+ val isExpanded: Boolean = false,
+ val run: suspend (printLog: (String) -> Unit) -> Boolean,
+)
+
+@Composable
+fun TerminalCheckScreen() {
+ val context = LocalContext.current
+ val checks = terminalChecks()
+
+ LaunchedEffect(Unit) {
+ for (i in checks.indices) {
+ val check = checks[i]
+ // Update to LOADING and clear logs
+ checks[i] = check.copy(status = CheckStatus.LOADING, logs = emptyList())
+
+ val success =
+ try {
+ val result = check.run { log ->
+ // Append log to the current check's logs
+ checks[i] = checks[i].copy(logs = checks[i].logs + log)
+ }
+
+ result
+ } catch (e: Exception) {
+ checks[i] = checks[i].copy(logs = checks[i].logs + "Crash: ${e.localizedMessage}")
+ false
+ }
+
+ checks[i] =
+ checks[i].copy(status = if (success) CheckStatus.SUCCESS else CheckStatus.FAILED, isExpanded = !success)
+ }
+ }
+
+ PreferenceLayout(label = stringResource(strings.terminal_health), backArrowVisible = true) {
+ if (isAffectedSamsungDevice()) {
+ InfoBlock(
+ icon = { Icon(imageVector = Icons.Outlined.Warning, contentDescription = null) },
+ text = stringResource(strings.samsung_proot_warning),
+ warning = true,
+ onClick = {
+ context.openUrl("https://github.com/Xed-Editor/Xed-Editor/issues/639")
+ },
+ )
+ }
+
+ if (isTerminalDegraded(context)) {
+ InfoBlock(
+ icon = { Icon(imageVector = Icons.Outlined.Warning, contentDescription = null) },
+ text = stringResource(strings.terminal_degraded_warning),
+ warning = true,
+ )
+ }
+
+ checks.forEachIndexed { index, check ->
+ PreferenceGroup {
+ PreferenceTemplate(
+ modifier = Modifier.clickable { checks[index] = check.copy(isExpanded = !check.isExpanded) },
+ title = { Text(text = check.label, style = MaterialTheme.typography.bodyLarge) },
+ startWidget = { StatusIcon(status = check.status) },
+ endWidget = {
+ val rotation by
+ animateFloatAsState(
+ targetValue = if (check.isExpanded) 90f else 0f,
+ label = "ChevronRotation",
+ )
+
+ Icon(
+ imageVector = Icons.AutoMirrored.Outlined.KeyboardArrowRight,
+ contentDescription = if (check.isExpanded) "Collapse" else "Expand",
+ modifier = Modifier.rotate(rotation).size(24.dp),
+ )
+ },
+ )
+
+ if (check.isExpanded) {
+ Box(
+ modifier =
+ Modifier.padding(horizontal = 16.dp)
+ .padding(bottom = 12.dp)
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(8.dp))
+ .padding(12.dp)
+ ) {
+ Column {
+ if (check.logs.isEmpty()) {
+ Text(
+ text = stringResource(strings.no_logs),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ } else {
+ check.logs.forEach { log ->
+ Text(
+ text = if (log.startsWith(">")) log else "> $log",
+ style =
+ MaterialTheme.typography.bodySmall.copy(
+ fontFamily = FontFamily.Monospace,
+ fontSize = 11.sp,
+ lineHeight = 16.sp,
+ ),
+ color =
+ if (
+ log.contains("Error", ignoreCase = true) ||
+ log.contains("Fail", ignoreCase = true) ||
+ log.contains("Crash", ignoreCase = true)
+ )
+ MaterialTheme.colorScheme.error
+ else MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun StatusIcon(status: CheckStatus) {
+ Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) {
+ when (status) {
+ CheckStatus.PENDING -> {
+ Icon(
+ imageVector = LucideCircleQuestionMark,
+ contentDescription = stringResource(strings.pending),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
+ modifier = Modifier.size(20.dp),
+ )
+ }
+
+ CheckStatus.LOADING -> {
+ CircularProgressIndicator(
+ modifier = Modifier.size(16.dp),
+ strokeWidth = 4.dp,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+
+ CheckStatus.SUCCESS -> {
+ Icon(
+ imageVector = Icons.Default.CheckCircle,
+ contentDescription = stringResource(strings.success),
+ tint = MaterialTheme.colorScheme.greenStatus,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+
+ CheckStatus.FAILED -> {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = stringResource(strings.failed),
+ tint = MaterialTheme.colorScheme.error,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+ }
+}
diff --git a/core/main/src/main/java/com/rk/settings/terminal/terminalChecks.kt b/core/main/src/main/java/com/rk/settings/terminal/terminalChecks.kt
new file mode 100644
index 000000000..6689549f8
--- /dev/null
+++ b/core/main/src/main/java/com/rk/settings/terminal/terminalChecks.kt
@@ -0,0 +1,258 @@
+package com.rk.settings.terminal
+
+import android.content.Context
+import android.os.Build
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.snapshots.SnapshotStateList
+import androidx.compose.ui.res.stringResource
+import com.rk.exec.isTerminalInstalled
+import com.rk.exec.readStderr
+import com.rk.exec.ubuntuProcess
+import com.rk.file.child
+import com.rk.file.localDir
+import com.rk.file.sandboxDir
+import com.rk.file.sandboxHomeDir
+import com.rk.resources.strings
+import com.rk.utils.application
+import com.rk.utils.getTempDir
+import java.io.File
+
+fun isAffectedSamsungDevice(): Boolean {
+ val model = Build.MODEL.uppercase()
+
+ return model.startsWith("SM-S911") || // S23
+ model.startsWith("SM-S721") || // S24 FE
+ model.startsWith("SM-S936") || // S25+
+ model.startsWith("SM-F96") || // Fold7
+ model.startsWith("SM-A56") || // A56
+ model.startsWith("SM-A17") || // A17
+ model.startsWith("SM-A16") // A16
+}
+
+fun isTerminalDegraded(context: Context): Boolean {
+ return localDir(context).child(".sandbox_degraded").exists()
+}
+
+/** These checks are intended for troubleshooting terminal issues */
+@Composable
+inline fun terminalChecks(): SnapshotStateList {
+ val checkProot = stringResource(strings.check_proot)
+ val checkSystemShell = stringResource(strings.check_system_shell)
+ val checkStoragePermissions = stringResource(strings.check_storage_permissions)
+ val checkUbuntu = stringResource(strings.check_ubuntu)
+ val checkNetworkAccess = stringResource(strings.check_network_access)
+ val checkAbnormalities = stringResource(strings.check_abnormalities)
+
+ return remember {
+ mutableStateListOf(
+ Check(
+ label = checkProot,
+ run = { printLog ->
+ val libproot = File(application!!.applicationInfo.nativeLibraryDir, "libproot.so")
+ val prootloader = File(application!!.applicationInfo.nativeLibraryDir, "libloader.so")
+ val prootloader32 = File(application!!.applicationInfo.nativeLibraryDir, "libloader32.so")
+
+ printLog("PRoot exists: ${libproot.exists()}")
+ printLog("PRoot readable: ${libproot.canRead()}")
+ printLog("PRoot executable: ${libproot.canExecute()}")
+ printLog("PRoot Loader exists: ${prootloader.exists()}")
+ printLog("32bit PRoot Loader exists: ${prootloader32.exists()}")
+
+ var exitCode = 999
+
+ try {
+ printLog("Creating a temporary sandbox environment...")
+
+ val process =
+ ProcessBuilder(libproot.absolutePath, "-0", "-r", "/", "true")
+ .apply {
+ environment()["PROOT_TMP_DIR"] = getTempDir().absolutePath
+ environment()["PROOT_LOADER"] = prootloader.absolutePath
+ environment()["PROOT_LOADER_32"] = prootloader32.absolutePath
+ }
+ .start()
+
+ exitCode = process.waitFor()
+
+ printLog("Exit code: $exitCode")
+
+ if (exitCode != 0) {
+ val stderr = process.errorStream.bufferedReader().use { it.readText() }
+
+ if (stderr.isNotBlank()) {
+ printLog("stderr:")
+ printLog(stderr)
+ }
+ }
+ } catch (e: Exception) {
+ printLog("Error while running PRoot: ${e.message}")
+ }
+
+ libproot.exists() && exitCode == 0
+ },
+ ),
+ Check(
+ label = checkSystemShell,
+ run = { printLog ->
+ val shell = File("/system/bin/sh")
+ printLog("$shell exists: ${shell.exists()}")
+
+ val shell1 = File("/bin/sh")
+ printLog("$shell1 exists: ${shell1.exists()}")
+
+ printLog("$shell readable: ${shell.canRead()}")
+ printLog("$shell1 readable: ${shell1.canRead()}")
+
+ printLog("$shell executable: ${shell.canExecute()}")
+ printLog("$shell1 executable: ${shell1.canExecute()}")
+
+ var exitcode: Int = 999
+ try {
+ exitcode = Runtime.getRuntime().exec(arrayOf("/system/bin/sh", "-c", "true")).waitFor()
+ printLog("Exit code: $exitcode")
+ } catch (e: Exception) {
+ printLog("Error while running shell: ${e.message}")
+ }
+
+ exitcode == 0 &&
+ shell.exists() &&
+ shell1.exists() &&
+ shell.canRead() &&
+ shell1.canRead() &&
+ shell.canExecute() &&
+ shell1.canExecute()
+ },
+ ),
+ Check(
+ label = checkStoragePermissions,
+ run = { printLog ->
+ val filesDir = application!!.filesDir
+ val totalSpace = filesDir.totalSpace / (1024 * 1024)
+ val freeSpace = filesDir.freeSpace / (1024 * 1024)
+ printLog("Internal Storage - Total: $totalSpace MB, Free: $freeSpace MB")
+
+ printLog("Files Dir: ${filesDir.absolutePath}")
+ printLog("Files Dir Writable: ${filesDir.canWrite()}")
+
+ val sandboxHome = sandboxHomeDir()
+ printLog("Sandbox Home: ${sandboxHome.absolutePath}")
+ printLog("Sandbox Home Writable: ${sandboxHome.canWrite()}")
+
+ if (freeSpace < 500) {
+ printLog("Warning: Low storage space (< 500 MB). Ubuntu might fail to install or update.")
+ }
+
+ freeSpace > 100 && filesDir.canWrite() && sandboxHome.canWrite()
+ },
+ ),
+ Check(
+ label = checkUbuntu,
+ run = { printLog ->
+ if (!isTerminalInstalled()) {
+ printLog("Ubuntu not installed, skipping")
+ return@Check true
+ }
+
+ val rootfs = sandboxDir()
+ printLog("RootFS path: ${rootfs.absolutePath}")
+
+ val bash = rootfs.child("bin/bash")
+ printLog("Bash exists: ${bash.exists()}")
+ if (bash.exists()) {
+ printLog("Bash executable: ${bash.canExecute()}")
+ }
+
+ val apt = rootfs.child("usr/bin/apt")
+ printLog("Apt exists: ${apt.exists()}")
+
+ val osRelease = rootfs.child("etc/os-release")
+ if (osRelease.exists()) {
+ printLog("OS Release info:")
+ osRelease.readLines().take(5).forEach { printLog(" $it") }
+ }
+
+ printLog("Testing Ubuntu execution...")
+ var working = false
+ try {
+ val process = ubuntuProcess(command = listOf("true"))
+ val exitCode = process.waitFor()
+ val stderr = process.readStderr().trim()
+
+ printLog("Exit code: $exitCode")
+ if (stderr.isNotEmpty()) printLog("Stderr: $stderr")
+
+ working = (exitCode == 0)
+ } catch (e: Exception) {
+ printLog("Execution failed: ${e.message}")
+ }
+
+ working
+ },
+ ),
+ Check(
+ label = checkNetworkAccess,
+ run = { printLog ->
+ if (!isTerminalInstalled()) {
+ printLog("Ubuntu not installed, skipping network check.")
+ return@Check true
+ }
+
+ printLog("Checking DNS resolution (google.com)...")
+ try {
+ val dnsProcess = ubuntuProcess(command = listOf("getent", "hosts", "google.com"))
+ val dnsExitCode = dnsProcess.waitFor()
+ if (dnsExitCode == 0) {
+ printLog("DNS resolution works.")
+ } else {
+ printLog("DNS resolution FAILED.")
+ val resolvConf = sandboxDir().child("etc/resolv.conf")
+ if (resolvConf.exists()) {
+ printLog("/etc/resolv.conf exists, content:")
+ resolvConf.readLines().forEach { printLog(" $it") }
+ } else {
+ printLog("/etc/resolv.conf is MISSING!")
+ }
+ printLog("Abnormality: Ubuntu will not have internet access without DNS.")
+ }
+ dnsExitCode == 0
+ } catch (e: Exception) {
+ printLog("Network check failed: ${e.message}")
+ false
+ }
+ },
+ ),
+ Check(
+ label = checkAbnormalities,
+ run = { printLog ->
+ if (!isTerminalInstalled()) {
+ printLog("Ubuntu not installed, skipping")
+ return@Check true
+ }
+ var abnormalities = 0
+
+ try {
+ val process = ubuntuProcess(command = listOf("touch", "/tmp/.test_xed"))
+ if (process.waitFor() == 0) {
+ ubuntuProcess(command = listOf("rm", "/tmp/.test_xed")).waitFor()
+ } else {
+ printLog("Abnormality: /tmp is not writable inside sandbox.")
+ abnormalities++
+ }
+ } catch (e: Exception) {
+ printLog("Error checking /tmp: ${e.message}")
+ }
+
+ if (abnormalities == 0) {
+ printLog("No major abnormalities detected.")
+ } else {
+ printLog("Found $abnormalities abnormality/ies.")
+ }
+
+ abnormalities == 0
+ },
+ ),
+ )
+ }
+}
diff --git a/core/main/src/main/java/com/rk/settings/theme/Theme.kt b/core/main/src/main/java/com/rk/settings/theme/Theme.kt
index 3e548fac1..b4ca5d6c2 100644
--- a/core/main/src/main/java/com/rk/settings/theme/Theme.kt
+++ b/core/main/src/main/java/com/rk/settings/theme/Theme.kt
@@ -35,7 +35,7 @@ import com.rk.App.Companion.iconPackManager
import com.rk.DefaultScope
import com.rk.activities.settings.SettingsActivity
import com.rk.components.BottomSheetContent
-import com.rk.components.SettingsToggle
+import com.rk.components.SettingsItem
import com.rk.components.compose.preferences.base.PreferenceGroup
import com.rk.components.compose.preferences.base.PreferenceLayout
import com.rk.components.compose.preferences.base.PreferenceTemplate
@@ -68,7 +68,7 @@ fun ThemeScreen(modifier: Modifier = Modifier) {
PreferenceLayout(label = stringResource(strings.themes)) {
PreferenceGroup(heading = stringResource(strings.theme_settings)) {
- SettingsToggle(
+ SettingsItem(
label = stringResource(id = strings.theme_mode),
description = stringResource(id = strings.theme_mode_desc),
showSwitch = false,
@@ -76,7 +76,7 @@ fun ThemeScreen(modifier: Modifier = Modifier) {
sideEffect = { showDayNightBottomSheet.value = true },
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(id = strings.oled),
description = stringResource(id = strings.oled_desc),
default = Settings.amoled,
@@ -88,7 +88,7 @@ fun ThemeScreen(modifier: Modifier = Modifier) {
},
)
- SettingsToggle(
+ SettingsItem(
label = stringResource(id = strings.monet),
description = stringResource(id = strings.monet_desc),
default = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && Settings.monet,
@@ -104,7 +104,7 @@ fun ThemeScreen(modifier: Modifier = Modifier) {
PreferenceGroup(heading = stringResource(strings.themes)) {
themes.forEach { theme ->
- SettingsToggle(
+ SettingsItem(
isEnabled = !dynamicTheme.value,
label = theme.name,
description = null,
@@ -144,7 +144,7 @@ fun ThemeScreen(modifier: Modifier = Modifier) {
)
}
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.add_theme),
description = null,
showSwitch = false,
@@ -161,7 +161,7 @@ fun ThemeScreen(modifier: Modifier = Modifier) {
}
PreferenceGroup(heading = stringResource(strings.icon_packs)) {
- SettingsToggle(
+ SettingsItem(
label = "Simple Icons (${stringResource(strings.default_option)})",
description = null,
showSwitch = false,
@@ -180,17 +180,17 @@ fun ThemeScreen(modifier: Modifier = Modifier) {
)
iconPackManager.iconPacks.forEach { (id, iconPack) ->
- val iconPackInfo = iconPack.info
+ val iconPackManifest = iconPack.manifest
- SettingsToggle(
- label = iconPackInfo.name,
+ SettingsItem(
+ label = iconPackManifest.name,
description = null,
showSwitch = false,
default = false,
startWidget = {
RadioButton(
modifier = Modifier.padding(start = 16.dp),
- selected = currentIconPack.value?.info?.id == id,
+ selected = currentIconPack.value?.manifest?.id == id,
onClick = null,
)
},
@@ -201,7 +201,7 @@ fun ThemeScreen(modifier: Modifier = Modifier) {
endWidget = {
IconButton(
onClick = {
- if (currentIconPack.value?.info?.id == id) {
+ if (currentIconPack.value?.manifest?.id == id) {
currentIconPack.value = null
Settings.icon_pack = ""
}
@@ -215,7 +215,7 @@ fun ThemeScreen(modifier: Modifier = Modifier) {
)
}
- SettingsToggle(
+ SettingsItem(
label = stringResource(strings.add_icon_pack),
description = null,
showSwitch = false,
diff --git a/core/main/src/main/java/com/rk/tabs/base/TabRegistry.kt b/core/main/src/main/java/com/rk/tabs/base/TabRegistry.kt
index 932224895..473cea4a0 100644
--- a/core/main/src/main/java/com/rk/tabs/base/TabRegistry.kt
+++ b/core/main/src/main/java/com/rk/tabs/base/TabRegistry.kt
@@ -1,11 +1,13 @@
package com.rk.tabs.base
import com.rk.activities.main.MainViewModel
+import com.rk.extension.api.XedExtensionPoint
import com.rk.file.BuiltinFileType
import com.rk.file.FileObject
import com.rk.file.FileTypeManager
import com.rk.tabs.image.ImageTab
+@XedExtensionPoint
fun interface TabFactory {
fun createTab(file: FileObject, projectRoot: FileObject?, viewModel: MainViewModel): Tab
}
@@ -13,10 +15,12 @@ fun interface TabFactory {
object TabRegistry {
private val registeredTabs = mutableMapOf()
+ @XedExtensionPoint
fun registerTab(tabFactory: TabFactory, fileExtensions: List) {
fileExtensions.forEach { registeredTabs[it] = tabFactory }
}
+ @XedExtensionPoint
fun unregisterTab(tabFactory: TabFactory) {
registeredTabs.values.remove(tabFactory)
}
diff --git a/core/main/src/main/java/com/rk/tabs/editor/CodeEditorCompose.kt b/core/main/src/main/java/com/rk/tabs/editor/CodeEditorCompose.kt
index c04a40fdd..b86239483 100644
--- a/core/main/src/main/java/com/rk/tabs/editor/CodeEditorCompose.kt
+++ b/core/main/src/main/java/com/rk/tabs/editor/CodeEditorCompose.kt
@@ -1,6 +1,6 @@
package com.rk.tabs.editor
-import android.content.Context
+import android.app.Activity
import android.content.Intent
import android.view.KeyEvent
import android.view.View
@@ -42,7 +42,7 @@ import com.rk.resources.strings
import com.rk.settings.Preference
import com.rk.settings.Settings
import com.rk.settings.app.InbuiltFeatures
-import com.rk.utils.info
+import com.rk.utils.logInfo
import com.rk.utils.logWarn
import com.rk.utils.toast
import io.github.rosemoe.sora.event.ContentChangeEvent
@@ -55,13 +55,13 @@ import io.github.rosemoe.sora.lang.styling.inlayHint.ColorInlayHint
import io.github.rosemoe.sora.text.CharPosition
import io.github.rosemoe.sora.text.TextRange
import io.github.rosemoe.sora.widget.component.TextActionItem
-import java.lang.ref.WeakReference
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
+import java.lang.ref.WeakReference
@OptIn(DelicateCoroutinesApi::class, ExperimentalLayoutApi::class)
@Composable
@@ -79,10 +79,10 @@ fun EditorTab.CodeEditor(
AndroidView(
modifier = Modifier.weight(1f),
onRelease = { it.release() },
- update = { info("Editor view update") },
+ update = { logInfo("Editor view update") },
factory = { ctx ->
Editor(ctx).apply {
- info("New Editor instance")
+ logInfo("New Editor instance")
editable = editorState.editable
val isTxtFile = file.getName().endsWith(".txt")
@@ -112,8 +112,6 @@ fun EditorTab.CodeEditor(
}
}
}
-
- scope.launch { editorState.editorConfigLoaded?.await()?.let { applySettings() } }
}
},
)
@@ -148,29 +146,30 @@ fun Editor.registerXedEvents(
onTextChange: () -> Unit,
) {
subscribeAlways(InlayHintClickEvent::class.java) { event ->
- val hint = event.inlayHint
- if (hint is ColorInlayHint) {
- val colorRange = hint.colorRange
- val indexedColorRange =
- colorRange?.let {
- val startIndex = event.editor.text.getCharIndex(colorRange.start.line, colorRange.start.column)
- val endIndex = event.editor.text.getCharIndex(colorRange.end.line, colorRange.end.column)
- TextRange(
- CharPosition(colorRange.start.line, colorRange.start.column, startIndex),
- CharPosition(colorRange.end.line, colorRange.end.column, endIndex),
- )
+ val hint = event.inlayHint as? ColorInlayHint ?: return@subscribeAlways
+ val range =
+ hint.colorRange
+ ?: run {
+ toast(strings.invalid_color)
+ return@subscribeAlways
}
- if (indexedColorRange != null) {
- val colorText = event.editor.text.substring(indexedColorRange.startIndex, indexedColorRange.endIndex)
- val colorValue = hint.color.resolve(colorScheme).let { Color(it) }
- val parsedColor = colorText.parseUnknownColor() ?: (colorValue to ColorFormat.HEX)
+ val editor = event.editor
+ val text = editor.text
- editorTab.editorState.showColorPicker = parsedColor
- editorTab.editorState.colorPickerRange = indexedColorRange
- } else {
- toast(strings.invalid_color)
- }
+ val start = CharPosition(range.start.line, range.start.column)
+ val end = CharPosition(range.end.line, range.end.column)
+
+ val indexedRange = TextRange(start, end)
+
+ val colorText = text.subContent(start.line, start.column, end.line, end.column).toString()
+
+ val colorValue = hint.color.resolve(colorScheme).let(::Color)
+ val parsed = colorText.parseUnknownColor() ?: (colorValue to ColorFormat.HEX)
+
+ editorTab.editorState.apply {
+ showColorPicker = parsed
+ colorPickerRange = indexedRange
}
}
@@ -250,87 +249,118 @@ fun Editor.registerXedEvents(
fun EditorTab.applyHighlightingAndConnectLSP() {
val editor = editorState.editor.get() ?: return
- with(editor) {
- scope.launch(Dispatchers.IO) {
- editorState.textmateScope?.let { setLanguage(it) }
+ scope.launch(Dispatchers.IO) {
+ editorState.textmateScope?.let { editor.configureLanguage(it) }
- val builtin = getBuiltinServers(context)
- val extension = getExtensionServers(context)
- val external = getExternalServers()
- val servers = builtin + extension + external
- if (servers.isEmpty()) return@launch
+ val editorConfigProps = editorState.editorConfigLoaded?.await()
+ editorConfigProps?.let { withContext(Dispatchers.Main) { editor.applySettings(it) } }
- val wrapperLanguage =
- editorState.textmateScope?.let {
- LanguageManager.createLanguage(textmateScope = it, createIdentifiers = false)
- }
- val projectFile =
- projectRoot
- ?: run {
- logWarn(
- "File ${file.getName()} has no suitable project root. Skipping language server connection."
- )
- return@launch
- }
+ val activity = editor.context as? Activity ?: return@launch
+ val builtin = getBuiltinServers(activity)
+ val extension = getExtensionServers(activity)
+ val external = getExternalServers()
+ val servers = builtin + extension + external
+ if (servers.isEmpty()) return@launch
- lspConnector =
- LspConnector(
- projectFile = projectFile,
- fileObject = file,
- codeEditor = this@with,
- editorTab = this@applyHighlightingAndConnectLSP,
- servers = servers,
- )
-
- info("Trying to connect language servers...")
- lspConnector?.connect(wrapperLanguage)
- info("isConnected : ${lspConnector?.isConnected() ?: false}")
+ // Language servers fail with content URIs
+ if (file !is FileWrapper) {
+ logWarn("File ${file.getName()} is not a file wrapper. Skipping language server connection.")
+ return@launch
}
+
+ if (!InbuiltFeatures.terminal.state.value) {
+ logWarn("Terminal is not enabled. Skipping language server connection.")
+ return@launch
+ }
+
+ // Create another language, as created identifiers cannot be modified retroactively
+ val wrapperLanguage =
+ editorState.textmateScope
+ ?.let { LanguageManager.createLanguage(textmateScope = it, createIdentifiers = false) }
+ ?.apply {
+ editor.getTextMateLanguage()?.let {
+ useTab(it.useTab())
+ tabSize = it.tabSize
+ }
+ }
+
+ val projectFile =
+ projectRoot
+ ?: run {
+ logWarn("File ${file.getName()} has no suitable project root. Skipping language server connection.")
+ return@launch
+ }
+
+ lspConnector =
+ LspConnector(
+ projectFile = projectFile,
+ fileObject = file,
+ codeEditor = editor,
+ editorTab = this@applyHighlightingAndConnectLSP,
+ servers = servers,
+ )
+
+ logInfo("Trying to connect language servers...")
+ lspConnector?.connect(wrapperLanguage)
+ logInfo("isConnected : ${lspConnector?.isConnected() ?: false}")
}
}
-private suspend fun EditorTab.getBuiltinServers(context: Context): List {
+private suspend fun EditorTab.getBuiltinServers(activity: Activity): List {
val servers = LspRegistry.builtInServer.filter { it.isSupported(file) }
- return findActiveLspServers(servers, context)
+ return findActiveLspServers(servers, activity)
}
-private fun EditorTab.promptLspInstall(context: Context, server: LspServer) {
+private fun EditorTab.promptLspInstall(activity: Activity, server: LspServer) {
scope.launch {
val snackbarHost = snackbarHostStateRef.get() ?: return@launch
val result =
snackbarHost.showSnackbar(
- message = strings.ask_lsp_install.getFilledString(server.languageName, context),
+ message = strings.ask_lsp_install.getFilledString(server.languageName, activity),
actionLabel = strings.install.getString(),
withDismissAction = true,
duration = SnackbarDuration.Short,
)
if (result == SnackbarResult.ActionPerformed) {
- server.install(context)
+ val ext = file.getExtension().lowercase()
+ if (ext.isNotEmpty()) {
+ Preference.setInt("lsp_install_reject_count_$ext", 0)
+ }
+ server.install(activity)
+ } else if (result == SnackbarResult.Dismissed) {
+ val ext = file.getExtension().lowercase()
+ if (ext.isNotEmpty()) {
+ val rejectCount = Preference.getInt("lsp_install_reject_count_$ext", 0)
+ Preference.setInt("lsp_install_reject_count_$ext", rejectCount + 1)
+ }
}
}
}
-private fun EditorTab.promptLspUpdate(context: Context, server: LspServer) {
+private fun EditorTab.promptLspUpdate(activity: Activity, server: LspServer) {
scope.launch {
val snackbarHost = snackbarHostStateRef.get() ?: return@launch
val result =
snackbarHost.showSnackbar(
- message = strings.ask_lsp_update.getFilledString(server.languageName, context),
+ message = strings.ask_lsp_update.getFilledString(server.languageName, activity),
actionLabel = strings.update.getString(),
duration = SnackbarDuration.Long,
)
if (result == SnackbarResult.ActionPerformed) {
- server.update(context)
+ server.update(activity)
}
}
}
-private suspend fun EditorTab.getExtensionServers(context: Context): List {
+private suspend fun EditorTab.getExtensionServers(activity: Activity): List {
val servers = LspRegistry.extensionServers.filter { server -> server.isSupported(file) }
- return findActiveLspServers(servers, context)
+ return findActiveLspServers(servers, activity)
}
-private suspend fun EditorTab.findActiveLspServers(servers: List, context: Context): MutableList {
+private suspend fun EditorTab.findActiveLspServers(
+ servers: List,
+ activity: Activity,
+): MutableList {
val matchedServers = mutableListOf()
servers.forEach { server ->
@@ -338,16 +368,20 @@ private suspend fun EditorTab.findActiveLspServers(servers: List, con
return@forEach
}
- if (InbuiltFeatures.terminal.state.value && !server.isInstalled(context) && file is FileWrapper) {
- info("Server ${server.id} is not installed")
- promptLspInstall(context, server)
+ if (!server.isInstalled(activity)) {
+ logInfo("Server ${server.id} is not installed")
+ val ext = file.getExtension().lowercase()
+ if (ext.isNotEmpty() && Preference.getInt("lsp_install_reject_count_$ext", 0) >= 3) {
+ return@forEach
+ }
+ promptLspInstall(activity, server)
return@forEach
}
scope.launch(Dispatchers.IO) {
- if (server.isUpdatable(context)) {
- info("Server ${server.id} is updatable")
- promptLspUpdate(context, server)
+ if (server.isUpdatable(activity)) {
+ logInfo("Server ${server.id} is updatable")
+ promptLspUpdate(activity, server)
}
}
diff --git a/core/main/src/main/java/com/rk/tabs/editor/CodeEditorState.kt b/core/main/src/main/java/com/rk/tabs/editor/CodeEditorState.kt
index 33da3e601..c417c688a 100644
--- a/core/main/src/main/java/com/rk/tabs/editor/CodeEditorState.kt
+++ b/core/main/src/main/java/com/rk/tabs/editor/CodeEditorState.kt
@@ -6,9 +6,10 @@ import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.input.TextFieldValue
import com.rk.color.ColorFormat
import com.rk.editor.Editor
-import com.rk.runner.RunnerImpl
+import com.rk.runner.Runner
import com.rk.search.CodeItem
import com.rk.settings.Settings
import io.github.rosemoe.sora.text.Content
@@ -38,8 +39,8 @@ data class CodeEditorState(val initialContent: Content? = null) {
var searchRegex by mutableStateOf(false)
var searchWholeWord by mutableStateOf(false)
var showOptionsMenu by mutableStateOf(false)
- var searchKeyword by mutableStateOf("")
- var replaceKeyword by mutableStateOf("")
+ var searchKeyword by mutableStateOf(TextFieldValue(""))
+ var replaceKeyword by mutableStateOf(TextFieldValue(""))
var showFindingsDialog by mutableStateOf(false)
var findingsItems by mutableStateOf(listOf())
@@ -57,7 +58,7 @@ data class CodeEditorState(val initialContent: Content? = null) {
var textmateScope by mutableStateOf(null)
- var runnersToShow by mutableStateOf>(emptyList())
+ var runnersToShow by mutableStateOf>(emptyList())
var showRunnerDialog by mutableStateOf(false)
var showColorPicker by mutableStateOf?>(null)
diff --git a/core/main/src/main/java/com/rk/tabs/editor/EditorTab.kt b/core/main/src/main/java/com/rk/tabs/editor/EditorTab.kt
index e6551e155..08fa91a4a 100644
--- a/core/main/src/main/java/com/rk/tabs/editor/EditorTab.kt
+++ b/core/main/src/main/java/com/rk/tabs/editor/EditorTab.kt
@@ -1,7 +1,10 @@
package com.rk.tabs.editor
+import android.content.Context
+import androidx.activity.compose.LocalActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
@@ -42,6 +45,7 @@ import com.rk.color.ColorPicker
import com.rk.components.AddDialogItem
import com.rk.components.SingleInputDialog
import com.rk.editor.intelligent.IntelligentFeatureRegistry
+import com.rk.extension.api.XedExtensionPoint
import com.rk.file.FileObject
import com.rk.file.FileTypeManager
import com.rk.file.child
@@ -51,7 +55,6 @@ import com.rk.lsp.formatDocumentSuspend
import com.rk.resources.drawables
import com.rk.resources.getString
import com.rk.resources.strings
-import com.rk.runner.currentRunner
import com.rk.search.EditorSearchPanel
import com.rk.search.FindingsDialog
import com.rk.settings.Settings
@@ -62,7 +65,6 @@ import com.rk.utils.errorDialog
import com.rk.utils.getTempDir
import com.rk.utils.hasBinaryChars
import io.github.rosemoe.sora.text.ContentIO
-import java.lang.ref.WeakReference
import java.nio.charset.Charset
import java.nio.file.Paths
import kotlinx.coroutines.CompletableDeferred
@@ -162,15 +164,15 @@ open class EditorTab(override var file: FileObject, var projectRoot: FileObject?
showNotice(BINARY_NOTICE_KEY) { id -> BinaryNotice(id) }
}
}
- .onFailure { errorDialog(it) }
+ .onFailure { errorDialog(throwable = it) }
}
}
}
}
companion object {
- const val BINARY_NOTICE_KEY = "binary_file"
- const val EDITORCONFIG_NOTICE_KEY = "editorconfig_changed"
+ private const val BINARY_NOTICE_KEY = "binary_file"
+ private const val EDITORCONFIG_NOTICE_KEY = "editorconfig_changed"
}
@Composable
@@ -209,11 +211,13 @@ open class EditorTab(override var file: FileObject, var projectRoot: FileObject?
)
}
+ @XedExtensionPoint
fun showNotice(id: String, notice: @Composable (String) -> Unit) {
if (editorState.notices.contains(id)) return
editorState.notices[id] = notice
}
+ @XedExtensionPoint
fun removeNotice(id: String) {
editorState.notices.remove(id)
}
@@ -299,7 +303,7 @@ open class EditorTab(override var file: FileObject, var projectRoot: FileObject?
editorState.isDirty = false
lspConnector?.notifySave()
}
- .onFailure { errorDialog(it) }
+ .onFailure { errorDialog(throwable = it) }
}
}
@@ -352,28 +356,7 @@ open class EditorTab(override var file: FileObject, var projectRoot: FileObject?
Column {
if (editorState.showRunnerDialog) {
- ModalBottomSheet(
- onDismissRequest = {
- editorState.showRunnerDialog = false
- editorState.runnersToShow = emptyList()
- }
- ) {
- Column(modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp, top = 0.dp)) {
- editorState.runnersToShow.forEach { runner ->
- AddDialogItem(
- icon = runner.getIcon(context) ?: Icon.DrawableRes(drawableRes = drawables.run),
- title = runner.getName(),
- ) {
- DefaultScope.launch {
- currentRunner = WeakReference(runner)
- runner.run(context, file)
- editorState.showRunnerDialog = false
- editorState.runnersToShow = emptyList()
- }
- }
- }
- }
- }
+ RunnerSheet(context)
}
if (editorState.showFindingsDialog) {
@@ -486,7 +469,13 @@ open class EditorTab(override var file: FileObject, var projectRoot: FileObject?
onApply = {
val textRange = editorState.colorPickerRange ?: return@ColorPicker
val editor = editorState.editor.get() ?: return@ColorPicker
- editor.text.replace(textRange.startIndex, textRange.endIndex, it)
+ editor.text.replace(
+ textRange.start.line,
+ textRange.start.column,
+ textRange.end.line,
+ textRange.end.column,
+ it,
+ )
},
) {
editorState.showColorPicker = null
@@ -551,3 +540,38 @@ open class EditorTab(override var file: FileObject, var projectRoot: FileObject?
return "[EditorTab] ${file.getAbsolutePath()}"
}
}
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+private fun EditorTab.RunnerSheet(context: Context) {
+ ModalBottomSheet(
+ onDismissRequest = {
+ editorState.showRunnerDialog = false
+ editorState.runnersToShow = emptyList()
+ }
+ ) {
+ Column(
+ modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp, top = 0.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text(text = stringResource(strings.choose_runner), style = MaterialTheme.typography.titleLarge)
+
+ Column {
+ editorState.runnersToShow.forEach { runner ->
+ val activity = LocalActivity.current
+
+ AddDialogItem(
+ icon = runner.getIcon(context) ?: Icon.ResourceIcon(drawableRes = drawables.run),
+ title = runner.label,
+ ) {
+ DefaultScope.launch {
+ activity?.let { runner.run(it, file) }
+ editorState.showRunnerDialog = false
+ editorState.runnersToShow = emptyList()
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/core/main/src/main/java/com/rk/tabs/editor/EditorToolbarActions.kt b/core/main/src/main/java/com/rk/tabs/editor/EditorToolbarActions.kt
index 14b6ecb3e..3472da83b 100644
--- a/core/main/src/main/java/com/rk/tabs/editor/EditorToolbarActions.kt
+++ b/core/main/src/main/java/com/rk/tabs/editor/EditorToolbarActions.kt
@@ -20,6 +20,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -32,13 +33,12 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.rk.activities.main.MainViewModel
import com.rk.commands.ActionContext
-import com.rk.commands.CommandProvider
import com.rk.commands.KeybindingsManager
import com.rk.commands.ToggleableCommand
+import com.rk.commands.ToolbarConfiguration
import com.rk.icons.Icon
import com.rk.icons.XedIcon
import com.rk.resources.strings
-import com.rk.settings.Settings
import com.rk.terminal.isV
import com.rk.theme.Typography
import com.rk.utils.x
@@ -49,7 +49,9 @@ fun EditorToolbarActions(modifier: Modifier = Modifier, viewModel: MainViewModel
var expanded by remember { mutableStateOf(false) }
val activity = LocalActivity.current
- val allActions = Settings.action_items.split("|").mapNotNull { CommandProvider.getForId(it) }
+ val allActions by remember {
+ derivedStateOf { ToolbarConfiguration.editorCommands }
+ }
BoxWithConstraints(modifier = modifier) {
val itemWidth = 64.dp
diff --git a/core/main/src/main/java/com/rk/terminal/MkSession.kt b/core/main/src/main/java/com/rk/terminal/MkSession.kt
index 5c85561ab..5d7d5041d 100644
--- a/core/main/src/main/java/com/rk/terminal/MkSession.kt
+++ b/core/main/src/main/java/com/rk/terminal/MkSession.kt
@@ -1,8 +1,8 @@
package com.rk.terminal
-import android.os.Build
+import android.app.Activity
+import android.content.Context
import com.rk.activities.main.MainActivity
-import com.rk.activities.terminal.Terminal
import com.rk.exec.pendingCommand
import com.rk.file.FileWrapper
import com.rk.file.child
@@ -12,9 +12,9 @@ import com.rk.file.localLibDir
import com.rk.file.sandboxHomeDir
import com.rk.settings.Settings
import com.rk.tabs.editor.EditorTab
+import com.rk.utils.application
import com.rk.utils.getSourceDirOfPackage
import com.rk.utils.getTempDir
-import com.rk.utils.isFDroid
import com.rk.xededitor.BuildConfig
import com.termux.terminal.TerminalSession
import com.termux.terminal.TerminalSessionClient
@@ -24,159 +24,146 @@ import kotlinx.coroutines.runBlocking
object MkSession {
fun createSession(
- activity: Terminal,
+ context: Context,
sessionClient: TerminalSessionClient,
sessionId: String,
+ isExtraction: Boolean = false,
): Pair {
- with(activity) {
- val envVariables =
- mapOf(
- "ANDROID_ART_ROOT" to System.getenv("ANDROID_ART_ROOT"),
- "ANDROID_DATA" to System.getenv("ANDROID_DATA"),
- "ANDROID_I18N_ROOT" to System.getenv("ANDROID_I18N_ROOT"),
- "ANDROID_ROOT" to System.getenv("ANDROID_ROOT"),
- "ANDROID_RUNTIME_ROOT" to System.getenv("ANDROID_RUNTIME_ROOT"),
- "ANDROID_TZDATA_ROOT" to System.getenv("ANDROID_TZDATA_ROOT"),
- "BOOTCLASSPATH" to System.getenv("BOOTCLASSPATH"),
- "DEX2OATBOOTCLASSPATH" to System.getenv("DEX2OATBOOTCLASSPATH"),
- "EXTERNAL_STORAGE" to System.getenv("EXTERNAL_STORAGE"),
- "PATH" to "${System.getenv("PATH")}:${localBinDir().absolutePath}",
- )
-
- val workingDir = runBlocking { getPwd() }
-
- val tmpDir = File(getTempDir(), "terminal/$sessionId")
-
- if (tmpDir.exists()) {
- tmpDir.deleteRecursively()
- }
-
- tmpDir.mkdirs()
-
- val env =
- mutableListOf(
- "PROOT_TMP_DIR=${tmpDir.absolutePath}",
- "WKDIR=${workingDir}",
- "PUBLIC_HOME=${getExternalFilesDir(null)?.absolutePath}",
- "COLORTERM=truecolor",
- "TERM=xterm-256color",
- "LANG=C.UTF-8",
- "DEBUG=${BuildConfig.DEBUG}",
- "LOCAL=${localDir().absolutePath}",
- "PRIVATE_DIR=${filesDir.parentFile!!.absolutePath}",
- "LD_LIBRARY_PATH=${localLibDir().absolutePath}",
- "EXT_HOME=${sandboxHomeDir()}",
- "HOME=${if (Settings.sandbox){ "/home"} else{ sandboxHomeDir()}}",
- "PROMPT_DIRTRIM=2",
- "LINKER=${if(File("/system/bin/linker64").exists()){"/system/bin/linker64"}else{"/system/bin/linker"}}",
- "NATIVE_LIB_DIR=${applicationInfo.nativeLibraryDir}",
- "FDROID=${isFDroid}",
- "SANDBOX=${Settings.sandbox}",
- "TMP_DIR=${getTempDir()}",
- "TMPDIR=${getTempDir()}",
- "TZ=UTC",
- "DOTNET_GCHeapHardLimit=1C0000000",
- "SOURCE_DIR=${applicationInfo.sourceDir}",
- "TERMUX_X11_SOURCE_DIR=${getSourceDirOfPackage(application!!,"com.termux.x11")}",
- "DISPLAY=:0",
- )
-
- if (!isFDroid) {
- env.add("PROOT_LOADER=${applicationInfo.nativeLibraryDir}/libproot-loader.so")
- if (
- Build.SUPPORTED_32_BIT_ABIS.isNotEmpty() &&
- File(applicationInfo.nativeLibraryDir).child("libproot-loader32.so").exists()
- ) {
- env.add("PROOT_LOADER32=${applicationInfo.nativeLibraryDir}/libproot-loader32.so")
- }
- }
+ val envVariables =
+ mapOf(
+ "ANDROID_ART_ROOT" to System.getenv("ANDROID_ART_ROOT"),
+ "ANDROID_DATA" to System.getenv("ANDROID_DATA"),
+ "ANDROID_I18N_ROOT" to System.getenv("ANDROID_I18N_ROOT"),
+ "ANDROID_ROOT" to System.getenv("ANDROID_ROOT"),
+ "ANDROID_RUNTIME_ROOT" to System.getenv("ANDROID_RUNTIME_ROOT"),
+ "ANDROID_TZDATA_ROOT" to System.getenv("ANDROID_TZDATA_ROOT"),
+ "BOOTCLASSPATH" to System.getenv("BOOTCLASSPATH"),
+ "DEX2OATBOOTCLASSPATH" to System.getenv("DEX2OATBOOTCLASSPATH"),
+ "EXTERNAL_STORAGE" to System.getenv("EXTERNAL_STORAGE"),
+ "PATH" to "${System.getenv("PATH")}:${localBinDir(context).absolutePath}",
+ )
+
+ val workingDir = runBlocking { getPwd(context) }
+
+ val tmpDir = File(getTempDir(), "terminal/$sessionId")
+
+ if (tmpDir.exists()) {
+ tmpDir.deleteRecursively()
+ }
- if (Settings.seccomp) {
- env.add("SECCOMP=1")
- }
+ tmpDir.mkdirs()
+
+ val env =
+ mutableListOf(
+ "PROOT=${application!!.applicationInfo.nativeLibraryDir}/libproot.so",
+ "PROOT_LOADER=${application!!.applicationInfo.nativeLibraryDir}/libloader.so",
+ "PROOT_TMP_DIR=${tmpDir.absolutePath}",
+ "WKDIR=${workingDir}",
+ "PUBLIC_HOME=${context.getExternalFilesDir(null)?.absolutePath}",
+ "COLORTERM=truecolor",
+ "TERM=xterm-256color",
+ "LANG=C.UTF-8",
+ "DEBUG=${BuildConfig.DEBUG}",
+ "LOCAL=${localDir(context).absolutePath}",
+ "PRIVATE_DIR=${context.filesDir.parentFile!!.absolutePath}",
+ "LD_LIBRARY_PATH=${localLibDir(context).absolutePath}",
+ "EXT_HOME=${sandboxHomeDir(context)}",
+ "HOME=${if (Settings.sandbox){ "/home"} else{ sandboxHomeDir(context)}}",
+ "PROMPT_DIRTRIM=2",
+ "LINKER=${if(File("/system/bin/linker64").exists()){"/system/bin/linker64"}else{"/system/bin/linker"}}",
+ "NATIVE_LIB_DIR=${context.applicationInfo.nativeLibraryDir}",
+ "SANDBOX=${Settings.sandbox}",
+ "TMP_DIR=${getTempDir()}",
+ "TMPDIR=${getTempDir()}",
+ "TZ=UTC",
+ "DOTNET_GCHeapHardLimit=1C0000000",
+ "SOURCE_DIR=${context.applicationInfo.sourceDir}",
+ "TERMUX_X11_SOURCE_DIR=${getSourceDirOfPackage(application!!,"com.termux.x11")}",
+ "DISPLAY=:0",
+ )
+
+ val loader32 = "${context.applicationInfo.nativeLibraryDir}/libloader32.so"
+ if (File(loader32).exists()) {
+ env.add("PROOT_LOADER_32=$loader32")
+ }
- env.addAll(envVariables.map { "${it.key}=${it.value}" })
+ when (Settings.seccomp_mode) {
+ "yes" -> env.add("SECCOMP=1")
+ "no" -> env.add("PROOT_NO_SECCOMP=1")
+ }
- pendingCommand?.env?.let { env.addAll(it) }
+ env.addAll(envVariables.map { "${it.key}=${it.value}" })
- setupTerminalFiles()
+ pendingCommand?.env?.let { env.addAll(it) }
- val sandboxSH = localBinDir().child("sandbox")
- val setupSH = localBinDir().child("setup")
+ setupTerminalFiles()
- val args: Array
+ val sandboxSH = localBinDir(context).child("sandbox")
+ val setupSH = localBinDir(context).child("setup")
- val shell =
- if (pendingCommand == null) {
- args =
- if (Settings.sandbox) {
- arrayOf(sandboxSH.absolutePath)
- } else {
- arrayOf()
- }
- "/system/bin/sh"
- } else if (pendingCommand!!.sandbox.not()) {
- args = pendingCommand!!.args
- pendingCommand!!.exe
- } else {
- args =
- mutableListOf(sandboxSH.absolutePath, pendingCommand!!.exe, *pendingCommand!!.args)
- .toTypedArray()
+ val args: Array
- "/system/bin/sh"
- }
+ val shell =
+ if (pendingCommand == null) {
+ args =
+ if (Settings.sandbox) {
+ arrayOf(sandboxSH.absolutePath)
+ } else {
+ arrayOf()
+ }
+ "/system/bin/sh"
+ } else if (pendingCommand!!.sandbox.not()) {
+ args = pendingCommand!!.args
+ pendingCommand!!.exe
+ } else {
+ args =
+ mutableListOf(sandboxSH.absolutePath, pendingCommand!!.exe, *pendingCommand!!.args)
+ .toTypedArray()
+
+ "/system/bin/sh"
+ }
- val actualShell: String
- val actualArgs: Array =
- if (installNextStage != null && installNextStage == NEXT_STAGE.EXTRACTION) {
- actualShell = "/system/bin/sh"
- mutableListOf("-c", setupSH.absolutePath, *args).toTypedArray()
- } else {
- actualShell = shell
- arrayOf("-c", *args)
- }
+ val actualShell: String
+ val actualArgs: Array =
+ if (isExtraction) {
+ actualShell = "/system/bin/sh"
+ mutableListOf("-c", setupSH.absolutePath, *args).toTypedArray()
+ } else {
+ actualShell = shell
+ arrayOf("-c", *args)
+ }
- pendingCommand = null
+ pendingCommand = null
- return TerminalSession(
- actualShell,
- localDir().absolutePath,
- actualArgs,
- env.toTypedArray(),
- Settings.terminal_scrollback_buffer,
- sessionClient,
- ) to workingDir
- }
+ return TerminalSession(
+ actualShell,
+ localDir(context).absolutePath,
+ actualArgs,
+ env.toTypedArray(),
+ Settings.terminal_scrollback_buffer,
+ sessionClient,
+ ) to workingDir
}
}
-suspend fun Terminal.getPwd(): String {
+suspend fun getPwd(context: Context): String {
val pendingWorkingDir = pendingCommand?.workingDir
if (pendingWorkingDir != null) {
return pendingWorkingDir
}
- if (intent.hasExtra("cwd")) {
- return intent.getStringExtra("cwd").toString()
+ if (context is Activity && context.intent.hasExtra("cwd")) {
+ return context.intent.getStringExtra("cwd").toString()
}
val currentTab = MainActivity.instance?.viewModel?.tabManager?.currentTab
if (Settings.project_as_pwd) {
- // if (currentProject != null && currentProject is FileWrapper) {
- // val absolutePath = currentProject!!.getAbsolutePath()
- // return if (Settings.sandbox) {
- // absolutePath.removePrefix(localDir().absolutePath)
- // } else {
- // absolutePath
- // }
- // }
-
currentTab?.let {
if (it is EditorTab && it.file is FileWrapper) {
val parent = it.file.getParentFile()
if (parent != null && parent is FileWrapper) {
return if (Settings.sandbox) {
- parent.getAbsolutePath().removePrefix(localDir().absolutePath)
+ parent.getAbsolutePath().removePrefix(localDir(context).absolutePath)
} else {
parent.getAbsolutePath()
}
@@ -189,7 +176,7 @@ suspend fun Terminal.getPwd(): String {
val parent = it.file.getParentFile()
if (parent != null && parent is FileWrapper) {
return if (Settings.sandbox) {
- parent.getAbsolutePath().removePrefix(localDir().absolutePath)
+ parent.getAbsolutePath().removePrefix(localDir(context).absolutePath)
} else {
parent.getAbsolutePath()
}
@@ -200,6 +187,6 @@ suspend fun Terminal.getPwd(): String {
return if (Settings.sandbox) {
"/home"
} else {
- sandboxHomeDir().absolutePath
+ sandboxHomeDir(context).absolutePath
}
}
diff --git a/core/main/src/main/java/com/rk/terminal/SessionService.kt b/core/main/src/main/java/com/rk/terminal/SessionService.kt
index ffb5b4443..410b3f196 100644
--- a/core/main/src/main/java/com/rk/terminal/SessionService.kt
+++ b/core/main/src/main/java/com/rk/terminal/SessionService.kt
@@ -38,14 +38,20 @@ class SessionService : Service() {
}
fun createSession(id: SessionId, client: TerminalSessionClient, activity: Terminal): SessionInfo {
- return MkSession.createSession(activity, client, id).let {
- val (session, pwd) = it
- sessions[id] = session
- sessionWorkDirs[id] = pwd
- sessionList.add(id)
- updateNotification()
- SessionInfo(id, pwd, session)
- }
+ return MkSession.createSession(
+ activity,
+ client,
+ id,
+ activity.installNextStage != null && activity.installNextStage == NEXT_STAGE.EXTRACTION,
+ )
+ .let {
+ val (session, pwd) = it
+ sessions[id] = session
+ sessionWorkDirs[id] = pwd
+ sessionList.add(id)
+ updateNotification()
+ SessionInfo(id, pwd, session)
+ }
}
fun getSession(id: SessionId): TerminalSession? {
@@ -108,7 +114,15 @@ class SessionService : Service() {
super.onCreate()
createNotificationChannel()
val notification = createNotification()
- startForeground(1, notification)
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ startForeground(
+ 1,
+ notification,
+ android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
+ )
+ } else {
+ startForeground(1, notification)
+ }
if (deamonRunning.not()) {
GlobalScope.launch(Dispatchers.IO) { deamonRunning = true }
diff --git a/core/main/src/main/java/com/rk/terminal/TerminalBackEnd.kt b/core/main/src/main/java/com/rk/terminal/TerminalBackEnd.kt
index b5d0e2d62..e9f50d8af 100644
--- a/core/main/src/main/java/com/rk/terminal/TerminalBackEnd.kt
+++ b/core/main/src/main/java/com/rk/terminal/TerminalBackEnd.kt
@@ -29,8 +29,9 @@ class TerminalBackEnd : TerminalViewClient, TerminalSessionClient {
override fun onPasteTextFromClipboard(session: TerminalSession?) {
val clip = ClipboardUtils.getText().toString()
- if (clip.trim { it <= ' ' }.isNotEmpty() && terminalView.get()?.mEmulator != null) {
- terminalView.get()?.mEmulator?.paste(clip)
+ val emulator = terminalView.get()?.mEmulator ?: return
+ if (clip.isNotBlank()) {
+ emulator.paste(clip)
}
}
@@ -101,6 +102,10 @@ class TerminalBackEnd : TerminalViewClient, TerminalSessionClient {
return true
}
+ override fun shouldSupportClipboardKeybindings(): Boolean {
+ return Settings.terminal_clipboard_keybindings
+ }
+
override fun isTerminalViewSelected(): Boolean {
return true
}
@@ -110,13 +115,12 @@ class TerminalBackEnd : TerminalViewClient, TerminalSessionClient {
override fun onKeyDown(keyCode: Int, e: KeyEvent, session: TerminalSession): Boolean {
if (keyCode == KeyEvent.KEYCODE_ENTER && !session.isRunning) {
val activity = Terminal.instance ?: return false
- activity.sessionBinder
- ?.get()
- ?.terminateSession(activity.sessionBinder?.get()!!.getService().currentSession.value)
- if (activity.sessionBinder?.get()!!.getService().sessionList.isEmpty()) {
+ val sessionBinder = activity.sessionBinder?.get() ?: return false
+ sessionBinder.terminateSession(sessionBinder.getService().currentSession.value)
+ if (sessionBinder.getService().sessionList.isEmpty()) {
activity.finish()
} else {
- activity.changeSession(activity.sessionBinder?.get()!!.getService().sessionList.first())
+ activity.changeSession(sessionBinder.getService().sessionList.first())
}
return true
}
diff --git a/core/main/src/main/java/com/rk/terminal/TerminalScreen.kt b/core/main/src/main/java/com/rk/terminal/TerminalScreen.kt
index 9e5938eaa..13f49d3e3 100644
--- a/core/main/src/main/java/com/rk/terminal/TerminalScreen.kt
+++ b/core/main/src/main/java/com/rk/terminal/TerminalScreen.kt
@@ -77,7 +77,9 @@ import com.rk.resources.strings
import com.rk.settings.Settings
import com.rk.settings.editor.DEFAULT_TERMINAL_FONT_PATH
import com.rk.settings.editor.TerminalFontScreen
+import com.rk.settings.terminal.DEFAULT_TERMINAL_EXTRA_KEYS
import com.rk.settings.terminal.SettingsTerminalScreen
+import com.rk.settings.terminal.TerminalCheckScreen
import com.rk.settings.terminal.TerminalExtraKeys
import com.rk.terminal.virtualkeys.VirtualKeysConstants
import com.rk.terminal.virtualkeys.VirtualKeysInfo
@@ -86,12 +88,13 @@ import com.rk.terminal.virtualkeys.VirtualKeysView
import com.rk.theme.LocalThemeHolder
import com.rk.theme.ThemeHolder
import com.rk.utils.dpToPx
+import com.rk.utils.toast
import com.termux.terminal.TerminalColors
import com.termux.terminal.TextStyle
import com.termux.view.TerminalView
+import kotlinx.coroutines.launch
import java.lang.ref.WeakReference
import java.util.Properties
-import kotlinx.coroutines.launch
var terminalView = WeakReference(null)
var virtualKeysView = WeakReference(null)
@@ -113,6 +116,7 @@ fun TerminalScreen(modifier: Modifier = Modifier, terminalActivity: Terminal) {
composable(SettingsRoutes.TerminalSettings.route) { SettingsTerminalScreen(navController) }
composable(SettingsRoutes.TerminalFontScreen.route) { TerminalFontScreen() }
composable(SettingsRoutes.TerminalExtraKeys.route) { TerminalExtraKeys() }
+ composable(SettingsRoutes.TerminalCheck.route) { TerminalCheckScreen() }
}
}
@@ -170,13 +174,25 @@ fun TerminalScreenInternal(modifier: Modifier = Modifier, terminalActivity: Term
buttonTextColor = onSurfaceColor
- reload(
- VirtualKeysInfo(
- Settings.terminal_extra_keys,
- "",
- VirtualKeysConstants.CONTROL_CHARS_ALIASES,
- )
- )
+ runCatching {
+ reload(
+ VirtualKeysInfo(
+ Settings.terminal_extra_keys,
+ "",
+ VirtualKeysConstants.CONTROL_CHARS_ALIASES,
+ )
+ )
+ }
+ .onFailure {
+ toast(strings.invalid_terminal_extra_keys)
+ reload(
+ VirtualKeysInfo(
+ DEFAULT_TERMINAL_EXTRA_KEYS,
+ "",
+ VirtualKeysConstants.CONTROL_CHARS_ALIASES,
+ )
+ )
+ }
}
},
modifier = Modifier.fillMaxWidth().height(75.dp),
@@ -380,7 +396,7 @@ private fun TerminalDrawer(drawerWidth: Dp, terminalActivity: Terminal, navContr
Icon(imageVector = Icons.Default.Add, contentDescription = stringResource(strings.add_session))
}
- IconButton(onClick = { navController.navigate("terminal_settings") }) {
+ IconButton(onClick = { navController.navigate(SettingsRoutes.TerminalSettings.route) }) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = stringResource(strings.settings),
@@ -448,7 +464,7 @@ fun Terminal.changeSession(sessionId: String) {
terminalView.apply {
post {
keepScreenOn = true
- setFocusableInTouchMode(true)
+ isFocusableInTouchMode = true
requestFocus()
}
}
diff --git a/core/main/src/main/java/com/rk/theme/ThemeConfig.kt b/core/main/src/main/java/com/rk/theme/ThemeConfig.kt
index a595411bb..ef89d92d4 100644
--- a/core/main/src/main/java/com/rk/theme/ThemeConfig.kt
+++ b/core/main/src/main/java/com/rk/theme/ThemeConfig.kt
@@ -1,5 +1,6 @@
package com.rk.theme
+import androidx.annotation.Keep
import com.google.gson.JsonElement
import com.google.gson.JsonParser
import java.io.ObjectInputStream
@@ -7,6 +8,7 @@ import java.io.ObjectOutputStream
import java.io.Serial
import java.io.Serializable
+@Keep
data class BaseColors(
val primary: String? = null,
val onPrimary: String? = null,
@@ -46,6 +48,7 @@ data class BaseColors(
val surfaceContainerHighest: String? = null,
) : Serializable
+@Keep
data class ThemePalette(
val baseColors: BaseColors?,
val terminalColors: Map? = null,
@@ -101,10 +104,11 @@ data class ThemePalette(
}
}
+@Keep
data class ThemeConfig(
val id: String?,
val name: String?,
- val targetVersion: Int?,
+ val minAppVersion: Int?,
val inheritBase: Boolean?,
val light: ThemePalette?,
val dark: ThemePalette?,
diff --git a/core/main/src/main/java/com/rk/theme/ThemeHolder.kt b/core/main/src/main/java/com/rk/theme/ThemeHolder.kt
index bb1f6deef..fb6a30fa2 100644
--- a/core/main/src/main/java/com/rk/theme/ThemeHolder.kt
+++ b/core/main/src/main/java/com/rk/theme/ThemeHolder.kt
@@ -1,9 +1,11 @@
package com.rk.theme
+import androidx.annotation.Keep
import androidx.compose.material3.ColorScheme
import com.google.gson.JsonArray
import java.util.Properties
+@Keep
data class ThemeHolder(
val id: String,
val name: String,
diff --git a/core/main/src/main/java/com/rk/theme/ThemeLoader.kt b/core/main/src/main/java/com/rk/theme/ThemeLoader.kt
index e8715c99d..c34f26c22 100644
--- a/core/main/src/main/java/com/rk/theme/ThemeLoader.kt
+++ b/core/main/src/main/java/com/rk/theme/ThemeLoader.kt
@@ -18,7 +18,7 @@ import com.rk.resources.getString
import com.rk.resources.strings
import com.rk.settings.theme.themes
import com.rk.utils.application
-import com.rk.utils.dialog
+import com.rk.utils.dialogRes
import com.rk.utils.errorDialog
import com.rk.utils.toast
import java.io.FileInputStream
@@ -47,8 +47,8 @@ suspend fun loadConfigFromJson(file: FileObject): ThemeConfig? =
suspend fun ThemeConfig.installTheme() =
withContext(Dispatchers.IO) {
if (id == null) {
- dialog(
- context = SettingsActivity.instance,
+ dialogRes(
+ activity = SettingsActivity.instance,
title = strings.theme_install_failed.getString(),
msg = strings.theme_id_missing.getString(),
cancelable = false,
@@ -57,8 +57,8 @@ suspend fun ThemeConfig.installTheme() =
}
if (name == null) {
- dialog(
- context = SettingsActivity.instance,
+ dialogRes(
+ activity = SettingsActivity.instance,
title = strings.theme_install_failed.getString(),
msg = strings.theme_name_missing.getString(),
cancelable = false,
@@ -66,26 +66,16 @@ suspend fun ThemeConfig.installTheme() =
return@withContext
}
- if (targetVersion == null) {
- dialog(
- context = SettingsActivity.instance,
- title = strings.theme_install_failed.getString(),
- msg = strings.theme_version_missing.getString(),
- cancelable = false,
- )
- return@withContext
- }
-
val packageName = application!!.packageName
val packageManager = application!!.packageManager
val currentVersionCode = PackageInfoCompat.getLongVersionCode(packageManager.getPackageInfo(packageName, 0))
- if (targetVersion.toLong() != currentVersionCode) {
- dialog(
- context = SettingsActivity.instance,
+ if (minAppVersion != null && minAppVersion.toLong() > currentVersionCode) {
+ dialogRes(
+ activity = SettingsActivity.instance,
title = strings.warning.getString(),
msg = strings.incompatible_theme_warning.getString(),
- cancelString = strings.cancel,
- okString = strings.continue_action,
+ cancelRes = strings.cancel,
+ okRes = strings.continue_action,
onOk = {
finishThemeInstall(name)
updateThemes()
diff --git a/core/main/src/main/java/com/rk/utils/ApplicationContext.kt b/core/main/src/main/java/com/rk/utils/ApplicationContext.kt
index 45fd98f6e..f5f08046a 100644
--- a/core/main/src/main/java/com/rk/utils/ApplicationContext.kt
+++ b/core/main/src/main/java/com/rk/utils/ApplicationContext.kt
@@ -1,6 +1,22 @@
package com.rk.utils
import android.app.Application
+import okhttp3.Cache
+import okhttp3.OkHttpClient
+import java.io.File
// legacy - this should have been moved to App.instance but its being used everywhere
var application: Application? = null
+
+val okHttpClient: OkHttpClient by lazy {
+ val context = application ?: throw IllegalStateException("Application is not initialized yet")
+ OkHttpClient.Builder()
+ .cache(
+ Cache(
+ directory = File(context.cacheDir, "http_cache"),
+ maxSize = 10L * 1024L * 1024L // 10 MiB
+ )
+ )
+ .build()
+}
+
diff --git a/core/main/src/main/java/com/rk/utils/Log.kt b/core/main/src/main/java/com/rk/utils/Log.kt
index fcf952283..e4990e760 100644
--- a/core/main/src/main/java/com/rk/utils/Log.kt
+++ b/core/main/src/main/java/com/rk/utils/Log.kt
@@ -1,14 +1,16 @@
package com.rk.utils
import android.util.Log
+import com.rk.resources.getString
+import com.rk.resources.strings
import com.rk.settings.debugOptions.LogCollector
-fun Any.debug(msg: String) {
+fun Any.logDebug(msg: String) {
Log.d(this::class.java.simpleName, msg)
LogCollector.reportDebug(msg)
}
-fun Any.info(msg: String) {
+fun Any.logInfo(msg: String) {
Log.i(this::class.java.simpleName, msg)
LogCollector.reportInfo(msg)
}
@@ -22,3 +24,8 @@ fun Any.logError(msg: String) {
Log.e(this::class.java.simpleName, msg)
LogCollector.reportError(msg)
}
+
+fun Any.logError(throwable: Throwable, msg: String = strings.unknown_error.getString()) {
+ Log.e(this::class.java.simpleName, msg, throwable)
+ LogCollector.reportError("$msg: \n${throwable.stackTraceToString()}")
+}
diff --git a/core/main/src/main/java/com/rk/utils/Utils.kt b/core/main/src/main/java/com/rk/utils/Utils.kt
index 93d08756e..b2e22398f 100644
--- a/core/main/src/main/java/com/rk/utils/Utils.kt
+++ b/core/main/src/main/java/com/rk/utils/Utils.kt
@@ -8,6 +8,8 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.graphics.Typeface
+import android.graphics.drawable.Drawable
+import android.graphics.drawable.PictureDrawable
import android.os.Build
import android.telephony.TelephonyManager
import android.text.Spanned
@@ -37,7 +39,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.core.net.toUri
import com.blankj.utilcode.util.ThreadUtils
+import com.caverock.androidsvg.SVG
+import com.rk.activities.main.MainActivity
import com.rk.activities.main.gitViewModel
+import com.rk.extension.ActivityProvider
import com.rk.file.BuiltinFileType
import com.rk.file.FileObject
import com.rk.filetree.FileTreeViewModel
@@ -55,6 +60,7 @@ import com.rk.theme.gitDeleted
import com.rk.theme.gitModified
import io.github.rosemoe.sora.widget.schemes.EditorColorScheme
import java.io.File
+import java.io.InputStream
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
import java.text.NumberFormat
@@ -87,6 +93,7 @@ suspend fun FileObject.readObject(): Any? =
}
}
+
fun toast(message: String?) {
if (message.isNullOrBlank()) {
Log.w("UTILS", "Toast with null or empty message")
@@ -96,7 +103,16 @@ fun toast(message: String?) {
Log.w("TOAST", message)
return
}
- runOnUiThread { Toast.makeText(application!!, message, Toast.LENGTH_SHORT).show() }
+
+ runOnUiThread {
+ val context = ActivityProvider.currentActivity as? Context
+ if (context != null){
+ Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
+ }else{
+ Log.w("Utils","no valid ui context available for making toast: $message")
+ }
+
+ }
}
/** Returns true if the currently selected user theme is dark. If it's set to system, the system theme is used. */
@@ -111,7 +127,7 @@ fun isDarkTheme(ctx: Context): Boolean {
/** Returns true if the system theme is dark. **NOTE:** Prefer [isDarkTheme] to respect user settings. */
fun isSystemInDarkTheme(ctx: Context): Boolean {
return ((ctx.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
- Configuration.UI_MODE_NIGHT_YES)
+ Configuration.UI_MODE_NIGHT_YES)
}
inline fun dpToPx(dp: Float, ctx: Context): Int {
@@ -134,7 +150,11 @@ fun x(m: MutableCollection, c: Int) {
}
}
-fun Activity.openUrl(url: String) {
+fun Context.openDocs(page: String) {
+ openUrl("https://xed-editor.github.io/Xed-Docs/docs/$page")
+}
+
+fun Context.openUrl(url: String) {
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
startActivity(intent)
}
@@ -180,57 +200,6 @@ fun expectOOM(requiredMemBytes: Long): Boolean {
return requiredMemory > availableMemory
}
-// used for warning purposes
-fun isChinaDevice(context: Context): Boolean {
- val manufacturer = Build.MANUFACTURER.lowercase()
-
- if (
- manufacturer.contains("huawei") ||
- manufacturer.contains("xiaomi") ||
- manufacturer.contains("oppo") ||
- manufacturer.contains("vivo") ||
- manufacturer.contains("realme") ||
- manufacturer.contains("oneplus")
- ) {
- return true
- }
-
- val localeCountry = Locale.getDefault().country
- if (localeCountry.equals("CN", ignoreCase = true)) return true
-
- val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager
- val simCountry = tm.simCountryIso
- return simCountry.equals("cn", ignoreCase = true)
-}
-
-fun showTerminalNotice(activity: Activity, onOk: () -> Unit) {
- if (isChinaDevice(activity) && !Settings.terminal_virus_notice) {
- dialog(
- context = activity,
- title = strings.attention.getString(),
- msg = strings.terminal_virus_notice.getString(),
- onOk = {
- Settings.terminal_virus_notice = true
- it?.dismiss()
- onOk()
- },
- onCancel = {},
- cancelable = false,
- )
- } else {
- onOk()
- }
-}
-
-fun isAppInstalled(context: Context, packageName: String): Boolean {
- return try {
- context.packageManager.getPackageInfo(packageName, 0)
- true // App is installed
- } catch (e: PackageManager.NameNotFoundException) {
- false // App not found
- }
-}
-
fun getSourceDirOfPackage(context: Context, packageName: String): String? {
return try {
val info = context.packageManager.getApplicationInfo(packageName, 0)
@@ -248,11 +217,6 @@ fun getTempDir(): File {
return tmp
}
-val isFDroid by lazy {
- val targetSdkVersion = application!!.applicationInfo.targetSdkVersion
- targetSdkVersion == 28
-}
-
/** Converts a [Spanned] text object to an [AnnotatedString]. */
fun Spanned.toAnnotatedString(): AnnotatedString {
val builder = AnnotatedString.Builder(this.toString())
@@ -384,9 +348,16 @@ fun getGitColor(changeType: ChangeType): Color =
suspend fun findGitRoot(path: String): String? =
withContext(Dispatchers.IO) {
- val startDir = File(path).let { if (it.isDirectory) it else it.parentFile }
- val repo = FileRepositoryBuilder().findGitDir(startDir).takeIf { it.gitDir != null }?.build()
- repo?.workTree?.canonicalPath
+ runCatching {
+ val startDir = File(path).let { if (it.isDirectory) it else it.parentFile }
+ FileRepositoryBuilder().findGitDir(startDir).takeIf { it.gitDir != null }?.build()?.use { repo ->
+ if (!repo.isBare) {
+ repo.workTree?.canonicalPath
+ } else {
+ null
+ }
+ }
+ }.getOrNull()
}
fun hasBinaryChars(text: String): Boolean {
@@ -476,3 +447,15 @@ fun timeAgo(currentTimeMillis: Long, startTimeMillis: Long): String? {
else -> plurals.time_days_ago.getQuantityString(days, days)
}
}
+
+fun loadSvg(inputStream: InputStream): Drawable? {
+ val svg =
+ try {
+ SVG.getFromInputStream(inputStream)
+ } catch (_: Exception) {
+ return null
+ }
+
+ val picture = svg.renderToPicture()
+ return PictureDrawable(picture)
+}
diff --git a/core/main/src/main/java/com/rk/utils/Dialogs.kt b/core/main/src/main/java/com/rk/utils/dialogs.kt
similarity index 57%
rename from core/main/src/main/java/com/rk/utils/Dialogs.kt
rename to core/main/src/main/java/com/rk/utils/dialogs.kt
index 32fe1aecb..ac5c2308d 100644
--- a/core/main/src/main/java/com/rk/utils/Dialogs.kt
+++ b/core/main/src/main/java/com/rk/utils/dialogs.kt
@@ -21,17 +21,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ComposeView
-import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.rk.activities.main.MainActivity
-import com.rk.components.compose.preferences.base.DividerColumn
+import com.rk.extension.api.XedExtensionPoint
import com.rk.resources.getString
import com.rk.resources.strings
import com.rk.settings.Settings
import com.rk.theme.XedTheme
-fun errorDialog(msg: String, activity: Activity? = MainActivity.instance, title: String = strings.error.getString()) {
+fun errorDialog(activity: Activity? = MainActivity.instance, title: String = strings.error.getString(), msg: String) {
Log.e("ERROR_DIALOG", msg)
runOnUiThread {
@@ -44,7 +43,7 @@ fun errorDialog(msg: String, activity: Activity? = MainActivity.instance, title:
return@runOnUiThread
}
- dialog(context = activity, title = title, msg = msg, onOk = {})
+ dialogRes(activity = activity, title = title, msg = msg, onOk = {})
}
}
@@ -52,8 +51,11 @@ fun errorDialog(@StringRes msgRes: Int) {
runOnUiThread { errorDialog(msg = msgRes.getString()) }
}
-// todo handle multple function call for same throwable
-fun errorDialog(throwable: Throwable, activity: Activity? = MainActivity.instance) {
+fun errorDialog(
+ activity: Activity? = MainActivity.instance,
+ throwable: Throwable,
+ title: String = strings.error.getString(),
+) {
runOnUiThread {
if (throwable.message.toString().contains("Job was cancelled")) {
Log.w("ERROR_DIALOG", throwable.message.toString())
@@ -67,7 +69,7 @@ fun errorDialog(throwable: Throwable, activity: Activity? = MainActivity.instanc
}
}
- errorDialog(msg = message.toString(), activity = activity)
+ errorDialog(activity = activity, title = title, msg = message.toString())
}
}
@@ -90,59 +92,75 @@ fun errorDialog(exception: Exception) {
var isDialogShowing = false
private set
+fun dialogRes(
+ activity: Activity? = MainActivity.instance,
+ title: String? = null,
+ msg: String,
+ @StringRes cancelRes: Int = strings.cancel,
+ @StringRes okRes: Int = strings.ok,
+ onOk: (AlertDialog?) -> Unit = {},
+ onCancel: ((AlertDialog?) -> Unit)? = null,
+ cancelable: Boolean = true,
+) {
+ dialog(
+ activity = activity,
+ title = title,
+ msg = msg,
+ cancelText = cancelRes.getString(),
+ okText = okRes.getString(),
+ onOk = onOk,
+ onCancel = onCancel,
+ cancelable = cancelable,
+ )
+}
+
+@XedExtensionPoint
fun dialog(
- context: Activity? = MainActivity.instance,
+ activity: Activity? = MainActivity.instance,
title: String? = null,
msg: String,
- @StringRes cancelString: Int = strings.cancel,
- @StringRes okString: Int = strings.ok,
- onDialog: (AlertDialog?) -> Unit = {},
+ cancelText: String = strings.cancel.getString(),
+ okText: String = strings.ok.getString(),
onOk: (AlertDialog?) -> Unit = {},
onCancel: ((AlertDialog?) -> Unit)? = null,
cancelable: Boolean = true,
) {
- if (context == null) {
+ if (activity == null) {
toast(strings.unknown_error)
return
}
var alertDialog: AlertDialog? = null
runOnUiThread {
- MaterialAlertDialogBuilder(context).apply {
+ MaterialAlertDialogBuilder(activity).apply {
setOnCancelListener { isDialogShowing = false }
setView(
- ComposeView(context).apply {
+ ComposeView(activity).apply {
setContent {
XedTheme {
- Surface {
- Surface {
- Surface(shape = MaterialTheme.shapes.large, tonalElevation = 1.dp) {
- DividerColumn(startIndent = 0.dp, endIndent = 0.dp, dividersToSkip = 0) {
- alertDialog?.setCancelable(cancelable)
- DialogContent(
- alertDialog = alertDialog,
- title = title,
- msg = msg,
- cancelString = cancelString,
- okString = okString,
- onOk = { onOk(alertDialog) },
- onCancel =
- if (onCancel == null) {
- null
- } else {
- { onCancel.invoke(alertDialog) }
- },
- )
- }
- }
- }
+ Surface(shape = MaterialTheme.shapes.large, tonalElevation = 1.dp) {
+ alertDialog?.setCancelable(cancelable)
+ DialogContent(
+ alertDialog = alertDialog,
+ title = title,
+ msg = msg,
+ cancelString = cancelText,
+ okString = okText,
+ onOk = { onOk(alertDialog) },
+ onCancel =
+ if (onCancel == null) {
+ null
+ } else {
+ { onCancel.invoke(alertDialog) }
+ },
+ )
}
}
}
}
)
- if (context.isFinishing || context.isDestroyed) {
+ if (activity.isFinishing || activity.isDestroyed) {
toast(msg)
return@runOnUiThread
}
@@ -158,8 +176,8 @@ private fun DialogContent(
alertDialog: AlertDialog?,
title: String?,
msg: String,
- @StringRes cancelString: Int,
- @StringRes okString: Int,
+ cancelString: String,
+ okString: String,
onOk: () -> Unit,
onCancel: (() -> Unit)? = null,
) {
@@ -188,7 +206,7 @@ private fun DialogContent(
onCancel()
}
) {
- Text(stringResource(cancelString))
+ Text(cancelString)
}
Spacer(modifier = Modifier.width(8.dp))
@@ -200,8 +218,42 @@ private fun DialogContent(
onOk()
}
) {
- Text(stringResource(okString))
+ Text(okString)
}
}
}
}
+
+@XedExtensionPoint
+fun composableDialog(
+ activity: Activity? = MainActivity.instance,
+ cancelable: Boolean = true,
+ composable: @Composable (AlertDialog?) -> Unit,
+) {
+ if (activity == null) {
+ toast(strings.unknown_error)
+ return
+ }
+ var alertDialog: AlertDialog? = null
+ runOnUiThread {
+ MaterialAlertDialogBuilder(activity).apply {
+ setOnCancelListener { isDialogShowing = false }
+
+ setView(
+ ComposeView(activity).apply {
+ setContent {
+ XedTheme {
+ Surface(shape = MaterialTheme.shapes.large, tonalElevation = 1.dp) {
+ alertDialog?.setCancelable(cancelable)
+ composable(alertDialog)
+ }
+ }
+ }
+ }
+ )
+
+ alertDialog = show()
+ isDialogShowing = true
+ }
+ }
+}
diff --git a/core/proot/.gitignore b/core/proot/.gitignore
new file mode 100644
index 000000000..42afabfd2
--- /dev/null
+++ b/core/proot/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/core/proot/build.gradle.kts b/core/proot/build.gradle.kts
new file mode 100644
index 000000000..5b1a4aee3
--- /dev/null
+++ b/core/proot/build.gradle.kts
@@ -0,0 +1,52 @@
+plugins {
+ alias(libs.plugins.android.library)
+}
+
+android {
+ namespace = "com.rk.proot"
+ ndkVersion = "29.0.13846066"
+ compileSdk {
+ version = release(36)
+ }
+
+ defaultConfig {
+ minSdk = 26
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ consumerProguardFiles("consumer-rules.pro")
+ externalNativeBuild {
+ cmake {
+ cppFlags("")
+ }
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro",
+ )
+ }
+ }
+ externalNativeBuild {
+ cmake {
+ path("src/main/cpp/CMakeLists.txt")
+ version = "3.22.1"
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.appcompat)
+ implementation(libs.material)
+ testImplementation(libs.junit)
+ androidTestImplementation(libs.androidx.test.junit)
+ androidTestImplementation(libs.androidx.test.espresso)
+}
diff --git a/core/extension/consumer-rules.pro b/core/proot/consumer-rules.pro
similarity index 100%
rename from core/extension/consumer-rules.pro
rename to core/proot/consumer-rules.pro
diff --git a/core/extension/proguard-rules.pro b/core/proot/proguard-rules.pro
similarity index 100%
rename from core/extension/proguard-rules.pro
rename to core/proot/proguard-rules.pro
diff --git a/core/proot/src/main/AndroidManifest.xml b/core/proot/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..a5918e68a
--- /dev/null
+++ b/core/proot/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/core/proot/src/main/cpp/CMakeLists.txt b/core/proot/src/main/cpp/CMakeLists.txt
new file mode 100644
index 000000000..618cf5432
--- /dev/null
+++ b/core/proot/src/main/cpp/CMakeLists.txt
@@ -0,0 +1,304 @@
+cmake_minimum_required(VERSION 3.10)
+project(proot C ASM)
+
+# Compatibility flags and headers
+add_definitions(-D_FILE_OFFSET_BITS=64 -D_GNU_SOURCE -D__STDC_WANT_LIB_EXT1__=1)
+include_directories(${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/talloc)
+
+# Compiler flags
+set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wall -Wextra -O2")
+# Reproducibility flags
+set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -ffile-prefix-map=${CMAKE_CURRENT_SOURCE_DIR}=. -fno-ident")
+set(CMAKE_ASM_FLAGS "${CMAKE_ASM_FLAGS} -ffile-prefix-map=${CMAKE_CURRENT_SOURCE_DIR}=.")
+set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -Wl,-z,noexecstack -Wl,--build-id=sha1")
+
+# Tools from environment (NDK)
+find_program(OBJCOPY_TOOL NAMES ${CMAKE_OBJCOPY} $ENV{OBJCOPY} llvm-objcopy objcopy)
+find_program(OBJDUMP_TOOL NAMES ${CMAKE_OBJDUMP} $ENV{OBJDUMP} llvm-objdump objdump)
+find_program(STRIP_TOOL NAMES ${CMAKE_STRIP} $ENV{STRIP} llvm-strip strip)
+find_program(READELF_TOOL NAMES ${CMAKE_READELF} llvm-readelf readelf)
+
+if(NOT OBJCOPY_TOOL OR NOT OBJDUMP_TOOL OR NOT STRIP_TOOL OR NOT READELF_TOOL)
+ message(FATAL_ERROR "Could not find required binary tools (objcopy, objdump, strip, readelf)")
+endif()
+
+# Feature Detection (build.h)
+include(CheckCSourceCompiles)
+
+add_definitions(-DPROOT_UNBUNDLE_LOADER)
+
+function(check_feature FEATURE_NAME SOURCE_CODE DEFINE_VAR)
+ check_c_source_compiles("${SOURCE_CODE}" ${DEFINE_VAR})
+ if(${${DEFINE_VAR}})
+ set(${DEFINE_VAR}_STR "#define HAVE_${FEATURE_NAME}" PARENT_SCOPE)
+ else()
+ set(${DEFINE_VAR}_STR "" PARENT_SCOPE)
+ endif()
+endfunction()
+
+check_feature(PROCESS_VM "
+#define _GNU_SOURCE
+#include
+int main(void) {
+ process_vm_readv(0, (const struct iovec *)0, 0, (const struct iovec *)0, 0, 0);
+ return 0;
+}" HAVE_PROCESS_VM)
+
+check_feature(SECCOMP_FILTER "
+#include
+#include
+#include
+#include
+#include
+int main(void) {
+ prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, 0);
+ return 0;
+}" HAVE_SECCOMP_FILTER)
+
+# Version Detection
+execute_process(
+ COMMAND git describe --tags --dirty --abbrev=8 --always
+ OUTPUT_VARIABLE GIT_VERSION
+ OUTPUT_STRIP_TRAILING_WHITESPACE
+ ERROR_QUIET
+)
+if(NOT GIT_VERSION)
+ set(GIT_VERSION "unknown")
+endif()
+
+# Generate build.h
+set(BUILD_H_CONTENT "/* This file is auto-generated, edit at your own risk. */\n")
+set(BUILD_H_CONTENT "${BUILD_H_CONTENT}#ifndef BUILD_H\n#define BUILD_H\n")
+set(BUILD_H_CONTENT "${BUILD_H_CONTENT}#undef VERSION\n#define VERSION \"${GIT_VERSION}\"\n")
+set(BUILD_H_CONTENT "${BUILD_H_CONTENT}${HAVE_PROCESS_VM_STR}\n")
+set(BUILD_H_CONTENT "${BUILD_H_CONTENT}${HAVE_SECCOMP_FILTER_STR}\n")
+set(BUILD_H_CONTENT "${BUILD_H_CONTENT}#endif /* BUILD_H */\n")
+file(WRITE ${CMAKE_CURRENT_BINARY_DIR}/build.h "${BUILD_H_CONTENT}")
+include_directories(${CMAKE_CURRENT_BINARY_DIR})
+
+# Architecture-specific info from arch.h
+function(get_arch_define DEFINE_NAME VAR_NAME EXTRA_CFLAGS)
+ set(FLAGS "")
+ if(CMAKE_C_COMPILER_TARGET)
+ list(APPEND FLAGS "--target=${CMAKE_C_COMPILER_TARGET}")
+ endif()
+ if(CMAKE_SYSROOT)
+ list(APPEND FLAGS "--sysroot=${CMAKE_SYSROOT}")
+ endif()
+ separate_arguments(C_FLAGS_LIST NATIVE_COMMAND "${CMAKE_C_FLAGS}")
+ list(APPEND FLAGS ${C_FLAGS_LIST})
+ if(EXTRA_CFLAGS)
+ separate_arguments(EXTRA_FLAGS_LIST NATIVE_COMMAND "${EXTRA_CFLAGS}")
+ list(APPEND FLAGS ${EXTRA_FLAGS_LIST})
+ endif()
+
+ execute_process(
+ COMMAND ${CMAKE_C_COMPILER} ${FLAGS} -E -dM -DNO_LIBC_HEADER ${CMAKE_CURRENT_SOURCE_DIR}/arch.h
+ OUTPUT_VARIABLE PREPROCESSOR_OUTPUT
+ ERROR_VARIABLE PREPROCESSOR_ERROR
+ )
+ if(PREPROCESSOR_OUTPUT MATCHES "#define ${DEFINE_NAME} ([^\n]+)")
+ set(VAL "${CMAKE_MATCH_1}")
+ string(STRIP "${VAL}" VAL)
+ set(${VAR_NAME} "${VAL}" PARENT_SCOPE)
+ else()
+ set(${VAR_NAME} "" PARENT_SCOPE)
+ endif()
+endfunction()
+
+# We always set these to TRUE for the sake of setting up build rules.
+# The actual activation happens in the C code via #ifdef.
+set(HAS_POKEDATA_WORKAROUND TRUE)
+
+get_arch_define(HAS_LOADER_32BIT HAS_LOADER_32BIT_VAL "")
+if(HAS_LOADER_32BIT_VAL)
+ set(HAS_LOADER_32BIT TRUE)
+else()
+ set(HAS_LOADER_32BIT FALSE)
+endif()
+
+get_arch_define(LOADER_ADDRESS LOADER_ADDRESS_64 "")
+get_arch_define(LOADER_ARCH_CFLAGS LOADER_ARCH_CFLAGS_64 "")
+
+if(ANDROID)
+ if(ANDROID_ABI STREQUAL "arm64-v8a")
+ set(M32_FLAG "--target=armv7a-linux-androideabi${ANDROID_PLATFORM_LEVEL}")
+ elseif(ANDROID_ABI STREQUAL "x86_64")
+ set(M32_FLAG "--target=i686-linux-android${ANDROID_PLATFORM_LEVEL}")
+ else()
+ set(M32_FLAG "-m32")
+ endif()
+else()
+ set(M32_FLAG "-m32")
+endif()
+
+if(HAS_LOADER_32BIT)
+ get_arch_define(LOADER_ADDRESS LOADER_ADDRESS_32 "${M32_FLAG}")
+ get_arch_define(LOADER_ARCH_CFLAGS LOADER_ARCH_CFLAGS_32 "${M32_FLAG}")
+endif()
+
+# Check if 32-bit loader can actually be built
+if(HAS_LOADER_32BIT)
+ if(ANDROID)
+ set(COMPILER_SUPPORTS_M32 true)
+ else()
+ include(CheckCCompilerFlag)
+ check_c_compiler_flag("${M32_FLAG}" COMPILER_SUPPORTS_M32)
+ endif()
+ if(NOT COMPILER_SUPPORTS_M32 OR LOADER_ADDRESS_32 STREQUAL "")
+ message(STATUS "Disabling 32-bit loader: ${M32_FLAG} not supported or address unknown")
+ set(HAS_LOADER_32BIT false)
+ endif()
+endif()
+
+# Talloc sources
+set(TALLOC_SOURCES
+ talloc/talloc.c
+ talloc/replace.c
+ talloc/closefrom.c
+)
+
+# PRoot sources
+set(PROOT_SOURCES
+ cli/cli.c
+ cli/proot.c
+ cli/note.c
+ execve/enter.c
+ execve/exit.c
+ execve/shebang.c
+ execve/elf.c
+ execve/ldso.c
+ execve/auxv.c
+ execve/aoxp.c
+ path/binding.c
+ path/glue.c
+ path/canon.c
+ path/f2fs-bug.c
+ path/path.c
+ path/proc.c
+ path/temp.c
+ syscall/seccomp.c
+ syscall/syscall.c
+ syscall/chain.c
+ syscall/enter.c
+ syscall/exit.c
+ syscall/sysnum.c
+ syscall/socket.c
+ syscall/heap.c
+ syscall/rlimit.c
+ tracee/tracee.c
+ tracee/mem.c
+ tracee/reg.c
+ tracee/event.c
+ tracee/seccomp.c
+ tracee/statx.c
+ ptrace/ptrace.c
+ ptrace/user.c
+ ptrace/wait.c
+ extension/extension.c
+ extension/ashmem_memfd/ashmem_memfd.c
+ extension/kompat/kompat.c
+ extension/fake_id0/chown.c
+ extension/fake_id0/chroot.c
+ extension/fake_id0/getsockopt.c
+ extension/fake_id0/sendmsg.c
+ extension/fake_id0/socket.c
+ extension/fake_id0/open.c
+ extension/fake_id0/unlink.c
+ extension/fake_id0/rename.c
+ extension/fake_id0/chmod.c
+ extension/fake_id0/utimensat.c
+ extension/fake_id0/access.c
+ extension/fake_id0/exec.c
+ extension/fake_id0/link.c
+ extension/fake_id0/symlink.c
+ extension/fake_id0/mk.c
+ extension/fake_id0/stat.c
+ extension/fake_id0/helper_functions.c
+ extension/fake_id0/fake_id0.c
+ extension/hidden_files/hidden_files.c
+ extension/mountinfo/mountinfo.c
+ extension/port_switch/port_switch.c
+ extension/sysvipc/sysvipc.c
+ extension/sysvipc/sysvipc_msg.c
+ extension/sysvipc/sysvipc_sem.c
+ extension/sysvipc/sysvipc_shm.c
+ extension/link2symlink/link2symlink.c
+ extension/fix_symlink_size/fix_symlink_size.c
+)
+
+# We need a probe object to determine objcopy parameters
+add_library(cli_obj OBJECT cli/cli.c)
+set_target_properties(cli_obj PROPERTIES COMPILE_FLAGS "-I${CMAKE_CURRENT_BINARY_DIR}")
+
+# Build loaders
+function(build_loader SUFFIX EXTRA_CFLAGS LOADER_ADDR)
+ set(LOADER_TARGET loader_${SUFFIX})
+ if(SUFFIX STREQUAL "64")
+ set(LOADER_NAME loader)
+ else()
+ set(LOADER_NAME loader32)
+ endif()
+
+ add_executable(${LOADER_TARGET} loader/loader.c loader/assembly.S)
+ set_target_properties(${LOADER_TARGET} PROPERTIES
+ COMPILE_FLAGS "-fPIC -ffreestanding ${EXTRA_CFLAGS}"
+ LINK_FLAGS "-static -nostdlib ${EXTRA_CFLAGS} -Wl,-Ttext=${LOADER_ADDR},--rosegment,-z,noexecstack"
+ RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/loader
+ OUTPUT_NAME ${LOADER_NAME}
+ PREFIX "lib"
+ SUFFIX ".so"
+ )
+
+endfunction()
+
+build_loader("64" "${LOADER_ARCH_CFLAGS_64}" "${LOADER_ADDRESS_64}")
+
+if(HAS_LOADER_32BIT)
+ build_loader("-m32" "${LOADER_ARCH_CFLAGS_32}" "${LOADER_ADDRESS_32}")
+endif()
+
+if(HAS_POKEDATA_WORKAROUND)
+ # Loader info generation
+ set(LOADER_INFO_C "${CMAKE_CURRENT_BINARY_DIR}/loader/loader-info.c")
+ add_custom_command(
+ OUTPUT "${LOADER_INFO_C}"
+ COMMAND ${READELF_TOOL} -s "$" | awk -f "${CMAKE_CURRENT_SOURCE_DIR}/loader/loader-info.awk" > "${LOADER_INFO_C}"
+ DEPENDS loader_64
+ WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}"
+ VERBATIM
+ )
+ list(APPEND PROOT_SOURCES "${LOADER_INFO_C}")
+endif()
+
+add_executable(proot ${PROOT_SOURCES} ${TALLOC_SOURCES})
+add_dependencies(proot cli_obj loader_64)
+if(HAS_LOADER_32BIT)
+ add_dependencies(proot loader_-m32)
+endif()
+
+target_link_libraries(proot dl)
+
+# Rename the output to libproot.so for Android packaging
+set_target_properties(proot PROPERTIES
+ OUTPUT_NAME "proot"
+ PREFIX "lib"
+ SUFFIX ".so"
+)
+
+add_custom_command(
+ TARGET proot POST_BUILD
+ COMMAND ${STRIP_TOOL} --strip-unneeded $
+ COMMAND ${STRIP_TOOL} $
+ COMMENT "Stripping libproot.so and loader"
+)
+
+if(HAS_LOADER_32BIT)
+ add_custom_command(
+ TARGET proot POST_BUILD
+ COMMAND ${STRIP_TOOL} $
+ COMMENT "Stripping loader32"
+ )
+endif()
+
+install(TARGETS proot DESTINATION bin)
diff --git a/core/proot/src/main/cpp/arch.h b/core/proot/src/main/cpp/arch.h
new file mode 100644
index 000000000..06b7f1b03
--- /dev/null
+++ b/core/proot/src/main/cpp/arch.h
@@ -0,0 +1,196 @@
+/* -*- c-set-style: "K&R"; c-basic-offset: 8 -*-
+ *
+ * This file is part of PRoot.
+ *
+ * Copyright (C) 2015 STMicroelectronics
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+ * 02110-1301 USA.
+ */
+
+#ifndef ARCH_H
+#define ARCH_H
+
+#ifndef NO_LIBC_HEADER
+#include /* linux.git:c0a3a20b */
+#include /* AUDIT_ARCH_*, */
+#endif
+
+typedef unsigned long word_t;
+typedef unsigned char byte_t;
+
+#define SYSCALL_AVOIDER ((word_t) -2)
+#define SYSTRAP_NUM SYSARG_NUM
+
+#if !defined(ARCH_X86_64) && !defined(ARCH_ARM_EABI) && !defined(ARCH_X86) && !defined(ARCH_SH4)
+# if defined(__x86_64__)
+# define ARCH_X86_64 1
+# elif defined(__ARM_EABI__)
+# define ARCH_ARM_EABI 1
+# elif defined(__aarch64__)
+# define ARCH_ARM64 1
+# elif defined(__arm__)
+# error "Only EABI is currently supported for ARM"
+# elif defined(__i386__)
+# define ARCH_X86 1
+# elif defined(__SH4__)
+# define ARCH_SH4 1
+# else
+# error "Unsupported architecture"
+# endif
+#endif
+
+/* Architecture specific definitions. */
+#if defined(ARCH_X86_64)
+
+ #define SYSNUMS_HEADER1 "syscall/sysnums-x86_64.h"
+ #define SYSNUMS_HEADER2 "syscall/sysnums-i386.h"
+ #define SYSNUMS_HEADER3 "syscall/sysnums-x32.h"
+
+ #define SYSNUMS_ABI1 sysnums_x86_64
+ #define SYSNUMS_ABI2 sysnums_i386
+ #define SYSNUMS_ABI3 sysnums_x32
+
+ #undef SYSTRAP_NUM
+ #define SYSTRAP_NUM SYSARG_RESULT
+ #define SYSTRAP_SIZE 2
+
+ #define SECCOMP_ARCHS { \
+ { .value = AUDIT_ARCH_X86_64, .nb_abis = 2, .abis = { ABI_DEFAULT, ABI_3 } }, \
+ { .value = AUDIT_ARCH_I386, .nb_abis = 1, .abis = { ABI_2 } }, \
+ }
+
+ #define HOST_ELF_MACHINE {62, 3, 6, 0}
+ #define RED_ZONE_SIZE 128
+ #define OFFSETOF_STAT_UID_32 24
+ #define OFFSETOF_STAT_GID_32 28
+
+ #define LOADER_ADDRESS 0x600000000000
+ #define HAS_LOADER_32BIT true
+
+ #define EXEC_PIC_ADDRESS 0x500000000000
+ #define INTERP_PIC_ADDRESS 0x6f0000000000
+ #define EXEC_PIC_ADDRESS_32 0x0f000000
+ #define INTERP_PIC_ADDRESS_32 0xaf000000
+
+#elif defined(ARCH_ARM_EABI)
+
+ #define SYSNUMS_HEADER1 "syscall/sysnums-arm.h"
+ #define SYSNUMS_ABI1 sysnums_arm
+
+ #define SYSTRAP_SIZE 4
+
+ #define SECCOMP_ARCHS { { .value = AUDIT_ARCH_ARM, .nb_abis = 1, .abis = { ABI_DEFAULT } } }
+
+ #define user_regs_struct user_regs
+ #define HOST_ELF_MACHINE {40, 0};
+ #define RED_ZONE_SIZE 0
+ #define OFFSETOF_STAT_UID_32 0
+ #define OFFSETOF_STAT_GID_32 0
+ #define EM_ARM 40
+
+ #define LOADER_ADDRESS 0x20000000
+
+ #define EXEC_PIC_ADDRESS 0x0f000000
+ #define INTERP_PIC_ADDRESS 0x1f000000
+
+ /* The syscall number has to be valid on ARM, so use tuxcall(2) as
+ * the "void" syscall since it has no side effects. */
+ #undef SYSCALL_AVOIDER
+ #define SYSCALL_AVOIDER ((word_t) 222)
+
+#elif defined(ARCH_ARM64)
+
+ #define SYSNUMS_HEADER1 "syscall/sysnums-arm64.h"
+ #define SYSNUMS_HEADER2 "syscall/sysnums-arm.h"
+
+ #define SYSNUMS_ABI1 sysnums_arm64
+ #define SYSNUMS_ABI2 sysnums_arm
+
+ #define SYSTRAP_SIZE 4
+
+ #ifndef AUDIT_ARCH_AARCH64
+ #define AUDIT_ARCH_AARCH64 (EM_AARCH64 | __AUDIT_ARCH_64BIT | __AUDIT_ARCH_LE)
+ #endif
+
+ #define SECCOMP_ARCHS { \
+ { .value = AUDIT_ARCH_AARCH64, .nb_abis = 1, .abis = { ABI_DEFAULT } }, \
+ { .value = AUDIT_ARCH_ARM, .nb_abis = 1, .abis = { ABI_2 } }, \
+ }
+
+ #define HOST_ELF_MACHINE {183, 0};
+ #define RED_ZONE_SIZE 0
+ #define OFFSETOF_STAT_UID_32 24
+ #define OFFSETOF_STAT_GID_32 28
+
+ #define LOADER_ADDRESS 0x2000000000
+ #define EXEC_PIC_ADDRESS 0x3000000000
+ #define INTERP_PIC_ADDRESS 0x3f00000000
+ #define HAS_POKEDATA_WORKAROUND true
+
+ #define HAS_LOADER_32BIT true
+ #define EXEC_PIC_ADDRESS_32 0x0f000000
+ #define INTERP_PIC_ADDRESS_32 0x1f000000
+
+ /* Syscall -2 appears to cause some odd side effects, use -1. */
+ /* See https://github.com/termux/termux-packages/pull/390 */
+ #undef SYSCALL_AVOIDER
+ #define SYSCALL_AVOIDER ((word_t) -1)
+
+#elif defined(ARCH_X86)
+
+ #define SYSNUMS_HEADER1 "syscall/sysnums-i386.h"
+ #define SYSNUMS_ABI1 sysnums_i386
+
+ #undef SYSTRAP_NUM
+ #define SYSTRAP_NUM SYSARG_RESULT
+ #define SYSTRAP_SIZE 2
+
+ #define SECCOMP_ARCHS { { .value = AUDIT_ARCH_I386, .nb_abis = 1, .abis = { ABI_DEFAULT } } }
+
+ #define HOST_ELF_MACHINE {3, 6, 0};
+ #define RED_ZONE_SIZE 0
+ #define OFFSETOF_STAT_UID_32 0
+ #define OFFSETOF_STAT_GID_32 0
+
+ #define LOADER_ADDRESS 0xa0000000
+ #define LOADER_ARCH_CFLAGS -mregparm=3
+
+ #define EXEC_PIC_ADDRESS 0x0f000000
+ #define INTERP_PIC_ADDRESS 0xaf000000
+
+#elif defined(ARCH_SH4)
+
+ #define SYSNUMS_HEADER1 "syscall/sysnums-sh4.h"
+ #define SYSNUMS_ABI1 sysnums_sh4
+
+ #define SYSTRAP_SIZE 2
+
+ #define SECCOMP_ARCHS { }
+
+ #define user_regs_struct pt_regs
+ #define HOST_ELF_MACHINE {42, 0};
+ #define RED_ZONE_SIZE 0
+ #define OFFSETOF_STAT_UID_32 0
+ #define OFFSETOF_STAT_GID_32 0
+ #define NO_MISALIGNED_ACCESS 1
+
+#else
+
+ #error "Unsupported architecture"
+
+#endif
+
+#endif /* ARCH_H */
diff --git a/core/proot/src/main/cpp/attribute.h b/core/proot/src/main/cpp/attribute.h
new file mode 100644
index 000000000..f77295423
--- /dev/null
+++ b/core/proot/src/main/cpp/attribute.h
@@ -0,0 +1,32 @@
+/* -*- c-set-style: "K&R"; c-basic-offset: 8 -*-
+ *
+ * This file is part of PRoot.
+ *
+ * Copyright (C) 2015 STMicroelectronics
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+ * 02110-1301 USA.
+ */
+
+#ifndef ATTRIBUTE_H
+#define ATTRIBUTE_H
+
+#define UNUSED __attribute__((unused))
+#define FORMAT(a, b, c) __attribute__ ((format (a, b, c)))
+#define DONT_INSTRUMENT __attribute__((no_instrument_function))
+#define PACKED __attribute__((packed))
+#define WEAK __attribute__((weak))
+
+#endif /* ATTRIBUTE_H */
diff --git a/core/proot/src/main/cpp/build.h b/core/proot/src/main/cpp/build.h
new file mode 100644
index 000000000..6339f3e02
--- /dev/null
+++ b/core/proot/src/main/cpp/build.h
@@ -0,0 +1,8 @@
+/* This file is auto-generated, edit at your own risk. */
+#ifndef BUILD_H
+#define BUILD_H
+#undef VERSION
+#define VERSION "v5.1.107.77-2-g81b37778-dirty"
+
+#define HAVE_SECCOMP_FILTER
+#endif /* BUILD_H */
diff --git a/core/proot/src/main/cpp/cli/cli.c b/core/proot/src/main/cpp/cli/cli.c
new file mode 100644
index 000000000..d48ab0b7d
--- /dev/null
+++ b/core/proot/src/main/cpp/cli/cli.c
@@ -0,0 +1,599 @@
+/* -*- c-set-style: "K&R"; c-basic-offset: 8 -*-
+ *
+ * This file is part of PRoot.
+ *
+ * Copyright (C) 2015 STMicroelectronics
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+ * 02110-1301 USA.
+ */
+
+#include /* printf(3), */
+#include /* bool, true, false, */
+#include /* ARG_MAX, PATH_MAX, */
+#include /* str*(3), basename(3), */
+#include /* talloc*, */
+#include /* exit(3), EXIT_*, strtol(3), {g,s}etenv(3), */
+#include /* assert(3), */
+#include /* getpid(2), */
+#include /* getpid(2), */
+#include /* errno(3), */
+#include /* basename(3), */
+#ifdef __GLIBC__
+#include /* backtrace_symbols(3), */
+#endif
+#include /* INT_MAX, */
+
+#include "cli/cli.h"
+#include "cli/note.h"
+#include "extension/extension.h"
+#include "tracee/tracee.h"
+#include "tracee/event.h"
+#include "path/binding.h"
+#include "path/canon.h"
+#include "path/path.h"
+#include
+#include
+
+#include "build.h"
+
+/**
+ * Print a (@detailed) usage of PRoot.
+ */
+void print_usage(Tracee *tracee, const Cli *cli, bool detailed)
+{
+ const char *current_class = "none";
+ const Option *options;
+ size_t i, j;
+
+#define DETAIL(a) if (detailed) a
+
+ DETAIL(printf("%s %s: %s.\n\n", cli->name, cli->version, cli->subtitle));
+ printf("Usage:\n %s\n", cli->synopsis);
+ DETAIL(printf("\n"));
+
+ options = cli->options;
+ for (i = 0; options[i].class != NULL; i++) {
+ for (j = 0; ; j++) {
+ const Argument *argument = &(options[i].arguments[j]);
+
+ if (!argument->name || (!detailed && j != 0)) {
+ DETAIL(printf("\n"));
+ printf("\t%s\n", options[i].description);
+ if (detailed) {
+ if (options[i].detail[0] != '\0')
+ printf("\n%s\n\n", options[i].detail);
+ else
+ printf("\n");
+ }
+ break;
+ }
+
+ if (strcmp(options[i].class, current_class) != 0) {
+ current_class = options[i].class;
+ printf("\n%s:\n", current_class);
+ }
+
+ if (j == 0)
+ printf(" %s", argument->name);
+ else
+ printf(", %s", argument->name);
+
+ if (argument->separator != '\0')
+ printf("%c*%s*", argument->separator, argument->value);
+ else if (!detailed)
+ printf("\t");
+ }
+ }
+
+ notify_extensions(tracee, PRINT_USAGE, detailed, 0);
+
+ if (detailed)
+ printf("%s\n", cli->colophon);
+}
+
+/**
+ * Print the version of PRoot.
+ */
+void print_version(const Cli *cli)
+{
+ printf("%s %s\n\n", cli->logo, cli->version);
+ printf("built-in accelerators: process_vm = %s, seccomp_filter = %s\n",
+#if defined(HAVE_PROCESS_VM)
+ "yes",
+#else
+ "no",
+#endif
+#if defined(HAVE_SECCOMP_FILTER)
+ "yes"
+#else
+ "no"
+#endif
+ );
+}
+
+static void print_execve_help(const Tracee *tracee, const char *argv0, int status)
+{
+ note(tracee, ERROR, SYSTEM, "execve(\"%s\")", argv0);
+
+ /* termux-exec replaced execve with path with one that doesn't exist inside proot? */
+ if (status == -ENOENT && getenv("LD_PRELOAD") != NULL && strstr(getenv("LD_PRELOAD"), "libtermux-exec.so") != NULL) {
+ note(tracee, INFO, USER,
+"It seems that termux-exec is active and is prepending /data/data/com.termux/... to executable paths\n"
+"If this is path is not available inside proot, please \"unset LD_PRELOAD\"");
+ return;
+ }
+
+ /* Ubuntu kernel bug? */
+ if (status == -EPERM && getenv("PROOT_NO_SECCOMP") == NULL) {
+ note(tracee, INFO, USER,
+"It seems your kernel contains this bug: https://bugs.launchpad.net/ubuntu/+source/linux/+bug/1202161\n"
+"To workaround it, set the env. variable PROOT_NO_SECCOMP to 1.");
+ return;
+ }
+
+ note(tracee, INFO, USER, "possible causes:\n"
+" * the program is a script but its interpreter (eg. /bin/sh) was not found;\n"
+" * the program is an ELF but its interpreter (eg. ld-linux.so) was not found;\n"
+" * the program is a foreign binary but qemu was not specified;\n"
+" * qemu does not work correctly (if specified);\n"
+" * the loader was not found or doesn't work.");
+}
+
+static void print_error_separator(const Tracee *tracee, const Argument *argument)
+{
+ if (argument->separator == '\0')
+ note(tracee, ERROR, USER, "option '%s' expects no value.", argument->name);
+ else
+ note(tracee, ERROR, USER, "option '%s' and its value must be separated by '%c'.",
+ argument->name, argument->separator);
+}
+
+static void print_argv(const Tracee *tracee, const char *prompt, char *const argv[])
+{
+ char string[ARG_MAX] = "";
+ size_t i;
+
+ if (!argv)
+ return;
+
+#define APPEND(post) \
+ do { \
+ ssize_t length = sizeof(string) - (strlen(string) + strlen(post)); \
+ if (length <= 0) \
+ return; \
+ strncat(string, post, length); \
+ } while (0)
+
+ APPEND(prompt);
+ APPEND(" =");
+ for (i = 0; argv[i] != NULL; i++) {
+ APPEND(" ");
+ APPEND(argv[i]);
+ }
+ string[sizeof(string) - 1] = '\0';
+
+#undef APPEND
+
+ note(tracee, INFO, USER, "%s", string);
+}
+
+static void print_config(Tracee *tracee, char *const argv[])
+{
+ assert(tracee != NULL);
+
+ if (tracee->verbose <= 0)
+ return;
+
+ if (tracee->qemu)
+ note(tracee, INFO, USER, "host rootfs = %s", HOST_ROOTFS);
+
+ if (tracee->glue)
+ note(tracee, INFO, USER, "glue rootfs = %s", tracee->glue);
+
+ note(tracee, INFO, USER, "exe = %s", tracee->exe);
+ print_argv(tracee, "argv", argv);
+ print_argv(tracee, "qemu", tracee->qemu);
+ note(tracee, INFO, USER, "initial cwd = %s", tracee->fs->cwd);
+ note(tracee, INFO, USER, "verbose level = %d", tracee->verbose);
+
+ notify_extensions(tracee, PRINT_CONFIG, 0, 0);
+}
+
+/**
+ * Initialize @tracee's current working directory. This function
+ * returns -1 if an error occurred, otherwise 0.
+ */
+static int initialize_cwd(Tracee *tracee)
+{
+ char path2[PATH_MAX];
+ char path[PATH_MAX];
+ int status;
+
+ /* Compute the base directory. */
+ if (tracee->fs->cwd[0] != '/') {
+ status = getcwd2(tracee->reconf.tracee, path);
+ if (status < 0) {
+ note(tracee, ERROR, INTERNAL, "getcwd: %s", strerror(-status));
+ return -1;
+ }
+ }
+ else
+ strcpy(path, "/");
+
+ /* The ending "." ensures canonicalize() will report an error
+ * if tracee->fs->cwd does not exist or if it is not a
+ * directory. */
+ status = join_paths(3, path2, path, tracee->fs->cwd, ".");
+ if (status < 0) {
+ note(tracee, ERROR, INTERNAL, "getcwd: %s", strerror(-status));
+ return -1;
+ }
+
+ /* Initiale state for canonicalization. */
+ strcpy(path, "/");
+
+ status = canonicalize(tracee, path2, true, path, 0);
+ if (status < 0) {
+ note(tracee, WARNING, USER, "can't chdir(\"%s\") in the guest rootfs: %s",
+ path2, strerror(-status));
+ note(tracee, INFO, USER, "default working directory is now \"/\"");
+ strcpy(path, "/");
+ }
+ chop_finality(path);
+
+ /* Replace with the canonicalized working directory. */
+ TALLOC_FREE(tracee->fs->cwd);
+ tracee->fs->cwd = talloc_strdup(tracee->fs, path);
+ if (tracee->fs->cwd == NULL)
+ return -1;
+ talloc_set_name_const(tracee->fs->cwd, "$cwd");
+
+ /* Keep this special environment variable consistent. */
+ setenv("PWD", path, 1);
+
+ return 0;
+}
+
+/**
+ * Initialize @tracee->exe from @exe, i.e. canonicalize it from a
+ * guest point-of-view.
+ */
+static int initialize_exe(Tracee *tracee, const char *exe)
+{
+ char path[PATH_MAX];
+ int status;
+
+ status = which(tracee, tracee->reconf.paths, path, exe ?: "/bin/sh");
+ if (status < 0)
+ return -1;
+
+ status = detranslate_path(tracee, path, NULL);
+ if (status < 0)
+ return -1;
+
+ tracee->exe = talloc_strdup(tracee, path);
+ if (tracee->exe == NULL)
+ return -1;
+ talloc_set_name_const(tracee->exe, "$exe");
+
+ return 0;
+}
+
+/**
+ * Configure @tracee according to the command-line arguments stored in
+ * @argv[]. This function returns the index in @argv[] of the command
+ * to launch, otherwise -1 if an error occured.
+ */
+static int parse_config(Tracee *tracee, size_t argc, char *const argv[])
+{
+ option_handler_t handler = NULL;
+ const Option *options;
+ const Cli *cli = NULL;
+ size_t argc_offset;
+ size_t i, j, k;
+ int status;
+
+ /* Unknown tool name? Default to PRoot. */
+ if (cli == NULL)
+ cli = get_proot_cli(tracee->ctx);
+ tracee->tool_name = cli->name;
+
+ if (argc == 1) {
+ print_usage(tracee, cli, false);
+ return -1;
+ }
+
+ for (i = 1; i < argc; i++) {
+ const char *arg = argv[i];
+
+ /* The current argument is the value of a short option. */
+ if (handler != NULL) {
+ status = handler(tracee, cli, arg);
+ if (status < 0)
+ return -1;
+ handler = NULL;
+ continue;
+ }
+
+ if (arg[0] != '-')
+ break; /* End of PRoot options. */
+
+ options = cli->options;
+ for (j = 0; options[j].class != NULL; j++) {
+ const Option *option = &options[j];
+
+ /* A given option has several aliases. */
+ for (k = 0; ; k++) {
+ const Argument *argument;
+ size_t length;
+
+ argument = &option->arguments[k];
+
+ /* End of aliases for this option. */
+ if (!argument->name)
+ break;
+
+ length = strlen(argument->name);
+ if (strncmp(arg, argument->name, length) != 0)
+ continue;
+
+ /* Avoid ambiguities. */
+ if (strlen(arg) > length
+ && arg[length] != argument->separator) {
+ print_error_separator(tracee, argument);
+ return -1;
+ }
+
+ /* No option value. */
+ if (!argument->value) {
+ status = option->handler(tracee, cli, NULL);
+ if (status < 0)
+ return -1;
+ goto known_option;
+ }
+
+ /* Value coalesced with to its option. */
+ if (argument->separator == arg[length]) {
+ assert(strlen(arg) >= length);
+ status = option->handler(tracee, cli, &arg[length + 1]);
+ if (status < 0)
+ return -1;
+ goto known_option;
+ }
+
+ /* Avoid ambiguities. */
+ if (argument->separator != ' ') {
+ print_error_separator(tracee, argument);
+ return -1;
+ }
+
+ /* Short option with a separated value. */
+ handler = option->handler;
+ goto known_option;
+ }
+ }
+
+ note(tracee, ERROR, USER, "unknown option '%s'.", arg);
+ return -1;
+
+ known_option:
+ if (handler != NULL && i == argc - 1) {
+ note(tracee, ERROR, USER, "missing value for option '%s'.", arg);
+ return -1;
+ }
+ }
+ argc_offset = i;
+
+#define HOOK_CONFIG(callback) \
+ do { \
+ if (cli->callback != NULL) { \
+ status = cli->callback(tracee, cli, argc, argv, i); \
+ if (status < 0) \
+ return -1; \
+ i = status; \
+ } \
+ } while (0)
+
+ HOOK_CONFIG(pre_initialize_bindings);
+
+ /* The guest rootfs is now known: bindings specified by the
+ * user (tracee->bindings.user) can be canonicalized. */
+ status = initialize_bindings(tracee);
+ if (status < 0)
+ return -1;
+
+ HOOK_CONFIG(post_initialize_bindings);
+ HOOK_CONFIG(pre_initialize_cwd);
+
+ /* Bindings are now installed (tracee->bindings.guest &
+ * tracee->bindings.host): the current working directory can
+ * be canonicalized. */
+ status = initialize_cwd(tracee);
+ if (status < 0)
+ return -1;
+
+ HOOK_CONFIG(post_initialize_cwd);
+ HOOK_CONFIG(pre_initialize_exe);
+
+ /* Bindings are now installed and the current working
+ * directory is canonicalized: resolve path to @tracee->exe
+ * and configure @tracee->cmdline. */
+ status = initialize_exe(tracee, argv[argc_offset]);
+ if (status < 0)
+ return -1;
+
+ HOOK_CONFIG(post_initialize_exe);
+#undef HOOK_CONFIG
+
+ print_config(tracee, &argv[argc_offset]);
+
+ return argc_offset;
+}
+
+bool exit_failure = true;
+
+int main(int argc, char *const argv[])
+{
+ Tracee *tracee;
+ int status;
+
+ /* Configure the memory allocator. */
+ talloc_enable_leak_report();
+
+#if defined(TALLOC_VERSION_MAJOR) && TALLOC_VERSION_MAJOR >= 2
+ talloc_set_log_stderr();
+#endif
+
+ if (argc == 2 && strcmp(argv[1], "--shm-helper") == 0) {
+ sysvipc_shm_helper_main();
+ }
+
+ /* Pre-create the first tracee (pid == 0). */
+ tracee = get_tracee(NULL, 0, true);
+ if (tracee == NULL)
+ goto error;
+ tracee->pid = getpid();
+
+ /* Set verboseness from env variable, may be overriden by option */
+ {
+ const char *verbose_env = getenv("PROOT_VERBOSE");
+ if (verbose_env != NULL) {
+ tracee->verbose = strtol(verbose_env, NULL, 10);
+ global_verbose_level = tracee->verbose;
+ }
+ }
+
+ /* Pre-configure the first tracee. */
+ status = parse_config(tracee, argc, argv);
+ if (status < 0)
+ goto error;
+
+ if (NULL == getenv("PROOT_NO_MOUNTINFO"))
+ initialize_extension(tracee, mountinfo_callback, NULL);
+
+ /* Start the first tracee. */
+ status = launch_process(tracee, &argv[status]);
+ if (status < 0) {
+ print_execve_help(tracee, tracee->exe, status);
+ goto error;
+ }
+
+ /* Start tracing the first tracee and all its children. */
+ exit(event_loop());
+
+error:
+ TALLOC_FREE(tracee);
+
+ if (exit_failure) {
+ fprintf(stderr, "fatal error: see `%s --help`.\n", basename(argv[0]));
+ exit(EXIT_FAILURE);
+ }
+ else
+ exit(EXIT_SUCCESS);
+}
+
+/**
+ * Convert @value into an integer, then put the result into
+ * *@variable. This function prints a warning and returns -1 if a
+ * conversion error occured, otherwise it returns 0.
+ */
+int parse_integer_option(const Tracee *tracee, int *variable, const char *value, const char *option)
+{
+ char *end_ptr = NULL;
+
+ errno = 0;
+ *variable = strtol(value, &end_ptr, 10);
+ if (errno != 0 || end_ptr == value) {
+ note(tracee, ERROR, USER, "option `%s` expects an integer value.", option);
+ return -1;
+ }
+
+ return 0;
+}
+
+/**
+ * Expand the environment variable in front of @string, if any. For
+ * example, this function can expand "$HOME" or "$HOME/.ICEauthority".
+ */
+const char *expand_front_variable(TALLOC_CTX *context, const char *string)
+{
+ const char *suffix;
+ char *expanded;
+ ptrdiff_t size;
+
+ if (string[0] != '$')
+ return string;
+
+ suffix = strchr(string, '/');
+ if (suffix == NULL)
+ return (getenv(&string[1]) ?: string);
+
+ size = suffix - string;
+ if (size <= 1)
+ return string;
+
+ expanded = talloc_strndup(context, &string[1], size - 1);
+ if (expanded == NULL)
+ return string;
+
+ expanded = getenv(expanded);
+ if (expanded == NULL)
+ return string;
+
+ expanded = talloc_asprintf(context, "%s%s", expanded, suffix);
+ if (expanded == NULL)
+ return string;
+
+ return expanded;
+}
+
+/* Here follows the support for GCC function instrumentation. Build
+ * with CFLAGS='-finstrument-functions -O0 -g' and LDFLAGS='-rdynamic'
+ * to enable this mechanism. */
+
+static int indent_level = 0;
+
+void __cyg_profile_func_enter(void *this_function, void *call_site) DONT_INSTRUMENT;
+void __cyg_profile_func_enter(void *this_function, void *call_site)
+{
+#ifdef __GLIBC__
+ void *const pointers[] = { this_function, call_site };
+ char **symbols = NULL;
+
+ symbols = backtrace_symbols(pointers, 2);
+ if (symbols == NULL)
+ goto end;
+
+ fprintf(stderr, "%*s from %s\n", (int) strlen(symbols[0]) + indent_level, symbols[0], symbols[1]);
+
+end:
+ if (symbols != NULL)
+ free(symbols);
+#else
+ (void)this_function;
+ (void)call_site;
+#endif
+
+ if (indent_level < INT_MAX)
+ indent_level++;
+}
+
+void __cyg_profile_func_exit(void *this_function UNUSED, void *call_site UNUSED) DONT_INSTRUMENT;
+void __cyg_profile_func_exit(void *this_function UNUSED, void *call_site UNUSED)
+{
+ if (indent_level > 0)
+ indent_level--;
+}
diff --git a/core/proot/src/main/cpp/cli/cli.h b/core/proot/src/main/cpp/cli/cli.h
new file mode 100644
index 000000000..ed883c913
--- /dev/null
+++ b/core/proot/src/main/cpp/cli/cli.h
@@ -0,0 +1,64 @@
+/* This file is automatically generated from the documentation. EDIT AT YOUR OWN RISK. */
+
+#ifndef CLI_H
+#define CLI_H
+
+#include
+#include "tracee/tracee.h"
+#include "attribute.h"
+
+typedef struct {
+ const char *name;
+ char separator;
+ const char *value;
+} Argument;
+
+struct Cli;
+typedef int (*option_handler_t)(Tracee *tracee, const struct Cli *cli, const char *value);
+
+typedef struct {
+ const char *class;
+ option_handler_t handler;
+ const char *description;
+ const char *detail;
+ Argument arguments[5];
+} Option;
+
+#define END_OF_OPTIONS { .class = NULL, \
+ .arguments = {{ .name = NULL, .separator = '\0', .value = NULL }}, \
+ .handler = NULL, \
+ .description = NULL, \
+ .detail = NULL \
+ }
+
+typedef int (*initialization_hook_t)(Tracee *tracee, const struct Cli *cli,
+ size_t argc, char *const argv[], size_t cursor);
+typedef struct Cli {
+ const char *name;
+ const char *version;
+ const char *subtitle;
+ const char *synopsis;
+ const char *colophon;
+ const char *logo;
+
+ initialization_hook_t pre_initialize_bindings;
+ initialization_hook_t post_initialize_bindings;
+ initialization_hook_t pre_initialize_cwd;
+ initialization_hook_t post_initialize_cwd;
+ initialization_hook_t pre_initialize_exe;
+ initialization_hook_t post_initialize_exe;
+ void *private;
+
+ const Option options[];
+} Cli;
+
+extern const Cli *get_proot_cli(TALLOC_CTX *context);
+
+extern void print_usage(Tracee *tracee, const Cli *cli, bool detailed);
+extern void print_version(const Cli *cli);
+extern int parse_integer_option(const Tracee *tracee, int *variable, const char *value, const char *option);
+extern const char *expand_front_variable(TALLOC_CTX *context, const char *string);
+
+extern bool exit_failure;
+
+#endif /* CLI_H */
diff --git a/core/proot/src/main/cpp/cli/note.c b/core/proot/src/main/cpp/cli/note.c
new file mode 100644
index 000000000..a48636d22
--- /dev/null
+++ b/core/proot/src/main/cpp/cli/note.c
@@ -0,0 +1,97 @@
+/* -*- c-set-style: "K&R"; c-basic-offset: 8 -*-
+ *
+ * This file is part of PRoot.
+ *
+ * Copyright (C) 2015 STMicroelectronics
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+ * 02110-1301 USA.
+ */
+
+#include /* errno, */
+#include /* strerror(3), */
+#include /* va_*, */
+#include /* vfprintf(3), */
+#include /* INT_MAX, */
+
+#include "cli/note.h"
+#include "tracee/tracee.h"
+
+int global_verbose_level;
+const char *global_tool_name;
+
+/**
+ * Print @message to the standard error stream according to its
+ * @severity and @origin.
+ */
+void note(const Tracee *tracee, Severity severity, Origin origin, const char *message, ...)
+{
+ const char *tool_name;
+ va_list extra_params;
+ int verbose_level;
+
+ if (tracee == NULL) {
+ verbose_level = global_verbose_level;
+ tool_name = global_tool_name ?: "";
+ }
+ else {
+ verbose_level = tracee->verbose;
+ tool_name = tracee->tool_name;
+ }
+
+ if (verbose_level < 0 && severity != ERROR)
+ return;
+
+ switch (severity) {
+ case WARNING:
+ fprintf(stderr, "%s warning: ", tool_name);
+ break;
+
+ case ERROR:
+ fprintf(stderr, "%s error: ", tool_name);
+ break;
+
+ case INFO:
+ default:
+ fprintf(stderr, "%s info: ", tool_name);
+ break;
+ }
+
+ if (origin == TALLOC)
+ fprintf(stderr, "talloc: ");
+
+ va_start(extra_params, message);
+ vfprintf(stderr, message, extra_params);
+ va_end(extra_params);
+
+ switch (origin) {
+ case SYSTEM:
+ fprintf(stderr, ": ");
+ perror(NULL);
+ break;
+
+ case TALLOC:
+ break;
+
+ case INTERNAL:
+ case USER:
+ default:
+ fprintf(stderr, "\n");
+ break;
+ }
+
+ return;
+}
+
diff --git a/core/proot/src/main/cpp/cli/note.h b/core/proot/src/main/cpp/cli/note.h
new file mode 100644
index 000000000..8c73f8479
--- /dev/null
+++ b/core/proot/src/main/cpp/cli/note.h
@@ -0,0 +1,54 @@
+/* -*- c-set-style: "K&R"; c-basic-offset: 8 -*-
+ *
+ * This file is part of PRoot.
+ *
+ * Copyright (C) 2015 STMicroelectronics
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+ * 02110-1301 USA.
+ */
+
+#ifndef NOTE_H
+#define NOTE_H
+
+#include "tracee/tracee.h"
+#include "attribute.h"
+
+/* Specify where a notice is coming from. */
+typedef enum {
+ SYSTEM,
+ INTERNAL,
+ USER,
+ TALLOC,
+} Origin;
+
+/* Specify the severity of a notice. */
+typedef enum {
+ ERROR,
+ WARNING,
+ INFO,
+} Severity;
+
+#define VERBOSE(tracee, level, message, args...) do { \
+ if (tracee == NULL || tracee->verbose >= (level)) \
+ note(tracee, INFO, INTERNAL, (message), ## args); \
+ } while (0)
+
+extern void note(const Tracee *tracee, Severity severity, Origin origin, const char *message, ...) FORMAT(printf, 4, 5);
+
+extern int global_verbose_level;
+extern const char *global_tool_name;
+
+#endif /* NOTE_H */
diff --git a/core/proot/src/main/cpp/cli/proot.c b/core/proot/src/main/cpp/cli/proot.c
new file mode 100644
index 000000000..29365e81f
--- /dev/null
+++ b/core/proot/src/main/cpp/cli/proot.c
@@ -0,0 +1,402 @@
+/* -*- c-set-style: "K&R"; c-basic-offset: 8 -*-
+ *
+ * This file is part of PRoot.
+ *
+ * Copyright (C) 2015 STMicroelectronics
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+ * 02110-1301 USA.
+ */
+
+#include /* str*(3), */
+#include /* assert(3), */
+#include /* printf(3), fflush(3), */
+#include /* write(2), */
+
+#include "cli/cli.h"
+#include "cli/note.h"
+#include "extension/extension.h"
+#include "extension/sysvipc/sysvipc.h"
+#include "path/binding.h"
+#include "attribute.h"
+
+/* These should be included last. */
+#include "build.h"
+#include "cli/proot.h"
+
+static int handle_option_r(Tracee *tracee, const Cli *cli UNUSED, const char *value)
+{
+ Binding *binding;
+
+ /* ``chroot $PATH`` is semantically equivalent to ``mount
+ * --bind $PATH /``. */
+ binding = new_binding(tracee, value, "/", true);
+ if (binding == NULL)
+ return -1;
+
+ return 0;
+}
+
+static int handle_option_b(Tracee *tracee, const Cli *cli UNUSED, const char *value)
+{
+ char *host;
+ char *guest;
+
+ host = talloc_strdup(tracee->ctx, value);
+ if (host == NULL) {
+ note(tracee, ERROR, INTERNAL, "can't allocate memory");
+ return -1;
+ }
+
+ guest = strchr(host, ':');
+ if (guest != NULL) {
+ *guest = '\0';
+ guest++;
+ }
+
+ new_binding(tracee, host, guest, true);
+ return 0;
+}
+
+static int handle_option_q(Tracee *tracee, const Cli *cli UNUSED, const char *value)
+{
+ const char *ptr;
+ size_t nb_args;
+ bool last;
+ size_t i;
+
+ nb_args = 0;
+ ptr = value;
+ while (1) {
+ nb_args++;
+
+ /* Keep consecutive non-space characters. */
+ while (*ptr != ' ' && *ptr != '\0')
+ ptr++;
+
+ /* End-of-string ? */
+ if (*ptr == '\0')
+ break;
+
+ /* Skip consecutive space separators. */
+ while (*ptr == ' ' && *ptr != '\0')
+ ptr++;
+
+ /* End-of-string ? */
+ if (*ptr == '\0')
+ break;
+ }
+
+ tracee->qemu = talloc_zero_array(tracee, char *, nb_args + 1);
+ if (tracee->qemu == NULL)
+ return -1;
+ talloc_set_name_const(tracee->qemu, "@qemu");
+
+ i = 0;
+ ptr = value;
+ do {
+ const void *start;
+ const void *end;
+ last = true;
+
+ /* Keep consecutive non-space characters. */
+ start = ptr;
+ while (*ptr != ' ' && *ptr != '\0')
+ ptr++;
+ end = ptr;
+
+ /* End-of-string ? */
+ if (*ptr == '\0')
+ goto next;
+
+ /* Remove consecutive space separators. */
+ while (*ptr == ' ' && *ptr != '\0')
+ ptr++;
+
+ /* End-of-string ? */
+ if (*ptr == '\0')
+ goto next;
+
+ last = false;
+ next:
+ tracee->qemu[i] = talloc_strndup(tracee->qemu, start, end - start);
+ if (tracee->qemu[i] == NULL)
+ return -1;
+ i++;
+ } while (!last);
+ assert(i == nb_args);
+
+ new_binding(tracee, "/", HOST_ROOTFS, true);
+ new_binding(tracee, "/dev/null", "/etc/ld.so.preload", false);
+
+ return 0;
+}
+
+static int handle_option_w(Tracee *tracee, const Cli *cli UNUSED, const char *value)
+{
+ tracee->fs->cwd = talloc_strdup(tracee->fs, value);
+ if (tracee->fs->cwd == NULL)
+ return -1;
+ talloc_set_name_const(tracee->fs->cwd, "$cwd");
+ return 0;
+}
+
+static int handle_option_k(Tracee *tracee, const Cli *cli UNUSED, const char *value)
+{
+ void *extension;
+ int status;
+
+ extension = get_extension(tracee, kompat_callback);
+ if (extension != NULL) {
+ note(tracee, WARNING, USER, "option -k was already specified");
+ note(tracee, INFO, USER, "only the last -k option is enabled");
+ TALLOC_FREE(extension);
+ }
+
+ status = initialize_extension(tracee, kompat_callback, value);
+ if (status < 0)
+ note(tracee, WARNING, INTERNAL, "option \"-k %s\" discarded", value);
+
+ return 0;
+}
+
+static int handle_option_i(Tracee *tracee, const Cli *cli UNUSED, const char *value)
+{
+ void *extension;
+
+ extension = get_extension(tracee, fake_id0_callback);
+ if (extension != NULL) {
+ note(tracee, WARNING, USER, "option -i/-0/-S was already specified");
+ note(tracee, INFO, USER, "only the last -i/-0/-S option is enabled");
+ TALLOC_FREE(extension);
+ }
+
+ (void) initialize_extension(tracee, fake_id0_callback, value);
+ return 0;
+}
+
+static int handle_option_0(Tracee *tracee, const Cli *cli, const char *value UNUSED)
+{
+ return handle_option_i(tracee, cli, "0:0");
+}
+
+static int handle_option_kill_on_exit(Tracee *tracee, const Cli *cli UNUSED, const char *value UNUSED)
+{
+ tracee->killall_on_exit = true;
+ return 0;
+}
+
+static int handle_option_v(Tracee *tracee, const Cli *cli UNUSED, const char *value)
+{
+ int status;
+
+ status = parse_integer_option(tracee, &tracee->verbose, value, "-v");
+ if (status < 0)
+ return status;
+
+ global_verbose_level = tracee->verbose;
+ return 0;
+}
+
+extern unsigned char WEAK _binary_licenses_start;
+extern unsigned char WEAK _binary_licenses_end;
+
+static int handle_option_V(Tracee *tracee UNUSED, const Cli *cli, const char *value UNUSED)
+{
+ size_t size;
+
+ print_version(cli);
+ printf("\n%s\n", cli->colophon);
+ fflush(stdout);
+
+ size = &_binary_licenses_end - &_binary_licenses_start;
+ if (size > 0)
+ write(1, &_binary_licenses_start, size);
+
+ exit_failure = false;
+ return -1;
+}
+
+static int handle_option_h(Tracee *tracee, const Cli *cli, const char *value UNUSED)
+{
+ print_usage(tracee, cli, true);
+ exit_failure = false;
+ return -1;
+}
+
+static void new_bindings(Tracee *tracee, const char *bindings[], const char *value)
+{
+ int i;
+
+ for (i = 0; bindings[i] != NULL; i++) {
+ const char *path;
+
+ path = (strcmp(bindings[i], "*path*") != 0
+ ? expand_front_variable(tracee->ctx, bindings[i])
+ : value);
+
+ new_binding(tracee, path, NULL, false);
+ }
+}
+
+static int handle_option_R(Tracee *tracee, const Cli *cli, const char *value)
+{
+ int status;
+
+ status = handle_option_r(tracee, cli, value);
+ if (status < 0)
+ return status;
+
+ new_bindings(tracee, recommended_bindings, value);
+
+ return 0;
+}
+
+static int handle_option_S(Tracee *tracee, const Cli *cli, const char *value)
+{
+ int status;
+
+ status = handle_option_0(tracee, cli, value);
+ if (status < 0)
+ return status;
+
+ status = handle_option_r(tracee, cli, value);
+ if (status < 0)
+ return status;
+
+ new_bindings(tracee, recommended_su_bindings, value);
+
+ return 0;
+}
+
+static int handle_option_link2symlink(Tracee *tracee, const Cli *cli UNUSED, const char *value UNUSED)
+{
+ int status;
+
+ /* Initialize the link2symlink extension. */
+ status = initialize_extension(tracee, link2symlink_callback, NULL);
+ if (status < 0)
+ note(tracee, WARNING, INTERNAL, "link2symlink not initialized");
+
+ return 0;
+}
+
+static int handle_option_ashmem_memfd(Tracee *tracee, const Cli *cli UNUSED, const char *value UNUSED)
+{
+ int status;
+
+ /* Initialize the ashmem-memfd extension. */
+ status = initialize_extension(tracee, ashmem_memfd_callback, NULL);
+ if (status < 0)
+ note(tracee, WARNING, INTERNAL, "ashmem-memfd not initialized");
+
+ return 0;
+}
+
+static int handle_option_sysvipc(Tracee *tracee, const Cli *cli UNUSED, const char *value UNUSED)
+{
+ int status;
+
+ /* Initialize the sysvipc extension. */
+ status = initialize_extension(tracee, sysvipc_callback, NULL);
+ if (status < 0)
+ note(tracee, WARNING, INTERNAL, "sysvipc not initialized");
+
+ return 0;
+}
+
+static int handle_option_L(Tracee *tracee, const Cli *cli UNUSED, const char *value UNUSED)
+{
+ (void) initialize_extension(tracee, fix_symlink_size_callback, NULL);
+ return 0;
+}
+
+static int handle_option_H(Tracee *tracee, const Cli *cli UNUSED, const char *value UNUSED)
+{
+ (void) initialize_extension(tracee, hidden_files_callback, NULL);
+ return 0;
+}
+
+static int handle_option_p(Tracee *tracee, const Cli *cli UNUSED, const char *value UNUSED)
+{
+ (void) initialize_extension(tracee, port_switch_callback, NULL);
+ return 0;
+}
+
+/**
+ * Initialize @tracee->qemu.
+ */
+static int post_initialize_exe(Tracee *tracee, const Cli *cli UNUSED,
+ size_t argc UNUSED, char *const argv[] UNUSED, size_t cursor UNUSED)
+{
+ char path[PATH_MAX];
+ int status;
+
+ /* Nothing else to do ? */
+ if (tracee->qemu == NULL)
+ return 0;
+
+ /* Resolve the full guest path to tracee->qemu[0]. */
+ status = which(tracee->reconf.tracee, tracee->reconf.paths, path, tracee->qemu[0]);
+ if (status < 0)
+ return -1;
+
+ /* Actually tracee->qemu[0] has to be a host path from the tracee's
+ * point-of-view, not from the PRoot's point-of-view. See
+ * translate_execve() for details. */
+ if (tracee->reconf.tracee != NULL) {
+ status = detranslate_path(tracee->reconf.tracee, path, NULL);
+ if (status < 0)
+ return -1;
+ }
+
+ tracee->qemu[0] = talloc_strdup(tracee->qemu, path);
+ if (tracee->qemu[0] == NULL)
+ return -1;
+
+ return 0;
+}
+
+/**
+ * Initialize @tracee's fields that are mandatory for PRoot but that
+ * are not required on the command line, i.e. "-w" and "-r".
+ */
+static int pre_initialize_bindings(Tracee *tracee, const Cli *cli,
+ size_t argc UNUSED, char *const argv[] UNUSED, size_t cursor)
+{
+ int status;
+
+ /* Default to "." if no CWD were specified. */
+ if (tracee->fs->cwd == NULL) {
+ status = handle_option_w(tracee, cli, ".");
+ if (status < 0)
+ return -1;
+ }
+
+ /* The default guest rootfs is "/" if none was specified. */
+ if (get_root(tracee) == NULL) {
+ status = handle_option_r(tracee, cli, "/");
+ if (status < 0)
+ return -1;
+ }
+
+ return cursor;
+}
+
+const Cli *get_proot_cli(TALLOC_CTX *context UNUSED)
+{
+ global_tool_name = proot_cli.name;
+ return &proot_cli;
+}
diff --git a/core/proot/src/main/cpp/cli/proot.h b/core/proot/src/main/cpp/cli/proot.h
new file mode 100644
index 000000000..3f6193135
--- /dev/null
+++ b/core/proot/src/main/cpp/cli/proot.h
@@ -0,0 +1,346 @@
+/* This file is automatically generated from the documentation. EDIT AT YOUR OWN RISK. */
+
+#ifndef PROOT_CLI_H
+#define PROOT_CLI_H
+
+#include "cli/cli.h"
+
+#ifndef VERSION
+#define VERSION "5.1.0"
+#endif
+
+static const char *recommended_bindings[] = {
+ "/etc/host.conf",
+ "/etc/hosts",
+ "/etc/hosts.equiv",
+ "/etc/mtab",
+ "/etc/netgroup",
+ "/etc/networks",
+ "/etc/passwd",
+ "/etc/group",
+ "/etc/nsswitch.conf",
+ "/etc/resolv.conf",
+ "/etc/localtime",
+ "/dev/",
+ "/sys/",
+ "/proc/",
+ "/tmp/",
+ "/run/",
+ "/var/run/dbus/system_bus_socket",
+/* "/var/tmp/kdecache-$LOGNAME", */
+ "$HOME",
+ "*path*",
+ NULL,
+};
+
+static const char *recommended_su_bindings[] = {
+ "/etc/host.conf",
+ "/etc/hosts",
+ "/etc/nsswitch.conf",
+ "/etc/resolv.conf",
+ "/dev/",
+ "/sys/",
+ "/proc/",
+ "/tmp/",
+ "/run/shm",
+ "$HOME",
+ "*path*",
+ NULL,
+};
+
+static int handle_option_r(Tracee *tracee, const Cli *cli, const char *value);
+static int handle_option_b(Tracee *tracee, const Cli *cli, const char *value);
+static int handle_option_q(Tracee *tracee, const Cli *cli, const char *value);
+static int handle_option_w(Tracee *tracee, const Cli *cli, const char *value);
+static int handle_option_v(Tracee *tracee, const Cli *cli, const char *value);
+static int handle_option_V(Tracee *tracee, const Cli *cli, const char *value);
+static int handle_option_h(Tracee *tracee, const Cli *cli, const char *value);
+static int handle_option_k(Tracee *tracee, const Cli *cli, const char *value);
+static int handle_option_0(Tracee *tracee, const Cli *cli, const char *value);
+static int handle_option_i(Tracee *tracee, const Cli *cli, const char *value);
+static int handle_option_R(Tracee *tracee, const Cli *cli, const char *value);
+static int handle_option_S(Tracee *tracee, const Cli *cli, const char *value);
+static int handle_option_link2symlink(Tracee *tracee, const Cli *cli, const char *value);
+static int handle_option_ashmem_memfd(Tracee *tracee, const Cli *cli, const char *value);
+static int handle_option_sysvipc(Tracee *tracee, const Cli *cli, const char *value);
+static int handle_option_kill_on_exit(Tracee *tracee, const Cli *cli, const char *value);
+static int handle_option_L(Tracee *tracee, const Cli *cli, const char *value);
+static int handle_option_H(Tracee *tracee, const Cli *cli, const char *value);
+static int handle_option_p(Tracee *tracee, const Cli *cli, const char *value);
+
+static int pre_initialize_bindings(Tracee *, const Cli *, size_t, char *const *, size_t);
+static int post_initialize_exe(Tracee *, const Cli *, size_t, char *const *, size_t);
+
+static Cli proot_cli = {
+ .version = VERSION,
+ .name = "proot",
+ .subtitle = "chroot, mount --bind, and binfmt_misc without privilege/setup",
+ .synopsis = "proot [option] ... [command]",
+ .colophon = "Visit http://proot.me for help, bug reports, suggestions, patchs, ...\n\
+Copyright (C) 2015 STMicroelectronics, licensed under GPL v2 or later.",
+ .logo = "\
+ _____ _____ ___\n\
+| __ \\ __ \\_____ _____| |_\n\
+| __/ / _ \\/ _ \\ _|\n\
+|__| |__|__\\_____/\\_____/\\____|",
+
+ .pre_initialize_bindings = pre_initialize_bindings,
+ .post_initialize_exe = post_initialize_exe,
+
+ .options = {
+ { .class = "Regular options",
+ .arguments = {
+ { .name = "-r", .separator = ' ', .value = "path" },
+ { .name = "--rootfs", .separator = '=', .value = "path" },
+ { .name = NULL, .separator = '\0', .value = NULL } },
+ .handler = handle_option_r,
+ .description = "Use *path* as the new guest root file-system, default is /.",
+ .detail = "\tThe specified path typically contains a Linux distribution where\n\
+\tall new programs will be confined. The default rootfs is /\n\
+\twhen none is specified, this makes sense when the bind mechanism\n\
+\tis used to relocate host files and directories, see the -b\n\
+\toption and the Examples section for details.\n\
+\t\n\
+\tIt is recommended to use the -R or -S options instead.",
+ },
+ { .class = "Regular options",
+ .arguments = {
+ { .name = "-b", .separator = ' ', .value = "path" },
+ { .name = "--bind", .separator = '=', .value = "path" },
+ { .name = "-m", .separator = ' ', .value = "path" },
+ { .name = "--mount", .separator = '=', .value = "path" },
+ { .name = NULL, .separator = '\0', .value = NULL } },
+ .handler = handle_option_b,
+ .description = "Make the content of *path* accessible in the guest rootfs.",
+ .detail = "\tThis option makes any file or directory of the host rootfs\n\
+\taccessible in the confined environment just as if it were part of\n\
+\tthe guest rootfs. By default the host path is bound to the same\n\
+\tpath in the guest rootfs but users can specify any other location\n\
+\twith the syntax: -b *host_path*:*guest_location*. If the\n\
+\tguest location is a symbolic link, it is dereferenced to ensure\n\
+\tthe new content is accessible through all the symbolic links that\n\
+\tpoint to the overlaid content. In most cases this default\n\
+\tbehavior shouldn't be a problem, although it is possible to\n\
+\texplicitly not dereference the guest location by appending it the\n\
+\t! character: -b *host_path*:*guest_location!*.",
+ },
+ { .class = "Regular options",
+ .arguments = {
+ { .name = "-q", .separator = ' ', .value = "command" },
+ { .name = "--qemu", .separator = '=', .value = "command" },
+ { .name = NULL, .separator = '\0', .value = NULL } },
+ .handler = handle_option_q,
+ .description = "Execute guest programs through QEMU as specified by *command*.",
+ .detail = "\tEach time a guest program is going to be executed, PRoot inserts\n\
+\tthe QEMU user-mode command in front of the initial request.\n\
+\tThat way, guest programs actually run on a virtual guest CPU\n\
+\temulated by QEMU user-mode. The native execution of host programs\n\
+\tis still effective and the whole host rootfs is bound to\n\
+\t/host-rootfs in the guest environment.",
+ },
+ { .class = "Regular options",
+ .arguments = {
+ { .name = "-w", .separator = ' ', .value = "path" },
+ { .name = "--pwd", .separator = '=', .value = "path" },
+ { .name = "--cwd", .separator = '=', .value = "path" },
+ { .name = NULL, .separator = '\0', .value = NULL } },
+ .handler = handle_option_w,
+ .description = "Set the initial working directory to *path*.",
+ .detail = "\tSome programs expect to be launched from a given directory but do\n\
+\tnot perform any chdir by themselves. This option avoids the\n\
+\tneed for running a shell and then entering the directory manually.",
+ },
+ { .class = "Regular options",
+ .arguments = {
+ { .name = "--kill-on-exit", .separator = '\0', .value = NULL },
+ { .name = NULL, .separator = '\0', .value = NULL } },
+ .handler = handle_option_kill_on_exit,
+ .description = "Kill all processes on command exit.",
+ .detail = "\tWhen the executed command leaves orphean or detached processes\n\
+\taround, proot waits until all processes possibly terminate. This option forces\n\
+\tthe immediate termination of all tracee processes when the main command exits.",
+ },
+ { .class = "Regular options",
+ .arguments = {
+ { .name = "-v", .separator = ' ', .value = "value" },
+ { .name = "--verbose", .separator = '=', .value = "value" },
+ { .name = NULL, .separator = '\0', .value = NULL } },
+ .handler = handle_option_v,
+ .description = "Set the level of debug information to *value*.",
+ .detail = "\tThe higher the integer value is, the more detailed debug\n\
+\tinformation is printed to the standard error stream. A negative\n\
+\tvalue makes PRoot quiet except on fatal errors.",
+ },
+ { .class = "Regular options",
+ .arguments = {
+ { .name = "-V", .separator = '\0', .value = NULL },
+ { .name = "--version", .separator = '\0', .value = NULL },
+ { .name = "--about", .separator = '\0', .value = NULL },
+ { .name = NULL, .separator = '\0', .value = NULL } },
+ .handler = handle_option_V,
+ .description = "Print version, copyright, license and contact, then exit.",
+ .detail = "",
+ },
+ { .class = "Regular options",
+ .arguments = {
+ { .name = "-h", .separator = '\0', .value = NULL },
+ { .name = "--help", .separator = '\0', .value = NULL },
+ { .name = "--usage", .separator = '\0', .value = NULL },
+ { .name = NULL, .separator = '\0', .value = NULL } },
+ .handler = handle_option_h,
+ .description = "Print the version and the command-line usage, then exit.",
+ .detail = "",
+ },
+ { .class = "Extension options",
+ .arguments = {
+ { .name = "-k", .separator = ' ', .value = "string" },
+ { .name = "--kernel-release", .separator = '=', .value = "string" },
+ { .name = NULL, .separator = '\0', .value = NULL } },
+ .handler = handle_option_k,
+ .description = "Make current kernel appear as kernel release *string*.",
+ .detail = "\tIf a program is run on a kernel older than the one expected by its\n\
+\tGNU C library, the following error is reported: \"FATAL: kernel too\n\
+\told\". To be able to run such programs, PRoot can emulate some of\n\
+\tthe features that are available in the kernel release specified by\n\
+\t*string* but that are missing in the current kernel.",
+ },
+ { .class = "Extension options",
+ .arguments = {
+ { .name = "-0", .separator = '\0', .value = NULL },
+ { .name = "--root-id", .separator = '\0', .value = NULL },
+ { .name = NULL, .separator = '\0', .value = NULL } },
+ .handler = handle_option_0,
+ .description = "Make current user appear as \"root\" and fake its privileges.",
+ .detail = "\tSome programs will refuse to work if they are not run with \"root\"\n\
+\tprivileges, even if there is no technical reason for that. This\n\
+\tis typically the case with package managers. This option allows\n\
+\tusers to bypass this kind of limitation by faking the user/group\n\
+\tidentity, and by faking the success of some operations like\n\
+\tchanging the ownership of files, changing the root directory to\n\
+\t/, ... Note that this option is quite limited compared to\n\
+\tfakeroot.",
+ },
+ { .class = "Extension options",
+ .arguments = {
+ { .name = "-i", .separator = ' ', .value = "string" },
+ { .name = "--change-id", .separator = '=', .value = "string" },
+ { .name = NULL, .separator = '\0', .value = NULL } },
+ .handler = handle_option_i,
+ .description = "Make current user and group appear as *string* \"uid:gid\".",
+ .detail = "\tThis option makes the current user and group appear as uid and\n\
+\tgid. Likewise, files actually owned by the current user and\n\
+\tgroup appear as if they were owned by uid and gid instead.\n\
+\tNote that the -0 option is the same as -i 0:0.",
+ },
+ { .class = "Extension options",
+ .arguments = {
+ { .name = "--link2symlink", .separator = '\0', .value = NULL },
+ { .name = "-l", .separator = '\0', .value = NULL },
+ { .name = NULL, .separator = '\0', .value = NULL } },
+ .handler = handle_option_link2symlink,
+ .description = "Replace hard links with symlinks, pretending they are really hardlinks",
+ .detail = "\tEmulates hard links with symbolic links when SELinux policies\n\
+\tdo not allow hard links.",
+ },
+ { .class = "Extension options",
+ .arguments = {
+ { .name = "--sysvipc", .separator = '\0', .value = NULL },
+ { .name = NULL, .separator = '\0', .value = NULL } },
+ .handler = handle_option_sysvipc,
+ .description = "Handle System V IPC syscalls in proot",
+ .detail = "\tHandles System V IPC syscalls (shmget, semget, msgget, etc.)\n\
+\tsyscalls inside proot. IPC is handled inside proot and launching 2 proot instances\n\
+\twill lead to 2 different IPC Namespaces",
+ },
+ { .class = "Extension options",
+ .arguments = {
+ { .name = "--ashmem-memfd", .separator = '\0', .value = NULL },
+ { .name = NULL, .separator = '\0', .value = NULL } },
+ .handler = handle_option_ashmem_memfd,
+ .description = "Emulate memfd_create support through ashmem and simulate fstat.st_size for ashmem",
+ .detail = "",
+ },
+ { .class = "Extension options",
+ .arguments = {
+ { .name = "-H", .separator = '\0', .value = NULL },
+ { .name = NULL, .separator = '\0', .value = NULL } },
+ .handler = handle_option_H,
+ .description = "Hide files and directories starting with '.proot.' .",
+ .detail = "",
+ },
+ { .class = "Extension options",
+ .arguments = {
+ { .name = "-p", .separator = '\0', .value = NULL },
+ { .name = NULL, .separator = '\0', .value = NULL } },
+ .handler = handle_option_p,
+ .description = "Modify bindings to protected ports to use a higher port number.",
+ .detail = "",
+ },
+ { .class = "Extension options",
+ .arguments = {
+ { .name = "-L", .separator = '\0', .value = NULL },
+ { .name = NULL, .separator = '\0', .value = NULL } },
+ .handler = handle_option_L,
+ .description = "Correct the size returned from lstat for symbolic links.",
+ .detail = "",
+ },
+ { .class = "Alias options",
+ .arguments = {
+ { .name = "-R", .separator = ' ', .value = "path" },
+ { .name = NULL, .separator = '\0', .value = NULL } },
+ .handler = handle_option_R,
+ .description = "Alias: -r *path* + a couple of recommended -b.",
+ .detail = "\tPrograms isolated in *path*, a guest rootfs, might still need to\n\
+\taccess information about the host system, as it is illustrated in\n\
+\tthe Examples section of the manual. These host information\n\
+\tare typically: user/group definition, network setup, run-time\n\
+\tinformation, users' files, ... On all Linux distributions, they\n\
+\tall lie in a couple of host files and directories that are\n\
+\tautomatically bound by this option:\n\
+\t\n\
+\t * /etc/host.conf\n\
+\t * /etc/hosts\n\
+\t * /etc/hosts.equiv\n\
+\t * /etc/mtab\n\
+\t * /etc/netgroup\n\
+\t * /etc/networks\n\
+\t * /etc/passwd\n\
+\t * /etc/group\n\
+\t * /etc/nsswitch.conf\n\
+\t * /etc/resolv.conf\n\
+\t * /etc/localtime\n\
+\t * /dev/\n\
+\t * /sys/\n\
+\t * /proc/\n\
+\t * /tmp/\n\
+\t * /run/\n\
+\t * /var/run/dbus/system_bus_socket\n\
+\t * $HOME",
+ },
+ { .class = "Alias options",
+ .arguments = {
+ { .name = "-S", .separator = ' ', .value = "path" },
+ { .name = NULL, .separator = '\0', .value = NULL } },
+ .handler = handle_option_S,
+ .description = "Alias: -0 -r *path* + a couple of recommended -b.",
+ .detail = "\tThis option is useful to safely create and install packages into\n\
+\tthe guest rootfs. It is similar to the -R option expect it\n\
+\tenables the -0 option and binds only the following minimal set\n\
+\tof paths to avoid unexpected changes on host files:\n\
+\t\n\
+\t * /etc/host.conf\n\
+\t * /etc/hosts\n\
+\t * /etc/nsswitch.conf\n\
+\t * /etc/resolv.conf\n\
+\t * /dev/\n\
+\t * /sys/\n\
+\t * /proc/\n\
+\t * /tmp/\n\
+\t * /run/shm\n\
+\t * $HOME",
+ },
+ END_OF_OPTIONS,
+ },
+};
+
+#endif /* PROOT_CLI_H */
diff --git a/core/proot/src/main/cpp/compat.h b/core/proot/src/main/cpp/compat.h
new file mode 100644
index 000000000..bc96251f2
--- /dev/null
+++ b/core/proot/src/main/cpp/compat.h
@@ -0,0 +1,287 @@
+/* -*- c-set-style: "K&R"; c-basic-offset: 8 -*-
+ *
+ * This file is part of PRoot.
+ *
+ * Copyright (C) 2015 STMicroelectronics
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+ * 02110-1301 USA.
+ */
+
+#ifndef COMPAT_H
+#define COMPAT_H
+
+/* Local definitions for compatibility with old and/or broken distros... */
+# ifndef AT_NULL
+# define AT_NULL 0
+# endif
+# ifndef AT_PHDR
+# define AT_PHDR 3
+# endif
+# ifndef AT_PHENT
+# define AT_PHENT 4
+# endif
+# ifndef AT_PHNUM
+# define AT_PHNUM 5
+# endif
+# ifndef AT_BASE
+# define AT_BASE 7
+# endif
+# ifndef AT_ENTRY
+# define AT_ENTRY 9
+# endif
+# ifndef AT_RANDOM
+# define AT_RANDOM 25
+# endif
+# ifndef AT_EXECFN
+# define AT_EXECFN 31
+# endif
+# ifndef AT_SYSINFO
+# define AT_SYSINFO 32
+# endif
+# ifndef AT_SYSINFO_EHDR
+# define AT_SYSINFO_EHDR 33
+# endif
+# ifndef AT_FDCWD
+# define AT_FDCWD -100
+# endif
+# ifndef AT_SYMLINK_FOLLOW
+# define AT_SYMLINK_FOLLOW 0x400
+# endif
+# ifndef AT_REMOVEDIR
+# define AT_REMOVEDIR 0x200
+# endif
+# ifndef AT_SYMLINK_NOFOLLOW
+# define AT_SYMLINK_NOFOLLOW 0x100
+# endif
+# ifndef IN_DONT_FOLLOW
+# define IN_DONT_FOLLOW 0x02000000
+# endif
+# ifndef WIFCONTINUED
+# define WIFCONTINUED(status) ((status) == 0xffff)
+# endif
+# ifndef PTRACE_GETREGS
+# define PTRACE_GETREGS 12
+# endif
+# ifndef PTRACE_SETREGS
+# define PTRACE_SETREGS 13
+# endif
+# ifndef PTRACE_GETFPREGS
+# define PTRACE_GETFPREGS 14
+# endif
+# ifndef PTRACE_SETFPREGS
+# define PTRACE_SETFPREGS 15
+# endif
+# ifndef PTRACE_GETFPXREGS
+# define PTRACE_GETFPXREGS 18
+# endif
+# ifndef PTRACE_SETFPXREGS
+# define PTRACE_SETFPXREGS 19
+# endif
+# ifndef PTRACE_SETOPTIONS
+# define PTRACE_SETOPTIONS 0x4200
+# endif
+# ifndef PTRACE_GETEVENTMSG
+# define PTRACE_GETEVENTMSG 0x4201
+# endif
+# ifndef PTRACE_GETREGSET
+# define PTRACE_GETREGSET 0x4204
+# endif
+# ifndef PTRACE_SETREGSET
+# define PTRACE_SETREGSET 0x4205
+# endif
+# ifndef PTRACE_SEIZE
+# define PTRACE_SEIZE 0x4206
+# endif
+# ifndef PTRACE_INTERRUPT
+# define PTRACE_INTERRUPT 0x4207
+# endif
+# ifndef PTRACE_LISTEN
+# define PTRACE_LISTEN 0x4208
+# endif
+# ifndef PTRACE_O_TRACESYSGOOD
+# define PTRACE_O_TRACESYSGOOD 0x00000001
+# endif
+# ifndef PTRACE_O_TRACEFORK
+# define PTRACE_O_TRACEFORK 0x00000002
+# endif
+# ifndef PTRACE_O_TRACEVFORK
+# define PTRACE_O_TRACEVFORK 0x00000004
+# endif
+# ifndef PTRACE_O_TRACECLONE
+# define PTRACE_O_TRACECLONE 0x00000008
+# endif
+# ifndef PTRACE_O_TRACEEXEC
+# define PTRACE_O_TRACEEXEC 0x00000010
+# endif
+# ifndef PTRACE_O_TRACEVFORKDONE
+# define PTRACE_O_TRACEVFORKDONE 0x00000020
+# endif
+# ifndef PTRACE_O_TRACEEXIT
+# define PTRACE_O_TRACEEXIT 0x00000040
+# endif
+# ifndef PTRACE_O_TRACESECCOMP
+# define PTRACE_O_TRACESECCOMP 0x00000080
+# endif
+# ifndef PTRACE_EVENT_FORK
+# define PTRACE_EVENT_FORK 1
+# endif
+# ifndef PTRACE_EVENT_VFORK
+# define PTRACE_EVENT_VFORK 2
+# endif
+# ifndef PTRACE_EVENT_CLONE
+# define PTRACE_EVENT_CLONE 3
+# endif
+# ifndef PTRACE_EVENT_EXEC
+# define PTRACE_EVENT_EXEC 4
+# endif
+# ifndef PTRACE_EVENT_VFORK_DONE
+# define PTRACE_EVENT_VFORK_DONE 5
+# endif
+# ifndef PTRACE_EVENT_EXIT
+# define PTRACE_EVENT_EXIT 6
+# endif
+# ifndef PTRACE_EVENT_SECCOMP
+# define PTRACE_EVENT_SECCOMP 7
+# endif
+# ifndef PTRACE_EVENT_SECCOMP2
+# if PTRACE_EVENT_SECCOMP == 7
+# define PTRACE_EVENT_SECCOMP2 8
+# elif PTRACE_EVENT_SECCOMP == 8
+# define PTRACE_EVENT_SECCOMP2 7
+# else
+# error "unknown PTRACE_EVENT_SECCOMP value"
+# endif
+# endif
+# ifndef PTRACE_SET_SYSCALL
+# define PTRACE_SET_SYSCALL 23
+# endif
+# ifndef PTRACE_GET_THREAD_AREA
+# define PTRACE_GET_THREAD_AREA 25
+# endif
+# ifndef PTRACE_SET_THREAD_AREA
+# define PTRACE_SET_THREAD_AREA 26
+# endif
+# ifndef PTRACE_GETVFPREGS
+# define PTRACE_GETVFPREGS 27
+# endif
+# ifndef PTRACE_ARCH_PRCTL
+# define PTRACE_ARCH_PRCTL 30
+# endif
+# ifndef ARCH_SET_GS
+# define ARCH_SET_GS 0x1001
+# endif
+# ifndef ARCH_SET_FS
+# define ARCH_SET_FS 0x1002
+# endif
+# ifndef ARCH_GET_GS
+# define ARCH_GET_FS 0x1003
+# endif
+# ifndef ARCH_GET_FS
+# define ARCH_GET_GS 0x1004
+# endif
+# ifndef PTRACE_SINGLEBLOCK
+# define PTRACE_SINGLEBLOCK 33
+# endif
+# ifndef ADDR_NO_RANDOMIZE
+# define ADDR_NO_RANDOMIZE 0x0040000
+# endif
+# ifndef SYS_ACCEPT4
+# define SYS_ACCEPT4 18
+# endif
+# ifndef TALLOC_FREE
+# define TALLOC_FREE(ctx) do { talloc_free(ctx); ctx = NULL; } while(0)
+# endif
+# ifndef PR_SET_NAME
+# define PR_SET_NAME 15
+# endif
+# ifndef PR_SET_NO_NEW_PRIVS
+# define PR_SET_NO_NEW_PRIVS 38
+# endif
+# ifndef PR_GET_NO_NEW_PRIVS
+# define PR_GET_NO_NEW_PRIVS 39
+# endif
+# ifndef PR_SET_SECCOMP
+# define PR_SET_SECCOMP 22
+# endif
+# ifndef SECCOMP_MODE_FILTER
+# define SECCOMP_MODE_FILTER 2
+# endif
+# ifndef talloc_get_type_abort
+# define talloc_get_type_abort talloc_get_type
+# endif
+# ifndef FUTEX_PRIVATE_FLAG
+# define FUTEX_PRIVATE_FLAG 128
+# endif
+# ifndef EFD_SEMAPHORE
+# define EFD_SEMAPHORE 1
+# endif
+# ifndef F_DUPFD_CLOEXEC
+# define F_DUPFD_CLOEXEC 1030
+# endif
+# ifndef O_RDONLY
+# define O_RDONLY 00000000
+# endif
+# ifndef O_CLOEXEC
+# define O_CLOEXEC 02000000
+# endif
+# ifndef MAP_PRIVATE
+# define MAP_PRIVATE 0x02
+# endif
+# ifndef MAP_FIXED
+# define MAP_FIXED 0x10
+# endif
+# ifndef MAP_ANONYMOUS
+# define MAP_ANONYMOUS 0x20
+# endif
+# ifndef PROT_READ
+# define PROT_READ 0x1
+# endif
+# ifndef PROT_WRITE
+# define PROT_WRITE 0x2
+# endif
+# ifndef PROT_EXEC
+# define PROT_EXEC 0x4
+# endif
+# ifndef PROT_GROWSDOWN
+# define PROT_GROWSDOWN 0x01000000
+# endif
+# ifndef NT_ARM_SYSTEM_CALL
+# define NT_ARM_SYSTEM_CALL 0x404
+# endif
+# ifndef SYS_SECCOMP
+# define SYS_SECCOMP 1
+# endif
+
+/* openat2(2) "how" argument. Mirrors struct open_how from
+ * , but uses a private name so we don't depend on (or
+ * clash with) that kernel header, which is present on some build
+ * platforms (e.g. Termux) and absent on others. */
+struct proot_open_how {
+ unsigned long long flags;
+ unsigned long long mode;
+ unsigned long long resolve;
+};
+
+static inline unsigned long untag_pointer(unsigned long addr)
+{
+#if defined(__aarch64__)
+ return addr & 0x00FFFFFFFFFFFFFFULL;
+#else
+ return addr;
+#endif
+}
+
+#endif /* COMPAT_H */
diff --git a/core/proot/src/main/cpp/execve/aoxp.c b/core/proot/src/main/cpp/execve/aoxp.c
new file mode 100644
index 000000000..b1103a605
--- /dev/null
+++ b/core/proot/src/main/cpp/execve/aoxp.c
@@ -0,0 +1,439 @@
+/* -*- c-set-style: "K&R"; c-basic-offset: 8 -*-
+ *
+ * This file is part of PRoot.
+ *
+ * Copyright (C) 2015 STMicroelectronics
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+ * 02110-1301 USA.
+ */
+
+#include /* ARG_MAX, */
+#include /* assert(3), */
+#include /* strlen(3), memcmp(3), memcpy(3), */
+#include /* bzero(3), */
+#include /* bool, true, false, */
+#include /* E*, */
+#include /* va_*, */
+#include /* uint32_t, */
+#include /* talloc_*, */
+
+#include "arch.h"
+#include "tracee/tracee.h"
+#include "tracee/mem.h"
+#include "tracee/abi.h"
+#include "build.h"
+
+struct mixed_pointer {
+ /* Pointer -- in tracee's address space -- to the current
+ * object, if local == NULL. */
+ word_t remote;
+
+ /* Pointer -- in tracer's address space -- to the current
+ * object, if local != NULL. */
+ void *local;
+};
+
+#include "execve/aoxp.h"
+
+/**
+ * Read object pointed to by @array[@index] from tracee's memory, then
+ * make @local_pointer points to the locally *cached* version. This
+ * function returns -errno when an error occured, otherwise 0.
+ */
+int read_xpointee_as_object(ArrayOfXPointers *array, size_t index, void **local_pointer)
+{
+ int status;
+ int size;
+
+ assert(index < array->length);
+
+ /* Already cached locally? */
+ if (array->_xpointers[index].local != NULL)
+ goto end;
+
+ /* Remote NULL is mapped to local NULL. */
+ if (array->_xpointers[index].remote == 0) {
+ array->_xpointers[index].local = NULL;
+ goto end;
+ }
+
+ size = sizeof_xpointee(array, index);
+ if (size < 0)
+ return size;
+
+ array->_xpointers[index].local = talloc_size(array, size);
+ if (array->_xpointers[index].local == NULL)
+ return -ENOMEM;
+
+ /* Copy locally the remote object. */
+ status = read_data(TRACEE(array), array->_xpointers[index].local,
+ array->_xpointers[index].remote, size);
+ if (status < 0) {
+ array->_xpointers[index].local = NULL;
+ return status;
+ }
+
+end:
+ *local_pointer = array->_xpointers[index].local;
+ return 0;
+}
+
+/**
+ * Read string pointed to by @array[@index] from tracee's memory, then
+ * make @local_pointer points to the locally *cached* version. This
+ * function returns -errno when an error occured, otherwise 0.
+ */
+int read_xpointee_as_string(ArrayOfXPointers *array, size_t index, char **local_pointer)
+{
+ char tmp[ARG_MAX];
+ int status;
+
+ assert(index < array->length);
+
+ /* Already cached locally? */
+ if (array->_xpointers[index].local != NULL)
+ goto end;
+
+ /* Remote NULL is mapped to local NULL. */
+ if (array->_xpointers[index].remote == 0) {
+ array->_xpointers[index].local = NULL;
+ goto end;
+ }
+
+ /* Copy locally the remote string into a temporary buffer. */
+ status = read_string(TRACEE(array), tmp, array->_xpointers[index].remote, ARG_MAX);
+ if (status < 0)
+ return status;
+ if (status >= ARG_MAX)
+ return -ENOMEM;
+
+ /* Save the local string in a "persistent" buffer. */
+ array->_xpointers[index].local = talloc_strdup(array, tmp);
+ if (array->_xpointers[index].local == NULL)
+ return -ENOMEM;
+
+end:
+ *local_pointer = array->_xpointers[index].local;
+ return 0;
+}
+
+/**
+ * This function returns the number of bytes of the string pointed to
+ * by @array[@index], otherwise -errno if an error occured.
+ */
+int sizeof_xpointee_as_string(ArrayOfXPointers *array, size_t index)
+{
+ char *string;
+ int status;
+
+ assert(index < array->length);
+
+ status = read_xpointee_as_string(array, index, &string);
+ if (status < 0)
+ return status;
+
+ if (string == NULL)
+ return 0;
+
+ return strlen(string) + 1;
+}
+
+/**
+ * Compare object pointed to by @array[@index] with object pointed to
+ * by @local_reference. This function returns 1 if they are
+ * equivalent, 0 otherwise. On error, -errno is returned.
+ */
+int compare_xpointee_generic(ArrayOfXPointers *array, size_t index, const void *local_reference)
+{
+ void *object;
+ int status;
+
+ assert(index < array->length);
+
+ status = read_xpointee(array, index, &object);
+ if (status < 0)
+ return status;
+
+ if (object == NULL && local_reference == NULL)
+ return 1;
+
+ if (object == NULL && local_reference != NULL)
+ return 0;
+
+ if (object != NULL && local_reference == NULL)
+ return 0;
+
+ status = sizeof_xpointee(array, index);
+ if (status < 0)
+ return status;
+
+ return (int) (memcmp(object, local_reference, status) == 0);
+}
+
+/**
+ * This function returns the index in @array of the first pointee
+ * equivalent to the @local_reference pointee, otherwise it returns
+ * -errno if an error occured.
+ */
+int find_xpointee(ArrayOfXPointers *array, const void *local_reference)
+{
+ size_t i;
+
+ for (i = 0; i < array->length; i++) {
+ int status;
+
+ status = compare_xpointee(array, i, local_reference);
+ if (status < 0)
+ return status;
+ if (status != 0)
+ break;
+ }
+
+ return i;
+}
+
+/**
+ * Make @array[@index] points to a copy of the string pointed to by
+ * @string. This function returns -errno when an error occured,
+ * otherwise 0.
+ */
+int write_xpointee_as_string(ArrayOfXPointers *array, size_t index, const char *string)
+{
+ assert(index < array->length);
+
+ array->_xpointers[index].local = talloc_strdup(array, string);
+ if (array->_xpointers[index].local == NULL)
+ return -ENOMEM;
+
+ return 0;
+}
+
+/**
+ * Make @array[@index ... @index + @nb_xpointees] points to a copy of
+ * the variadic arguments. This function returns -errno when an error
+ * occured, otherwise 0.
+ */
+int write_xpointees(ArrayOfXPointers *array, size_t index, size_t nb_xpointees, ...)
+{
+ va_list va_xpointees;
+ int status;
+ size_t i;
+
+ va_start(va_xpointees, nb_xpointees);
+
+ for (i = 0; i < nb_xpointees; i++) {
+ void *object = va_arg(va_xpointees, void *);
+
+ status = write_xpointee(array, index + i, object);
+ if (status < 0)
+ goto end;
+ }
+ status = 0;
+
+end:
+ va_end(va_xpointees);
+ return status;
+}
+
+
+/**
+ * Resize the @array at the given @index by the @delta_nb_entries.
+ * This function returns -errno when an error occured, otherwise 0.
+ */
+int resize_array_of_xpointers(ArrayOfXPointers *array, size_t index, ssize_t delta_nb_entries)
+{
+ size_t nb_moved_entries;
+ size_t new_length;
+ void *tmp;
+
+ assert(index < array->length);
+
+ if (delta_nb_entries == 0)
+ return 0;
+
+ new_length = array->length + delta_nb_entries;
+ nb_moved_entries = array->length - index;
+
+ if (delta_nb_entries > 0) {
+ tmp = talloc_realloc(array, array->_xpointers, XPointer, new_length);
+ if (tmp == NULL)
+ return -ENOMEM;
+ array->_xpointers = tmp;
+
+ memmove(array->_xpointers + index + delta_nb_entries, array->_xpointers + index,
+ nb_moved_entries * sizeof(XPointer));
+
+ bzero(array->_xpointers + index, delta_nb_entries * sizeof(XPointer));
+ }
+ else {
+ assert(delta_nb_entries <= 0);
+ assert(index >= (size_t) -delta_nb_entries);
+
+ memmove(array->_xpointers + index + delta_nb_entries, array->_xpointers + index,
+ nb_moved_entries * sizeof(XPointer));
+
+ tmp = talloc_realloc(array, array->_xpointers, XPointer, new_length);
+ if (tmp == NULL)
+ return -ENOMEM;
+ array->_xpointers = tmp;
+ }
+
+ array->length = new_length;
+ return 0;
+}
+
+/**
+ * Copy into *@array_ the pointer array pointed to by @reg from
+ * @tracee's memory space. Only the first @nb_entries are copied,
+ * unless it is 0 then all the entries up to the NULL pointer are
+ * copied. This function returns -errno when an error occured,
+ * otherwise 0.
+ */
+int fetch_array_of_xpointers(Tracee *tracee, ArrayOfXPointers **array_, Reg reg, size_t nb_entries)
+{
+ word_t pointer = 1; /* ie. != 0 */
+ word_t address;
+ ArrayOfXPointers *array;
+ size_t i;
+
+ assert(array_ != NULL);
+
+ *array_ = talloc_zero(tracee->ctx, ArrayOfXPointers);
+ if (*array_ == NULL)
+ return -ENOMEM;
+ array = *array_;
+
+ address = peek_reg(tracee, CURRENT, reg);
+
+ for (i = 0; nb_entries != 0 ? i < nb_entries : pointer != 0; i++) {
+ void *tmp = talloc_realloc(array, array->_xpointers, XPointer, i + 1);
+ if (tmp == NULL)
+ return -ENOMEM;
+ array->_xpointers = tmp;
+
+ pointer = peek_word(tracee, address + i * sizeof_word(tracee));
+ if (errno != 0)
+ return -errno;
+
+ array->_xpointers[i].remote = pointer;
+ array->_xpointers[i].local = NULL;
+ }
+ array->length = i;
+
+ /* By default, assume it is an array of string pointers. */
+ array->read_xpointee = (read_xpointee_t) read_xpointee_as_string;
+ array->sizeof_xpointee = sizeof_xpointee_as_string;
+ array->write_xpointee = (write_xpointee_t) write_xpointee_as_string;
+
+ /* By default, use generic callbacks: they rely on
+ * array->read_xpointee() and array->sizeof_xpointee(). */
+ array->compare_xpointee = compare_xpointee_generic;
+
+ return 0;
+}
+
+/**
+ * Copy @array into tracee's memory space, then put in @reg the
+ * address where it was copied. This function returns -errno if an
+ * error occured, otherwise 0.
+ */
+int push_array_of_xpointers(ArrayOfXPointers *array, Reg reg)
+{
+ Tracee *tracee;
+ struct iovec *local;
+ size_t local_count;
+ size_t total_size;
+ word_t *pod_array;
+ word_t tracee_ptr;
+ int status;
+ size_t i;
+
+ /* Nothing to do, for sure. */
+ if (array == NULL)
+ return 0;
+
+ tracee = TRACEE(array);
+
+ /* The pointer table is a POD array in the tracee's memory. */
+ pod_array = talloc_zero_size(tracee->ctx, array->length * sizeof_word(tracee));
+ if (pod_array == NULL)
+ return -ENOMEM;
+
+ /* There's one vector per modified pointee + one vector for the
+ * pod array. */
+ local = talloc_zero_array(tracee->ctx, struct iovec, array->length + 1);
+ if (local == NULL)
+ return -ENOMEM;
+
+ /* The pod array is expected to be at the beginning of the
+ * allocated memory by the caller. */
+ total_size = array->length * sizeof_word(tracee);
+ local[0].iov_base = pod_array;
+ local[0].iov_len = total_size;
+ local_count = 1;
+
+ /* Create one vector for each modified pointee. */
+ for (i = 0; i < array->length; i++) {
+ ssize_t size;
+
+ if (array->_xpointers[i].local == NULL)
+ continue;
+
+ /* At this moment, we only know the offsets in the
+ * tracee's memory block. */
+ array->_xpointers[i].remote = total_size;
+
+ size = sizeof_xpointee(array, i);
+ if (size < 0)
+ return size;
+ total_size += size;
+
+ local[local_count].iov_base = array->_xpointers[i].local;
+ local[local_count].iov_len = size;
+ local_count++;
+ }
+
+ /* Nothing has changed, don't update anything. */
+ if (local_count == 1)
+ return 0;
+ assert(local_count < array->length + 1);
+
+ /* Modified pointees and the pod array are stored in a tracee's
+ * memory block. */
+ tracee_ptr = alloc_mem(tracee, total_size);
+ if (tracee_ptr == 0)
+ return -E2BIG;
+
+ /* Now, we know the absolute addresses in the tracee's
+ * memory. */
+ for (i = 0; i < array->length; i++) {
+ if (array->_xpointers[i].local != NULL)
+ array->_xpointers[i].remote += tracee_ptr;
+
+ if (is_32on64_mode(tracee))
+ ((uint32_t *) pod_array)[i] = array->_xpointers[i].remote;
+ else
+ pod_array[i] = array->_xpointers[i].remote;
+ }
+
+ /* Write all the modified pointees and the pod array at once. */
+ status = writev_data(tracee, tracee_ptr, local, local_count);
+ if (status < 0)
+ return status;
+
+ poke_reg(tracee, reg, tracee_ptr);
+ return 0;
+}
diff --git a/core/proot/src/main/cpp/execve/aoxp.h b/core/proot/src/main/cpp/execve/aoxp.h
new file mode 100644
index 000000000..7a852aa36
--- /dev/null
+++ b/core/proot/src/main/cpp/execve/aoxp.h
@@ -0,0 +1,80 @@
+/* -*- c-set-style: "K&R"; c-basic-offset: 8 -*-
+ *
+ * This file is part of PRoot.
+ *
+ * Copyright (C) 2015 STMicroelectronics
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+ * 02110-1301 USA.
+ */
+
+#ifndef AOXP_H
+#define AOXP_H
+
+#include
+
+#include "tracee/reg.h"
+#include "arch.h"
+
+typedef struct array_of_xpointers ArrayOfXPointers;
+typedef int (*read_xpointee_t)(ArrayOfXPointers *array, size_t index, void **object);
+typedef int (*write_xpointee_t)(ArrayOfXPointers *array, size_t index, const void *object);
+typedef int (*compare_xpointee_t)(ArrayOfXPointers *array, size_t index, const void *reference);
+typedef int (*sizeof_xpointee_t)(ArrayOfXPointers *array, size_t index);
+
+typedef struct mixed_pointer XPointer;
+struct array_of_xpointers {
+ XPointer *_xpointers;
+ size_t length;
+
+ read_xpointee_t read_xpointee;
+ write_xpointee_t write_xpointee;
+ compare_xpointee_t compare_xpointee;
+ sizeof_xpointee_t sizeof_xpointee;
+};
+
+static inline int read_xpointee(ArrayOfXPointers *array, size_t index, void **object)
+{
+ return array->read_xpointee(array, index, object);
+}
+
+static inline int write_xpointee(ArrayOfXPointers *array, size_t index, const void *object)
+{
+ return array->write_xpointee(array, index, object);
+}
+
+static inline int compare_xpointee(ArrayOfXPointers *array, size_t index, const void *reference)
+{
+ return array->compare_xpointee(array, index, reference);
+}
+
+static inline int sizeof_xpointee(ArrayOfXPointers *array, size_t index)
+{
+ return array->sizeof_xpointee(array, index);
+}
+
+extern int find_xpointee(ArrayOfXPointers *array, const void *reference);
+extern int resize_array_of_xpointers(ArrayOfXPointers *array, size_t index, ssize_t nb_delta_entries);
+extern int fetch_array_of_xpointers(Tracee *tracee, ArrayOfXPointers **array, Reg reg, size_t nb_entries);
+extern int push_array_of_xpointers(ArrayOfXPointers *array, Reg reg);
+
+extern int read_xpointee_as_object(ArrayOfXPointers *array, size_t index, void **object);
+extern int read_xpointee_as_string(ArrayOfXPointers *array, size_t index, char **string);
+extern int write_xpointee_as_string(ArrayOfXPointers *array, size_t index, const char *string);
+extern int write_xpointees(ArrayOfXPointers *array, size_t index, size_t nb_xpointees, ...);
+extern int compare_xpointee_generic(ArrayOfXPointers *array, size_t index, const void *reference);
+extern int sizeof_xpointee_as_string(ArrayOfXPointers *array, size_t index);
+
+#endif /* AOXP_H */
diff --git a/core/proot/src/main/cpp/execve/auxv.c b/core/proot/src/main/cpp/execve/auxv.c
new file mode 100644
index 000000000..45f9d8477
--- /dev/null
+++ b/core/proot/src/main/cpp/execve/auxv.c
@@ -0,0 +1,184 @@
+/* -*- c-set-style: "K&R"; c-basic-offset: 8 -*-
+ *
+ * This file is part of PRoot.
+ *
+ * Copyright (C) 2015 STMicroelectronics
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+ * 02110-1301 USA.
+ */
+
+#include /* AT_*, */
+#include /* assert(3), */
+#include /* E*, */
+#include /* write(3), close(3), */
+#include /* open(2), */
+#include /* open(2), */
+#include /* open(2), */
+
+#include "execve/auxv.h"
+#include "syscall/sysnum.h"
+#include "tracee/tracee.h"
+#include "tracee/mem.h"
+#include "tracee/reg.h"
+#include "tracee/abi.h"
+#include "arch.h"
+
+
+/**
+ * Add the given vector [@type, @value] to @vectors. This function
+ * returns -errno if an error occurred, otherwise 0.
+ */
+int add_elf_aux_vector(ElfAuxVector **vectors, word_t type, word_t value)
+{
+ ElfAuxVector *tmp;
+ size_t nb_vectors;
+
+ assert(*vectors != NULL);
+
+ nb_vectors = talloc_array_length(*vectors);
+
+ /* Sanity checks. */
+ assert(nb_vectors > 0);
+ assert((*vectors)[nb_vectors - 1].type == AT_NULL);
+
+ tmp = talloc_realloc(talloc_parent(*vectors), *vectors, ElfAuxVector, nb_vectors + 1);
+ if (tmp == NULL)
+ return -ENOMEM;
+ *vectors = tmp;
+
+ /* Replace the sentinel with the new vector. */
+ (*vectors)[nb_vectors - 1].type = type;
+ (*vectors)[nb_vectors - 1].value = value;
+
+ /* Restore the sentinel. */
+ (*vectors)[nb_vectors].type = AT_NULL;
+ (*vectors)[nb_vectors].value = 0;
+
+ return 0;
+}
+
+/**
+ * Get the address of the the ELF auxiliary vectors table for the
+ * given @tracee. This function returns 0 if an error occurred.
+ */
+word_t get_elf_aux_vectors_address(const Tracee *tracee)
+{
+ word_t address;
+ word_t data;
+
+ /* Sanity check: this works only in execve sysexit. */
+ assert(IS_IN_SYSEXIT2(tracee, PR_execve));
+
+ /* Right after execve, the stack layout is:
+ *
+ * argc, argv[0], ..., 0, envp[0], ..., 0, auxv[0].type, auxv[0].value, ..., 0, 0
+ */
+ address = peek_reg(tracee, CURRENT, STACK_POINTER);
+
+ /* Read: argc */
+ data = peek_word(tracee, address);
+ if (errno != 0)
+ return 0;
+
+ /* Skip: argc, argv, 0 */
+ address += (1 + data + 1) * sizeof_word(tracee);
+
+ /* Skip: envp, 0 */
+ do {
+ data = peek_word(tracee, address);
+ if (errno != 0)
+ return 0;
+ address += sizeof_word(tracee);
+ } while (data != 0);
+
+ return address;
+}
+
+/**
+ * Fetch ELF auxiliary vectors stored at the given @address in
+ * @tracee's memory. This function returns NULL if an error occurred,
+ * otherwise it returns a pointer to the new vectors, in an ABI
+ * independent form (the Talloc parent of this pointer is
+ * @tracee->ctx).
+ */
+ElfAuxVector *fetch_elf_aux_vectors(const Tracee *tracee, word_t address)
+{
+ ElfAuxVector *vectors = NULL;
+ ElfAuxVector vector;
+ int status;
+
+ /* It is assumed the sentinel always exists. */
+ vectors = talloc_array(tracee->ctx, ElfAuxVector, 1);
+ if (vectors == NULL)
+ return NULL;
+ vectors[0].type = AT_NULL;
+ vectors[0].value = 0;
+
+ while (1) {
+ vector.type = peek_word(tracee, address);
+ if (errno != 0)
+ return NULL;
+ address += sizeof_word(tracee);
+
+ if (vector.type == AT_NULL)
+ break; /* Already added. */
+
+ vector.value = peek_word(tracee, address);
+ if (errno != 0)
+ return NULL;
+ address += sizeof_word(tracee);
+
+ status = add_elf_aux_vector(&vectors, vector.type, vector.value);
+ if (status < 0)
+ return NULL;
+ }
+
+ return vectors;
+}
+
+/**
+ * Push ELF auxiliary @vectors to the given @address in @tracee's
+ * memory. This function returns -errno if an error occurred,
+ * otherwise 0.
+ */
+int push_elf_aux_vectors(const Tracee* tracee, ElfAuxVector *vectors, word_t address)
+{
+ size_t i;
+
+ for (i = 0; vectors[i].type != AT_NULL; i++) {
+ poke_word(tracee, address, vectors[i].type);
+ if (errno != 0)
+ return -errno;
+ address += sizeof_word(tracee);
+
+ poke_word(tracee, address, vectors[i].value);
+ if (errno != 0)
+ return -errno;
+ address += sizeof_word(tracee);
+ }
+
+ poke_word(tracee, address, AT_NULL);
+ if (errno != 0)
+ return -errno;
+ address += sizeof_word(tracee);
+
+ poke_word(tracee, address, 0);
+ if (errno != 0)
+ return -errno;
+ address += sizeof_word(tracee);
+
+ return 0;
+}
diff --git a/core/proot/src/main/cpp/execve/auxv.h b/core/proot/src/main/cpp/execve/auxv.h
new file mode 100644
index 000000000..cd5871b65
--- /dev/null
+++ b/core/proot/src/main/cpp/execve/auxv.h
@@ -0,0 +1,39 @@
+/* -*- c-set-style: "K&R"; c-basic-offset: 8 -*-
+ *
+ * This file is part of PRoot.
+ *
+ * Copyright (C) 2015 STMicroelectronics
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+ * 02110-1301 USA.
+ */
+
+#ifndef AUXV
+#define AUXV
+
+#include "tracee/tracee.h"
+#include "arch.h"
+
+typedef struct elf_aux_vector {
+ word_t type;
+ word_t value;
+} ElfAuxVector;
+
+extern word_t get_elf_aux_vectors_address(const Tracee *tracee);
+extern ElfAuxVector *fetch_elf_aux_vectors(const Tracee *tracee, word_t address);
+extern int add_elf_aux_vector(ElfAuxVector **vectors, word_t type, word_t value);
+extern int push_elf_aux_vectors(const Tracee* tracee, ElfAuxVector *vectors, word_t address);
+
+#endif /* AUXV */
diff --git a/core/proot/src/main/cpp/execve/elf.c b/core/proot/src/main/cpp/execve/elf.c
new file mode 100644
index 000000000..b1f78e6de
--- /dev/null
+++ b/core/proot/src/main/cpp/execve/elf.c
@@ -0,0 +1,178 @@
+/* -*- c-set-style: "K&R"; c-basic-offset: 8 -*-
+ *
+ * This file is part of PRoot.
+ *
+ * Copyright (C) 2015 STMicroelectronics
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+ * 02110-1301 USA.
+ */
+
+#include /* open(2), */
+#include /* read(2), close(2), */
+#include /* EACCES, ENOTSUP, */
+#include /* UINT64_MAX, */
+#include /* PATH_MAX, */
+#include /* str*(3), memcpy(3), */
+#include /* assert(3), */
+#include /* talloc_*, */
+#include /* bool, true, false, */
+
+#include "execve/elf.h"
+#include "tracee/tracee.h"
+#include "cli/note.h"
+#include "arch.h"
+
+#include "compat.h"
+
+/**
+ * Open the ELF file @t_path and extract its header into @elf_header.
+ * This function returns -errno if an error occured, otherwise the
+ * file descriptor for @t_path.
+ */
+int open_elf(const char *t_path, ElfHeader *elf_header)
+{
+ int fd;
+ int status;
+
+ /*
+ * Read the ELF header.
+ */
+
+ fd = open(t_path, O_RDONLY);
+ if (fd < 0)
+ return -errno;
+
+ /* Check if it is an ELF file. */
+ status = read(fd, elf_header, sizeof(ElfHeader));
+ if (status < 0) {
+ status = -errno;
+ goto end;
+ }
+ if ((size_t) status < sizeof(ElfHeader)
+ || ELF_IDENT(*elf_header, 0) != 0x7f
+ || ELF_IDENT(*elf_header, 1) != 'E'
+ || ELF_IDENT(*elf_header, 2) != 'L'
+ || ELF_IDENT(*elf_header, 3) != 'F') {
+ status = -ENOEXEC;
+ goto end;
+ }
+
+ /* Check if it is a known class (32-bit or 64-bit). */
+ if ( !IS_CLASS32(*elf_header)
+ && !IS_CLASS64(*elf_header)) {
+ status = -ENOEXEC;
+ goto end;
+ }
+
+ status = 0;
+end:
+ /* Delayed error handling. */
+ if (status < 0) {
+ close(fd);
+ return status;
+ }
+
+ return fd;
+}
+
+/**
+ * Invoke @callback(..., @data) for each program headers from the
+ * specified ELF file (referenced by @fd, with the given @elf_header).
+ * This function returns -errno if an error occured, or it returns
+ * immediately the value != 0 returned by @callback, otherwise 0.
+ */
+int iterate_program_headers(const Tracee *tracee, int fd, const ElfHeader *elf_header,
+ program_headers_iterator_t callback, void *data)
+{
+ ProgramHeader program_header;
+
+ uint64_t elf_phoff;
+ uint16_t elf_phentsize;
+ uint16_t elf_phnum;
+
+ int status;
+ int i;
+
+ /* Get class-specific fields. */
+ elf_phnum = ELF_FIELD(*elf_header, phnum);
+ elf_phentsize = ELF_FIELD(*elf_header, phentsize);
+ elf_phoff = ELF_FIELD(*elf_header, phoff);
+
+ /*
+ * Some sanity checks regarding the current
+ * support of the ELF specification in PRoot.
+ */
+
+ if (elf_phnum >= 0xffff) {
+ note(tracee, WARNING, INTERNAL, "%d: big PH tables are not yet supported.", fd);
+ return -ENOTSUP;
+ }
+
+ if (!KNOWN_PHENTSIZE(*elf_header, elf_phentsize)) {
+ note(tracee, WARNING, INTERNAL, "%d: unsupported size of program header.", fd);
+ return -ENOTSUP;
+ }
+
+ status = (int) lseek(fd, elf_phoff, SEEK_SET);
+ if (status < 0)
+ return -errno;
+
+ for (i = 0; i < elf_phnum; i++) {
+ status = read(fd, &program_header, elf_phentsize);
+ if (status != elf_phentsize)
+ return (status < 0 ? -errno : -ENOTSUP);
+
+ status = callback(elf_header, &program_header, data);
+ if (status != 0)
+ return status;
+ }
+
+ return 0;
+}
+
+/**
+ * Check if @host_path is an ELF file for the host architecture.
+ */
+bool is_host_elf(const Tracee *tracee, const char *host_path)
+{
+ int host_elf_machine[] = HOST_ELF_MACHINE;
+ static int force_foreign = -1;
+ ElfHeader elf_header;
+ uint16_t elf_machine;
+ int fd;
+ int i;
+
+ if (force_foreign < 0)
+ force_foreign = (getenv("PROOT_FORCE_FOREIGN_BINARY") != NULL);
+
+ if (force_foreign > 0 || !tracee->qemu)
+ return false;
+
+ fd = open_elf(host_path, &elf_header);
+ if (fd < 0)
+ return false;
+ close(fd);
+
+ elf_machine = ELF_FIELD(elf_header, machine);
+ for (i = 0; host_elf_machine[i] != 0; i++) {
+ if (host_elf_machine[i] == elf_machine) {
+ VERBOSE(tracee, 1, "'%s' is a host ELF", host_path);
+ return true;
+ }
+ }
+
+ return false;
+}
diff --git a/core/proot/src/main/cpp/execve/elf.h b/core/proot/src/main/cpp/execve/elf.h
new file mode 100644
index 000000000..a5b367b71
--- /dev/null
+++ b/core/proot/src/main/cpp/execve/elf.h
@@ -0,0 +1,179 @@
+/* -*- c-set-style: "K&R"; c-basic-offset: 8 -*-
+ *
+ * This file is part of PRoot.
+ *
+ * Copyright (C) 2015 STMicroelectronics
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+ * 02110-1301 USA.
+ */
+
+#ifndef ELF_H
+#define ELF_H
+
+#define EI_NIDENT 16
+
+#include
+#include
+
+typedef struct {
+ unsigned char e_ident[EI_NIDENT];
+ uint16_t e_type;
+ uint16_t e_machine;
+ uint32_t e_version;
+ uint32_t e_entry;
+ uint32_t e_phoff;
+ uint32_t e_shoff;
+ uint32_t e_flags;
+ uint16_t e_ehsize;
+ uint16_t e_phentsize;
+ uint16_t e_phnum;
+ uint16_t e_shentsize;
+ uint16_t e_shnum;
+ uint16_t e_shstrndx;
+} ElfHeader32;
+
+typedef struct {
+ unsigned char e_ident[EI_NIDENT];
+ uint16_t e_type;
+ uint16_t e_machine;
+ uint32_t e_version;
+ uint64_t e_entry;
+ uint64_t e_phoff;
+ uint64_t e_shoff;
+ uint32_t e_flags;
+ uint16_t e_ehsize;
+ uint16_t e_phentsize;
+ uint16_t e_phnum;
+ uint16_t e_shentsize;
+ uint16_t e_shnum;
+ uint16_t e_shstrndx;
+} ElfHeader64;
+
+typedef union {
+ ElfHeader32 class32;
+ ElfHeader64 class64;
+} ElfHeader;
+
+typedef struct {
+ uint32_t p_type;
+ uint32_t p_offset;
+ uint32_t p_vaddr;
+ uint32_t p_paddr;
+ uint32_t p_filesz;
+ uint32_t p_memsz;
+ uint32_t p_flags;
+ uint32_t p_align;
+} ProgramHeader32;
+
+typedef struct {
+ uint32_t p_type;
+ uint32_t p_flags;
+ uint64_t p_offset;
+ uint64_t p_vaddr;
+ uint64_t p_paddr;
+ uint64_t p_filesz;
+ uint64_t p_memsz;
+ uint64_t p_align;
+} ProgramHeader64;
+
+typedef union {
+ ProgramHeader32 class32;
+ ProgramHeader64 class64;
+} ProgramHeader;
+
+/* Object type: */
+#define ET_REL 1
+#define ET_EXEC 2
+#define ET_DYN 3
+#define ET_CORE 4
+
+/* Segment flags: */
+#define PF_X 1
+#define PF_W 2
+#define PF_R 4
+
+typedef enum {
+ PT_LOAD = 1,
+ PT_DYNAMIC = 2,
+ PT_INTERP = 3,
+ PT_GNU_STACK = 0x6474e551,
+} SegmentType;
+
+typedef struct {
+ int32_t d_tag;
+ uint32_t d_val;
+} DynamicEntry32;
+
+typedef struct {
+ int64_t d_tag;
+ uint64_t d_val;
+} DynamicEntry64;
+
+typedef union {
+ DynamicEntry32 class32;
+ DynamicEntry64 class64;
+} DynamicEntry;
+
+typedef enum {
+ DT_STRTAB = 5,
+ DT_RPATH = 15,
+ DT_RUNPATH = 29
+} DynamicType;
+
+/* The following macros are also compatible with ELF 64-bit. */
+#define ELF_IDENT(header, index) (header).class32.e_ident[(index)]
+#define ELF_CLASS(header) ELF_IDENT(header, 4)
+#define IS_CLASS32(header) (ELF_CLASS(header) == 1)
+#define IS_CLASS64(header) (ELF_CLASS(header) == 2)
+
+/* Helper to access a @field of the structure ElfHeaderXX. */
+#define ELF_FIELD(header, field) \
+ (IS_CLASS64(header) \
+ ? (header).class64. e_ ## field \
+ : (header).class32. e_ ## field)
+
+/* Helper to access a @field of the structure ProgramHeaderXX */
+#define PROGRAM_FIELD(ehdr, phdr, field) \
+ (IS_CLASS64(ehdr) \
+ ? (phdr).class64. p_ ## field \
+ : (phdr).class32. p_ ## field)
+
+/* Helper to access a @field of the structure DynamicEntryXX */
+#define DYNAMIC_FIELD(ehdr, dynent, field) \
+ (IS_CLASS64(ehdr) \
+ ? (dynent).class64. d_ ## field \
+ : (dynent).class32. d_ ## field)
+
+#define KNOWN_PHENTSIZE(header, size) \
+ ( (IS_CLASS32(header) && (size) == sizeof(ProgramHeader32)) \
+ || (IS_CLASS64(header) && (size) == sizeof(ProgramHeader64)))
+
+#define IS_POSITION_INDENPENDANT(elf_header) \
+ (ELF_FIELD((elf_header), type) == ET_DYN)
+
+#include "tracee/tracee.h"
+
+extern int open_elf(const char *t_path, ElfHeader *elf_header);
+
+extern bool is_host_elf(const Tracee *tracee, const char *t_path);
+
+typedef int (* program_headers_iterator_t)(const ElfHeader *elf_header,
+ const ProgramHeader *program_header, void *data);
+
+extern int iterate_program_headers(const Tracee *tracee, int fd, const ElfHeader *elf_header,
+ program_headers_iterator_t callback, void *data);
+
+#endif /* ELF_H */
diff --git a/core/proot/src/main/cpp/execve/enter.c b/core/proot/src/main/cpp/execve/enter.c
new file mode 100644
index 000000000..06e2db5f7
--- /dev/null
+++ b/core/proot/src/main/cpp/execve/enter.c
@@ -0,0 +1,721 @@
+/* -*- c-set-style: "K&R"; c-basic-offset: 8 -*-
+ *
+ * This file is part of PRoot.
+ *
+ * Copyright (C) 2015 STMicroelectronics
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+ * 02110-1301 USA.
+ */
+
+#include /* lstat(2), lseek(2), */
+#include /* lstat(2), lseek(2), fchmod(2), */
+#include /* access(2), lstat(2), close(2), read(2), */
+#include /* E*, */
+#include /* assert(3), */
+#include /* talloc*, */
+#include /* PROT_*, */
+#include /* strlen(3), strcpy(3), */
+#include /* getenv(3), */
+#include /* fwrite(3), */
+#include /* assert(3), */
+
+#include "execve/execve.h"
+#include "execve/shebang.h"
+#include "execve/aoxp.h"
+#include "execve/ldso.h"
+#include "execve/elf.h"
+#include "path/path.h"
+#include "path/temp.h"
+#include "path/binding.h"
+#include "tracee/tracee.h"
+#include "syscall/syscall.h"
+#include "syscall/sysnum.h"
+#include "arch.h"
+#include "cli/note.h"
+
+#define P(a) PROGRAM_FIELD(load_info->elf_header, *program_header, a)
+
+/**
+ * Add @program_header (type PT_LOAD) to @load_info->mappings. This
+ * function returns -errno if an error occured, otherwise it returns
+ * 0.
+ */
+static int add_mapping(const Tracee *tracee UNUSED, LoadInfo *load_info,
+ const ProgramHeader *program_header)
+{
+ size_t index;
+ word_t start_address;
+ word_t end_address;
+ static word_t page_size = 0;
+ static word_t page_mask = 0;
+
+ if (page_size == 0) {
+ page_size = sysconf(_SC_PAGE_SIZE);
+ if ((int) page_size <= 0)
+ page_size = 0x1000;
+ page_mask = ~(page_size - 1);
+ }
+
+ if (load_info->mappings == NULL)
+ index = 0;
+ else
+ index = talloc_array_length(load_info->mappings);
+
+ load_info->mappings = talloc_realloc(load_info, load_info->mappings, Mapping, index + 1);
+ if (load_info->mappings == NULL)
+ return -ENOMEM;
+
+ start_address = P(vaddr) & page_mask;
+ end_address = (P(vaddr) + P(filesz) + page_size) & page_mask;
+
+ load_info->mappings[index].fd = -1; /* Unknown yet. */
+ load_info->mappings[index].offset = P(offset) & page_mask;
+ load_info->mappings[index].addr = start_address;
+ load_info->mappings[index].length = end_address - start_address;
+ load_info->mappings[index].flags = MAP_PRIVATE | MAP_FIXED;
+ load_info->mappings[index].prot = ( (P(flags) & PF_R ? PROT_READ : 0)
+ | (P(flags) & PF_W ? PROT_WRITE : 0)
+ | (P(flags) & PF_X ? PROT_EXEC : 0));
+
+ /* "If the segment's memory size p_memsz is larger than the
+ * file size p_filesz, the "extra" bytes are defined to hold
+ * the value 0 and to follow the segment's initialized area."
+ * -- man 7 elf. */
+ if (P(memsz) > P(filesz)) {
+ /* How many extra bytes in the current page? */
+ load_info->mappings[index].clear_length = end_address - P(vaddr) - P(filesz);
+
+ /* Create new pages for the remaining extra bytes. */
+ start_address = end_address;
+ end_address = (P(vaddr) + P(memsz) + page_size) & page_mask;
+ if (end_address > start_address) {
+ index++;
+ load_info->mappings = talloc_realloc(load_info, load_info->mappings,
+ Mapping, index + 1);
+ if (load_info->mappings == NULL)
+ return -ENOMEM;
+
+ load_info->mappings[index].fd = -1; /* Anonymous. */
+ load_info->mappings[index].offset = 0;
+ load_info->mappings[index].addr = start_address;
+ load_info->mappings[index].length = end_address - start_address;
+ load_info->mappings[index].clear_length = 0;
+ load_info->mappings[index].flags = MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED;
+ load_info->mappings[index].prot = load_info->mappings[index - 1].prot;
+ }
+ }
+ else
+ load_info->mappings[index].clear_length = 0;
+
+ return 0;
+}
+
+/**
+ * Translate @user_path into @host_path and check if this latter exists, is
+ * executable and is a regular file. This function returns -errno if
+ * an error occured, 0 otherwise.
+ */
+int translate_and_check_exec(Tracee *tracee, char host_path[PATH_MAX], const char *user_path)
+{
+ struct stat statl;
+ int status;
+
+ if (user_path[0] == '\0')
+ return -ENOEXEC;
+
+ status = translate_path(tracee, host_path, AT_FDCWD, user_path, true);
+ if (status < 0)
+ return status;
+
+ status = access(host_path, F_OK);
+ if (status < 0)
+ return -ENOENT;
+
+ status = access(host_path, X_OK);
+ if (status < 0)
+ return -EACCES;
+
+ status = lstat(host_path, &statl);
+ if (status < 0)
+ return -EPERM;
+
+ return 0;
+}
+
+/**
+ * Add @program_header (type PT_INTERP) to @load_info->interp. This
+ * function returns -errno if an error occured, otherwise it returns
+ * 0.
+ */
+static int add_interp(Tracee *tracee, int fd, LoadInfo *load_info,
+ const ProgramHeader *program_header)
+{
+ char host_path[PATH_MAX];
+ char *user_path;
+ int status;
+
+ /* Only one PT_INTERP segment is allowed. */
+ if (load_info->interp != NULL)
+ return -EINVAL;
+
+ load_info->interp = talloc_zero(load_info, LoadInfo);
+ if (load_info->interp == NULL)
+ return -ENOMEM;
+
+ user_path = talloc_size(tracee->ctx, P(filesz) + 1);
+ if (user_path == NULL)
+ return -ENOMEM;
+
+ /* Remember pread(2) doesn't change the
+ * current position in the file. */
+ status = pread(fd, user_path, P(filesz), P(offset));
+ if ((size_t) status != P(filesz)) /* Unexpected size. */
+ status = -EACCES;
+ if (status < 0)
+ return status;
+
+ user_path[P(filesz)] = '\0';
+
+ /* When a QEMU command was specified:
+ *
+ * - if it's a foreign binary we are reading the ELF
+ * interpreter of QEMU instead.
+ *
+ * - if it's a host binary, we are reading its ELF
+ * interpreter.
+ *
+ * In both case, it lies in "/host-rootfs" from a guest
+ * point-of-view. */
+ if (tracee->qemu != NULL && user_path[0] == '/') {
+ user_path = talloc_asprintf(tracee->ctx, "%s%s", HOST_ROOTFS, user_path);
+ if (user_path == NULL)
+ return -ENOMEM;
+ }
+
+ status = translate_and_check_exec(tracee, host_path, user_path);
+ if (status < 0)
+ return status;
+
+ load_info->interp->host_path = talloc_strdup(load_info->interp, host_path);
+ if (load_info->interp->host_path == NULL)
+ return -ENOMEM;
+
+ load_info->interp->user_path = talloc_strdup(load_info->interp, user_path);
+ if (load_info->interp->user_path == NULL)
+ return -ENOMEM;
+
+ return 0;
+}
+
+#undef P
+
+struct add_load_info_data {
+ LoadInfo *load_info;
+ Tracee *tracee;
+ int fd;
+};
+
+/**
+ * This function is a program header iterator. It invokes
+ * add_mapping() or add_interp(), according to the type of
+ * @program_header. This function returns -errno if an error
+ * occurred, otherwise 0.
+ */
+static int add_load_info(const ElfHeader *elf_header,
+ const ProgramHeader *program_header, void *data_)
+{
+ struct add_load_info_data *data = data_;
+ int status;
+
+ switch (PROGRAM_FIELD(*elf_header, *program_header, type)) {
+ case PT_LOAD:
+ status = add_mapping(data->tracee, data->load_info, program_header);
+ if (status < 0)
+ return status;
+ break;
+
+ case PT_INTERP:
+ status = add_interp(data->tracee, data->fd, data->load_info, program_header);
+ if (status < 0)
+ return status;
+ break;
+
+ case PT_GNU_STACK:
+ data->load_info->needs_executable_stack |=
+ ((PROGRAM_FIELD(*elf_header, *program_header, flags) & PF_X) != 0);
+ break;
+
+ default:
+ break;
+ }
+
+ return 0;
+}
+
+/**
+ * Extract the load info from @load->host_path. This function returns
+ * -errno if an error occured, otherwise it returns 0.
+ */
+static int extract_load_info(Tracee *tracee, LoadInfo *load_info)
+{
+ struct add_load_info_data data;
+ int fd = -1;
+ int status;
+
+ assert(load_info != NULL);
+ assert(load_info->host_path != NULL);
+
+ fd = open_elf(load_info->host_path, &load_info->elf_header);
+ if (fd < 0)
+ return fd;
+
+ /* Sanity check. */
+ switch (ELF_FIELD(load_info->elf_header, type)) {
+ case ET_EXEC:
+ case ET_DYN:
+ break;
+
+ default:
+ status = -EINVAL;
+ goto end;
+ }
+
+ data.load_info = load_info;
+ data.tracee = tracee;
+ data.fd = fd;
+
+ status = iterate_program_headers(tracee, fd, &load_info->elf_header, add_load_info, &data);
+end:
+ if (fd >= 0)
+ close(fd);
+
+ return status;
+}
+
+/**
+ * Add @load_base to each adresses of @load_info.
+ */
+static void add_load_base(LoadInfo *load_info, word_t load_base)
+{
+ size_t nb_mappings;
+ size_t i;
+
+ nb_mappings = talloc_array_length(load_info->mappings);
+ for (i = 0; i < nb_mappings; i++)
+ load_info->mappings[i].addr += load_base;
+
+ if (IS_CLASS64(load_info->elf_header))
+ load_info->elf_header.class64.e_entry += load_base;
+ else
+ load_info->elf_header.class32.e_entry += load_base;
+}
+
+/**
+ * Compute the final load address for each position independant
+ * objects of @tracee.
+ *
+ * TODO: support for ASLR.
+ */
+static void compute_load_addresses(Tracee *tracee)
+{
+ if (IS_POSITION_INDENPENDANT(tracee->load_info->elf_header)
+ && tracee->load_info->mappings[0].addr == 0) {
+#if defined(HAS_LOADER_32BIT)
+ if (IS_CLASS32(tracee->load_info->elf_header))
+ add_load_base(tracee->load_info, EXEC_PIC_ADDRESS_32);
+ else
+#endif
+ add_load_base(tracee->load_info, EXEC_PIC_ADDRESS);
+ }
+
+ /* Nothing more to do? */
+ if (tracee->load_info->interp == NULL)
+ return;
+
+ if (IS_POSITION_INDENPENDANT(tracee->load_info->interp->elf_header)
+ && tracee->load_info->interp->mappings[0].addr == 0) {
+#if defined(HAS_LOADER_32BIT)
+ if (IS_CLASS32(tracee->load_info->elf_header))
+ add_load_base(tracee->load_info->interp, INTERP_PIC_ADDRESS_32);
+ else
+#endif
+ add_load_base(tracee->load_info->interp, INTERP_PIC_ADDRESS);
+ }
+}
+
+/**
+ * Expand in argv[] and envp[] the runner for @user_path, if needed.
+ * This function returns -errno if an error occurred, otherwise 0. On
+ * success, both @host_path and @user_path point to the program to
+ * execute (respectively from host and guest point-of-views), and both
+ * @tracee's argv[] (pointed to by SYSARG_2) @tracee's envp[] (pointed
+ * to by SYSARG_3) are correctly updated.
+ */
+static int expand_runner(Tracee* tracee, char host_path[PATH_MAX], char user_path[PATH_MAX])
+{
+ ArrayOfXPointers *envp;
+ char *argv0;
+ int status;
+
+ /* Execution of host programs when QEMU is in use relies on
+ * LD_ environment variables. */
+ status = fetch_array_of_xpointers(tracee, &envp, SYSARG_3, 0);
+ if (status < 0)
+ return status;
+
+ /* Environment variables should be compared with the "name"
+ * part of the "name=value" string format. */
+ envp->compare_xpointee = (compare_xpointee_t) compare_xpointee_env;
+
+ /* No need to adjust argv[] if it's a host binary (a.k.a
+ * mixed-mode). */
+ if (!is_host_elf(tracee, host_path)) {
+ ArrayOfXPointers *argv;
+ size_t nb_qemu_args;
+ size_t i;
+
+ if (getenv("PROOT_USE_LOADER_FOR_QEMU") == NULL) {
+ tracee->skip_proot_loader = true;
+ }
+
+ status = fetch_array_of_xpointers(tracee, &argv, SYSARG_2, 0);
+ if (status < 0)
+ return status;
+
+ status = read_xpointee_as_string(argv, 0, &argv0);
+ if (status < 0)
+ return status;
+
+ /* Assuming PRoot was invoked this way:
+ *
+ * proot -q 'qemu-arm -cpu cortex-a9' ...
+ *
+ * a call to:
+ *
+ * execve("/bin/true", { "true", NULL }, ...)
+ *
+ * becomes:
+ *
+ * execve("/usr/bin/qemu",
+ * { "qemu", "-cpu", "cortex-a9", "-0", "true", "/bin/true", NULL }, ...)
+ */
+
+ nb_qemu_args = talloc_array_length(tracee->qemu) - 1;
+ status = resize_array_of_xpointers(argv, 1, nb_qemu_args + 2);
+ if (status < 0)
+ return status;
+
+ for (i = 0; i < nb_qemu_args; i++) {
+ status = write_xpointee(argv, i, tracee->qemu[i]);
+ if (status < 0)
+ return status;
+ }
+
+ status = write_xpointees(argv, i, 3, "-0", argv0, user_path);
+ if (status < 0)
+ return status;
+
+ /* Ensure LD_ features should not be applied to QEMU
+ * iteself. */
+ status = ldso_env_passthru(tracee, envp, argv, "-E", "-U", i);
+ if (status < 0)
+ return status;
+
+ status = push_array_of_xpointers(argv, SYSARG_2);
+ if (status < 0)
+ return status;
+
+ /* Launch the runner in lieu of the initial
+ * program. */
+ assert(strlen(tracee->qemu[0]) + strlen(HOST_ROOTFS) < PATH_MAX);
+ assert(tracee->qemu[0][0] == '/');
+
+ strcpy(host_path, tracee->qemu[0]);
+
+ if (tracee->skip_proot_loader) {
+ strcpy(user_path, host_path);
+ } else {
+ strcpy(user_path, HOST_ROOTFS);
+ strcat(user_path, host_path);
+ }
+ }
+
+ /* Provide information to the host dynamic linker to find host
+ * libraries (remember the guest root file-system contains
+ * libraries for the guest architecture only). */
+ status = rebuild_host_ldso_paths(tracee, host_path, envp);
+ if (status < 0)
+ return status;
+
+ status = push_array_of_xpointers(envp, SYSARG_3);
+ if (status < 0)
+ return status;
+
+ return 0;
+}
+
+#if !defined(PROOT_UNBUNDLE_LOADER)
+extern unsigned char _binary_loader_exe_start;
+extern unsigned char _binary_loader_exe_end;
+
+extern unsigned char WEAK _binary_loader_m32_exe_start;
+extern unsigned char WEAK _binary_loader_m32_exe_end;
+
+/**
+ * Extract the built-in loader. This function returns NULL if an
+ * error occurred, otherwise it returns the path to the extracted
+ * loader. Note: @tracee is only used for notification purpose.
+ */
+static char *extract_loader(const Tracee *tracee, bool wants_32bit_version)
+{
+ char path[PATH_MAX];
+ size_t status2;
+ void *start;
+ size_t size;
+ int status;
+ int fd;
+
+ char *loader_path = NULL;
+ FILE *file = NULL;
+
+ file = open_temp_file(NULL, "prooted");
+ if (file == NULL)
+ goto end;
+ fd = fileno(file);
+
+ if (wants_32bit_version) {
+ start = (void *) &_binary_loader_m32_exe_start;
+ size = (size_t) (&_binary_loader_m32_exe_end - &_binary_loader_m32_exe_start);
+ }
+ else {
+ start = (void *) &_binary_loader_exe_start;
+ size = (size_t) (&_binary_loader_exe_end - &_binary_loader_exe_start);
+ }
+
+ status2 = write(fd, start, size);
+ if (status2 != size) {
+ note(tracee, ERROR, SYSTEM, "can't write the loader");
+ goto end;
+ }
+
+ status = fchmod(fd, S_IRUSR|S_IXUSR);
+ if (status < 0) {
+ note(tracee, ERROR, SYSTEM, "can't change loader permissions (u+rx)");
+ goto end;
+ }
+
+ status = readlink_proc_pid_fd(getpid(), fd, path);
+ if (status < 0) {
+ note(tracee, ERROR, INTERNAL, "can't retrieve loader path (/proc/self/fd/)");
+ goto end;
+ }
+
+ status = access(path, X_OK);
+ if (status < 0) {
+ note(tracee, ERROR, INTERNAL,
+ "it seems the current temporary directory (%s) "
+ "is mounted with no execution permission.",
+ get_temp_directory());
+ note(tracee, INFO, USER,
+ "Please set PROOT_TMP_DIR env. variable to an alternate "
+ "location ('%s/tmp' for example).", get_root(tracee));
+ goto end;
+ }
+
+ loader_path = talloc_strdup(talloc_autofree_context(), path);
+ if (loader_path == NULL) {
+ note(tracee, ERROR, INTERNAL, "can't allocate memory");
+ goto end;
+ }
+
+ if (tracee->verbose >= 2)
+ note(tracee, INFO, INTERNAL, "loader: %s", loader_path);
+
+end:
+ if (file != NULL) {
+ status = fclose(file);
+ if (status < 0)
+ note(tracee, WARNING, SYSTEM, "can't close loader file");
+ }
+
+ return loader_path;
+}
+#endif
+
+/**
+ * Get the path to the loader for the given @tracee. This function
+ * returns NULL if an error occurred.
+ */
+static inline const char *get_loader_path(const Tracee *tracee)
+{
+#if defined(PROOT_UNBUNDLE_LOADER)
+#if defined(HAS_LOADER_32BIT)
+ if (IS_CLASS32(tracee->load_info->elf_header)) {
+ return getenv("PROOT_LOADER_32") ?: "libloader32.so";
+ }
+#endif
+ return getenv("PROOT_LOADER") ?: "libloader.so";
+#else
+ static char *loader_path = NULL;
+
+#if defined(HAS_LOADER_32BIT)
+ static char *loader32_path = NULL;
+
+ if (IS_CLASS32(tracee->load_info->elf_header)) {
+ loader32_path = loader32_path ?: getenv("PROOT_LOADER_32") ?: extract_loader(tracee, true);
+ return loader32_path;
+ }
+ else
+#endif
+ {
+ loader_path = loader_path ?: getenv("PROOT_LOADER") ?: extract_loader(tracee, false);
+ return loader_path;
+ }
+#endif
+}
+
+/**
+ * Extract all the information that will be required by
+ * translate_load_*(). This function returns -errno if an error
+ * occured, otherwise 0.
+ */
+int translate_execve_enter(Tracee *tracee)
+{
+ char user_path[PATH_MAX];
+ char host_path[PATH_MAX];
+ char new_exe[PATH_MAX];
+ char *raw_path;
+ const char *loader_path;
+ int status;
+
+ if (IS_NOTIFICATION_PTRACED_LOAD_DONE(tracee)) {
+ /* Syscalls can now be reported to its ptracer. */
+ tracee->as_ptracee.ignore_loader_syscalls = false;
+
+ /* Cancel this spurious execve, it was only used as a
+ * notification. */
+ set_sysnum(tracee, PR_void);
+ return 0;
+ }
+
+ status = get_sysarg_path(tracee, user_path, SYSARG_1);
+ if (status < 0)
+ return status;
+
+ /* Remember the user path before it is overwritten by
+ * expand_shebang(). This "raw" path is useful to fix the
+ * value of AT_EXECFN and /proc/{@tracee->pid}/comm. */
+ raw_path = talloc_strdup(tracee->ctx, user_path);
+ if (raw_path == NULL)
+ return -ENOMEM;
+
+ status = expand_shebang(tracee, host_path, user_path);
+ if (status < 0)
+ /* The Linux kernel actually returns -EACCES when
+ * trying to execute a directory. */
+ return status == -EISDIR ? -EACCES : status;
+
+ /* user_path is modified only if there's an interpreter
+ * (ie. for a script or with qemu). */
+ if (status == 0 && tracee->qemu == NULL)
+ TALLOC_FREE(raw_path);
+
+ /* Remember the new value for "/proc/self/exe". It points to
+ * a canonicalized guest path, hence detranslate_path()
+ * instead of using user_path directly. */
+ talloc_unlink(tracee, tracee->host_exe);
+ tracee->host_exe = talloc_strdup(tracee, host_path);
+
+ strcpy(new_exe, host_path);
+ status = detranslate_path(tracee, new_exe, NULL);
+ if (status >= 0) {
+ talloc_unlink(tracee, tracee->new_exe);
+ tracee->new_exe = talloc_strdup(tracee, new_exe);
+ }
+ else
+ tracee->new_exe = NULL;
+
+ tracee->skip_proot_loader = false;
+ if (tracee->qemu != NULL) {
+ status = expand_runner(tracee, host_path, user_path);
+ if (status < 0)
+ return status;
+ }
+
+ talloc_unlink(tracee, tracee->load_info);
+
+ if (tracee->skip_proot_loader) {
+ tracee->load_info = NULL;
+ tracee->heap->disabled = true;
+
+ status = set_sysarg_path(tracee, host_path, SYSARG_1);
+ if (status < 0)
+ return status;
+
+ return 0;
+ }
+
+ tracee->load_info = talloc_zero(tracee, LoadInfo);
+ if (tracee->load_info == NULL)
+ return -ENOMEM;
+
+ tracee->load_info->host_path = talloc_strdup(tracee->load_info, host_path);
+ if (tracee->load_info->host_path == NULL)
+ return -ENOMEM;
+
+ tracee->load_info->user_path = talloc_strdup(tracee->load_info, user_path);
+ if (tracee->load_info->user_path == NULL)
+ return -ENOMEM;
+
+ tracee->load_info->raw_path = (raw_path != NULL
+ ? talloc_reparent(tracee->ctx, tracee->load_info, raw_path)
+ : talloc_reference(tracee->load_info, tracee->load_info->user_path));
+ if (tracee->load_info->raw_path == NULL)
+ return -ENOMEM;
+
+ status = extract_load_info(tracee, tracee->load_info);
+ if (status < 0)
+ return status;
+
+ if (tracee->load_info->interp != NULL) {
+ status = extract_load_info(tracee, tracee->load_info->interp);
+ if (status < 0)
+ return status;
+
+ /* An ELF interpreter is supposed to be
+ * standalone. */
+ if (tracee->load_info->interp->interp != NULL) {
+ TALLOC_FREE(tracee->load_info->interp->interp);
+ // TODO: Print warning?
+ }
+ }
+
+ compute_load_addresses(tracee);
+
+ /* Execute the loader instead of the program. */
+ loader_path = get_loader_path(tracee);
+ if (loader_path == NULL)
+ return -ENOENT;
+
+ status = set_sysarg_path(tracee, loader_path, SYSARG_1);
+ if (status < 0)
+ return status;
+
+ /* Mask to its ptracer syscalls performed by the loader. */
+ tracee->as_ptracee.ignore_loader_syscalls = true;
+
+ return 0;
+}
diff --git a/core/proot/src/main/cpp/execve/execve.h b/core/proot/src/main/cpp/execve/execve.h
new file mode 100644
index 000000000..4a1a4094c
--- /dev/null
+++ b/core/proot/src/main/cpp/execve/execve.h
@@ -0,0 +1,64 @@
+/* -*- c-set-style: "K&R"; c-basic-offset: 8 -*-
+ *
+ * This file is part of PRoot.
+ *
+ * Copyright (C) 2015 STMicroelectronics
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+ * 02110-1301 USA.
+ */
+
+#ifndef EXECVE_H
+#define EXECVE_H
+
+#include /* PATH_MAX, */
+
+#include "tracee/tracee.h"
+#include "execve/elf.h"
+#include "arch.h"
+
+extern int translate_execve_enter(Tracee *tracee);
+extern void translate_execve_exit(Tracee *tracee);
+extern int translate_and_check_exec(Tracee *tracee, char host_path[PATH_MAX], const char *user_path);
+
+typedef struct mapping {
+ word_t addr;
+ word_t length;
+ word_t clear_length;
+ word_t prot;
+ word_t flags;
+ word_t fd;
+ word_t offset;
+} Mapping;
+
+typedef struct load_info {
+ char *host_path;
+ char *user_path;
+ char *raw_path;
+ Mapping *mappings;
+ ElfHeader elf_header;
+ bool needs_executable_stack;
+
+ struct load_info *interp;
+} LoadInfo;
+
+#define IS_NOTIFICATION_PTRACED_LOAD_DONE(tracee) ( \
+ (tracee)->as_ptracee.ptracer != NULL \
+ && peek_reg((tracee), ORIGINAL, SYSARG_1) == (word_t) 1 \
+ && peek_reg((tracee), ORIGINAL, SYSARG_4) == (word_t) 2 \
+ && peek_reg((tracee), ORIGINAL, SYSARG_5) == (word_t) 3 \
+ && peek_reg((tracee), ORIGINAL, SYSARG_6) == (word_t) 4)
+
+#endif /* EXECVE_H */
diff --git a/core/proot/src/main/cpp/execve/exit.c b/core/proot/src/main/cpp/execve/exit.c
new file mode 100644
index 000000000..348a1fd81
--- /dev/null
+++ b/core/proot/src/main/cpp/execve/exit.c
@@ -0,0 +1,513 @@
+/* -*- c-set-style: "K&R"; c-basic-offset: 8 -*-
+ *
+ * This file is part of PRoot.
+ *
+ * Copyright (C) 2015 STMicroelectronics
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+ * 02110-1301 USA.
+ */
+
+#include /* AT_*, */
+#include /* talloc*, */
+#include /* MAP_*, */
+#include /* assert(3), */
+#include /* strlen(3), strerror(3), */
+#include /* bzero(3), */
+#include /* kill(2), SIG*, */
+#include /* write(2), */
+#include /* E*, */
+
+#include "execve/execve.h"
+#include "execve/elf.h"
+#include "loader/script.h"
+#include "tracee/reg.h"
+#include "tracee/abi.h"
+#include "tracee/mem.h"
+#include "syscall/sysnum.h"
+#include "execve/auxv.h"
+#include "path/binding.h"
+#include "path/temp.h"
+#include "cli/note.h"
+
+
+/**
+ * Fill @path with the content of @vectors, formatted according to
+ * @ptracee's current ABI.
+ */
+static int fill_file_with_auxv(const Tracee *ptracee, const char *path,
+ const ElfAuxVector *vectors)
+{
+ const ssize_t current_sizeof_word = sizeof_word(ptracee);
+ ssize_t status;
+ int fd = -1;
+ int i;
+
+ fd = open(path, O_WRONLY);
+ if (fd < 0)
+ return -1;
+
+ i = 0;
+ do {
+ status = write(fd, &vectors[i].type, current_sizeof_word);
+ if (status < current_sizeof_word) {
+ status = -1;
+ goto end;
+ }
+
+ status = write(fd, &vectors[i].value, current_sizeof_word);
+ if (status < current_sizeof_word) {
+ status = -1;
+ goto end;
+ }
+ } while (vectors[i++].type != AT_NULL);
+
+ status = 0;
+end:
+ if (fd >= 0)
+ (void) close(fd);
+
+ return status;
+}
+
+/**
+ * Bind content of @vectors over /proc/{@ptracee->pid}/auxv. This
+ * function returns -1 if an error occurred, otherwise 0.
+ */
+static int bind_proc_pid_auxv(const Tracee *ptracee)
+{
+ word_t vectors_address;
+ ElfAuxVector *vectors;
+
+ const char *guest_path;
+ const char *host_path;
+ Binding *binding;
+ int status;
+
+ vectors_address = get_elf_aux_vectors_address(ptracee);
+ if (vectors_address == 0)
+ return -1;
+
+ vectors = fetch_elf_aux_vectors(ptracee, vectors_address);
+ if (vectors == NULL)
+ return -1;
+
+ /* Path to these ELF auxiliary vectors. */
+ guest_path = talloc_asprintf(ptracee->ctx, "/proc/%d/auxv", ptracee->pid);
+ if (guest_path == NULL)
+ return -1;
+
+ /* Remove binding to this path, if any. It contains ELF
+ * auxiliary vectors of the previous execve(2). */
+ binding = get_binding(ptracee, GUEST, guest_path);
+ if (binding != NULL && compare_paths(binding->guest.path, guest_path) == PATHS_ARE_EQUAL) {
+ remove_binding_from_all_lists(ptracee, binding);
+ TALLOC_FREE(binding);
+ }
+
+ host_path = create_temp_file(ptracee->ctx, "auxv");
+ if (host_path == NULL)
+ return -1;
+
+ status = fill_file_with_auxv(ptracee, host_path, vectors);
+ if (status < 0)
+ return -1;
+
+ /* Note: this binding will be removed once ptracee gets freed. */
+ binding = insort_binding3(ptracee, ptracee->life_context, host_path, guest_path);
+ if (binding == NULL)
+ return -1;
+
+ /* This temporary file (host_path) will be removed once the
+ * binding is freed. */
+ talloc_reparent(ptracee->ctx, binding, host_path);
+
+ return 0;
+}
+
+/**
+ * Convert @mappings into load @script statements at the given @cursor
+ * position. This function returns the new cursor position.
+ */
+static void *transcript_mappings(void *cursor, const Mapping *mappings)
+{
+ size_t nb_mappings;
+ size_t i;
+
+ nb_mappings = talloc_array_length(mappings);
+ for (i = 0; i < nb_mappings; i++) {
+ LoadStatement *statement = cursor;
+
+ if ((mappings[i].flags & MAP_ANONYMOUS) != 0)
+ statement->action = LOAD_ACTION_MMAP_ANON;
+ else
+ statement->action = LOAD_ACTION_MMAP_FILE;
+
+ statement->mmap.addr = mappings[i].addr;
+ statement->mmap.length = mappings[i].length;
+ statement->mmap.prot = mappings[i].prot;
+ statement->mmap.offset = mappings[i].offset;
+ statement->mmap.clear_length = mappings[i].clear_length;
+
+ cursor += LOAD_STATEMENT_SIZE(*statement, mmap);
+ }
+
+ return cursor;
+}
+
+/**
+ * Convert @tracee->load_info into a load script, then transfer this
+ * latter into @tracee's memory.
+ */
+static int transfer_load_script(Tracee *tracee)
+{
+ const word_t stack_pointer = peek_reg(tracee, CURRENT, STACK_POINTER);
+ static word_t page_size = 0;
+ static word_t page_mask = 0;
+
+ word_t entry_point;
+
+ size_t script_size;
+ size_t strings_size;
+ size_t string1_size;
+ size_t string2_size;
+ size_t string3_size;
+ size_t padding_size;
+
+ word_t string1_address;
+ word_t string2_address;
+ word_t string3_address;
+
+ void *buffer;
+ size_t buffer_size;
+
+ bool needs_executable_stack;
+ LoadStatement *statement;
+ void *cursor;
+ int status;
+
+ if (page_size == 0) {
+ page_size = sysconf(_SC_PAGE_SIZE);
+ if ((int) page_size <= 0)
+ page_size = 0x1000;
+ page_mask = ~(page_size - 1);
+ }
+
+ /* Capture argv[0]'s address from the initial stack as the correct
+ * AT_EXECFN value. The kernel sets AT_EXECFN to the loader temp file
+ * path; argv[0] holds the actual program name. This is stored so that
+ * prctl(PR_GET_AUXV) can be fixed up later in the syscall exit handler,
+ * since PR_GET_AUXV reads from kernel memory and bypasses the loader's
+ * in-memory auxv patch. */
+ tracee->execfn_addr = peek_word(tracee, stack_pointer + sizeof_word(tracee));
+
+ needs_executable_stack = (tracee->load_info->needs_executable_stack
+ || ( tracee->load_info->interp != NULL
+ && tracee->load_info->interp->needs_executable_stack));
+
+ /* Strings addresses are required to generate the load script,
+ * for "open" actions. Since I want to generate it in one
+ * pass, these strings will be put right below the current
+ * stack pointer -- the only known adresses so far -- in the
+ * "strings area". */
+ string1_size = strlen(tracee->load_info->user_path) + 1;
+
+ string2_size = (tracee->load_info->interp == NULL ? 0
+ : strlen(tracee->load_info->interp->user_path) + 1);
+
+ string3_size = (tracee->load_info->raw_path == tracee->load_info->user_path ? 0
+ : strlen(tracee->load_info->raw_path) + 1);
+
+ /* A padding will be appended at the end of the load script
+ * (a.k.a "strings area") to ensure this latter is aligned to
+ * a word boundary, for the sake of performance
+ * (or 16 bytes, since AArch64 needs SP 16-bytes-aligned). */
+ padding_size = (stack_pointer - string1_size - string2_size - string3_size)
+#ifdef ARCH_ARM64
+ % 16;
+#else
+ % sizeof_word(tracee);
+#endif
+
+ strings_size = string1_size + string2_size + string3_size + padding_size;
+ string1_address = stack_pointer - strings_size;
+ string2_address = stack_pointer - strings_size + string1_size;
+ string3_address = (string3_size == 0
+ ? string1_address
+ : stack_pointer - strings_size + string1_size + string2_size);
+
+ /* Compute the size of the load script. */
+ script_size =
+ LOAD_STATEMENT_SIZE(*statement, open)
+ + (LOAD_STATEMENT_SIZE(*statement, mmap)
+ * talloc_array_length(tracee->load_info->mappings))
+ + (tracee->load_info->interp == NULL ? 0
+ : LOAD_STATEMENT_SIZE(*statement, open)
+ + (LOAD_STATEMENT_SIZE(*statement, mmap)
+ * talloc_array_length(tracee->load_info->interp->mappings)))
+ + (needs_executable_stack ? LOAD_STATEMENT_SIZE(*statement, make_stack_exec) : 0)
+ + LOAD_STATEMENT_SIZE(*statement, start);
+
+ /* Allocate enough room for both the load script and the
+ * strings area. */
+ buffer_size = script_size + strings_size;
+ buffer = talloc_zero_size(tracee->ctx, buffer_size);
+ if (buffer == NULL)
+ return -ENOMEM;
+
+ cursor = buffer;
+
+ /* Load script statement: open. */
+ statement = cursor;
+ statement->action = LOAD_ACTION_OPEN;
+ statement->open.string_address = string1_address;
+
+ cursor += LOAD_STATEMENT_SIZE(*statement, open);
+
+ /* Load script statements: mmap. */
+ cursor = transcript_mappings(cursor, tracee->load_info->mappings);
+
+ if (tracee->load_info->interp != NULL) {
+ /* Load script statement: open. */
+ statement = cursor;
+ statement->action = LOAD_ACTION_OPEN_NEXT;
+ statement->open.string_address = string2_address;
+
+ cursor += LOAD_STATEMENT_SIZE(*statement, open);
+
+ /* Load script statements: mmap. */
+ cursor = transcript_mappings(cursor, tracee->load_info->interp->mappings);
+
+ entry_point = ELF_FIELD(tracee->load_info->interp->elf_header, entry);
+ }
+ else
+ entry_point = ELF_FIELD(tracee->load_info->elf_header, entry);
+
+ if (needs_executable_stack) {
+ /* Load script statement: stack_exec. */
+ statement = cursor;
+
+ statement->action = LOAD_ACTION_MAKE_STACK_EXEC;
+ statement->make_stack_exec.start = stack_pointer & page_mask;
+
+ cursor += LOAD_STATEMENT_SIZE(*statement, make_stack_exec);
+ }
+
+ /* Load script statement: start. */
+ statement = cursor;
+
+ /* Start of the program slightly differs when ptraced. */
+ if (tracee->as_ptracee.ptracer != NULL)
+ statement->action = LOAD_ACTION_START_TRACED;
+ else
+ statement->action = LOAD_ACTION_START;
+
+ statement->start.stack_pointer = stack_pointer;
+ statement->start.entry_point = entry_point;
+ statement->start.at_phent = ELF_FIELD(tracee->load_info->elf_header, phentsize);
+ statement->start.at_phnum = ELF_FIELD(tracee->load_info->elf_header, phnum);
+ statement->start.at_entry = ELF_FIELD(tracee->load_info->elf_header, entry);
+ statement->start.at_phdr = ELF_FIELD(tracee->load_info->elf_header, phoff)
+ + tracee->load_info->mappings[0].addr;
+ statement->start.at_execfn = string3_address;
+
+ cursor += LOAD_STATEMENT_SIZE(*statement, start);
+
+ /* Sanity check. */
+ assert((uintptr_t) cursor - (uintptr_t) buffer == script_size);
+
+ /* Convert the load script to the expected format. */
+ if (is_32on64_mode(tracee)) {
+ int i;
+ for (i = 0; buffer + i * sizeof(uint64_t) < cursor; i++)
+ ((uint32_t *) buffer)[i] = ((uint64_t *) buffer)[i];
+ }
+
+ /* Concatenate the load script and the strings. */
+ memcpy(cursor, tracee->load_info->user_path, string1_size);
+ cursor += string1_size;
+
+ if (string2_size != 0) {
+ memcpy(cursor, tracee->load_info->interp->user_path, string2_size);
+ cursor += string2_size;
+ }
+
+ if (string3_size != 0) {
+ memcpy(cursor, tracee->load_info->raw_path, string3_size);
+ cursor += string3_size;
+ }
+
+ /* Sanity check. */
+ cursor += padding_size;
+ assert((uintptr_t) cursor - (uintptr_t) buffer == buffer_size);
+
+ /* Allocate enough room in tracee's memory for the load
+ * script, and make the first user argument points to this
+ * location. Note that it is safe to update the stack pointer
+ * manually since we are in execve sysexit. However it should
+ * be done before transfering data since the kernel might not
+ * allow page faults below the stack pointer. */
+ poke_reg(tracee, STACK_POINTER, stack_pointer - buffer_size);
+ poke_reg(tracee, USERARG_1, stack_pointer - buffer_size);
+
+ /* Copy everything in the tracee's memory at once. */
+ status = write_data(tracee, stack_pointer - buffer_size, buffer, buffer_size);
+ if (status < 0)
+ return status;
+
+ /* Tracee's stack content is now as follow:
+ *
+ * +------------+ <- initial stack pointer (higher address)
+ * | padding |
+ * +------------+
+ * | string3 |
+ * +------------+
+ * | string2 |
+ * +------------+
+ * | string1 |
+ * +------------+
+ * | start |
+ * +------------+
+ * | mmap anon |
+ * +------------+
+ * | mmap file |
+ * +------------+
+ * | open next |
+ * +------------+
+ * | mmap anon. |
+ * +------------+
+ * | mmap file |
+ * +------------+
+ * | open |
+ * +------------+ <- stack pointer, userarg1 (word aligned)
+ */
+
+ /* Remember we are in the sysexit stage, so be sure the
+ * current register values will be used as-is at the end. */
+ save_current_regs(tracee, ORIGINAL);
+ tracee->_regs_were_changed = true;
+
+ return 0;
+}
+
+/**
+ * Start the loading of @tracee. This function returns no error since
+ * it's either too late to do anything useful (the calling process is
+ * already replaced) or the error reported by the kernel
+ * (syscall_result < 0) will be propagated as-is.
+ */
+void translate_execve_exit(Tracee *tracee)
+{
+ word_t syscall_result;
+ int status;
+
+ tracee->auxv_fd = -1;
+
+ if (tracee->skip_proot_loader) {
+ tracee->restore_original_regs = false;
+ tracee->seen_execve = true;
+ return;
+ }
+
+ if (IS_NOTIFICATION_PTRACED_LOAD_DONE(tracee)) {
+ /* Be sure not to confuse the ptracer with an
+ * unexpected syscall/returned value. */
+ poke_reg(tracee, SYSARG_RESULT, 0);
+ set_sysnum(tracee, PR_execve);
+
+ /* According to most ABIs, all registers have
+ * undefined values at program startup except:
+ *
+ * - the stack pointer
+ * - the instruction pointer
+ * - the rtld_fini pointer
+ * - the state flags
+ */
+ poke_reg(tracee, STACK_POINTER, peek_reg(tracee, ORIGINAL, SYSARG_2));
+ poke_reg(tracee, INSTR_POINTER, peek_reg(tracee, ORIGINAL, SYSARG_3));
+ poke_reg(tracee, RTLD_FINI, 0);
+ poke_reg(tracee, STATE_FLAGS, 0);
+
+#if defined(ARCH_ARM_EABI) && defined(__thumb__)
+ /* Leave ARM thumb mode */
+ tracee->_regs[CURRENT].ARM_cpsr &= ~PSR_T_BIT;
+#endif
+
+ /* Restore registers to their current values. */
+ save_current_regs(tracee, ORIGINAL);
+ tracee->_regs_were_changed = true;
+
+ /* This is is required to make GDB work correctly
+ * under PRoot, however it deserves to be used
+ * unconditionally. */
+ (void) bind_proc_pid_auxv(tracee);
+
+ /* If the PTRACE_O_TRACEEXEC option is *not* in effect
+ * for the execing tracee, the kernel delivers an
+ * extra SIGTRAP to the tracee after execve(2)
+ * *returns*. This is an ordinary signal (similar to
+ * one which can be generated by "kill -TRAP"), not a
+ * special kind of ptrace-stop. Employing
+ * PTRACE_GETSIGINFO for this signal returns si_code
+ * set to 0 (SI_USER). This signal may be blocked by
+ * signal mask, and thus may be delivered (much)
+ * later. -- man 2 ptrace
+ *
+ * This signal is delayed so far since the program was
+ * not fully loaded yet; GDB would get "invalid
+ * adress" errors otherwise. */
+ if ((tracee->as_ptracee.options & PTRACE_O_TRACEEXEC) == 0)
+ kill(tracee->pid, SIGTRAP);
+
+ return;
+ }
+
+#ifdef ARCH_ARM64
+ tracee->is_aarch32 = IS_CLASS32(tracee->load_info->elf_header);
+#endif
+
+ syscall_result = peek_reg(tracee, CURRENT, SYSARG_RESULT);
+ if ((int) syscall_result < 0)
+ return;
+
+ /* The guest program is now running; PR_SET_NO_NEW_PRIVS calls from
+ * here on belong to the guest, not to PRoot's own pre-execve setup. */
+ tracee->seen_execve = true;
+
+ /* Execve happened; commit the new "/proc/self/exe". */
+ if (tracee->new_exe != NULL) {
+ (void) talloc_unlink(tracee, tracee->exe);
+ tracee->exe = talloc_reference(tracee, tracee->new_exe);
+ talloc_set_name_const(tracee->exe, "$exe");
+ }
+
+ /* New processes have no heap. */
+ if (talloc_reference_count(tracee->heap) >= 1) {
+ talloc_unlink(tracee, tracee->heap);
+ tracee->heap = talloc_zero(tracee, Heap);
+ if (tracee->heap == NULL)
+ note(tracee, ERROR, INTERNAL, "can't alloc heap after execve");
+ } else {
+ bzero(tracee->heap, sizeof(Heap));
+ }
+
+ /* Transfer the load script to the loader. */
+ mem_prepare_after_execve(tracee);
+ status = transfer_load_script(tracee);
+ if (status < 0)
+ note(tracee, ERROR, INTERNAL, "can't transfer load script: %s", strerror(-status));
+
+ return;
+}
diff --git a/core/proot/src/main/cpp/execve/ldso.c b/core/proot/src/main/cpp/execve/ldso.c
new file mode 100644
index 000000000..bcce6adb0
--- /dev/null
+++ b/core/proot/src/main/cpp/execve/ldso.c
@@ -0,0 +1,579 @@
+/* -*- c-set-style: "K&R"; c-basic-offset: 8 -*-
+ *
+ * This file is part of PRoot.
+ *
+ * Copyright (C) 2015 STMicroelectronics
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+ * 02110-1301 USA.
+ */
+
+#include /* bool, true, false, */
+#include /* strlen(3), strcpy(3), strcat(3), strcmp(3), */
+#include /* getenv(3), */
+#include /* assert(3), */
+#include /* ENOMEM, */
+#include /* close(2), */
+#include /* PATH_MAX, ARG_MAX, */
+
+#include "execve/ldso.h"
+#include "execve/elf.h"
+#include "execve/aoxp.h"
+#include "tracee/tracee.h"
+#include "cli/note.h"
+
+/**
+ * Check if the environment @variable has the given @name.
+ */
+bool is_env_name(const char *variable, const char *name)
+{
+ size_t length = strlen(name);
+
+ return (variable[0] == name[0]
+ && length < strlen(variable)
+ && variable[length] == '='
+ && strncmp(variable, name, length) == 0);
+}
+
+/**
+ * This function returns 1 or 0 depending on the equivalence of the
+ * @reference environment variable and the one pointed to by the entry
+ * in @envp at the given @index, otherwise it returns -errno when an
+ * error occured.
+ */
+int compare_xpointee_env(ArrayOfXPointers *envp, size_t index, const char *reference)
+{
+ char *value;
+ int status;
+
+ assert(index < envp->length);
+
+ status = read_xpointee_as_string(envp, index, &value);
+ if (status < 0)
+ return status;
+
+ if (value == NULL)
+ return 0;
+
+ return (int)is_env_name(value, reference);
+}
+
+/**
+ * This function ensures that environment variables related to the
+ * dynamic linker are applied to the emulated program, not to QEMU
+ * itself. For instance, let's say the user has entered the
+ * command-line below:
+ *
+ * env LD_TRACE_LOADED_OBJECTS=1 /bin/ls
+ *
+ * It should be converted to:
+ *
+ * qemu -E LD_TRACE_LOADED_OBJECTS=1 /bin/ls
+ *
+ * instead of:
+ *
+ * env LD_TRACE_LOADED_OBJECTS=1 qemu /bin/ls
+ *
+ * Note that the LD_LIBRARY_PATH variable is always required to run
+ * QEMU (a host binary):
+ *
+ * env LD_LIBRARY_PATH=... qemu -U LD_LIBRARY_PATH /bin/ls
+ *
+ * or when LD_LIBRARY_PATH was also specified by the user:
+ *
+ * env LD_LIBRARY_PATH=... qemu -E LD_LIBRARY_PATH=... /bin/ls
+ *
+ * This funtion returns -errno if an error occured, otherwise 0.
+ */
+int ldso_env_passthru(const Tracee *tracee, ArrayOfXPointers *envp, ArrayOfXPointers *argv,
+ const char *define, const char *undefine, size_t offset)
+{
+ bool has_seen_library_path = false;
+ int status;
+ size_t i;
+
+ for (i = 0; i < envp->length; i++) {
+ bool is_known = false;
+ char *env;
+
+ status = read_xpointee_as_string(envp, i, &env);
+ if (status < 0)
+ return status;
+
+ /* Skip variables that do not start with "LD_". */
+ if (env == NULL || strncmp(env, "LD_", sizeof("LD_") - 1) != 0)
+ continue;
+
+ /* When a host program executes a guest program, use
+ * the value of LD_LIBRARY_PATH as it was before being
+ * swapped by the mixed-mode support. */
+ if ( tracee->host_ldso_paths != NULL
+ && tracee->guest_ldso_paths != NULL
+ && is_env_name(env, "LD_LIBRARY_PATH")
+ && strcmp(env, tracee->host_ldso_paths) == 0)
+ env = (char *) tracee->guest_ldso_paths;
+
+#define PASSTHRU(check, name) \
+ if (is_env_name(env, name)) { \
+ check |= true; \
+ /* Errors are not fatal here. */ \
+ status = resize_array_of_xpointers(argv, offset, 2); \
+ if (status >= 0) { \
+ status = write_xpointees(argv, offset, 2, define, env); \
+ if (status < 0) \
+ return status; \
+ } \
+ write_xpointee(envp, i, ""); \
+ continue; \
+ } \
+
+ PASSTHRU(has_seen_library_path, "LD_LIBRARY_PATH");
+ PASSTHRU(is_known, "LD_PRELOAD");
+ PASSTHRU(is_known, "LD_BIND_NOW");
+ PASSTHRU(is_known, "LD_TRACE_LOADED_OBJECTS");
+ PASSTHRU(is_known, "LD_AOUT_LIBRARY_PATH");
+ PASSTHRU(is_known, "LD_AOUT_PRELOAD");
+ PASSTHRU(is_known, "LD_AUDIT");
+ PASSTHRU(is_known, "LD_BIND_NOT");
+ PASSTHRU(is_known, "LD_DEBUG");
+ PASSTHRU(is_known, "LD_DEBUG_OUTPUT");
+ PASSTHRU(is_known, "LD_DYNAMIC_WEAK");
+ PASSTHRU(is_known, "LD_HWCAP_MASK");
+ PASSTHRU(is_known, "LD_KEEPDIR");
+ PASSTHRU(is_known, "LD_NOWARN");
+ PASSTHRU(is_known, "LD_ORIGIN_PATH");
+ PASSTHRU(is_known, "LD_POINTER_GUARD");
+ PASSTHRU(is_known, "LD_PROFILE");
+ PASSTHRU(is_known, "LD_PROFILE_OUTPUT");
+ PASSTHRU(is_known, "LD_SHOW_AUXV");
+ PASSTHRU(is_known, "LD_USE_LOAD_BIAS");
+ PASSTHRU(is_known, "LD_VERBOSE");
+ PASSTHRU(is_known, "LD_WARN");
+ }
+
+ if (!has_seen_library_path) {
+ /* Errors are not fatal here. */
+ status = resize_array_of_xpointers(argv, offset, 2);
+ if (status >= 0) {
+ status = write_xpointees(argv, offset, 2, undefine, "LD_LIBRARY_PATH");
+ if (status < 0)
+ return status;
+ }
+ }
+
+ return 0;
+}
+
+/**
+ * Add to @host_ldso_paths the list of @paths prefixed with the path
+ * to the host rootfs.
+ */
+static int add_host_ldso_paths(char host_ldso_paths[ARG_MAX], const char *paths)
+{
+ char *cursor1;
+ const char *cursor2;
+
+ cursor1 = host_ldso_paths + strlen(host_ldso_paths);
+ cursor2 = paths;
+
+ do {
+ bool is_absolute;
+ size_t length1;
+ size_t length2 = strcspn(cursor2, ":");
+
+ is_absolute = (*cursor2 == '/');
+
+ length1 = 1 + length2;
+ if (is_absolute)
+ length1 += strlen(HOST_ROOTFS);
+
+ /* Check there's enough room. */
+ if (cursor1 + length1 >= host_ldso_paths + ARG_MAX)
+ return -ENOEXEC;
+
+ if (cursor1 != host_ldso_paths) {
+ strcpy(cursor1, ":");
+ cursor1++;
+ }
+
+ /* Since we are executing a host binary under a
+ * QEMUlated environment, we have to access its
+ * library paths through the "host-rootfs" binding.
+ * Technically it means a path like "/lib" is accessed
+ * as "${HOST_ROOTFS}/lib" to avoid conflict with the
+ * guest "/lib". */
+ if (is_absolute) {
+ strcpy(cursor1, HOST_ROOTFS);
+ cursor1 += strlen(HOST_ROOTFS);
+ }
+
+ strncpy(cursor1, cursor2, length2);
+ cursor1 += length2;
+
+ cursor2 += length2 + 1;
+ } while (*(cursor2 - 1) != '\0');
+
+ *cursor1 = '\0';
+
+ return 0;
+}
+
+struct find_program_header_data {
+ ProgramHeader *program_header;
+ SegmentType type;
+ uint64_t address;
+};
+
+/**
+ * This function is a program header iterator. It stops the iteration
+ * (by returning 1) once it has found a program header that matches
+ * @data. This function returns -errno if an error occurred,
+ * otherwise 0 or 1.
+ */
+static int find_program_header(const ElfHeader *elf_header,
+ const ProgramHeader *program_header, void *data_)
+{
+ struct find_program_header_data *data = data_;
+
+ if (PROGRAM_FIELD(*elf_header, *program_header, type) == data->type) {
+ uint64_t start;
+ uint64_t end;
+
+ memcpy(data->program_header, program_header, sizeof(ProgramHeader));
+
+ if (data->address == (uint64_t) -1)
+ return 1;
+
+ start = PROGRAM_FIELD(*elf_header, *program_header, vaddr);
+ end = start + PROGRAM_FIELD(*elf_header, *program_header, memsz);
+
+ if (start < end
+ && data->address >= start
+ && data->address <= end)
+ return 1;
+ }
+
+ return 0;
+}
+
+/**
+ * Add to @xpaths the paths (':'-separated list) from the file
+ * referenced by @fd at the given @offset. This function returns
+ * -errno if an error occured, otherwise 0.
+ */
+static int add_xpaths(const Tracee *tracee, int fd, uint64_t offset, char **xpaths)
+{
+ char *paths = NULL;
+ char *tmp;
+
+ size_t length;
+ size_t size;
+ int status;
+
+ status = (int) lseek(fd, offset, SEEK_SET);
+ if (status < 0)
+ return -errno;
+
+ /* Read the complete list of paths. */
+ length = 0;
+ paths = NULL;
+ do {
+ size = length + 1024;
+
+ tmp = talloc_realloc(tracee->ctx, paths, char, size);
+ if (!tmp)
+ return -ENOMEM;
+ paths = tmp;
+
+ status = read(fd, paths + length, 1024);
+ if (status < 0)
+ return status;
+
+ length += strnlen(paths + length, 1024);
+ } while (length == size);
+
+ /* Concatene this list of paths to xpaths. */
+ if (!*xpaths) {
+ *xpaths = talloc_array(tracee->ctx, char, length + 1);
+ if (!*xpaths)
+ return -ENOMEM;
+
+ strcpy(*xpaths, paths);
+ }
+ else {
+ length += strlen(*xpaths);
+ length++; /* ":" separator */
+
+ tmp = talloc_realloc(tracee->ctx, *xpaths, char, length + 1);
+ if (!tmp)
+ return -ENOMEM;
+ *xpaths = tmp;
+
+ strcat(*xpaths, ":");
+ strcat(*xpaths, paths);
+ }
+
+ /* I don't know if DT_R*PATH entries are unique. In
+ * doubt I support multiple entries. */
+ return 0;
+}
+
+/**
+ * Put the RPATH and RUNPATH dynamic entries from the file referenced
+ * by @fd -- which has the provided @elf_header -- in @rpaths and
+ * @runpaths respectively. This function returns -errno if an error
+ * occured, otherwise 0.
+ */
+static int read_ldso_rpaths(const Tracee* tracee, int fd, const ElfHeader *elf_header,
+ char **rpaths, char **runpaths)
+{
+ ProgramHeader dynamic_segment;
+ ProgramHeader strtab_segment;
+ struct find_program_header_data data;
+ uint64_t strtab_address = (uint64_t) -1;
+ off_t strtab_offset;
+ int status;
+ size_t i;
+
+ uint64_t offsetof_dynamic_segment;
+ uint64_t sizeof_dynamic_segment;
+ size_t sizeof_dynamic_entry;
+
+ data.program_header = &dynamic_segment;
+ data.type = PT_DYNAMIC;
+ data.address = (uint64_t) -1;
+
+ status = iterate_program_headers(tracee, fd, elf_header, find_program_header, &data);
+ if (status <= 0)
+ return status;
+
+ offsetof_dynamic_segment = PROGRAM_FIELD(*elf_header, dynamic_segment, offset);
+ sizeof_dynamic_segment = PROGRAM_FIELD(*elf_header, dynamic_segment, filesz);
+
+ if (IS_CLASS32(*elf_header))
+ sizeof_dynamic_entry = sizeof(DynamicEntry32);
+ else
+ sizeof_dynamic_entry = sizeof(DynamicEntry64);
+
+ if (sizeof_dynamic_segment % sizeof_dynamic_entry != 0)
+ return -ENOEXEC;
+
+/**
+ * Invoke @embedded_code on each dynamic entry of the given @type.
+ */
+#define FOREACH_DYNAMIC_ENTRY(type, embedded_code) \
+ for (i = 0; i < sizeof_dynamic_segment / sizeof_dynamic_entry; i++) { \
+ DynamicEntry dynamic_entry; \
+ uint64_t value; \
+ \
+ /* embedded_code may change the file offset. */ \
+ status = (int) lseek(fd, offsetof_dynamic_segment + i * sizeof_dynamic_entry, \
+ SEEK_SET); \
+ if (status < 0) \
+ return -errno; \
+ \
+ status = read(fd, &dynamic_entry, sizeof_dynamic_entry); \
+ if (status < 0) \
+ return status; \
+ \
+ if (DYNAMIC_FIELD(*elf_header, dynamic_entry, tag) != type) \
+ continue; \
+ \
+ value = DYNAMIC_FIELD(*elf_header, dynamic_entry, val); \
+ \
+ embedded_code \
+ }
+
+ /* Get the address of the *first* string table. The ELF
+ * specification doesn't mention if it may have several string
+ * table references. */
+ FOREACH_DYNAMIC_ENTRY(DT_STRTAB, {
+ strtab_address = value;
+ break;
+ })
+
+ if (strtab_address == (uint64_t) -1)
+ return 0;
+
+ data.program_header = &strtab_segment;
+ data.type = PT_LOAD;
+ data.address = strtab_address;
+
+ /* Search the program header that contains the given string table. */
+ status = iterate_program_headers(tracee, fd, elf_header, find_program_header, &data);
+ if (status < 0)
+ return status;
+
+ strtab_offset = PROGRAM_FIELD(*elf_header, strtab_segment, offset)
+ + (strtab_address - PROGRAM_FIELD(*elf_header, strtab_segment, vaddr));
+
+ FOREACH_DYNAMIC_ENTRY(DT_RPATH, {
+ if (strtab_offset < 0 || (uint64_t) strtab_offset > UINT64_MAX - value)
+ return -ENOEXEC;
+
+ status = add_xpaths(tracee, fd, strtab_offset + value, rpaths);
+ if (status < 0)
+ return status;
+ })
+
+ FOREACH_DYNAMIC_ENTRY(DT_RUNPATH, {
+ if (strtab_offset < 0 || (uint64_t) strtab_offset > UINT64_MAX - value)
+ return -ENOEXEC;
+
+ status = add_xpaths(tracee, fd, strtab_offset + value, runpaths);
+ if (status < 0)
+ return status;
+ })
+
+#undef FOREACH_DYNAMIC_ENTRY
+
+ return 0;
+}
+
+/**
+ * Rebuild the variable LD_LIBRARY_PATH in @envp for the program
+ * @host_path according to its RPATH, RUNPATH, and the initial
+ * LD_LIBRARY_PATH. This function returns -errno if an error occured,
+ * 1 if RPATH/RUNPATH entries were found, 0 otherwise.
+ */
+int rebuild_host_ldso_paths(Tracee *tracee, const char host_path[PATH_MAX], ArrayOfXPointers *envp)
+{
+ static char *initial_ldso_paths = NULL;
+ ElfHeader elf_header;
+
+ char host_ldso_paths[ARG_MAX] = "";
+ bool rpath_found = false;
+
+ char *rpaths = NULL;
+ char *runpaths = NULL;
+
+ size_t length1;
+ size_t length2;
+
+ size_t index;
+ int status;
+ int fd;
+
+ fd = open_elf(host_path, &elf_header);
+ if (fd < 0)
+ return fd;
+
+ status = read_ldso_rpaths(tracee, fd, &elf_header, &rpaths, &runpaths);
+ close(fd);
+ if (status < 0)
+ return status;
+
+ /* 1. DT_RPATH */
+ if (rpaths && !runpaths) {
+ status = add_host_ldso_paths(host_ldso_paths, rpaths);
+ if (status < 0)
+ return 0; /* Not fatal. */
+ rpath_found = true;
+ }
+
+ /* 2. LD_LIBRARY_PATH */
+ if (initial_ldso_paths == NULL)
+ initial_ldso_paths = strdup(getenv("LD_LIBRARY_PATH") ?: "/");
+ if (initial_ldso_paths != NULL && initial_ldso_paths[0] != '\0') {
+ status = add_host_ldso_paths(host_ldso_paths, initial_ldso_paths);
+ if (status < 0)
+ return 0; /* Not fatal. */
+ }
+
+ /* 3. DT_RUNPATH */
+ if (runpaths) {
+ status = add_host_ldso_paths(host_ldso_paths, runpaths);
+ if (status < 0)
+ return 0; /* Not fatal. */
+ rpath_found = true;
+ }
+
+ /* 4. /etc/ld.so.cache NYI. */
+
+ /* 5. /lib[32|64], /usr/lib[32|64] + /usr/local/lib[32|64] */
+ /* 6. /lib, /usr/lib + /usr/local/lib */
+ if (IS_CLASS32(elf_header))
+ status = add_host_ldso_paths(host_ldso_paths,
+#if defined(ARCH_X86) || defined(ARCH_X86_64)
+ "/lib/i386-linux-gnu:/usr/lib/i386-linux-gnu:"
+#endif
+ "/lib32:/usr/lib32:/usr/local/lib32"
+ ":/lib:/usr/lib:/usr/local/lib"
+#ifdef __ANDROID__
+ ":/system/lib"
+#endif
+ );
+ else
+ status = add_host_ldso_paths(host_ldso_paths,
+#if defined(ARCH_X86_64)
+ "/lib/x86_64-linux-gnu:/usr/lib/x86_64-linux-gnu:"
+#elif defined(ARCH_ARM64)
+ "/lib/aarch64-linux-gnu:/usr/lib/aarch64-linux-gnu:"
+#endif
+ "/lib64:/usr/lib64:/usr/local/lib64"
+ ":/lib:/usr/lib:/usr/local/lib"
+#ifdef __ANDROID__
+ ":/system/lib64"
+#endif
+ );
+ if (status < 0)
+ return 0; /* Not fatal. */
+
+ status = find_xpointee(envp, "LD_LIBRARY_PATH");
+ if (status < 0)
+ return 0; /* Not fatal. */
+ index = (size_t) status;
+
+ if (index == envp->length) {
+ /* Allocate a new entry at the end of envp[] when
+ * LD_LIBRARY_PATH was not found. */
+
+ index = (envp->length > 0 ? envp->length - 1 : 0);
+ status = resize_array_of_xpointers(envp, index, 1);
+ if (status < 0)
+ return 0; /* Not fatal. */
+ }
+ else if (tracee->guest_ldso_paths == NULL) {
+ /* Remember guest LD_LIBRARY_PATH in order to restore
+ * it when a host program will execute a guest
+ * program. */
+ char *env;
+
+ /* Errors are not fatal here. */
+ status = read_xpointee_as_string(envp, index, &env);
+ if (status >= 0)
+ tracee->guest_ldso_paths = talloc_strdup(tracee, env);
+ }
+
+ /* Forge the new LD_LIBRARY_PATH variable from
+ * host_ldso_paths. */
+ length1 = strlen("LD_LIBRARY_PATH=");
+ length2 = strlen(host_ldso_paths);
+ if (ARG_MAX - length2 - 1 < length1)
+ return 0; /* Not fatal. */
+
+ memmove(host_ldso_paths + length1, host_ldso_paths, length2 + 1);
+ memcpy(host_ldso_paths, "LD_LIBRARY_PATH=" , length1);
+
+ write_xpointee(envp, index, host_ldso_paths);
+
+ /* The guest LD_LIBRARY_PATH will be restored only if the host
+ * program didn't change it explicitly, so remember its
+ * initial value. */
+ if (tracee->host_ldso_paths == NULL)
+ tracee->host_ldso_paths = talloc_strdup(tracee, host_ldso_paths);
+
+ return (int) rpath_found;
+}
diff --git a/core/proot/src/main/cpp/execve/ldso.h b/core/proot/src/main/cpp/execve/ldso.h
new file mode 100644
index 000000000..43782c17c
--- /dev/null
+++ b/core/proot/src/main/cpp/execve/ldso.h
@@ -0,0 +1,42 @@
+/* -*- c-set-style: "K&R"; c-basic-offset: 8 -*-
+ *
+ * This file is part of PRoot.
+ *
+ * Copyright (C) 2015 STMicroelectronics
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+ * 02110-1301 USA.
+ */
+
+#ifndef LDSO_H
+#define LDSO_H
+
+#include
+#include
+
+#include "execve/aoxp.h"
+#include "execve/elf.h"
+
+extern int ldso_env_passthru(const Tracee *tracee, ArrayOfXPointers *envp, ArrayOfXPointers *argv,
+ const char *define, const char *undefine, size_t offset);
+
+extern int rebuild_host_ldso_paths(Tracee *tracee, const char t_program[PATH_MAX],
+ ArrayOfXPointers *envp);
+
+extern int compare_xpointee_env(ArrayOfXPointers *envp, size_t index, const char *name);
+
+extern bool is_env_name(const char *variable, const char *name);
+
+#endif /* LDSO_H */
diff --git a/core/proot/src/main/cpp/execve/shebang.c b/core/proot/src/main/cpp/execve/shebang.c
new file mode 100644
index 000000000..83ceeda1c
--- /dev/null
+++ b/core/proot/src/main/cpp/execve/shebang.c
@@ -0,0 +1,307 @@
+/* -*- c-set-style: "K&R"; c-basic-offset: 8 -*-
+ *
+ * This file is part of PRoot.
+ *
+ * Copyright (C) 2015 STMicroelectronics
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+ * 02110-1301 USA.
+ */
+
+#include /* open(2), */
+#include /* open(2), */
+#include /* open(2), */
+#include /* PATH_MAX, */
+#include /* BINPRM_BUF_SIZE, */
+#include /* read(2), close(2), */
+#include /* -E*, */
+#include /* MAXSYMLINKS, */
+#include /* bool, */
+#include /* assert(3), */
+
+#include "execve/shebang.h"
+#include "execve/execve.h"
+#include "execve/aoxp.h"
+#include "tracee/tracee.h"
+#include "attribute.h"
+
+/**
+ * Extract into @user_path and @argument the shebang from @host_path.
+ * This function returns -errno if an error occured, 1 if a shebang
+ * was found and extracted, otherwise 0.
+ *
+ * Extract from "man 2 execve":
+ *
+ * On Linux, the entire string following the interpreter name is
+ * passed as a *single* argument to the interpreter, and this
+ * string can include white space.
+ */
+static int extract_shebang(const Tracee *tracee UNUSED, const char *host_path,
+ char user_path[PATH_MAX], char argument[BINPRM_BUF_SIZE])
+{
+ char tmp2[2];
+ char tmp;
+
+ size_t current_length;
+ size_t i;
+
+ int status;
+ int fd;
+
+ /* Assumption. */
+ assert(BINPRM_BUF_SIZE < PATH_MAX);
+
+ argument[0] = '\0';
+
+ /* Inspect the executable. */
+ fd = open(host_path, O_RDONLY);
+ if (fd < 0)
+ return -errno;
+
+ status = read(fd, tmp2, 2 * sizeof(char));
+ if (status < 0) {
+ status = -errno;
+ goto end;
+ }
+ if ((size_t) status < 2 * sizeof(char)) { /* EOF */
+ status = 0;
+ goto end;
+ }
+
+ /* Check if it really is a script text. */
+ if (tmp2[0] != '#' || tmp2[1] != '!') {
+ status = 0;
+ goto end;
+ }
+ current_length = 2;
+ user_path[0] = '\0';
+
+ /* Skip leading spaces. */
+ do {
+ status = read(fd, &tmp, sizeof(char));
+ if (status < 0) {
+ status = -errno;
+ goto end;
+ }
+ if ((size_t) status < sizeof(char)) { /* EOF */
+ status = -ENOEXEC;
+ goto end;
+ }
+
+ current_length++;
+ } while ((tmp == ' ' || tmp == '\t') && current_length < BINPRM_BUF_SIZE);
+
+ /* Slurp the interpreter path until the first space or end-of-line. */
+ for (i = 0; current_length < BINPRM_BUF_SIZE; current_length++, i++) {
+ switch (tmp) {
+ case ' ':
+ case '\t':
+ /* Remove spaces in between the interpreter
+ * and the hypothetical argument. */
+ user_path[i] = '\0';
+ break;
+
+ case '\n':
+ case '\r':
+ /* There is no argument. */
+ user_path[i] = '\0';
+ argument[0] = '\0';
+ status = 1;
+ goto end;
+
+ default:
+ /* There is an argument if the previous
+ * character in user_path[] is '\0'. */
+ if (i > 1 && user_path[i - 1] == '\0')
+ goto argument;
+ else
+ user_path[i] = tmp;
+ break;
+ }
+
+ status = read(fd, &tmp, sizeof(char));
+ if (status < 0) {
+ status = -errno;
+ goto end;
+ }
+ if ((size_t) status < sizeof(char)) { /* EOF */
+ user_path[i] = '\0';
+ argument[0] = '\0';
+ status = 1;
+ goto end;
+ }
+ }
+
+ /* The interpreter path is too long, truncate it. */
+ user_path[i] = '\0';
+ argument[0] = '\0';
+ status = 1;
+ goto end;
+
+argument:
+
+ /* Slurp the argument until the end-of-line. */
+ for (i = 0; current_length < BINPRM_BUF_SIZE; current_length++, i++) {
+ switch (tmp) {
+ case '\n':
+ case '\r':
+ argument[i] = '\0';
+
+ /* Remove trailing spaces. */
+ for (i--; i > 0 && (argument[i] == ' ' || argument[i] == '\t'); i--)
+ argument[i] = '\0';
+
+ status = 1;
+ goto end;
+
+ default:
+ argument[i] = tmp;
+ break;
+ }
+
+ status = read(fd, &tmp, sizeof(char));
+ if (status < 0) {
+ status = -errno;
+ goto end;
+ }
+ if ((size_t) status < sizeof(char)) { /* EOF */
+ argument[0] = '\0';
+ status = 1;
+ goto end;
+ }
+ }
+
+ /* The argument is too long, truncate it. */
+ argument[i] = '\0';
+ status = 1;
+
+end:
+ close(fd);
+
+ /* Did an error occur or isn't a script? */
+ if (status <= 0)
+ return status;
+
+ return 1;
+}
+
+/**
+ * Expand in argv[] the shebang of @user_path, if any. This function
+ * returns -errno if an error occurred, 1 if a shebang was found and
+ * extracted, otherwise 0. On success, both @host_path and @user_path
+ * point to the program to execute (respectively from host
+ * point-of-view and as-is), and @tracee's argv[] (pointed to by
+ * SYSARG_2) is correctly updated.
+ */
+int expand_shebang(Tracee *tracee, char host_path[PATH_MAX], char user_path[PATH_MAX])
+{
+ ArrayOfXPointers *argv = NULL;
+ bool has_shebang = false;
+
+ char argument[BINPRM_BUF_SIZE];
+ int status;
+ size_t i;
+
+ /* "The interpreter must be a valid pathname for an executable
+ * which is not itself a script [1]. If the filename
+ * argument of execve() specifies an interpreter script, then
+ * interpreter will be invoked with the following arguments:
+ *
+ * interpreter [optional-arg] filename arg...
+ *
+ * where arg... is the series of words pointed to by the argv
+ * argument of execve()." -- man 2 execve
+ *
+ * [1]: as of this writing (3.10.17) this is true only for the
+ * ELF interpreter; ie. a script can use a script as
+ * interpreter.
+ */
+ for (i = 0; i < MAXSYMLINKS; i++) {
+ char *old_user_path;
+
+ /* Translate this path (user -> host), then check it is executable. */
+ status = translate_and_check_exec(tracee, host_path, user_path);
+ if (status < 0)
+ return status;
+
+ /* Remember the initial user path. */
+ old_user_path = talloc_strdup(tracee->ctx, user_path);
+ if (old_user_path == NULL)
+ return -ENOMEM;
+
+ /* Extract into user_path and argument the shebang from host_path. */
+ status = extract_shebang(tracee, host_path, user_path, argument);
+ if (status < 0)
+ return status;
+
+ /* No more shebang. */
+ if (status == 0)
+ break;
+ has_shebang = true;
+
+ /* Translate new path (user -> host), then check it is executable. */
+ status = translate_and_check_exec(tracee, host_path, user_path);
+ if (status < 0)
+ return status;
+
+ /* Fetch argv[] only on demand. */
+ if (argv == NULL) {
+ status = fetch_array_of_xpointers(tracee, &argv, SYSARG_2, 0);
+ if (status < 0)
+ return status;
+ }
+
+ /* Assuming the shebang of "script" is "#!/bin/sh -x",
+ * a call to:
+ *
+ * execve("./script", { "script.sh", NULL }, ...)
+ *
+ * becomes:
+ *
+ * execve("/bin/sh", { "/bin/sh", "-x", "./script", NULL }, ...)
+ *
+ * See commit 8c8fbe85 about "argv->length == 1". */
+ if (argument[0] != '\0') {
+ status = resize_array_of_xpointers(argv, 0, 2 + (argv->length == 1));
+ if (status < 0)
+ return status;
+
+ status = write_xpointees(argv, 0, 3, user_path, argument, old_user_path);
+ if (status < 0)
+ return status;
+ }
+ else {
+ status = resize_array_of_xpointers(argv, 0, 1 + (argv->length == 1));
+ if (status < 0)
+ return status;
+
+ status = write_xpointees(argv, 0, 2, user_path, old_user_path);
+ if (status < 0)
+ return status;
+ }
+ }
+
+ if (i == MAXSYMLINKS)
+ return -ELOOP;
+
+ /* Push argv[] only on demand. */
+ if (argv != NULL) {
+ status = push_array_of_xpointers(argv, SYSARG_2);
+ if (status < 0)
+ return status;
+ }
+
+ return (has_shebang ? 1 : 0);
+}
diff --git a/core/proot/src/main/cpp/execve/shebang.h b/core/proot/src/main/cpp/execve/shebang.h
new file mode 100644
index 000000000..1ae63be1b
--- /dev/null
+++ b/core/proot/src/main/cpp/execve/shebang.h
@@ -0,0 +1,32 @@
+/* -*- c-set-style: "K&R"; c-basic-offset: 8 -*-
+ *
+ * This file is part of PRoot.
+ *
+ * Copyright (C) 2015 STMicroelectronics
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+ * 02110-1301 USA.
+ */
+
+#ifndef SHEBANG_H
+#define SHEBANG_H
+
+#include /* PATH_MAX, ARG_MAX, */
+
+#include "tracee/tracee.h"
+
+extern int expand_shebang(Tracee *tracee, char host_path[PATH_MAX], char user_path[PATH_MAX]);
+
+#endif /* SHEBANG_H */
diff --git a/core/proot/src/main/cpp/extension/ashmem_memfd/ashmem_memfd.c b/core/proot/src/main/cpp/extension/ashmem_memfd/ashmem_memfd.c
new file mode 100644
index 000000000..632e5e6d2
--- /dev/null
+++ b/core/proot/src/main/cpp/extension/ashmem_memfd/ashmem_memfd.c
@@ -0,0 +1,239 @@
+#include
+#include
+#include
+#include
+#include /* __NR_memfd_create, */
+#include /* ASHMEM_GET_SIZE, */
+#include /* MFD_CLOEXEC */
+
+#include
+
+#include "extension/extension.h"
+#include "path/path.h"
+#include "tracee/mem.h"
+#include "tracee/seccomp.h"
+#include "syscall/chain.h"
+#include "syscall/syscall.h" /* set_sysarg_data, */
+
+enum AshmemMemfdChainState {
+ CS_IDLE,
+ CS_STAT_ENTERED,
+ CS_STAT_CHAINED_IOCTL
+};
+
+typedef struct {
+ bool memfd_supported;
+ enum AshmemMemfdChainState chain_state;
+ int fd;
+ word_t addr;
+} AshmemMemfdState;
+
+static FilteredSysnum filtered_sysnums[] = {
+ { PR_memfd_create, 0 },
+ { PR_ftruncate, 0 },
+ { PR_fstat, 0 },
+ FILTERED_SYSNUM_END,
+};
+
+static int detect_memfd_support() {
+ const char *assume_unsupported = getenv("PROOT_ASSUME_MEMFD_UNSUPPORTED");
+ if (assume_unsupported != NULL) {
+ if (0 == strcmp(assume_unsupported, "1")) {
+ return 0;
+ }
+ if (0 == strcmp(assume_unsupported, "0")) {
+ return 1;
+ }
+ }
+
+ int reply_pipe[2];
+ int status = pipe(reply_pipe);
+ if (status < 0) {
+ return -1;
+ }
+
+ status = fork();
+ if (status < 0) {
+ close(reply_pipe[0]);
+ close(reply_pipe[1]);
+ return -1;
+ }
+
+ if (status == 0) {
+ /** Child process. Close readable end of pipe. */
+ close(reply_pipe[0]);
+
+ /** Attempt creating memfd. */
+ signal(SIGSYS, SIG_DFL);
+ int memfd = syscall(__NR_memfd_create, "support_probe", 0);
+
+ /** Send message to parent on success. */
+ if (memfd >= 0) {
+ write(reply_pipe[1], "\x01", 1);
+ close(memfd);
+ }
+ close(reply_pipe[1]);
+ _exit(0);
+ }
+
+ /** Parent process. */
+ close(reply_pipe[1]);
+ char reply_value = 0;
+ read(reply_pipe[0], &reply_value, 1);
+ close(reply_pipe[0]);
+ return reply_value == 1;
+}
+
+static bool is_ashmem_fd(Tracee *tracee, int fd) {
+ char path[PATH_MAX] = {};
+ if (readlink_proc_pid_fd(tracee->pid, fd, path) < 0) {
+ return false;
+ }
+ return 0 == strcmp(path, "/dev/ashmem");
+}
+
+static void ashmem_memfd_handle_stat(Extension *extension, Tracee *tracee, int fd, Reg stat_reg) {
+ if (is_ashmem_fd(tracee, fd)) {
+ AshmemMemfdState *state = talloc_get_type_abort(extension->config, AshmemMemfdState);
+ state->chain_state = CS_STAT_ENTERED;
+ state->fd = fd;
+ state->addr = peek_reg(tracee, CURRENT, stat_reg) + offsetof(struct stat, st_size);
+ tracee->restart_how = PTRACE_SYSCALL;
+ }
+}
+
+static int ashmem_memfd_handle_memfd_create(Extension *extension, Tracee *tracee, bool from_sigsys) {
+ AshmemMemfdState *state = talloc_get_type_abort(extension->config, AshmemMemfdState);
+ if (!state->memfd_supported) {
+ word_t flags = peek_reg(tracee, CURRENT, SYSARG_2);
+ set_sysnum(tracee, PR_openat);
+ set_sysarg_data(tracee, "/dev/ashmem", 12, SYSARG_2);
+ poke_reg(tracee, SYSARG_1, AT_FDCWD);
+ poke_reg(tracee, SYSARG_3, O_RDWR | ((flags & MFD_CLOEXEC) ? O_CLOEXEC : 0));
+ poke_reg(tracee, SYSARG_4, 0);
+ if (from_sigsys) {
+ restart_syscall_after_seccomp(tracee);
+ /* Skip further processing (such as forcing syscall result) from SIGSYS handler. */
+ return 2;
+ }
+ }
+ return 0;
+}
+
+static void ashmem_memfd_handle_syscall(Extension *extension) {
+ Tracee *tracee = TRACEE(extension);
+ switch (get_sysnum(tracee, CURRENT)) {
+ case PR_memfd_create:
+ {
+ ashmem_memfd_handle_memfd_create(extension, tracee, false);
+ break;
+ }
+ case PR_ftruncate:
+ case PR_ftruncate64:
+ {
+ int fd = peek_reg(tracee, CURRENT, SYSARG_1);
+ if (is_ashmem_fd(tracee, fd)) {
+ set_sysnum(tracee, PR_ioctl);
+ poke_reg(tracee, SYSARG_3, peek_reg(tracee, CURRENT, SYSARG_2));
+ poke_reg(tracee, SYSARG_2, ASHMEM_SET_SIZE);
+ }
+ }
+ case PR_fstat:
+ ashmem_memfd_handle_stat(extension, tracee, peek_reg(tracee, CURRENT, SYSARG_1), SYSARG_2);
+ break;
+ case PR_fstatat64:
+ {
+ int fd = peek_reg(tracee, CURRENT, SYSARG_1);
+ if (
+ fd >= 0 &&
+ /** Is path argument an empty string? */
+ !(peek_word(tracee, peek_reg(tracee, CURRENT, SYSARG_2)) & 0xFF)
+ ) {
+ ashmem_memfd_handle_stat(extension, tracee, fd, SYSARG_3);
+ }
+ break;
+ }
+ default:
+ break;
+ }
+}
+
+static void ashmem_memfd_handle_stat_exit(Tracee *tracee, AshmemMemfdState *state) {
+ if (peek_reg(tracee, CURRENT, SYSARG_RESULT) || peek_word(tracee, state->addr)) {
+ state->chain_state = CS_IDLE;
+ return;
+ }
+
+ register_chained_syscall(tracee, PR_ioctl, state->fd, ASHMEM_GET_SIZE, 0, 0, 0, 0);
+ state->chain_state = CS_STAT_CHAINED_IOCTL;
+}
+
+int ashmem_memfd_callback(Extension *extension, ExtensionEvent event, intptr_t data1, intptr_t data2)
+{
+ switch (event) {
+ case INITIALIZATION: {
+ extension->config = talloc_zero(extension, AshmemMemfdState);
+ AshmemMemfdState *state = talloc_get_type_abort(extension->config, AshmemMemfdState);
+
+ memset(state, 0, sizeof(*state));
+ state->memfd_supported = detect_memfd_support();
+
+ extension->filtered_sysnums = filtered_sysnums;
+
+ return 0;
+ }
+ case INHERIT_PARENT: /* Inheritable for sub reconfiguration ... */
+ return 1;
+
+ case INHERIT_CHILD: {
+ /* Create configuration in child. */
+ Extension *parent = (Extension *) data1;
+ extension->config = talloc_zero(extension, AshmemMemfdState);
+ if (extension->config == NULL)
+ return -1;
+
+ AshmemMemfdState *old_state = talloc_get_type_abort(parent->config, AshmemMemfdState);
+ AshmemMemfdState *state = talloc_get_type_abort(extension->config, AshmemMemfdState);
+ state->memfd_supported = old_state->memfd_supported;
+ }
+
+ case SYSCALL_ENTER_END:
+ ashmem_memfd_handle_syscall(extension);
+ return 0;
+
+ case SYSCALL_EXIT_START:
+ {
+ AshmemMemfdState *state = talloc_get_type_abort(extension->config, AshmemMemfdState);
+ switch (state->chain_state) {
+ case CS_IDLE:
+ case CS_STAT_CHAINED_IOCTL:
+ break;
+ case CS_STAT_ENTERED:
+ ashmem_memfd_handle_stat_exit(TRACEE(extension), state);
+ break;
+ }
+ return 0;
+ }
+ case SIGSYS_OCC:
+ {
+ Tracee *tracee = TRACEE(extension);
+ if (get_sysnum(tracee, CURRENT) == PR_memfd_create) {
+ return ashmem_memfd_handle_memfd_create(extension, tracee, true);
+ }
+ return 0;
+ }
+ case SYSCALL_CHAINED_EXIT:
+ {
+ AshmemMemfdState *state = talloc_get_type_abort(extension->config, AshmemMemfdState);
+ if (state->chain_state == CS_STAT_CHAINED_IOCTL) {
+ state->chain_state = CS_IDLE;
+ Tracee *tracee = TRACEE(extension);
+ poke_uint32(tracee, state->addr, peek_reg(tracee, CURRENT, SYSARG_RESULT));
+ poke_reg(tracee, SYSARG_RESULT, 0);
+ }
+ return 0;
+ }
+ default:
+ return 0;
+ }
+}
diff --git a/core/proot/src/main/cpp/extension/extension.c b/core/proot/src/main/cpp/extension/extension.c
new file mode 100644
index 000000000..1e52ee2e0
--- /dev/null
+++ b/core/proot/src/main/cpp/extension/extension.c
@@ -0,0 +1,169 @@
+/* -*- c-set-style: "K&R"; c-basic-offset: 8 -*-
+ *
+ * This file is part of PRoot.
+ *
+ * Copyright (C) 2015 STMicroelectronics
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+ * 02110-1301 USA.
+ */
+
+#include /* assert(3), */
+#include /* talloc_*, */
+#include /* LIST_*, */
+#include /* bzero(3), */
+
+#include "extension/extension.h"
+#include "cli/note.h"
+#include "build.h"
+
+#include "compat.h"
+
+/**
+ * Remove an @extension from its tracee's list, then send it the
+ * "REMOVED" event.
+ *
+ * Note: this is a Talloc destructor.
+ */
+static int remove_extension(Extension *extension)
+{
+ LIST_REMOVE(extension, link);
+ extension->callback(extension, REMOVED, 0, 0);
+
+ bzero(extension, sizeof(Extension));
+ return 0;
+}
+
+/**
+ * Allocate a new extension for the given @callback then attach it to
+ * its @tracee. This function returns NULL on error, otherwise the
+ * new extension.
+ */
+static Extension *new_extension(Tracee *tracee, extension_callback_t callback)
+{
+ Extension *extension;
+
+ /* Lazy allocation of the list head. */
+ if (tracee->extensions == NULL) {
+ tracee->extensions = talloc_zero(tracee, Extensions);
+ if (tracee->extensions == NULL)
+ return NULL;
+ }
+
+ /* Allocate a new extension. */
+ extension = talloc_zero(tracee->extensions, Extension);
+ if (extension == NULL)
+ return NULL;
+ extension->callback = callback;
+
+ /* Attach it to its tracee. */
+ LIST_INSERT_HEAD(tracee->extensions, extension, link);
+ talloc_set_destructor(extension, remove_extension);
+
+ return extension;
+}
+
+/**
+ * Retrieve from @tracee->extensions the extension for the given
+ * @callback.
+ */
+Extension *get_extension(Tracee *tracee, extension_callback_t callback)
+{
+ Extension *extension;
+
+ if (tracee->extensions == NULL)
+ return NULL;
+
+ LIST_FOREACH(extension, tracee->extensions, link) {
+ if (extension->callback == callback)
+ return extension;
+ }
+
+ return NULL;
+}
+
+/**
+ * Initialize a new extension for the given @callback then attach it
+ * to its @tracee. The parameter @cli is its argument that was passed
+ * to the command-line interface. This function return -1 if an error
+ * occurred, otherwise 0.
+ */
+int initialize_extension(Tracee *tracee, extension_callback_t callback, const char *cli)
+{
+ Extension *extension;
+ int status;
+
+ extension = new_extension(tracee, callback);
+ if (extension == NULL) {
+ note(tracee, WARNING, INTERNAL, "can't create a new extension");
+ return -1;
+ }
+
+ /* Remove the new extension if its initialized has failed. */
+ status = extension->callback(extension, INITIALIZATION, (intptr_t) cli, 0);
+ if (status < 0) {
+ TALLOC_FREE(extension);
+ return status;
+ }
+
+ return 0;
+}
+
+/**
+ * Rebuild a new list of extensions for this @child from its @parent.
+ * The inheritance model is controlled by the @parent.
+ */
+void inherit_extensions(Tracee *child, Tracee *parent, word_t clone_flags)
+{
+ Extension *parent_extension;
+ Extension *child_extension;
+ int status;
+
+ if (parent->extensions == NULL)
+ return;
+
+ /* Sanity check. */
+ assert(child->extensions == NULL || clone_flags == CLONE_RECONF);
+
+ LIST_FOREACH(parent_extension, parent->extensions, link) {
+ /* Ask the parent how this extension is
+ * inheritable. */
+ status = parent_extension->callback(parent_extension, INHERIT_PARENT,
+ (intptr_t)child, clone_flags);
+
+ /* Not inheritable. */
+ if (status < 0)
+ continue;
+
+ /* Inheritable... */
+ child_extension = new_extension(child, parent_extension->callback);
+ if (child_extension == NULL) {
+ note(parent, WARNING, INTERNAL,
+ "can't create a new extension for pid %d", child->pid);
+ continue;
+ }
+
+ if (status == 0) {
+ /* ... with a shared config or ... */
+ child_extension->config =
+ talloc_reference(child_extension, parent_extension->config);
+ }
+ else {
+ /* ... with another inheritance model. */
+ child_extension->callback(child_extension, INHERIT_CHILD,
+ (intptr_t)parent_extension, clone_flags);
+ }
+ }
+}
diff --git a/core/proot/src/main/cpp/extension/extension.h b/core/proot/src/main/cpp/extension/extension.h
new file mode 100644
index 000000000..24409b9e5
--- /dev/null
+++ b/core/proot/src/main/cpp/extension/extension.h
@@ -0,0 +1,211 @@
+/* -*- c-set-style: "K&R"; c-basic-offset: 8 -*-
+ *
+ * This file is part of PRoot.
+ *
+ * Copyright (C) 2015 STMicroelectronics
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
+ * 02110-1301 USA.
+ */
+
+#ifndef EXTENSION_H
+#define EXTENSION_H
+
+#include /* LIST_, */
+#include /* intptr_t, */
+
+#include "tracee/tracee.h"
+#include "syscall/seccomp.h"
+
+/* List of possible events. */
+typedef enum {
+ /* A guest path passed as an argument of the current syscall
+ * is about to be translated: "(char *) data1" is the base for
+ * "(char *) data2" -- the guest path -- if this latter is
+ * relative. If the extension returns > 0, then PRoot skips
+ * its own handling. If the extension returns < 0, then PRoot
+ * reports this errno as-is. */
+ GUEST_PATH,
+
+ /* A canonicalized host path is being accessed during the
+ * translation of a guest path: "(char *) data1" is the
+ * canonicalized host path and "(bool) data2" is true if it is
+ * the last iteration. Note that several host paths are accessed
+ * for a given guest path since PRoot has to walk along all
+ * parent directories and symlinks in order to translate it.
+ * If the extension returns < 0, then PRoot reports this errno
+ * as-is. */
+ HOST_PATH,
+
+ /* The canonicalization succeed: "(char *) data1" is the
+ * translated path from the host point-of-view. It can be
+ * substituted by the extension. If the extension returns <
+ * 0, then PRoot reports this errno as-is. */
+ TRANSLATED_PATH,
+
+ /* The tracee enters a syscall, and PRoot hasn't do anything
+ * yet. If the extension returns > 0, then PRoot skips its
+ * own handling. If the extension returns < 0, then PRoot
+ * cancels the syscall and reports this errno to the
+ * tracee. */
+ SYSCALL_ENTER_START,
+
+ /* The tracee enters a syscall, and PRoot has already handled
+ * it: "(int) data1" is the current status, it is < 0 when
+ * something went wrong. If the extension returns < 0, then
+ * PRoot cancels the syscall and reports this errno to the
+ * tracee. */
+ SYSCALL_ENTER_END,
+
+ /* The tracee exits a syscall, and PRoot hasn't do anything
+ * yet. If the extension returns > 0, then PRoot skips its
+ * own handling. If the extension returns < 0, then PRoot
+ * reports this errno to the tracee. */
+ SYSCALL_EXIT_START,
+
+ /* The tracee exits a syscall, and PRoot has already handled
+ * it. If the extension returns < 0, then PRoot reports this
+ * errno to the tracee. */
+ SYSCALL_EXIT_END,
+
+ /* The tracee is stopped either because of a syscall or a
+ * signal: "(int) data1" is its new status as reported by
+ * waitpid(2). If the extension returns != 0, then PRoot
+ * skips its own handling. */
+ NEW_STATUS,
+
+ /* Ask how this extension is inheritable: "(Tracee *) data1"
+ * is the child tracee and "(bool) data2" is the clone(2)
+ * flags (CLONE_RECONF for sub-reconfiguration). The meaning
+ * of the returned value is:
+ *
+ * < 0 : not inheritable
+ * == 0 : inheritable + shared configuration.
+ * > 0 : inheritable + call INHERIT_CHILD. */
+ INHERIT_PARENT,
+
+ /* Control the inheritance: "(Extension *) data1" is the
+ * extension of the parent and "(word_t) data2" is the clone(2)
+ * flags (CLONE_RECONF for sub-reconfiguration). For instance
+ * the extension for the child could use a configuration
+ * different from the parent's configuration. */
+ INHERIT_CHILD,
+
+ /* The tracee enters a "chained" syscall, that is, an
+ * unrequested syscall inserted by PRoot after an actual
+ * syscall. If the extension returns < 0, then PRoot cancels
+ * the syscall and reports this errno to the tracee. */
+ SYSCALL_CHAINED_ENTER,
+
+ /* The tracee exists a "chained" syscall, that is, an
+ * unrequested syscall inserted by PRoot after an actual
+ * syscall. */
+ SYSCALL_CHAINED_EXIT,
+
+ /* Initialize the extension: "(const char *) data1" is its
+ * argument that was passed to the command-line interface. If
+ * the extension returns < 0, then PRoot removed it. */
+ INITIALIZATION,
+
+ /* The extension is not attached to its tracee anymore
+ * (destructor). */
+ REMOVED,
+
+ /* Print the current configuration of the extension. See
+ * print_config() as an example. */
+ PRINT_CONFIG,
+
+ /* Print the usage of the extension: "(bool) data1" is true
+ * for a detailed usage. See print_usage() as an example. */
+ PRINT_USAGE,
+
+ /* A SIGSYS has occurred and we are going to see if any of the extensions wants to handle it for us*/
+ SIGSYS_OCC,
+
+ /* link2symlink notifies other extensions when it is moving
+ * a file */
+ LINK2SYMLINK_RENAME,
+
+ /* link2symlink notifies other extensions when it is unlinking
+ * a file */
+ LINK2SYMLINK_UNLINK,
+
+ /* statx() syscall was used by tracee and is being replaced by proot
+ * data1 argument contains pointer to statx_syscall_state struct
+ * defined in tracee/statx.h
+ * */
+ STATX_SYSCALL,
+} ExtensionEvent;
+
+#define CLONE_RECONF ((word_t) -1)
+
+struct extension;
+typedef int (*extension_callback_t)(struct extension *extension, ExtensionEvent event,
+ intptr_t data1, intptr_t data2);
+
+typedef struct extension {
+ /* Function to be called when any event occured. */
+ extension_callback_t callback;
+
+ /* A chunk of memory allocated by any talloc functions.
+ * Mainly useful to store a configuration. */
+ TALLOC_CTX *config;
+
+ /* List of sysnum handled by this extension. */
+ const FilteredSysnum *filtered_sysnums;
+
+ /* Link to the next and previous extensions. Note the order
+ * is *never* garantee. */
+ LIST_ENTRY(extension) link;
+} Extension;
+
+typedef LIST_HEAD(extensions, extension) Extensions;
+
+extern int initialize_extension(Tracee *tracee, extension_callback_t callback, const char *cli);
+extern void inherit_extensions(Tracee *child, Tracee *parent, word_t clone_flags);
+extern Extension *get_extension(Tracee *tracee, extension_callback_t callback);
+
+/**
+ * Notify all extensions of @tracee that the given @event occured.
+ * See ExtensionEvent for the meaning of @data1 and @data2.
+ */
+static inline int notify_extensions(Tracee *tracee, ExtensionEvent event,
+ intptr_t data1, intptr_t data2)
+{
+ Extension *extension;
+
+ if (tracee->extensions == NULL)
+ return 0;
+
+ LIST_FOREACH(extension, tracee->extensions, link) {
+ int status = extension->callback(extension, event, data1, data2);
+ if (status != 0)
+ return status;
+ }
+
+ return 0;
+}
+
+/* Built-in extensions. */
+extern int kompat_callback(Extension *extension, ExtensionEvent event, intptr_t d1, intptr_t d2);
+extern int fake_id0_callback(Extension *extension, ExtensionEvent event, intptr_t d1, intptr_t d2);
+extern int hidden_files_callback(Extension *extension, ExtensionEvent event, intptr_t d1, intptr_t d2);
+extern int port_switch_callback(Extension *extension, ExtensionEvent event, intptr_t d1, intptr_t d2);
+extern int link2symlink_callback(Extension *extension, ExtensionEvent event, intptr_t d1, intptr_t d2);
+extern int fix_symlink_size_callback(Extension *extension, ExtensionEvent event, intptr_t d1, intptr_t d2);
+extern int ashmem_memfd_callback(Extension *extension, ExtensionEvent event, intptr_t d1, intptr_t d2);
+extern int mountinfo_callback(Extension *extension, ExtensionEvent event, intptr_t d1, intptr_t d2);
+
+#endif /* EXTENSION_H */
diff --git a/core/proot/src/main/cpp/extension/fake_id0/access.c b/core/proot/src/main/cpp/extension/fake_id0/access.c
new file mode 100644
index 000000000..d9197843e
--- /dev/null
+++ b/core/proot/src/main/cpp/extension/fake_id0/access.c
@@ -0,0 +1,55 @@
+#include
+#include
+#include
+
+#include "extension/fake_id0/access.h"
+#include "extension/fake_id0/helper_functions.h"
+
+/** Handles the access and faccessat syscalls. Checks permissions according to
+ * a meta file if it exists. See access(2) for returned errors.
+ */
+int handle_access_enter_end(Tracee *tracee, Reg path_sysarg,
+ Reg mode_sysarg, Reg dirfd_sysarg, Config *config)
+{
+ int status, mode, perms, mask;
+ char path[PATH_MAX];
+ char rel_path[PATH_MAX];
+ char meta_path[PATH_MAX];
+
+ status = read_sysarg_path(tracee, path, path_sysarg, CURRENT);
+ if(status < 0)
+ return status;
+ if(status == 1)
+ return 0;
+
+ status = get_fd_path(tracee, rel_path, dirfd_sysarg, CURRENT);
+ if(status < 0)
+ return status;
+
+ status = check_dir_perms(tracee, 'r', path, rel_path, config);
+ if(status < 0)
+ return status;
+
+ // Only care about calls checking permissions.
+ mode = peek_reg(tracee, ORIGINAL, mode_sysarg);
+ if(mode & F_OK)
+ return 0;
+
+ status = get_meta_path(path, meta_path);
+ if(status < 0)
+ return status;
+
+ mask = 0;
+ if((mode & R_OK) == R_OK)
+ mask += 4;
+ if((mode & W_OK) == W_OK)
+ mask += 2;
+ if((mode & X_OK) == X_OK)
+ mask += 1;
+
+ perms = get_permissions(meta_path, config, 1);
+ if((perms & mask) != mask)
+ return -EACCES;
+
+ return 0;
+}
diff --git a/core/proot/src/main/cpp/extension/fake_id0/access.h b/core/proot/src/main/cpp/extension/fake_id0/access.h
new file mode 100644
index 000000000..ac5a42955
--- /dev/null
+++ b/core/proot/src/main/cpp/extension/fake_id0/access.h
@@ -0,0 +1,10 @@
+#ifndef FAKE_ID0_ACCESS_H
+#define FAKE_ID0_ACCESS_H
+
+#include "tracee/tracee.h"
+#include "tracee/reg.h"
+#include "extension/fake_id0/config.h"
+
+int handle_access_enter_end(Tracee *tracee, Reg path_sysarg, Reg mode_sysarg, Reg dirfd_sysarg, Config *config);
+
+#endif /* FAKE_ID0_ACCESS_H */
diff --git a/core/proot/src/main/cpp/extension/fake_id0/chmod.c b/core/proot/src/main/cpp/extension/fake_id0/chmod.c
new file mode 100644
index 000000000..e1accbc56
--- /dev/null
+++ b/core/proot/src/main/cpp/extension/fake_id0/chmod.c
@@ -0,0 +1,56 @@
+#include