Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/build-project-gt.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Build Project-GT

on:
push:
branches:
- main

jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read

steps:
- name: Clone repository
uses: actions/checkout@v4

- name: Setup Java 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'

- name: Setup Android SDK
uses: android-actions/setup-android@v3

- name: Install NDK
run: |
sdkmanager "ndk;29.0.14206865"

- name: Grant Gradle wrapper execute permission
run: chmod +x gradlew

- name: Build debug APK
env:
TERMUX_PACKAGE_VARIANT: apt-android-7
run: ./gradlew assembleDebug

- name: Upload APK artifacts
uses: actions/upload-artifact@v4
with:
name: project-gt-debug-apk
path: app/build/outputs/apk/debug/*.apk
if-no-files-found: error
20 changes: 20 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
id "com.android.application"
id "org.jetbrains.kotlin.android"
}

ext {
Expand Down Expand Up @@ -36,6 +37,15 @@ android {
implementation "io.noties.markwon:recycler:$markwonVersion"
implementation 'com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava'

// Jetpack Compose
implementation platform("androidx.compose:compose-bom:2024.09.00")
implementation "androidx.compose.ui:ui"
implementation "androidx.compose.ui:ui-tooling-preview"
implementation "androidx.compose.material3:material3"
implementation "androidx.compose.foundation:foundation"
implementation "androidx.activity:activity-compose:1.9.3"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.7"

implementation project(":terminal-view")
implementation project(":termux-shared")
}
Expand Down Expand Up @@ -106,6 +116,10 @@ android {
targetCompatibility JavaVersion.VERSION_1_8
}

kotlinOptions {
jvmTarget = "1.8"
}

externalNativeBuild {
ndkBuild {
path "src/main/cpp/Android.mk"
Expand Down Expand Up @@ -142,13 +156,19 @@ android {

buildFeatures {
buildConfig true
compose true
}

composeOptions {
kotlinCompilerExtensionVersion = "1.5.15"
}
}

dependencies {
testImplementation "junit:junit:4.13.2"
testImplementation "org.robolectric:robolectric:4.10"
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:1.1.5"
debugImplementation "androidx.compose.ui:ui-tooling"
}

task versionName {
Expand Down
22 changes: 17 additions & 5 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,6 @@
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>

Expand All @@ -74,6 +69,23 @@
android:resource="@xml/shortcuts" />
</activity>

<!-- Project-GT: Compose UI entry point (primary launcher) -->
<activity
android:name=".app.MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|smallestScreenSize|density|screenLayout|keyboard|keyboardHidden|navigation"
android:label="@string/application_name"
android:launchMode="singleTask"
android:resizeableActivity="true"
android:windowSoftInputMode="adjustResize"
tools:targetApi="n">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<activity-alias
android:name=".HomeActivity"
android:exported="true"
Expand Down
51 changes: 51 additions & 0 deletions app/src/main/assets/gt-boot.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#!/data/data/com.termux/files/usr/bin/bash
# Project-GT headless boot script
# Automatically deployed to $HOME/.gt-boot.sh on first run.
# Starts ollama and the Goose agent WebSocket daemon as background processes.

PREFIX=/data/data/com.termux/files/usr
HOME_DIR=/data/data/com.termux/files/home
LOG="$HOME_DIR/.gt-boot.log"

echo "[$(date)] GT boot sequence starting" > "$LOG"

# --- Start Ollama serve (local LLM) ---
if command -v ollama >/dev/null 2>&1; then
if ! pgrep -x ollama >/dev/null 2>&1; then
echo "[$(date)] Starting ollama serve" >> "$LOG"
nohup ollama serve >>"$LOG" 2>&1 &
echo "[$(date)] ollama PID: $!" >> "$LOG"
else
echo "[$(date)] ollama already running" >> "$LOG"
fi
else
echo "[$(date)] ollama not found, skipping" >> "$LOG"
fi

# --- Start Goose agent daemon (SPL/NTR-style native binary startup path) ---
GOOSE_WS_BIN="$PREFIX/bin/goose-ws-daemon"
GOOSE_NATIVE_BIN="$PREFIX/lib/libgoose.so"
GOOSE_ARGS="serve --host 127.0.0.1 --port 7437 --with-builtin developer,memory"

if pgrep -f "goose-ws-daemon" >/dev/null 2>&1 \
|| (ps -A -o args= | grep -F "$GOOSE_NATIVE_BIN serve" | grep -v "grep -F" >/dev/null 2>&1) \
|| (ps -A -o args= | grep -F "$PREFIX/bin/goose serve" | grep -v "grep -F" >/dev/null 2>&1); then
echo "[$(date)] Goose daemon already running" >> "$LOG"
elif [ -x "$GOOSE_WS_BIN" ]; then
echo "[$(date)] Starting goose-ws-daemon bridge" >> "$LOG"
nohup "$GOOSE_WS_BIN" >>"$LOG" 2>&1 &
echo "[$(date)] goose-ws-daemon PID: $!" >> "$LOG"
elif [ -f "$GOOSE_NATIVE_BIN" ]; then
chmod 700 "$GOOSE_NATIVE_BIN" 2>/dev/null || true
echo "[$(date)] Starting native Goose binary: $GOOSE_NATIVE_BIN $GOOSE_ARGS" >> "$LOG"
nohup "$GOOSE_NATIVE_BIN" $GOOSE_ARGS >>"$LOG" 2>&1 &
echo "[$(date)] native goose PID: $!" >> "$LOG"
elif command -v goose >/dev/null 2>&1; then
echo "[$(date)] Starting PATH Goose binary: goose $GOOSE_ARGS" >> "$LOG"
nohup goose $GOOSE_ARGS >>"$LOG" 2>&1 &
echo "[$(date)] goose PID: $!" >> "$LOG"
else
echo "[$(date)] Goose binary not found (bridge/native/PATH), skipping" >> "$LOG"
fi

echo "[$(date)] GT boot sequence complete" >> "$LOG"
126 changes: 126 additions & 0 deletions app/src/main/java/com/termux/app/FilesNativeHelper.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package com.termux.app

import com.termux.shared.termux.TermuxConstants
import java.io.File
import java.io.IOException

/**
* Provides direct CRUD operations on the Termux home directory.
*
* Because the Compose UI and the Termux backend share the same Linux UID
* (`com.termux`), all file access is through standard [java.io.File] APIs —
* no Android Storage Access Framework permissions are required.
*
* All methods are synchronous and should be called from a background coroutine
* or thread. Paths that escape [TERMUX_HOME] are rejected to prevent accidental
* writes outside the sandbox.
*/
object FilesNativeHelper {

/** Termux home directory shared by the Compose UI and the Termux backend. */
val TERMUX_HOME: String get() = TermuxConstants.TERMUX_HOME_DIR_PATH

// ── Read ─────────────────────────────────────────────────────────────────

/**
* Read the entire contents of [relativePath] (relative to [TERMUX_HOME]) as a UTF-8 string.
*
* @throws IOException if the file cannot be read.
* @throws SecurityException if [relativePath] tries to escape the home directory.
*/
fun readFile(relativePath: String): String {
return resolvedFile(relativePath).readText(Charsets.UTF_8)
}

/**
* Return a list of [File] entries inside the directory at [relativePath].
* Returns an empty list when the directory is empty or does not exist.
*/
fun listDirectory(relativePath: String): List<File> {
val dir = resolvedFile(relativePath)
return if (dir.isDirectory) dir.listFiles()?.toList() ?: emptyList() else emptyList()
}

// ── Write ────────────────────────────────────────────────────────────────

/**
* Write [content] to [relativePath], creating parent directories as needed.
* Overwrites any existing file.
*
* @throws IOException if the file cannot be written.
* @throws SecurityException if [relativePath] tries to escape the home directory.
*/
fun writeFile(relativePath: String, content: String) {
val file = resolvedFile(relativePath)
file.parentFile?.mkdirs()
file.writeText(content, Charsets.UTF_8)
}

/**
* Append [content] to [relativePath], creating the file and parent directories if needed.
*
* @throws IOException if the file cannot be written.
* @throws SecurityException if [relativePath] tries to escape the home directory.
*/
fun appendFile(relativePath: String, content: String) {
val file = resolvedFile(relativePath)
file.parentFile?.mkdirs()
file.appendText(content, Charsets.UTF_8)
}

// ── Delete ───────────────────────────────────────────────────────────────

/**
* Delete the file or **empty** directory at [relativePath].
*
* @return `true` if deletion succeeded, `false` if the path did not exist.
* @throws SecurityException if [relativePath] tries to escape the home directory.
* @throws IOException if the path is a non-empty directory.
*/
fun deleteFile(relativePath: String): Boolean {
val file = resolvedFile(relativePath)
if (!file.exists()) return false
if (file.isDirectory && (file.listFiles()?.isNotEmpty() == true)) {
throw IOException("Cannot delete non-empty directory: $relativePath")
}
return file.delete()
}

/**
* Recursively delete the directory at [relativePath] and all of its contents.
*
* @return `true` if the directory was deleted, `false` if it did not exist.
* @throws SecurityException if [relativePath] tries to escape the home directory.
*/
fun deleteDirectoryRecursive(relativePath: String): Boolean {
val dir = resolvedFile(relativePath)
if (!dir.exists()) return false
return dir.deleteRecursively()
}

// ── Utility ──────────────────────────────────────────────────────────────

/** Return `true` if the file or directory at [relativePath] exists. */
fun exists(relativePath: String): Boolean = resolvedFile(relativePath).exists()

// ── Internal helpers ─────────────────────────────────────────────────────

/**
* Resolve [relativePath] against [TERMUX_HOME] and verify it does not escape
* the sandbox via `..` traversal or symlink chains.
*
* @throws SecurityException if the canonical path is outside [TERMUX_HOME].
*/
private fun resolvedFile(relativePath: String): File {
val base = File(TERMUX_HOME).canonicalFile
val candidate = File(base, relativePath).canonicalFile
val basePath = base.path
// Allow access to the home directory itself (e.g. listDirectory("")) or any path inside it.
if (candidate.path != basePath && !candidate.path.startsWith(basePath + File.separator)) {
throw SecurityException(
"Path '$relativePath' resolves outside Termux home: ${candidate.path}"
)
}
return candidate
}
}
Loading
Loading