diff --git a/app.sh b/app.sh new file mode 100644 index 0000000..070d72e --- /dev/null +++ b/app.sh @@ -0,0 +1,423 @@ +#!/system/bin/sh +# ============================================================ +# WSA WebDAV Control Panel +# ============================================================ +# Usage: +# sh app.sh Interactive menu +# sh app.sh -verbose Interactive menu (verbose) +# sh app.sh Direct command +# sh app.sh -verbose Direct command (verbose) +# +# Commands: +# start, stop, restart, status, health, log, config, +# port, root, connections, auto-start, clear, help +# ============================================================ + +PKG="com.wsa.webdav" +DATA_DIR="/data/data/$PKG/files" +RECV="$PKG/.AdbCommandReceiver" +ACT_PREFIX="com.wsa.webdav" +RESULT_ACT="$ACT_PREFIX.RESULT" +RESULT_FILE="$DATA_DIR/result.json" +CONFIG_PATH="$DATA_DIR/config.json" +DEFAULT_PORT=8088 +VERBOSE=0 +JSON_OUT=0 + +# --- Colors --- +c_r='\033[0;31m' +c_g='\033[0;32m' +c_y='\033[0;33m' +c_b='\033[0;34m' +c_c='\033[0;36m' +c_w='\033[1;37m' +c_d='\033[0m' + +# --- Helpers --- +ok() { printf "${c_g}[OK]${c_d} %s\n" "$1"; } +err() { printf "${c_r}[ERR]${c_d} %s\n" "$1"; } +info() { printf "${c_c}[INFO]${c_d} %s\n" "$1"; } +warn() { printf "${c_y}[WARN]${c_d} %s\n" "$1"; } +vlog() { [ "$VERBOSE" -eq 1 ] && printf "${c_b}[*]${c_d} %s\n" "$1" || true; } + +# Extract JSON field without jq +json_val() { + echo "$1" | sed 's/[{}]//g' | tr ',' '\n' | grep "\"$2\"" | head -1 | sed 's/.*"'"$2"'"[[:space:]]*:[[:space:]]*//' | sed 's/[",]//g' | sed 's/^ *//;s/ *$//' +} + +# Send broadcast command, return result from file +send_cmd() { + local action="$1" + shift + local t1=$(date +%s%N 2>/dev/null || echo 0) + rm -f "$RESULT_FILE" 2>/dev/null + local out + out=$(am broadcast -a "$ACT_PREFIX.$action" "$@" -n "$RECV" 2>&1) + local t2=$(date +%s%N 2>/dev/null || echo 0) + vlog "Broadcast: $action | am output: $out" + [ "$VERBOSE" -eq 1 ] && [ "$t1" != "0" ] && vlog "Time: $(( (t2 - t1) / 1000000 ))ms" + sleep 0.2 + if [ -f "$RESULT_FILE" ]; then + local result=$(cat "$RESULT_FILE" 2>/dev/null) + rm -f "$RESULT_FILE" 2>/dev/null + vlog "Result: $result" + echo "$result" + else + echo "$out" + fi +} + +# Check if port is open +port_open() { + local port=${1:-$DEFAULT_PORT} + local hex_port=$(printf "%04X" "$port") + grep -q ":${hex_port}" /proc/net/tcp /proc/net/tcp6 2>/dev/null && return 0 || return 1 +} + +# Format uptime from ms +fmt_uptime() { + local ms=$1 + local s=$(( ms / 1000 )) + local m=$(( s / 60 )) + local h=$(( m / 60 )) + if [ "$h" -gt 0 ]; then + echo "${h}h $(( m % 60 ))m" + elif [ "$m" -gt 0 ]; then + echo "${m}m $(( s % 60 ))s" + else + echo "${s}s" + fi +} + +get_port() { + if [ -f "$CONFIG_PATH" ]; then + local p=$(cat "$CONFIG_PATH" 2>/dev/null | tr -d ' \r\n' | grep -o '"port":[0-9]*' | head -1 | sed 's/"port"://') + [ -n "$p" ] && [ "$p" -gt 0 ] 2>/dev/null && echo "$p" && return + fi + echo "$DEFAULT_PORT" +} + +# ============================================================ +# Commands +# ============================================================ + +cmd_start() { + info "Starting WebDAV server..." + send_cmd "START" > /dev/null + sleep 1 + local port=$(get_port) + if port_open "$port"; then + ok "Server running on 127.0.0.1:$port" + else + am start -n "$PKG/.MainActivity" > /dev/null 2>&1 + sleep 2 + if port_open "$port"; then + ok "Server running on 127.0.0.1:$port" + else + err "Server may not have started. Check: sh app.sh log" + fi + fi +} + +cmd_stop() { + info "Stopping WebDAV server..." + send_cmd "STOP" > /dev/null + sleep 1 + ok "Stop command sent" +} + +cmd_restart() { + info "Restarting WebDAV server..." + send_cmd "RESTART" > /dev/null + sleep 2 + local port=$(get_port) + if port_open "$port"; then + ok "Server restarted on 127.0.0.1:$port" + else + warn "Restarting... check status in a moment" + fi +} + +cmd_status() { + local port=$(get_port) + local running="stopped" + port_open "$port" && running="running" + + if [ "$running" = "running" ]; then + ok "Server: RUNNING" + else + err "Server: STOPPED" + fi + info "Port: $port" + + if [ -f "$CONFIG_PATH" ]; then + local auto_start=$(grep -o '"auto_start":[^,}]*' "$CONFIG_PATH" 2>/dev/null | sed 's/.*://') + [ "$auto_start" = "true" ] && info "Auto-start: ON" || info "Auto-start: OFF" + fi + + local root_out + root_out=$(send_cmd "ROOT_STATUS" 2>/dev/null) + local root_avail=$(echo "$root_out" | grep -o '"root_available"[[:space:]]*:[[:space:]]*[^,}]*' | sed 's/.*://' | sed 's/[",]//g; s/^ *//;s/ *$//') + [ "$root_avail" = "true" ] && info "Root: available" || info "Root: unavailable" +} + +cmd_health() { + info "Checking health..." + local out=$(send_cmd "HEALTH" 2>/dev/null) + local port_open_v=$(echo "$out" | grep -o '"port_open"[[:space:]]*:[[:space:]]*[^,}]*' | sed 's/.*://' | sed 's/[",]//g; s/^ *//;s/ *$//') + local mem_used=$(echo "$out" | grep -o '"memory_used_mb"[[:space:]]*:[[:space:]]*[^,}]*' | sed 's/.*://' | sed 's/[",]//g; s/^ *//;s/ *$//') + local mem_max=$(echo "$out" | grep -o '"memory_max_mb"[[:space:]]*:[[:space:]]*[^,}]*' | sed 's/.*://' | sed 's/[",]//g; s/^ *//;s/ *$//') + local threads=$(echo "$out" | grep -o '"threads"[[:space:]]*:[[:space:]]*[^,}]*' | sed 's/.*://' | sed 's/[",]//g; s/^ *//;s/ *$//') + local root_v=$(echo "$out" | grep -o '"root_available"[[:space:]]*:[[:space:]]*[^,}]*' | sed 's/.*://' | sed 's/[",]//g; s/^ *//;s/ *$//') + + [ "$port_open_v" = "true" ] && ok "Port: open" || err "Port: closed" + info "Memory: ${mem_used}MB / ${mem_max}MB" + info "Threads: $threads" + [ "$root_v" = "true" ] && ok "Root: available" || info "Root: unavailable" + + [ "$VERBOSE" -eq 1 ] && info "Raw: $out" +} + +cmd_log() { + local lines=${1:-50} + local logfile="$DATA_DIR/server.log" + if [ -f "$logfile" ]; then + info "Last $lines log lines:" + echo "---" + tail -"$lines" "$logfile" + echo "---" + else + warn "Log file not found" + fi +} + +cmd_clear() { + send_cmd "LOG_CLEAR" > /dev/null 2>&1 + logcat -c 2>/dev/null + ok "Logs cleared" +} + +cmd_config() { + if [ -f "$CONFIG_PATH" ]; then + info "Config: $CONFIG_PATH" + echo "---" + cat "$CONFIG_PATH" + echo "" + echo "---" + else + warn "Config file not found at $CONFIG_PATH" + info "Fetching from service..." + send_cmd "CONFIG_SHOW" 2>/dev/null + fi +} + +cmd_port() { + local new_port=$1 + if [ -z "$new_port" ]; then + local port=$(get_port) + info "Current port: $port" + return + fi + if ! [ "$new_port" -gt 0 ] 2>/dev/null; then + err "Invalid port: $new_port" + return + fi + info "Setting port to $new_port (will restart)..." + send_cmd "SET_PORT" --ei port "$new_port" > /dev/null + sleep 2 + if port_open "$new_port"; then + ok "Server running on port $new_port" + else + warn "Port changed, server may be restarting..." + fi +} + +cmd_root() { + info "Checking root access..." + local out=$(send_cmd "ROOT_STATUS" 2>/dev/null) + local avail=$(echo "$out" | grep -o '"root_available"[[:space:]]*:[[:space:]]*[^,}]*' | sed 's/.*://' | sed 's/[",]//g; s/^ *//;s/ *$//') + local configured=$(echo "$out" | grep -o '"root_configured"[[:space:]]*:[[:space:]]*[^,}]*' | sed 's/.*://' | sed 's/[",]//g; s/^ *//;s/ *$//') + + [ "$avail" = "true" ] && ok "Root: available" || err "Root: unavailable" + [ "$configured" = "true" ] && info "Root access: enabled" || info "Root access: disabled" + + if [ "$avail" != "true" ]; then + info "Attempting to reacquire root..." + send_cmd "REACQUIRE_ROOT" > /dev/null 2>&1 + sleep 1 + out=$(send_cmd "ROOT_STATUS" 2>/dev/null) + avail=$(echo "$out" | grep -o '"root_available":[^,}]*' | sed 's/.*://') + [ "$avail" = "true" ] && ok "Root acquired!" || warn "Root still unavailable" + fi + + [ "$VERBOSE" -eq 1 ] && info "Raw: $out" +} + +cmd_connections() { + local out=$(send_cmd "CONNECTIONS" 2>/dev/null) + local threads=$(echo "$out" | grep -o '"active_threads"[[:space:]]*:[[:space:]]*[^,}]*' | sed 's/.*://' | sed 's/[",]//g; s/^ *//;s/ *$//') + local running=$(echo "$out" | grep -o '"server_running"[[:space:]]*:[[:space:]]*[^,}]*' | sed 's/.*://' | sed 's/[",]//g; s/^ *//;s/ *$//') + + [ "$running" = "true" ] && ok "Server: running" || err "Server: stopped" + info "Active threads: $threads" + + [ "$VERBOSE" -eq 1 ] && info "Raw: $out" +} + +cmd_autostart() { + local state=$1 + case "$state" in + on|1|true|enable) + send_cmd "ENABLE_AUTO_START" > /dev/null 2>&1 + ok "Auto-start: enabled" + ;; + off|0|false|disable) + send_cmd "DISABLE_AUTO_START" > /dev/null 2>&1 + ok "Auto-start: disabled" + ;; + *) + if [ -f "$CONFIG_PATH" ]; then + local val=$(grep -o '"auto_start":[^,}]*' "$CONFIG_PATH" 2>/dev/null | sed 's/.*://') + info "Auto-start: $val" + fi + ;; + esac +} + +cmd_help() { + echo "${c_w}WSA WebDAV Control Panel${c_d}" + echo "" + echo "Usage: sh app.sh [-verbose] [args]" + echo "" + echo "Commands:" + echo " start Start the WebDAV server" + echo " stop Stop the server" + echo " restart Restart the server" + echo " status Show server status" + echo " health Health check (memory, threads)" + echo " log [N] Show last N log lines (default 50)" + echo " clear Clear logs" + echo " config Show configuration" + echo " port [NNNN] Show or set port" + echo " root Check root access" + echo " connections Show active connections" + echo " auto-start [on|off] Toggle auto-start" + echo " help Show this help" + echo "" + echo "Flags:" + echo " -verbose Verbose output" + echo " -json Raw JSON output" + echo "" + echo "Interactive: sh app.sh (no arguments)" +} + +# ============================================================ +# Interactive Menu +# ============================================================ + +show_menu() { + echo "" + echo "${c_w}+--------------------------------------+${c_d}" + echo "${c_w}| WSA WebDAV Control Panel |${c_d}" + echo "${c_w}+--------------------------------------+${c_d}" + echo "${c_d} ${c_c}[1]${c_d} Start Server" + echo "${c_d} ${c_c}[2]${c_d} Stop Server" + echo "${c_d} ${c_c}[3]${c_d} Restart Server" + echo "${c_d} ${c_c}[4]${c_d} Server Status" + echo "${c_d} ${c_c}[5]${c_d} Health Check" + echo "${c_d} ${c_c}[6]${c_d} View Logs" + echo "${c_d} ${c_c}[7]${c_d} Show Config" + echo "${c_d} ${c_c}[8]${c_d} Change Port" + echo "${c_d} ${c_c}[9]${c_d} Root Status" + echo "${c_d} ${c_c}[10]${c_d} Connections" + echo "${c_d} ${c_c}[11]${c_d} Toggle Auto-Start" + echo "${c_d} ${c_c}[12]${c_d} Clear Logs" + echo "${c_d} ${c_c}[0]${c_d} Exit" + echo "${c_w}+--------------------------------------+${c_d}" + echo "" +} + +run_menu() { + while true; do + show_menu + printf "${c_w}Choice: ${c_d}" + read choice + echo "" + case "$choice" in + 1) cmd_start ;; + 2) cmd_stop ;; + 3) cmd_restart ;; + 4) cmd_status ;; + 5) cmd_health ;; + 6) + printf "Lines (default 50): " + read n + cmd_log "${n:-50}" + ;; + 7) cmd_config ;; + 8) + printf "New port (empty to show): " + read p + cmd_port "$p" + ;; + 9) cmd_root ;; + 10) cmd_connections ;; + 11) + printf "Auto-start [on/off]: " + read s + cmd_autostart "$s" + ;; + 12) cmd_clear ;; + 0|"") + ok "Bye" + exit 0 + ;; + *) err "Invalid choice: $choice" ;; + esac + echo "" + printf "${c_d}Press Enter to continue..." + read dummy + done +} + +# ============================================================ +# Main +# ============================================================ + +# Parse flags +CMDS="" +for arg in "$@"; do + case "$arg" in + -verbose|-v) VERBOSE=1 ;; + -json|-j) JSON_OUT=1 ;; + *) CMDS="$CMDS $arg" ;; + esac +done + +CMDS=$(echo "$CMDS" | xargs) # trim + +if [ -z "$CMDS" ]; then + [ "$VERBOSE" -eq 1 ] && info "Verbose mode enabled" + run_menu +else + # Direct command mode + set -- $CMDS + cmd=$1 + shift + case "$cmd" in + start|s) cmd_start ;; + stop|q) cmd_stop ;; + restart|r) cmd_restart ;; + status|st) cmd_status ;; + health|h) cmd_health ;; + log|l) cmd_log "$@" ;; + clear|cl) cmd_clear ;; + config|c) cmd_config ;; + port|p) cmd_port "$@" ;; + root|ro) cmd_root ;; + connections|cn) cmd_connections ;; + auto-start|as) cmd_autostart "$@" ;; + help|--help|-h) cmd_help ;; + *) err "Unknown command: $cmd"; cmd_help ;; + esac +fi diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..ced5fe8 --- /dev/null +++ b/build.bat @@ -0,0 +1,174 @@ +@echo off +title WSA WebDAV APK Builder +setlocal enabledelayedexpansion + +echo. +echo ================================================================ +echo WSA Embedded CPython WebDAV Server - APK Builder +echo ================================================================ +echo. +echo [%time%] Starting build process... +echo. + +REM ============================================ +REM STEP 1: Check Python +REM ============================================ +echo ================================================================ +echo STEP 1/6: PYTHON SETUP +echo ================================================================ +echo. +echo [%time%] Checking Python... + +python --version >nul 2>&1 +if errorlevel 1 ( + echo [%time%] [!] Python NOT FOUND + echo [%time%] Installing Python via winget... + winget install Python.Python.3.11 --accept-package-agreements --accept-source-agreements + set "PATH=C:\Python311;C:\Python311\Scripts;%LOCALAPPDATA%\Programs\Python\Python311;%LOCALAPPDATA%\Programs\Python\Python311\Scripts;%PATH%" +) + +python --version >nul 2>&1 +if errorlevel 1 ( + echo [%time%] [x] Python install failed + pause + exit /b 1 +) + +echo [%time%] [+] Python OK +python --version +echo. + +REM ============================================ +REM STEP 2: Check Java +REM ============================================ +echo ================================================================ +echo STEP 2/6: JAVA SETUP +echo ================================================================ +echo. +echo [%time%] Checking Java... + +REM First check if java is in PATH +java -version >nul 2>&1 +if not errorlevel 1 ( + echo [%time%] [+] Java found in PATH + java -version 2>&1 + goto :java_done +) + +REM Search common installation paths +echo [%time%] Java not in PATH, searching common locations... + +set "JAVA_FOUND=0" + +REM Check Microsoft JDK +for /d %%i in ("C:\Program Files\Microsoft\jdk-*") do ( + if exist "%%i\bin\java.exe" ( + set "JAVA_HOME=%%i" + set "PATH=%%i\bin;!PATH!" + set "JAVA_FOUND=1" + echo [%time%] [+] Found: %%i + goto :java_found + ) +) + +REM Check Eclipse Adoptium +for /d %%i in ("C:\Program Files\Eclipse Adoptium\jdk-*") do ( + if exist "%%i\bin\java.exe" ( + set "JAVA_HOME=%%i" + set "PATH=%%i\bin;!PATH!" + set "JAVA_FOUND=1" + echo [%time%] [+] Found: %%i + goto :java_found + ) +) + +REM Check Oracle Java +for /d %%i in ("C:\Program Files\Java\jdk-*") do ( + if exist "%%i\bin\java.exe" ( + set "JAVA_HOME=%%i" + set "PATH=%%i\bin;!PATH!" + set "JAVA_FOUND=1" + echo [%time%] [+] Found: %%i + goto :java_found + ) +) + +REM Check Amazon Corretto +for /d %%i in ("C:\Program Files\Amazon Corretto\jdk-*") do ( + if exist "%%i\bin\java.exe" ( + set "JAVA_HOME=%%i" + set "PATH=%%i\bin;!PATH!" + set "JAVA_FOUND=1" + echo [%time%] [+] Found: %%i + goto :java_found + ) +) + +REM Check local user installations +for /d %%i in ("%LOCALAPPDATA%\Programs\Microsoft\jdk-*") do ( + if exist "%%i\bin\java.exe" ( + set "JAVA_HOME=%%i" + set "PATH=%%i\bin;!PATH!" + set "JAVA_FOUND=1" + echo [%time%] [+] Found: %%i + goto :java_found + ) +) + +:java_found +if "%JAVA_FOUND%"=="0" ( + echo [%time%] [!] Java NOT FOUND anywhere + echo [%time%] Installing JDK 17 via winget... + winget install Microsoft.OpenJDK.17 --accept-package-agreements --accept-source-agreements + + REM Try to find again after install + for /d %%i in ("C:\Program Files\Microsoft\jdk-*") do ( + if exist "%%i\bin\java.exe" ( + set "JAVA_HOME=%%i" + set "PATH=%%i\bin;!PATH!" + echo [%time%] [+] Installed: %%i + goto :java_final_check + ) + ) +) + +:java_final_check +java -version >nul 2>&1 +if errorlevel 1 ( + echo [%time%] [x] Java not working + pause + exit /b 1 +) + +:java_done +echo [%time%] [+] Java OK +echo. + +REM ============================================ +REM STEP 3-6: Run Python Build Script +REM ============================================ +echo ================================================================ +echo STEP 3-6/6: RUNNING BUILD SCRIPT +echo ================================================================ +echo. +echo [%time%] Starting build... +echo. + +python "%~dp0builder\build.py" --verbose +if errorlevel 1 ( + echo. + echo ================================================================ + echo BUILD FAILED + echo ================================================================ + pause + exit /b 1 +) + +echo. +echo ================================================================ +echo BUILD COMPLETE! +echo ================================================================ +echo. +echo APK: output\app-release.apk +echo. +pause diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..f484604 --- /dev/null +++ b/build.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# ============================================ +# WSA WebDAV - One-Click APK Builder (CLI) +# ============================================ +# Run to build. No Android Studio. +# ============================================ + +set -e +DIR="$(cd "$(dirname "$0")" && pwd)" + +if ! command -v python3 &>/dev/null && ! command -v python &>/dev/null; then + echo "[x] Python not found. Install Python 3.8+" + exit 1 +fi + +PY=$(command -v python3 || command -v python) +$PY "$DIR/builder/build.py" "$@" diff --git a/builder/build.py b/builder/build.py new file mode 100644 index 0000000..fb7592d --- /dev/null +++ b/builder/build.py @@ -0,0 +1,2380 @@ +#!/usr/bin/env python3 +""" +WSA Native Kotlin WebDAV Server - CLI APK Builder +=================================================== +Headless. No UI. Pure Kotlin. Full WebDAV. +""" + +import os +import sys +import json +import shutil +import subprocess +import platform +import urllib.request +import zipfile +import tarfile +import time +from pathlib import Path +from typing import Optional +from datetime import datetime + +sys.path.insert(0, str(Path(__file__).parent)) +from webui import webui_html + +ROOT = Path(__file__).parent.parent +BUILD = ROOT / ".build" +SDK = BUILD / "sdk" +PROJECT = BUILD / "project" +OUTPUT = ROOT / "output" + +ANDROID_PLATFORM = "android-34" +BUILD_TOOLS = "34.0.0" + +VERBOSE = "--verbose" in sys.argv or "-v" in sys.argv +BUILD_START_TIME = time.time() + +class Logger: + def __init__(self): + self.step_num = 0 + self.substep_num = 0 + self.log_file = ROOT / "build.log" + self.log_file.parent.mkdir(parents=True, exist_ok=True) + with open(self.log_file, "w") as f: + f.write(f"WSA Kotlin WebDAV Build Log\n") + f.write(f"Started: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n") + f.write("=" * 60 + "\n\n") + + def _timestamp(self): + elapsed = time.time() - BUILD_START_TIME + return f"[{int(elapsed//60):02d}:{int(elapsed%60):02d}]" + + def _write_log(self, msg): + with open(self.log_file, "a") as f: + f.write(msg + "\n") + + def step(self, num, total, msg): + self.step_num = num + self.substep_num = 0 + border = "=" * 60 + print(f"\n\033[1;36m{border}\033[0m") + print(f"\033[1;36m STEP {num}/{total}: {msg}\033[0m") + print(f"\033[1;36m{border}\033[0m\n") + self._write_log(f"\nSTEP {num}/{total}: {msg}\n{border}\n") + + def substep(self, msg): + self.substep_num += 1 + ts = self._timestamp() + print(f" {ts} [{self.step_num}.{self.substep_num}] {msg}") + self._write_log(f"{ts} [{self.step_num}.{self.substep_num}] {msg}") + + def info(self, msg): + ts = self._timestamp() + print(f" {ts} \033[32m[+]\033[0m {msg}") + self._write_log(f"{ts} [+] {msg}") + + def detail(self, msg): + ts = self._timestamp() + if VERBOSE: + print(f" {ts} {msg}") + self._write_log(f"{ts} {msg}") + + def warn(self, msg): + ts = self._timestamp() + print(f" {ts} \033[33m[!]\033[0m {msg}") + self._write_log(f"{ts} [!] {msg}") + + def error(self, msg): + ts = self._timestamp() + print(f" {ts} \033[31m[x]\033[0m {msg}") + self._write_log(f"{ts} [x] {msg}") + + def file_info(self, path): + if path.exists(): + size = path.stat().st_size + if size > 1048576: + return f"{size/1048576:.1f} MB" + elif size > 1024: + return f"{size/1024:.1f} KB" + return f"{size} bytes" + return "not found" + + def step_complete(self, msg=""): + ts = self._timestamp() + print(f" {ts} \033[1;32m>>> {msg or 'Complete'}\033[0m\n") + self._write_log(f"{ts} >>> {msg or 'Complete'}\n") + + def summary(self): + elapsed = time.time() - BUILD_START_TIME + print(f"\n{'='*60}") + print(f" BUILD SUMMARY") + print(f"{'='*60}") + print(f" Total Time: {int(elapsed//60)}m {int(elapsed%60)}s") + print(f" Log File: {self.log_file}") + print(f"{'='*60}\n") + +log = Logger() + +def download(url, dest, label="", max_retries=3): + if dest.exists() and dest.stat().st_size > 0: + log.info(f"Already exists: {dest.name} ({log.file_info(dest)})") + return True + dest.parent.mkdir(parents=True, exist_ok=True) + for attempt in range(1, max_retries + 1): + log.substep(f"Download: {label or dest.name} (attempt {attempt}/{max_retries})") + log.detail(f"URL: {url}") + log.detail(f"Destination: {dest}") + tmp = dest.with_suffix(dest.suffix + ".dl") + start_time = time.time() + try: + def hook(block_num, block_size, total_size): + downloaded = block_num * block_size + elapsed = time.time() - start_time + if total_size > 0 and total_size < 2**31: + pct = min(100, downloaded * 100 // total_size) + mb_done = downloaded / 1048576 + mb_total = total_size / 1048576 + speed = downloaded / elapsed / 1048576 if elapsed > 0 else 0 + eta = (total_size - downloaded) / (downloaded / elapsed) if downloaded > 0 and elapsed > 0 else 0 + bar_len = 30 + filled = int(bar_len * pct // 100) + bar = "#" * filled + "-" * (bar_len - filled) + print(f"\r {bar} {pct:3d}% {mb_done:.1f}/{mb_total:.1f}MB | {speed:.1f}MB/s | ETA: {int(eta)}s ", end="", flush=True) + urllib.request.urlretrieve(url, tmp, hook) + elapsed = time.time() - start_time + print() + log.info(f"Download complete in {elapsed:.1f}s ({log.file_info(tmp)})") + tmp.rename(dest) + return True + except Exception as e: + print() + log.warn(f"Download attempt {attempt} failed: {e}") + tmp.unlink(missing_ok=True) + if attempt < max_retries: + time.sleep(attempt * 5) + else: + log.error(f"Download failed after {max_retries} attempts") + return False + return False + +def extract(archive, dest): + log.substep(f"Extract: {archive.name} ({log.file_info(archive)})") + log.detail(f"Destination: {dest}") + dest.mkdir(parents=True, exist_ok=True) + start_time = time.time() + try: + if str(archive).endswith(".zip"): + with zipfile.ZipFile(archive) as z: + z.extractall(dest) + else: + with tarfile.open(archive) as t: + t.extractall(dest) + log.info(f"Extraction complete in {time.time()-start_time:.1f}s") + return True + except Exception as e: + log.error(f"Extraction failed: {e}") + return False + +def _find_java_home(): + jh = os.environ.get("JAVA_HOME") + if jh and Path(jh).exists(): + return jh + if platform.system() == "Windows": + import glob + for pattern in [r"C:\Program Files\Microsoft\jdk-17*", r"C:\Program Files\Microsoft\jdk-21*"]: + for path in sorted(glob.glob(pattern), reverse=True): + if (Path(path) / "bin" / "java.exe").exists(): + return path + return "" + +def _refresh_path(): + global PATH_ORIG + PATH_ORIG = os.environ.get("PATH", "") + +PATH_ORIG = os.environ.get("PATH", "") + +def _install_java_windows(): + log.substep("Installing JDK 17 on Windows...") + for method, cmd in [ + ("winget", ["winget", "install", "Microsoft.OpenJDK.17", "--accept-package-agreements", "--accept-source-agreements"]), + ("choco", ["choco", "install", "microsoft-openjdk17", "-y"]), + ]: + try: + log.detail(f"Trying {method}...") + r = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + if r.returncode == 0: + log.info(f"JDK 17 installed via {method}") + return True + except Exception: + pass + log.error("Failed to install Java automatically") + return False + +# ============================================================================ +# Step 1: Java +# ============================================================================ +def step_java(): + log.step(1, 5, "CHECKING JAVA") + log.substep("Checking for java.exe...") + try: + r = subprocess.run(["java", "-version"], capture_output=True, text=True) + if r.returncode == 0: + ver = r.stderr.strip().split("\n")[0] if r.stderr else "Unknown" + log.info(f"Java FOUND: {ver}") + jh = _find_java_home() + if jh: + log.detail(f"JAVA_HOME: {jh}") + log.step_complete("Java setup complete") + return True + except FileNotFoundError: + pass + return _install_java_windows() + +# ============================================================================ +# Step 2: Android SDK +# ============================================================================ +def step_sdk(): + log.step(2, 5, "ANDROID SDK") + sdkmanager = None + for name in ["sdkmanager.bat", "sdkmanager"]: + p = SDK / "cmdline-tools" / "latest" / "bin" / name + if p.exists(): + sdkmanager = p + break + if sdkmanager: + log.info("SDK already set up") + log.detail(f"sdkmanager: {sdkmanager}") + log.step_complete("SDK setup complete") + return True + + log.substep("Android SDK not found - downloading...") + system = platform.system() + urls = { + "Windows": "https://dl.google.com/android/repository/commandlinetools-win-11076708_latest.zip", + "Linux": "https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip", + "Darwin": "https://dl.google.com/android/repository/commandlinetools-mac-11076708_latest.zip", + } + archive = BUILD / "cmdline-tools.zip" + if not download(urls[system], archive, "Android SDK Command-line Tools"): + return False + + tools_dir = SDK / "cmdline-tools" + if not extract(archive, tools_dir): + return False + + latest = tools_dir / "latest" + if not latest.exists(): + for d in tools_dir.iterdir(): + if d.is_dir() and (d / "bin").exists(): + d.rename(latest) + break + archive.unlink(missing_ok=True) + + sdkmanager = None + for name in ["sdkmanager.bat", "sdkmanager"]: + p = SDK / "cmdline-tools" / "latest" / "bin" / name + if p.exists(): + sdkmanager = p + break + if not sdkmanager: + log.error("sdkmanager not found after install") + return False + + log.substep("Accepting SDK licenses...") + licenses_dir = SDK / "licenses" + licenses_dir.mkdir(parents=True, exist_ok=True) + h = "24333f8a63b6825ea9c5514f83c2829b004d1fee" + for name in ["android-sdk-license", "android-sdk-preview-license", "android-googletv-license", + "google-gdk-license", "intel-android-extra-license", "android-sdk-arm-dbt-license"]: + (licenses_dir / name).write_text(f"\n{h}\n") + + env = os.environ.copy() + env["ANDROID_HOME"] = str(SDK) + try: + subprocess.run([str(sdkmanager), f"--sdk_root={SDK}", "--licenses"], + input="y\ny\ny\ny\ny\ny\ny\ny\n", capture_output=True, text=True, timeout=60) + except Exception as e: + log.warn(f"License warning: {e}") + + log.substep("Installing SDK components...") + for comp_id, comp_name in [ + ("platform-tools", "Platform Tools"), + (f"platforms;{ANDROID_PLATFORM}", f"Android Platform {ANDROID_PLATFORM}"), + (f"build-tools;{BUILD_TOOLS}", f"Build Tools {BUILD_TOOLS}"), + ]: + log.detail(f"Installing: {comp_name}") + try: + r = subprocess.run([str(sdkmanager), f"--sdk_root={SDK}", comp_id], + capture_output=True, text=True, timeout=600) + if r.returncode == 0: + log.info(f"Installed: {comp_name}") + else: + log.warn(f"Failed: {comp_name}") + except Exception as e: + log.warn(f"Error: {e}") + + log.step_complete("SDK setup complete") + return True + +# ============================================================================ +# Step 3: Project Generation (Kotlin Headless) +# ============================================================================ +def step_project(): + log.step(3, 5, "ANDROID PROJECT GENERATION") + if PROJECT.exists(): + log.substep("Cleaning old project...") + shutil.rmtree(PROJECT) + + log.substep("Creating directory structure...") + dirs = [ + "app/src/main/java/com/wsa/webdav", + "app/src/main/res/values", + "app/src/main/res/raw", + "gradle/wrapper", + ] + for d in dirs: + (PROJECT / d).mkdir(parents=True, exist_ok=True) + log.detail(f" Created: {d}") + + log.substep("Writing Kotlin source files...") + _write_files() + log.step_complete("Project generated") + return True + +def _appsh_raw(): + appsh_path = ROOT / "app.sh" + if appsh_path.exists(): + return appsh_path.read_text(encoding="utf-8").replace("\r\n", "\n").replace("\r", "\n") + return '''#!/system/bin/sh +# WSA WebDAV Control Panel (fallback - app.sh not found in project root) +echo "app.sh not found. Run from project root or push manually." +echo "Usage: adb push app.sh /data/local/tmp/ && adb shell sh /data/local/tmp/app.sh" +''' + +def _write_files(): + files = { + "app/src/main/AndroidManifest.xml": _manifest(), + "app/src/main/java/com/wsa/webdav/Logger.kt": _logger_kt(), + "app/src/main/java/com/wsa/webdav/ConfigManager.kt": _configmanager_kt(), + "app/src/main/java/com/wsa/webdav/RootManager.kt": _rootmanager_kt(), + "app/src/main/java/com/wsa/webdav/FileSystemProvider.kt": _filesystemprovider_kt(), + "app/src/main/java/com/wsa/webdav/RootFileSystemProvider.kt": _rootfilesystemprovider_kt(), + "app/src/main/java/com/wsa/webdav/WebDavServer.kt": _webdavserver_kt(), + "app/src/main/java/com/wsa/webdav/ServiceManager.kt": _servicemanager_kt(), + "app/src/main/java/com/wsa/webdav/HealthMonitor.kt": _healthmonitor_kt(), + "app/src/main/java/com/wsa/webdav/RecoveryManager.kt": _recoverymanager_kt(), + "app/src/main/java/com/wsa/webdav/CommandDispatcher.kt": _commanddispatcher_kt(), + "app/src/main/java/com/wsa/webdav/AdbCommandReceiver.kt": _adbcommandreceiver_kt(), + "app/src/main/java/com/wsa/webdav/WebDavService.kt": _webdavservice_kt(), + "app/src/main/java/com/wsa/webdav/MainActivity.kt": _mainactivity_kt(), + "app/src/main/java/com/wsa/webdav/BootReceiver.kt": _bootreceiver_kt(), + "build.gradle": _build_gradle(), + "app/build.gradle": _app_build_gradle(), + "settings.gradle": "rootProject.name='WSAWebDAV'\ninclude':app'\n", + "gradle.properties": "android.useAndroidX=false\norg.gradle.jvmargs=-Xmx2048m\n", + "gradle/wrapper/gradle-wrapper.properties": "distributionBase=GRADLE_USER_HOME\ndistributionPath=wrapper/dists\ndistributionUrl=https\\://services.gradle.org/distributions/gradle-8.4-bin.zip\nzipStoreBase=GRADLE_USER_HOME\nzipStorePath=wrapper/dists\n", + "app/src/main/res/values/strings.xml": 'WSA WebDAV\n', + "app/src/main/res/raw/webui.html": webui_html(), + "app/src/main/res/raw/app.sh": _appsh_raw(), + } + for path, content in files.items(): + full_path = PROJECT / path + full_path.parent.mkdir(parents=True, exist_ok=True) + full_path.write_bytes(content.encode("utf-8")) + log.detail(f" Written: {path}") + +def _manifest(): + return ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +''' + +def _build_gradle(): + return '''buildscript { + ext.kotlin_version = "1.9.22" + repositories { + google() + mavenCentral() + } + dependencies { + classpath "com.android.tools.build:gradle:8.2.0" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} +allprojects { + repositories { + google() + mavenCentral() + } +} +''' + +def _app_build_gradle(): + return '''plugins { + id "com.android.application" + id "org.jetbrains.kotlin.android" +} +android { + namespace "com.wsa.webdav" + compileSdk 34 + defaultConfig { + applicationId "com.wsa.webdav" + minSdk 26 + targetSdk 34 + versionCode 1 + versionName "1.0.0" + } + buildTypes { + release { + minifyEnabled false + signingConfig signingConfigs.debug + } + debug { + debuggable true + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + sourceSets { + main { + manifest.srcFile "src/main/AndroidManifest.xml" + java.srcDirs = ["src/main/java"] + res.srcDirs = ["src/main/res"] + } + } + lint { + abortOnError false + } +} +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib:1.9.22" +} +''' + +# ============================================================================ +# Kotlin Source Templates +# ============================================================================ + +def _logger_kt(): + return '''package com.wsa.webdav + +import java.io.File +import java.io.FileWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +object Logger { + private const val LOG_DIR = "/data/data/com.wsa.webdav/files" + private const val LOG_FILE = "$LOG_DIR/server.log" + private const val MAX_SIZE = 5 * 1024L * 1024L + private val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US) + + fun info(msg: String) = log("INFO", msg) + fun warn(msg: String) = log("WARN", msg) + fun error(msg: String) = log("ERROR", msg) + fun error(msg: String, t: Throwable) = log("ERROR", "$msg: ${t.message}") + + private fun log(level: String, msg: String) { + try { + android.util.Log.d("WSADAV", "[$level] $msg") + val dir = File(LOG_DIR) + dir.mkdirs() + val file = File(LOG_FILE) + if (file.exists() && file.length() > MAX_SIZE) { + val rotated = File("$LOG_FILE.1") + if (rotated.exists()) rotated.delete() + file.renameTo(rotated) + } + FileWriter(file, true).use { w -> + w.write("${sdf.format(Date())} [$level] $msg\\n") + } + } catch (_: Exception) {} + } + + fun readLast(lines: Int = 100): String { + return try { + val file = File(LOG_FILE) + if (!file.exists()) return "" + file.readLines().takeLast(lines).joinToString("\\n") + } catch (_: Exception) { "" } + } + + fun clear() { + try { File(LOG_FILE).delete() } catch (_: Exception) {} + } +} +''' + +def _configmanager_kt(): + return '''package com.wsa.webdav + +import org.json.JSONObject +import java.io.File + +class ConfigManager { + private val configFile = File("/data/data/com.wsa.webdav/files/config.json") + private var config: JSONObject = load() + + private fun defaults() = JSONObject().apply { + put("enabled", true) + put("port", 8088) + put("auto_start", true) + put("anonymous_access", true) + put("enable_root_access", true) + put("user_storage_path", "/storage/emulated/0") + put("root_storage_path", "/") + } + + private fun load(): JSONObject { + return try { + if (configFile.exists()) { + val json = JSONObject(configFile.readText()) + val def = defaults() + for (key in def.keys()) { + if (!json.has(key)) json.put(key, def.get(key)) + } + json + } else { + defaults() + } + } catch (_: Exception) { + defaults() + } + } + + fun reload() { config = load(); Logger.info("Config reloaded") } + + fun save() { + configFile.parentFile?.mkdirs() + configFile.writeText(config.toString(2)) + Logger.info("Config saved") + } + + fun reset() { config = defaults(); save(); Logger.info("Config reset to defaults") } + + fun isEnabled() = config.optBoolean("enabled", true) + fun getPort() = config.optInt("port", 8088) + fun isAutoStart() = config.optBoolean("auto_start", true) + fun isAnonymousAccess() = config.optBoolean("anonymous_access", true) + fun isRootAccessEnabled() = config.optBoolean("enable_root_access", true) + fun getUserStoragePath() = config.optString("user_storage_path", "/storage/emulated/0") + fun getRootStoragePath() = config.optString("root_storage_path", "/") + + fun setPort(port: Int) { config.put("port", port); save() } + fun setEnabled(v: Boolean) { config.put("enabled", v); save() } + fun setAutoStart(v: Boolean) { config.put("auto_start", v); save() } + + fun toJson() = config.toString(2) +} +''' + +def _rootmanager_kt(): + return '''package com.wsa.webdav + +import android.util.Base64 +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.IOException +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock + +class RootManager { + private var suProcess: Process? = null + private var input: DataInputStream? = null + private var output: DataOutputStream? = null + private val rooted = AtomicBoolean(false) + private val lock = ReentrantLock() + + fun acquire(): Boolean = lock.withLock { + try { + val suPaths = arrayOf("/product/bin/su", "/system/bin/su", "/sbin/su", "su") + var p: Process? = null + for (su in suPaths) { + try { + p = Runtime.getRuntime().exec(arrayOf(su)) + break + } catch (_: Exception) { continue } + } + if (p == null) throw Exception("su not found") + output = DataOutputStream(p.outputStream) + input = DataInputStream(p.inputStream) + val result = execute("id") + if (result.contains("uid=0")) { + rooted.set(true) + suProcess = p + Logger.info("Root acquired: $result") + true + } else { + p.destroy() + rooted.set(false) + Logger.warn("Root denied: $result") + false + } + } catch (e: Exception) { + Logger.error("Root acquire failed", e) + rooted.set(false) + false + } + } + + fun execute(cmd: String): String = lock.withLock { + try { + val os = output ?: throw IOException("No su session") + val ins = input ?: throw IOException("No su input") + val marker = "_RMK_${System.nanoTime()}_" + os.writeBytes("$cmd 2>&1\\necho $marker\\n") + os.flush() + val sb = StringBuilder() + val lineBuf = StringBuilder() + while (true) { + val b = ins.readByte().toInt() and 0xFF + if (b == 0x0A) { + val line = lineBuf.toString().trim() + lineBuf.clear() + if (line == marker) break + sb.appendLine(line) + } else if (b != 0x0D) { + lineBuf.append(b.toChar()) + } + } + sb.toString().trim() + } catch (e: Exception) { + Logger.error("Root execute failed: $cmd", e) + "" + } + } + + fun readFile(path: String): ByteArray? { + val b64 = execute("base64 -w0 '$path' 2>/dev/null; echo") + return try { + if (b64.isNotEmpty()) Base64.decode(b64, Base64.NO_WRAP) else null + } catch (_: Exception) { null } + } + + private var rwRemounted = false + + fun writeFile(path: String, data: ByteArray) { + val dir = path.substringBeforeLast('/') + if (dir.isNotEmpty()) execute("mkdir -p '$dir'") + if (!rwRemounted) { + execute("mount -o rw,remount / 2>/dev/null") + rwRemounted = true + } + if (data.size > 80 * 1024) { + writeFileStream(path, data) + } else { + val b64 = Base64.encodeToString(data, Base64.NO_WRAP) + execute("printf '%s' '$b64' | base64 -d > '$path'; sync") + } + val verify = execute("test -e '$path' && echo OK 2>/dev/null") + if (!verify.contains("OK")) { + throw java.io.IOException("Write failed - filesystem may be read-only: $path") + } + } + + private fun writeFileStream(path: String, data: ByteArray) { + lock.withLock { + try { + val b64 = android.util.Base64.encodeToString(data, android.util.Base64.NO_WRAP) + val tmpB64 = "/data/local/tmp/.wsa_b64_$$" + execute("rm -f '$tmpB64'") + val chunkSize = 60000 + var offset = 0 + var first = true + while (offset < b64.length) { + val chunk = b64.substring(offset, minOf(offset + chunkSize, b64.length)) + val redirect = if (first) ">" else ">>" + execute("printf '%s' '$chunk' $redirect '$tmpB64'") + first = false + offset += chunkSize + } + execute("base64 -d '$tmpB64' > '$path' && rm -f '$tmpB64' && sync") + } catch (e: Exception) { + Logger.error("Root writeFileStream failed", e) + throw e + } + } + } + + fun listDir(path: String): List { + val out = execute("ls -1L '$path' 2>/dev/null") + return if (out.isNotEmpty()) out.split("\\n").filter { it.isNotBlank() } else emptyList() + } + + fun stat(path: String): String = execute("stat -L -c '%s %Y %F' '$path' 2>/dev/null") + + fun mkdir(path: String) { execute("mkdir -p '$path'") } + + fun rm(path: String, recursive: Boolean = false) { + execute(if (recursive) "rm -rf '$path'" else "rm -f '$path'") + } + + fun mv(src: String, dst: String) { + try { + execute("mv -f '$src' '$dst'") + } catch (e: Exception) { + Logger.error("Root mv failed", e) + } + } + + fun cp(src: String, dst: String) { + try { + execute("cp -rf '$src' '$dst'") + } catch (e: Exception) { + Logger.error("Root cp failed", e) + } + } + + fun exists(path: String): Boolean = execute("test -e '$path' && echo yes || echo no").trim() == "yes" + + fun isAvailable() = rooted.get() + + fun reacquire() { + release() + acquire() + } + + fun release() = lock.withLock { + try { + output?.writeBytes("exit\\n") + output?.flush() + suProcess?.waitFor() + } catch (_: Exception) {} + suProcess = null + input = null + output = null + rooted.set(false) + } +} +''' + +def _filesystemprovider_kt(): + return '''package com.wsa.webdav + +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +class FileSystemProvider(private val rootPath: String) { + + data class FileEntry( + val name: String, + val href: String, + val isDirectory: Boolean, + val size: Long = 0, + val lastModified: String = "", + val contentType: String = "application/octet-stream" + ) + + private fun resolve(virtualPath: String): File { + val clean = virtualPath.trimStart('/') + return File(rootPath, clean).canonicalFile + } + + fun exists(path: String): Boolean = resolve(path).exists() + + fun isDirectory(path: String): Boolean = resolve(path).isDirectory + + fun list(path: String): List { + val dir = resolve(path) + if (!dir.exists() || !dir.isDirectory) return emptyList() + return dir.listFiles()?.map { file -> + entryFor(file, path) + }?.sortedWith(compareBy { !it.isDirectory }.thenBy { it.name.lowercase() }) + ?: emptyList() + } + + fun getEntry(path: String): FileEntry? { + val file = resolve(path) + if (!file.exists()) return null + return entryFor(file, File(path).parent ?: "") + } + + fun read(path: String): FileInputStream? { + val file = resolve(path) + return if (file.exists() && file.isFile) FileInputStream(file) else null + } + + fun write(path: String, data: ByteArray) { + val file = resolve(path) + file.parentFile?.mkdirs() + FileOutputStream(file).use { it.write(data) } + } + + fun writeStream(path: String, input: java.io.InputStream) { + val file = resolve(path) + file.parentFile?.mkdirs() + FileOutputStream(file).use { fos -> + input.copyTo(fos) + } + } + + fun delete(path: String): Boolean { + val file = resolve(path) + return if (file.exists()) file.deleteRecursively() else false + } + + fun mkdir(path: String): Boolean { + val file = resolve(path) + if (file.exists()) return file.isDirectory + return file.mkdirs() + } + + fun copy(src: String, dst: String): Boolean { + val s = resolve(src) + val d = resolve(dst) + if (!s.exists()) return false + return try { + if (s.isDirectory) { + s.copyRecursively(d, overwrite = true) + } else { + d.parentFile?.mkdirs() + s.copyTo(d, overwrite = true) + } + true + } catch (_: Exception) { false } + } + + fun move(src: String, dst: String): Boolean { + val s = resolve(src) + val d = resolve(dst) + if (!s.exists()) return false + if (s.renameTo(d)) return true + return try { + if (s.isDirectory) { + s.copyRecursively(d, overwrite = true) + s.deleteRecursively() + } else { + d.parentFile?.mkdirs() + s.copyTo(d, overwrite = true) + s.delete() + } + true + } catch (_: Exception) { false } + } + + fun getContentLength(path: String): Long { + val file = resolve(path) + return if (file.isFile) file.length() else 0 + } + + fun getLastModified(path: String): Long { + return resolve(path).lastModified() + } + + private fun entryFor(file: File, parentVirtual: String): FileEntry { + val name = file.name + val href = "/${(parentVirtual.trimStart('/') + "/" + name).trimStart('/')}" + val isDir = file.isDirectory + val fmt = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US) + fmt.timeZone = TimeZone.getTimeZone("GMT") + return FileEntry( + name = name, + href = if (href.endsWith("/")) href else "$href/", + isDirectory = isDir, + size = if (isDir) 0 else file.length(), + lastModified = fmt.format(file.lastModified()), + contentType = if (isDir) "httpd/unix-directory" else guessType(name) + ) + } + + private fun guessType(name: String): String { + val ext = name.substringAfterLast(".", "").lowercase() + return when (ext) { + "html", "htm" -> "text/html" + "css" -> "text/css" + "js" -> "application/javascript" + "json" -> "application/json" + "xml" -> "text/xml" + "txt", "log" -> "text/plain" + "png" -> "image/png" + "jpg", "jpeg" -> "image/jpeg" + "gif" -> "image/gif" + "svg" -> "image/svg+xml" + "mp4" -> "video/mp4" + "mp3" -> "audio/mpeg" + "pdf" -> "application/pdf" + "zip" -> "application/zip" + "apk" -> "application/vnd.android.package-archive" + "dex" -> "application/dex" + else -> "application/octet-stream" + } + } +} +''' + +def _rootfilesystemprovider_kt(): + return '''package com.wsa.webdav + +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +class RootFileSystemProvider(private val rootManager: RootManager) { + + data class RootEntry( + val name: String, + val href: String, + val isDirectory: Boolean, + val size: Long = 0, + val lastModified: String = "", + val contentType: String = "application/octet-stream" + ) + + fun isAvailable() = rootManager.isAvailable() + + fun exists(path: String): Boolean = rootManager.exists(path) + + fun isDirectory(path: String): Boolean { + val out = rootManager.stat(path) + return out.contains("directory") + } + + fun list(path: String): List { + val files = rootManager.listDir(path) + return files.mapNotNull { name -> + val fullPath = "${path.trimEnd('/')}/$name" + val statOut = rootManager.stat(fullPath) + val parts = statOut.split(" ") + val isDir = statOut.contains("directory") + val size = parts.getOrNull(0)?.toLongOrNull() ?: 0 + val mtime = parts.getOrNull(1)?.toLongOrNull() ?: 0 + val fmt = SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US) + fmt.timeZone = TimeZone.getTimeZone("GMT") + RootEntry( + name = name, + href = "${path.trimEnd('/')}/$name/", + isDirectory = isDir, + size = if (isDir) 0 else size, + lastModified = if (mtime > 0) fmt.format(mtime * 1000) else "", + contentType = if (isDir) "httpd/unix-directory" else guessType(name) + ) + }.sortedWith(compareBy { !it.isDirectory }.thenBy { it.name.lowercase() }) + } + + fun read(path: String): ByteArray? = rootManager.readFile(path) + + fun write(path: String, data: ByteArray) = rootManager.writeFile(path, data) + + fun delete(path: String) = rootManager.rm(path, recursive = true) + + fun mkdir(path: String) = rootManager.mkdir(path) + + fun copy(src: String, dst: String) = rootManager.cp(src, dst) + + fun move(src: String, dst: String) = rootManager.mv(src, dst) + + fun getContentLength(path: String): Long { + val out = rootManager.stat(path) + return out.split(" ").firstOrNull()?.toLongOrNull() ?: 0 + } + + private fun guessType(name: String): String { + val ext = name.substringAfterLast(".", "").lowercase() + return when (ext) { + "html", "htm" -> "text/html" + "json" -> "application/json" + "xml" -> "text/xml" + "txt", "log" -> "text/plain" + "png" -> "image/png" + "jpg", "jpeg" -> "image/jpeg" + "apk" -> "application/vnd.android.package-archive" + "dex" -> "application/dex" + "so" -> "application/x-sharedlib" + else -> "application/octet-stream" + } + } +} +''' + +def _webdavserver_kt(): + return '''package com.wsa.webdav + +import java.io.BufferedInputStream +import java.io.DataOutputStream +import java.io.InputStream +import java.net.ServerSocket +import java.net.Socket +import java.net.URLDecoder +import java.util.StringTokenizer +import java.util.concurrent.Executors + +class WebDavServer( + private val port: Int, + private val fsProvider: FileSystemProvider, + private val rootFsProvider: RootFileSystemProvider, + private val config: ConfigManager, + private val webuiHtml: String +) { + @Volatile private var running = false + private var serverSocket: ServerSocket? = null + private val executor = java.util.concurrent.ThreadPoolExecutor( + 0, Integer.MAX_VALUE, 60L, java.util.concurrent.TimeUnit.SECONDS, + java.util.concurrent.SynchronousQueue(), + { r -> Thread(r, "WebDAV-Client").apply { isDaemon = true } } + ) + private val startTime = System.currentTimeMillis() + + fun start() { + running = true + serverSocket = ServerSocket(port, 50, java.net.InetAddress.getByName("0.0.0.0")) + Logger.info("WebDAV server listening on 0.0.0.0:$port") + while (running) { + try { + val client = serverSocket?.accept() ?: break + executor.submit { handleClient(client) } + } catch (_: Exception) { + if (running) Thread.sleep(100) + } + } + } + + fun stop() { + running = false + try { serverSocket?.close() } catch (_: Exception) {} + executor.shutdownNow() + Logger.info("WebDAV server stopped") + } + + fun isRunning() = running + fun getPort() = port + fun getUptime() = System.currentTimeMillis() - startTime + fun getActiveThreads() = (executor as? java.util.concurrent.ThreadPoolExecutor)?.activeCount ?: 0 + + private fun readHttpLine(bis: BufferedInputStream): String? { + val sb = StringBuilder() + var prev = 0 + while (true) { + val b = bis.read() + if (b == -1) return if (sb.isEmpty()) null else sb.toString() + if (b == 10) { + if (prev == 13) sb.deleteCharAt(sb.length - 1) + return sb.toString() + } + sb.append(b.toChar()) + prev = b + } + } + + private fun handleClient(client: Socket) { + try { + client.soTimeout = 30000 + val bis = BufferedInputStream(client.getInputStream()) + val output = DataOutputStream(client.getOutputStream()) + var keepAlive = true + while (keepAlive) { + val requestLine = try { + readHttpLine(bis) ?: break + } catch (e: java.net.SocketTimeoutException) { + break + } catch (e: java.io.IOException) { + break + } + if (requestLine.isEmpty()) break + val tokens = StringTokenizer(requestLine) + if (tokens.countTokens() < 2) { keepAlive = false; break } + val method = tokens.nextToken() + var path = URLDecoder.decode(tokens.nextToken(), "UTF-8") + val headers = mutableMapOf() + var line: String? + while (readHttpLine(bis).also { line = it } != null && line!!.isNotEmpty()) { + val idx = line!!.indexOf(':') + if (idx > 0) headers[line!!.substring(0, idx).trim().lowercase()] = line!!.substring(idx + 1).trim() + } + val conn = headers["connection"] ?: "" + if (conn.equals("close", ignoreCase = true)) keepAlive = false + val depth = headers["depth"] ?: "1" + val contentLength = headers["content-length"]?.toLongOrNull() ?: 0 + val chunked = headers["transfer-encoding"]?.contains("chunked", ignoreCase = true) == true + val translate = headers["translate"] ?: "" + val overwrite = headers["overwrite"] ?: "T" + Logger.info("${client.inetAddress.hostAddress} $method $path cl=$contentLength chunked=$chunked") + for ((k, v) in headers) Logger.info(" HDR: $k: $v") + try { + when (method) { + "OPTIONS" -> handleOptions(output) + "HEAD" -> handleHead(output, path) + "GET" -> handleGet(output, path, translate) + "PUT" -> handlePut(output, path, bis, contentLength, chunked) + "DELETE" -> handleDelete(output, path, bis, contentLength, chunked) + "MKCOL" -> handleMkcol(output, path, bis, contentLength, chunked) + "COPY" -> handleCopy(output, path, headers, overwrite) + "MOVE" -> handleMove(output, path, headers, overwrite) + "PROPFIND" -> handlePropfind(output, path, depth, bis, contentLength, chunked) + "PROPPATCH" -> handleProppatch(output, path, bis, contentLength, chunked) + "LOCK" -> handleLock(output, path, headers, bis, contentLength, chunked) + "UNLOCK" -> handleUnlock(output, path, headers, bis, contentLength, chunked) + "SEARCH" -> handlePropfind(output, path, depth, bis, contentLength, chunked) + "CHECKOUT" -> handleProppatch(output, path, bis, contentLength, chunked) + "UNCHECKOUT" -> handleProppatch(output, path, bis, contentLength, chunked) + "MERGE" -> sendStatus(output, 200, "OK") + "REPORT" -> handlePropfind(output, path, depth, bis, contentLength, chunked) + "VERSION-CONTROL" -> sendStatus(output, 200, "OK") + else -> { sendStatus(output, 405, "Method Not Allowed"); keepAlive = false } + } + } catch (e: Exception) { + Logger.error("Error handling $method $path: ${e.message}") + try { sendStatus(output, 500, "Internal Server Error") } catch (_: Exception) {} + } + try { output.flush() } catch (_: Exception) { keepAlive = false } + } + } catch (e: Exception) { + Logger.error("Client error: ${e.message}") + } finally { + try { client.close() } catch (_: Exception) {} + } + } + + private fun routePath(path: String): Pair { + return when { + path.startsWith("/files/") || path == "/files" -> { + val sub = path.removePrefix("/files").ifEmpty { "/" } + "user" to sub + } + path.startsWith("/root/") || path == "/root" -> { + val sub = path.removePrefix("/root").ifEmpty { "/" } + "root" to sub + } + path == "/" -> "index" to "" + else -> "user" to path + } + } + + private fun nowDate() = java.text.SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", java.util.Locale.US).format(java.util.Date()) + + private fun handleOptions(out: DataOutputStream) { + out.writeBytes("HTTP/1.1 200 OK\\r\\n") + out.writeBytes("Date: ${nowDate()}\\r\\n") + out.writeBytes("DAV: 1\\r\\n") + out.writeBytes("Allow: GET, PUT, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, DELETE, OPTIONS\\r\\n") + out.writeBytes("Connection: keep-alive\\r\\n") + out.writeBytes("Content-Length: 0\\r\\n") + out.writeBytes("\\r\\n") + } + + private fun handleHead(out: DataOutputStream, path: String) { + val (type, sub) = routePath(path) + if (type == "index") { + out.writeBytes("HTTP/1.1 200 OK\\r\\n") + out.writeBytes("Date: ${nowDate()}\\r\\n") + out.writeBytes("Content-Length: 0\\r\\n") + out.writeBytes("Connection: keep-alive\\r\\n") + out.writeBytes("\\r\\n") + return + } + if (type == "user") { + if (!fsProvider.exists(sub as String)) { sendStatus(out, 404, "Not Found"); return } + val entry = fsProvider.getEntry(sub) ?: sendStatus(out, 404, "Not Found").let { return } + out.writeBytes("HTTP/1.1 200 OK\\r\\n") + out.writeBytes("Date: ${nowDate()}\\r\\n") + out.writeBytes("Content-Type: ${entry.contentType}\\r\\n") + out.writeBytes("Content-Length: ${entry.size}\\r\\n") + out.writeBytes("Last-Modified: ${entry.lastModified}\\r\\n") + out.writeBytes("ETag: \\"${java.lang.Integer.toHexString(entry.name.hashCode())}\\"\\r\\n") + out.writeBytes("Connection: keep-alive\\r\\n") + out.writeBytes("\\r\\n") + } else { + if (!rootFsProvider.isAvailable()) { sendStatus(out, 403, "Root not available"); return } + if (!rootFsProvider.exists(sub as String)) { sendStatus(out, 404, "Not Found"); return } + val len = rootFsProvider.getContentLength(sub) + out.writeBytes("HTTP/1.1 200 OK\\r\\n") + out.writeBytes("Date: ${nowDate()}\\r\\n") + out.writeBytes("Content-Length: $len\\r\\n") + out.writeBytes("Connection: keep-alive\\r\\n") + out.writeBytes("\\r\\n") + } + } + + private fun handleGet(out: DataOutputStream, path: String, translate: String = "") { + val (type, sub) = routePath(path) + when (type) { + "index" -> { + sendHtml(out, webuiHtml) + } + "user" -> { + val p = sub as String + if (!fsProvider.exists(p)) { sendStatus(out, 404, "Not Found"); return } + if (fsProvider.isDirectory(p)) { + sendHtml(out, webuiHtml) + } else { + val file = fsProvider.read(p) + if (file != null) { + val entry = fsProvider.getEntry(p) + sendFile(out, file, entry?.contentType ?: "application/octet-stream", entry?.size ?: 0) + } else { + sendStatus(out, 500, "Error") + } + } + } + "root" -> { + val p = sub as String + if (!rootFsProvider.isAvailable()) { sendStatus(out, 403, "Root not available"); return } + if (!rootFsProvider.exists(p)) { sendStatus(out, 404, "Not Found"); return } + if (rootFsProvider.isDirectory(p)) { + sendHtml(out, webuiHtml) + } else { + val data = rootFsProvider.read(p) + if (data != null) { + sendFile(out, data.inputStream(), "application/octet-stream", data.size.toLong()) + } else { + sendStatus(out, 500, "Error reading file") + } + } + } + } + } + + private fun handlePut(out: DataOutputStream, path: String, bis: BufferedInputStream, length: Long, chunked: Boolean = false) { + val (type, sub) = routePath(path) + when (type) { + "user" -> { + try { + val data = readBody(bis, length, chunked) + fsProvider.write(sub as String, data) + val entry = fsProvider.getEntry(sub as String) + val now = nowDate() + val etag = "\\\"${java.util.UUID.randomUUID()}\\\"" + out.writeBytes("HTTP/1.1 201 Created\\r\\n") + out.writeBytes("Date: $now\\r\\n") + out.writeBytes("Content-Length: 0\\r\\n") + out.writeBytes("Content-Location: $path\\r\\n") + out.writeBytes("ETag: $etag\\r\\n") + out.writeBytes("Last-Modified: $now\\r\\n") + out.writeBytes("Connection: keep-alive\\r\\n") + out.writeBytes("\\r\\n") + } catch (e: Exception) { + Logger.error("PUT error", e) + sendStatus(out, 500, "Error") + } + } + "root" -> { + if (!rootFsProvider.isAvailable()) { sendStatus(out, 403, "Root not available"); return } + try { + val data = readBody(bis, length, chunked) + rootFsProvider.write(sub as String, data) + val now = nowDate() + val etag = "\\\"${java.util.UUID.randomUUID()}\\\"" + out.writeBytes("HTTP/1.1 201 Created\\r\\n") + out.writeBytes("Date: $now\\r\\n") + out.writeBytes("Content-Length: 0\\r\\n") + out.writeBytes("Content-Location: $path\\r\\n") + out.writeBytes("ETag: $etag\\r\\n") + out.writeBytes("Last-Modified: $now\\r\\n") + out.writeBytes("Connection: keep-alive\\r\\n") + out.writeBytes("\\r\\n") + } catch (e: Exception) { + Logger.error("PUT root error", e) + sendStatus(out, 500, "Error") + } + } + else -> sendStatus(out, 404, "Not Found") + } + } + + private fun handleDelete(out: DataOutputStream, path: String, bis: BufferedInputStream, length: Long, chunked: Boolean) { + if (length > 0 || chunked) readBody(bis, length, chunked) + val (type, sub) = routePath(path) + when (type) { + "user" -> { + if (fsProvider.delete(sub as String)) sendStatus(out, 204, "No Content") + else sendStatus(out, 404, "Not Found") + } + "root" -> { + if (!rootFsProvider.isAvailable()) { sendStatus(out, 403, "Root not available"); return } + rootFsProvider.delete(sub as String) + sendStatus(out, 204, "No Content") + } + else -> sendStatus(out, 404, "Not Found") + } + } + + private fun handleMkcol(out: DataOutputStream, path: String, bis: BufferedInputStream, length: Long, chunked: Boolean) { + if (length > 0 || chunked) readBody(bis, length, chunked) + val (type, sub) = routePath(path) + when (type) { + "user" -> { + val p = sub as String + if (fsProvider.exists(p)) { + if (fsProvider.isDirectory(p)) { + sendStatus(out, 200, "OK") + } else { + sendStatus(out, 405, "Method Not Allowed") + } + } else { + if (fsProvider.mkdir(p)) sendStatus(out, 201, "Created") + else sendStatus(out, 405, "Method Not Allowed") + } + } + "root" -> { + if (!rootFsProvider.isAvailable()) { sendStatus(out, 403, "Root not available"); return } + rootFsProvider.mkdir(sub as String) + sendStatus(out, 201, "Created") + } + else -> sendStatus(out, 405, "Method Not Allowed") + } + } + + private fun parseDestination(headers: Map): String { + var dest = headers["destination"]?.let { + URLDecoder.decode(it.trim(), "UTF-8") + } ?: "" + if (dest.isEmpty()) return "" + if (dest.startsWith("http://") || dest.startsWith("https://")) { + try { + val url = java.net.URL(dest) + dest = url.path + } catch (_: Exception) {} + } + return dest + } + + private fun handleCopy(out: DataOutputStream, path: String, headers: Map, overwrite: String) { + val dest = parseDestination(headers) + if (dest.isEmpty()) { sendStatus(out, 400, "Bad Request"); return } + + val (srcType, srcSub) = routePath(path) + val (_, dstSub) = routePath(dest) + + when (srcType) { + "user" -> { + if (fsProvider.exists(dstSub as String) && overwrite.equals("F", ignoreCase = true)) { + sendStatus(out, 412, "Precondition Failed") + return + } + if (fsProvider.copy(srcSub as String, dstSub)) sendStatus(out, 204, "No Content") + else sendStatus(out, 500, "Copy failed") + } + "root" -> { + if (!rootFsProvider.isAvailable()) { sendStatus(out, 403, "Root not available"); return } + val dstStr = dstSub as String + if (rootFsProvider.exists(dstStr) && overwrite.equals("F", ignoreCase = true)) { + sendStatus(out, 412, "Precondition Failed") + return + } + try { + rootFsProvider.copy(srcSub as String, dstStr) + sendStatus(out, 204, "No Content") + } catch (e: Exception) { + Logger.error("Root COPY failed", e) + sendStatus(out, 500, "Copy failed") + } + } + else -> sendStatus(out, 404, "Not Found") + } + } + + private fun handleMove(out: DataOutputStream, path: String, headers: Map, overwrite: String) { + val dest = parseDestination(headers) + if (dest.isEmpty()) { sendStatus(out, 400, "Bad Request"); return } + + val (srcType, srcSub) = routePath(path) + val (_, dstSub) = routePath(dest) + + when (srcType) { + "user" -> { + if (fsProvider.exists(dstSub as String) && overwrite.equals("F", ignoreCase = true)) { + sendStatus(out, 412, "Precondition Failed") + return + } + if (fsProvider.move(srcSub as String, dstSub as String)) sendStatus(out, 204, "No Content") + else sendStatus(out, 500, "Move failed") + } + "root" -> { + if (!rootFsProvider.isAvailable()) { sendStatus(out, 403, "Root not available"); return } + val srcStr = srcSub as String + val dstStr = dstSub as String + if (rootFsProvider.exists(dstStr) && overwrite.equals("F", ignoreCase = true)) { + sendStatus(out, 412, "Precondition Failed") + return + } + try { + rootFsProvider.move(srcStr, dstStr) + if (rootFsProvider.exists(dstStr) || !rootFsProvider.exists(srcStr)) { + sendStatus(out, 204, "No Content") + } else { + sendStatus(out, 500, "Move failed") + } + } catch (e: Exception) { + Logger.error("Root MOVE failed", e) + sendStatus(out, 500, "Move failed") + } + } + else -> sendStatus(out, 404, "Not Found") + } + } + + private fun handlePropfind(out: DataOutputStream, path: String, depth: String, bis: BufferedInputStream, length: Long, chunked: Boolean) { + if (length > 0 || chunked) readBody(bis, length, chunked) + val (type, sub) = routePath(path) + val href = path.trimEnd('/') + val includeChildren = depth != "0" + when (type) { + "index" -> { + val xml = buildPropfindResponse(href.ifEmpty { "/" }, true, listOf()) + sendXml(out, xml) + } + "user" -> { + val subPath = sub as String + if (subPath.isNotEmpty() && !fsProvider.exists(subPath)) { + sendStatus(out, 404, "Not Found"); return + } + val isDir = fsProvider.isDirectory(subPath) + val entries = if (includeChildren && isDir) fsProvider.list(subPath) + else emptyList() + val rootSize = if (!isDir) fsProvider.getContentLength(subPath) else 0L + val rootEntry = if (!isDir) fsProvider.getEntry(subPath) else null + val xml = buildPropfindResponse(href, isDir, entries, rootSize, rootEntry?.lastModified) + sendXml(out, xml) + } + "root" -> { + if (!rootFsProvider.isAvailable()) { sendStatus(out, 403, "Root not available"); return } + val subPath = sub as String + if (subPath.isNotEmpty() && !rootFsProvider.exists(subPath)) { + sendStatus(out, 404, "Not Found"); return + } + val isDir = rootFsProvider.isDirectory(subPath) + val entries = if (includeChildren && isDir) rootFsProvider.list(subPath).map { + FileSystemProvider.FileEntry(it.name, it.href, it.isDirectory, it.size, it.lastModified, it.contentType) + } else emptyList() + val xml = buildPropfindResponse(href, isDir, entries) + sendXml(out, xml) + } + else -> sendStatus(out, 404, "Not Found") + } + } + + private fun handleProppatch(out: DataOutputStream, path: String, bis: BufferedInputStream, length: Long, chunked: Boolean) { + if (length > 0 || chunked) readBody(bis, length, chunked) + val xml = "${xmlEsc(path)}HTTP/1.1 200 OK" + sendXml(out, xml) + } + + private fun handleLock(out: DataOutputStream, path: String, headers: Map, bis: BufferedInputStream, length: Long, chunked: Boolean) { + val uuid = java.util.UUID.randomUUID().toString() + val lockTokenUri = "urn:uuid:$uuid" + if (length > 0 || chunked) readBody(bis, length, chunked) + val xml = "${xmlEsc(path)}userSecond-3600$lockTokenUri" + val xmlBytes = xml.toByteArray(Charsets.UTF_8) + out.writeBytes("HTTP/1.1 200 OK\\r\\n") + out.writeBytes("Date: ${nowDate()}\\r\\n") + out.writeBytes("Lock-Token: <$lockTokenUri>\\r\\n") + out.writeBytes("Content-Type: application/xml; charset=utf-8\\r\\n") + out.writeBytes("Content-Length: ${xmlBytes.size}\\r\\n") + out.writeBytes("Connection: keep-alive\\r\\n") + out.writeBytes("\\r\\n") + out.write(xmlBytes) + } + + private fun handleUnlock(out: DataOutputStream, path: String, headers: Map, bis: BufferedInputStream, length: Long, chunked: Boolean) { + if (length > 0 || chunked) readBody(bis, length, chunked) + sendStatus(out, 200, "OK") + } + + private fun readBody(bis: BufferedInputStream, length: Long, chunked: Boolean = false): ByteArray { + if (chunked) return readChunkedBody(bis) + if (length <= 0) return ByteArray(0) + val buf = ByteArray(length.toInt()) + var offset = 0 + while (offset < buf.size) { + val read = bis.read(buf, offset, buf.size - offset) + if (read == -1) break + offset += read + } + return if (offset == buf.size) buf else buf.copyOf(offset) + } + + private fun readChunkedBody(bis: BufferedInputStream): ByteArray { + val out = java.io.ByteArrayOutputStream() + while (true) { + val sizeLine = readHttpLine(bis) ?: break + val chunkSize = sizeLine.trim().toLongOrNull(16) ?: break + if (chunkSize == 0L) { readHttpLine(bis); break } + val buf = ByteArray(chunkSize.toInt()) + var offset = 0 + while (offset < buf.size) { + val read = bis.read(buf, offset, buf.size - offset) + if (read == -1) break + offset += read + } + out.write(buf, 0, offset) + readHttpLine(bis) + } + return out.toByteArray() + } + + private fun sendStatus(out: DataOutputStream, code: Int, reason: String) { + out.writeBytes("HTTP/1.1 $code $reason\\r\\n") + out.writeBytes("Date: ${nowDate()}\\r\\n") + out.writeBytes("Content-Length: 0\\r\\n") + out.writeBytes("Connection: keep-alive\\r\\n") + out.writeBytes("\\r\\n") + } + + private fun sendHtml(out: DataOutputStream, html: String) { + val data = html.toByteArray(Charsets.UTF_8) + out.writeBytes("HTTP/1.1 200 OK\\r\\n") + out.writeBytes("Date: ${nowDate()}\\r\\n") + out.writeBytes("Content-Type: text/html; charset=utf-8\\r\\n") + out.writeBytes("Content-Length: ${data.size}\\r\\n") + out.writeBytes("Connection: keep-alive\\r\\n") + out.writeBytes("\\r\\n") + out.write(data) + } + + private fun sendXml(out: DataOutputStream, xml: String) { + val data = xml.toByteArray(Charsets.UTF_8) + out.writeBytes("HTTP/1.1 207 Multi-Status\\r\\n") + out.writeBytes("Date: ${nowDate()}\\r\\n") + out.writeBytes("Content-Type: application/xml; charset=utf-8\\r\\n") + out.writeBytes("Content-Length: ${data.size}\\r\\n") + out.writeBytes("Connection: keep-alive\\r\\n") + out.writeBytes("\\r\\n") + out.write(data) + } + + private fun sendFile(out: DataOutputStream, input: InputStream, contentType: String, size: Long) { + out.writeBytes("HTTP/1.1 200 OK\\r\\n") + out.writeBytes("Date: ${nowDate()}\\r\\n") + out.writeBytes("Content-Type: $contentType\\r\\n") + out.writeBytes("Content-Length: $size\\r\\n") + out.writeBytes("Connection: keep-alive\\r\\n") + out.writeBytes("\\r\\n") + input.copyTo(out) + input.close() + } + + private fun formatSize(bytes: Long): String = when { + bytes < 1024 -> "$bytes B" + bytes < 1048576 -> "${bytes / 1024} KB" + bytes < 1073741824 -> "${bytes / 1048576} MB" + else -> "${bytes / 1073741824} GB" + } + + private fun buildPropfindResponse(path: String, isDir: Boolean, entries: List, rootSize: Long = 0, rootLastModified: String? = null): String { + val sb = StringBuilder() + val now = java.text.SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss Z", java.util.Locale.US).format(java.util.Date()) + val basePath = path.trimEnd('/') + sb.append("") + sb.append("") + sb.append("") + sb.append("${xmlEsc(basePath.ifEmpty { "/" })}") + sb.append("") + sb.append("${xmlEsc(basePath.substringAfterLast('/').ifEmpty { "/" })}") + if (isDir) { + sb.append("") + sb.append("") + } else { + sb.append("") + sb.append("$rootSize") + sb.append("\\"${java.lang.Integer.toHexString(basePath.hashCode())}\\"") + } + sb.append("${xmlEsc(rootLastModified ?: now)}") + sb.append("") + sb.append("HTTP/1.1 200 OK") + sb.append("") + for (e in entries) { + val childHref = "$basePath/${e.name.trimStart('/')}" + sb.append("") + sb.append("${xmlEsc(childHref)}") + sb.append("") + sb.append("${xmlEsc(e.name)}") + if (e.isDirectory) { + sb.append("") + sb.append("") + sb.append("") + } else { + sb.append("") + sb.append("${e.size}") + sb.append("\\"${java.lang.Integer.toHexString(e.name.hashCode())}\\"") + sb.append("${e.contentType}") + } + sb.append("${xmlEsc(e.lastModified.ifEmpty { now })}") + sb.append("HTTP/1.1 200 OK") + sb.append("") + } + sb.append("") + return sb.toString() + } + + private fun xmlEsc(s: String) = s.replace("&", "&").replace("<", "<").replace(">", ">").replace("\\"", """) +} +''' + +def _servicemanager_kt(): + return '''package com.wsa.webdav + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import android.os.PowerManager + +class ServiceManager(private val service: Service) { + private var wakeLock: PowerManager.WakeLock? = null + private var notificationManager: NotificationManager? = null + + companion object { + private const val CHANNEL_ID = "wsa_webdav" + private const val NOTIFICATION_ID = 1 + } + + fun createChannel() { + notificationManager = service.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channel = NotificationChannel(CHANNEL_ID, "WSA WebDAV", NotificationManager.IMPORTANCE_LOW) + channel.description = "WebDAV Server" + notificationManager?.createNotificationChannel(channel) + } + + fun startForeground(port: Int, ip: String) { + val notification = Notification.Builder(service, CHANNEL_ID) + .setContentTitle("WSA WebDAV") + .setContentText("$ip:$port") + .setSmallIcon(android.R.drawable.ic_menu_share) + .setOngoing(true) + .build() + service.startForeground(NOTIFICATION_ID, notification) + } + + fun acquireWakeLock() { + val pm = service.getSystemService(Context.POWER_SERVICE) as PowerManager + wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "WSA:WebDAV").apply { acquire() } + } + + fun releaseWakeLock() { + wakeLock?.let { if (it.isHeld) it.release() } + wakeLock = null + } + + fun stopForeground() { + service.stopForeground(true) + } + + fun stopSelf() { + service.stopSelf() + } +} +''' + +def _healthmonitor_kt(): + return '''package com.wsa.webdav + +import android.os.Handler +import android.os.Looper +import java.net.ServerSocket + +class HealthMonitor( + private val config: ConfigManager, + private val rootManager: RootManager, + private val onRestart: () -> Unit +) { + private val handler = Handler(Looper.getMainLooper()) + private val interval = 30_000L + private var running = false + + private val checkRunnable = object : Runnable { + override fun run() { + if (!running) return + check() + handler.postDelayed(this, interval) + } + } + + fun start() { + running = true + handler.postDelayed(checkRunnable, interval) + Logger.info("Health monitor started (interval: ${interval/1000}s)") + } + + fun stop() { + running = false + handler.removeCallbacks(checkRunnable) + Logger.info("Health monitor stopped") + } + + private fun check() { + try { + val port = config.getPort() + val portOk = isPortOpen(port) + if (!portOk) { + Logger.warn("Health check: port $port not open, triggering restart") + onRestart() + return + } + + if (config.isRootAccessEnabled() && !rootManager.isAvailable()) { + Logger.warn("Health check: root session lost, reacquiring") + rootManager.reacquire() + } + + val runtime = Runtime.getRuntime() + val usedMB = (runtime.totalMemory() - runtime.freeMemory()) / 1048576 + Logger.info("Health: port=$port root=${rootManager.isAvailable()} mem=${usedMB}MB") + } catch (e: Exception) { + Logger.error("Health check error", e) + } + } + + private fun isPortOpen(port: Int): Boolean { + return try { + ServerSocket(port).use { false } + } catch (_: Exception) { + true + } + } +} +''' + +def _recoverymanager_kt(): + return '''package com.wsa.webdav + +import android.os.Handler +import android.os.Looper + +class RecoveryManager(private val onRestart: () -> Unit) { + private val handler = Handler(Looper.getMainLooper()) + private var restartCount = 0 + private val maxRestarts = 5 + private val baseDelay = 1000L + + fun onCrash(error: String) { + restartCount++ + if (restartCount > maxRestarts) { + Logger.error("Max restart attempts ($maxRestarts) reached, giving up") + return + } + val delay = baseDelay * restartCount + Logger.warn("Recovery: restart #$restartCount in ${delay}ms after: $error") + handler.postDelayed({ + Logger.info("Recovery: attempting restart #$restartCount") + onRestart() + }, delay) + } + + fun reset() { + restartCount = 0 + } +} +''' + +def _commanddispatcher_kt(): + return '''package com.wsa.webdav + +import android.content.Context +import android.content.Intent +import org.json.JSONObject + +class CommandDispatcher(private val context: Context) { + + private fun ensureServiceStarted() { + if (WebDavService.instance == null) { + val intent = Intent(context, WebDavService::class.java) + intent.action = "START" + try { context.startForegroundService(intent) } catch (_: Exception) { context.startService(intent) } + } + } + + fun dispatch(action: String, extras: Map): String { + return when (action) { + "START" -> { ensureServiceStarted(); jsonOk("STARTED") } + "STOP" -> { WebDavService.instance?.stopServer(); jsonOk("STOPPED") } + "RESTART" -> { ensureServiceStarted(); jsonOk("RESTARTED") } + "STATUS" -> getStatus() + "HEALTH" -> getHealth() + "ENABLE_AUTO_START" -> { ConfigManager().apply { setAutoStart(true) }; jsonOk("AUTO_START_ENABLED") } + "DISABLE_AUTO_START" -> { ConfigManager().apply { setAutoStart(false) }; jsonOk("AUTO_START_DISABLED") } + "ROOT_STATUS" -> getRootStatus() + "REACQUIRE_ROOT" -> { WebDavService.instance?.reacquireRoot(); jsonOk("REACQUIRING_ROOT") } + "CONNECTIONS" -> getConnections() + "CONFIG_SHOW" -> getConfigShow() + "CONFIG_RELOAD" -> { ConfigManager().reload(); jsonOk("CONFIG_RELOADED") } + "CONFIG_RESET" -> { ConfigManager().reset(); jsonOk("CONFIG_RESET") } + "LOG_SHOW" -> getLogShow(extras) + "LOG_CLEAR" -> { Logger.clear(); jsonOk("LOG_CLEARED") } + "GET_PORT" -> getPort() + "SET_PORT" -> setPort(extras) + else -> jsonError("Unknown action: $action") + } + } + + private fun getStatus(): String { + val svc = WebDavService.instance + val config = ConfigManager() + return JSONObject().apply { + put("status", if (svc?.isRunning() == true) "running" else "stopped") + put("port", config.getPort()) + put("uptime_ms", svc?.getUptime() ?: 0) + put("root_available", svc?.isRootAvailable() ?: false) + }.toString(2) + } + + private fun getHealth(): String { + val svc = WebDavService.instance + val runtime = Runtime.getRuntime() + val usedMB = (runtime.totalMemory() - runtime.freeMemory()) / 1048576 + val maxMB = runtime.maxMemory() / 1048576 + return JSONObject().apply { + put("status", "ok") + put("port_open", svc?.isRunning() ?: false) + put("root_available", svc?.isRootAvailable() ?: false) + put("memory_used_mb", usedMB) + put("memory_max_mb", maxMB) + put("threads", svc?.getThreadCount() ?: 0) + }.toString(2) + } + + private fun getRootStatus(): String { + val rm = WebDavService.instance?.rootManager + return JSONObject().apply { + put("root_available", rm?.isAvailable() ?: false) + put("root_configured", ConfigManager().isRootAccessEnabled()) + }.toString(2) + } + + private fun getConnections(): String { + val svc = WebDavService.instance + return JSONObject().apply { + put("active_threads", svc?.getThreadCount() ?: 0) + put("server_running", svc?.isRunning() ?: false) + }.toString(2) + } + + private fun getConfigShow(): String = ConfigManager().toJson() + + private fun getLogShow(extras: Map): String { + val lines = (extras["lines"] as? Int) ?: 50 + return JSONObject().apply { + put("log", Logger.readLast(lines)) + }.toString(2) + } + + private fun getPort(): String = JSONObject().apply { + put("port", ConfigManager().getPort()) + }.toString(2) + + private fun setPort(extras: Map): String { + val port = (extras["port"] as? Int) ?: (extras["port"] as? String)?.toIntOrNull() + if (port == null || port !in 1..65535) return jsonError("Invalid port") + ConfigManager().setPort(port) + WebDavService.instance?.restartServer() + return JSONObject().apply { put("status", "ok"); put("port", port) }.toString(2) + } + + private fun jsonOk(msg: String) = JSONObject().apply { put("status", "ok"); put("message", msg) }.toString(2) + private fun jsonError(msg: String) = JSONObject().apply { put("status", "error"); put("message", msg) }.toString(2) +} +''' + +def _adbcommandreceiver_kt(): + return '''package com.wsa.webdav + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +class AdbCommandReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val rawAction = intent.action ?: return + + val cmd: String + if (rawAction == "com.wsa.webdav.CMD") { + cmd = intent.getStringExtra("command") ?: return + } else if (rawAction.startsWith("com.wsa.webdav.")) { + cmd = rawAction.removePrefix("com.wsa.webdav.") + } else { + return + } + + val extras = mutableMapOf() + for (key in intent.extras?.keySet() ?: emptySet()) { + if (key != "command") extras[key] = intent.extras?.get(key) + } + + val dispatcher = CommandDispatcher(context) + val result = dispatcher.dispatch(cmd, extras) + + Logger.info("ADB command: $cmd -> $result") + + try { + val resultFile = java.io.File(context.filesDir, "result.json") + resultFile.writeText(result) + + try { + val su = Runtime.getRuntime().exec(arrayOf("su")) + su.outputStream.write(("chmod 644 " + resultFile.absolutePath + "\\n").toByteArray()) + su.outputStream.write("exit\\n".toByteArray()) + su.outputStream.flush() + su.waitFor() + } catch (_: Exception) {} + + val resultIntent = Intent("com.wsa.webdav.RESULT") + resultIntent.putExtra("result", result) + context.sendBroadcast(resultIntent) + } catch (_: Exception) {} + } +} +''' + +def _webdavservice_kt(): + return '''package com.wsa.webdav + +import android.app.Service +import android.content.Intent +import android.os.IBinder +import android.os.Handler +import android.os.Looper +import java.net.Inet4Address +import java.net.InetAddress +import java.net.NetworkInterface +import java.util.Collections + +class WebDavService : Service() { + + companion object { + var instance: WebDavService? = null + private set + } + + private var server: WebDavServer? = null + private var serverThread: Thread? = null + private var serviceManager: ServiceManager? = null + private var healthMonitor: HealthMonitor? = null + private var recoveryManager: RecoveryManager? = null + private var configManager: ConfigManager? = null + var rootManager: RootManager? = null + private set + private val handler = Handler(Looper.getMainLooper()) + + override fun onCreate() { + super.onCreate() + instance = this + configManager = ConfigManager() + rootManager = RootManager() + serviceManager = ServiceManager(this) + recoveryManager = RecoveryManager { restartServer() } + + serviceManager?.createChannel() + + if (configManager?.isRootAccessEnabled() == true) { + rootManager?.acquire() + } + + extractShellScript() + + Logger.info("WebDavService created") + } + + private fun extractShellScript() { + try { + val input = resources.openRawResource(R.raw.app) + val scriptContent = input.bufferedReader().use { it.readText() } + + val filesDir = filesDir + filesDir.mkdirs() + val outFile = java.io.File(filesDir, "app.sh") + outFile.writeText(scriptContent) + + val cmds = listOf( + "chmod 755 " + filesDir.parentFile.absolutePath, + "chmod 755 " + filesDir.absolutePath, + "chmod 755 " + outFile.absolutePath, + "chmod 644 " + java.io.File(filesDir, "config.json").absolutePath, + "chmod 644 " + java.io.File(filesDir, "result.json").absolutePath + ) + val su = Runtime.getRuntime().exec(arrayOf("su")) + val os = su.outputStream + for (cmd in cmds) { + os.write((cmd + "\\n").toByteArray()) + } + os.write("exit\\n".toByteArray()) + os.flush() + su.waitFor() + + if (outFile.exists()) { + Logger.info("Shell script: " + outFile.absolutePath) + } else { + Logger.warn("Shell script extraction failed") + } + } catch (e: Exception) { + Logger.warn("Failed to extract shell script: ${e.message}") + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val action = intent?.action + when (action) { + "START" -> startServer() + "STOP" -> stopServer() + "RESTART" -> restartServer() + else -> startServer() + } + return START_STICKY + } + + fun startServer() { + if (server?.isRunning() == true) return + + val config = configManager ?: ConfigManager() + val port = config.getPort() + val userPath = config.getUserStoragePath() + val rootPath = config.getRootStoragePath() + + val fsProvider = FileSystemProvider(userPath) + val rootFsProvider = RootFileSystemProvider(rootManager ?: RootManager()) + + val webuiHtml = resources.openRawResource(R.raw.webui).bufferedReader().use { it.readText() } + server = WebDavServer(port, fsProvider, rootFsProvider, config, webuiHtml) + serverThread = Thread({ + try { + server?.start() + } catch (e: Exception) { + Logger.error("Server error", e) + recoveryManager?.onCrash(e.message ?: "Unknown error") + } + }, "WebDavServer").apply { + isDaemon = true + start() + } + + serviceManager?.acquireWakeLock() + val ip = getLocalIpAddress() + serviceManager?.startForeground(port, ip) + + healthMonitor?.stop() + healthMonitor = HealthMonitor(config, rootManager ?: RootManager()) { restartServer() } + healthMonitor?.start() + + recoveryManager?.reset() + + Logger.info("Server started on $ip:$port") + broadcastStatus("RUNNING") + } + + fun stopServer() { + healthMonitor?.stop() + server?.stop() + server = null + serviceManager?.releaseWakeLock() + serviceManager?.stopForeground() + Logger.info("Server stopped") + broadcastStatus("STOPPED") + serviceManager?.stopSelf() + } + + fun restartServer() { + Logger.info("Restarting server...") + server?.stop() + server = null + handler.postDelayed({ startServer() }, 500) + } + + fun reacquireRoot() { + rootManager?.reacquire() + } + + fun isRunning() = server?.isRunning() == true + fun isRootAvailable() = rootManager?.isAvailable() == true + fun getUptime() = server?.getUptime() ?: 0 + fun getThreadCount() = server?.getActiveThreads() ?: 0 + + private fun broadcastStatus(status: String) { + try { + val intent = Intent("com.wsa.webdav.STATUS") + intent.putExtra("status", status) + sendBroadcast(intent) + } catch (_: Exception) {} + } + + private fun getLocalIpAddress(): String { + try { + for (ni in Collections.list(NetworkInterface.getNetworkInterfaces())) { + for (addr in Collections.list(ni.inetAddresses)) { + if (!addr.isLoopbackAddress && addr is Inet4Address) { + val ip = addr.hostAddress + if (ip != null && ip.contains(".")) return ip + } + } + } + } catch (_: Exception) {} + return "127.0.0.1" + } + + override fun onDestroy() { + healthMonitor?.stop() + server?.stop() + serviceManager?.releaseWakeLock() + rootManager?.release() + instance = null + Logger.info("Service destroyed") + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null +} +''' + +def _mainactivity_kt(): + return '''package com.wsa.webdav + +import android.app.Activity +import android.content.Intent +import android.os.Build +import android.os.Bundle + +class MainActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val intent = Intent(this, WebDavService::class.java) + intent.action = "START" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(intent) + } else { + startService(intent) + } + finish() + } +} +''' + +def _bootreceiver_kt(): + return '''package com.wsa.webdav + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build + +class BootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + Logger.info("Boot received: ${intent.action}") + + val config = ConfigManager() + if (!config.isAutoStart()) { + Logger.info("Auto-start disabled, skipping") + return + } + + val serviceIntent = Intent(context, WebDavService::class.java) + serviceIntent.action = "START" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(serviceIntent) + } else { + context.startService(serviceIntent) + } + Logger.info("Service start requested") + } +} +''' + +# ============================================================================ +# Step 4: Build APK +# ============================================================================ +def step_build(release=False): + log.step(4, 5, "BUILD APK") + gradlew = _setup_gradle() + if not gradlew: + return False + + task = "assembleRelease" if release else "assembleDebug" + log.substep(f"Running Gradle: {task}") + log.detail(f"Project: {PROJECT}") + + env = os.environ.copy() + env["ANDROID_HOME"] = str(SDK) + env["ANDROID_SDK_ROOT"] = str(SDK) + java_home = _find_java_home() + if java_home: + env["JAVA_HOME"] = java_home + log.detail(f"JAVA_HOME: {java_home}") + + start_time = time.time() + try: + log.detail("Starting Gradle build...") + r = subprocess.run( + [str(gradlew), task, "--info"], + cwd=str(PROJECT), env=env, capture_output=True, text=True, timeout=600, + ) + elapsed = time.time() - start_time + if r.returncode != 0: + log.error(f"Build failed after {elapsed:.1f}s") + lines = (r.stdout + r.stderr).splitlines() + for line in lines[-30:]: + log.detail(f" {line}") + return False + log.info(f"Build successful in {elapsed:.1f}s") + if VERBOSE: + for line in r.stdout.splitlines()[-20:]: + log.detail(f" {line}") + return True + except subprocess.TimeoutExpired: + log.error("Build timed out after 600 seconds") + return False + +def _setup_gradle(): + is_windows = platform.system() == "Windows" + gradlew = PROJECT / ("gradlew.bat" if is_windows else "gradlew") + + if not gradlew.exists(): + log.substep("Creating Gradle wrapper...") + if is_windows: + gradlew.write_text("""@echo off +setlocal +set "APP_HOME=%~dp0" +set "CLASSPATH=%APP_HOME%gradle\\wrapper\\gradle-wrapper.jar" +if not defined JAVA_HOME ( + for /d %%i in ("C:\\Program Files\\Microsoft\\jdk-*") do ( + if exist "%%i\\bin\\java.exe" (set "JAVA_HOME=%%i" & goto :found) + ) +) +:found +if defined JAVA_HOME (set "JAVA_EXE=%JAVA_HOME%\\bin\\java.exe") else (set "JAVA_EXE=java.exe") +"%JAVA_EXE%" -Xmx256m -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +if errorlevel 1 exit /b 1 +""") + else: + gradlew.write_text('#!/bin/sh\nAPP_HOME="$(cd "$(dirname "$0")" && pwd)"\nCLASSPATH="$APP_HOME/gradle/wrapper/gradle-wrapper.jar"\nJAVA_EXE="${JAVA_HOME:+$JAVA_HOME/bin/}java"\nexec "$JAVA_EXE" -Xmx256m -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"\n') + gradlew.chmod(0o755) + + jar = PROJECT / "gradle/wrapper/gradle-wrapper.jar" + if not jar.exists(): + log.substep("Downloading Gradle wrapper jar...") + jar.parent.mkdir(parents=True, exist_ok=True) + jar_url = "https://raw.githubusercontent.com/gradle/gradle/v8.4.0/gradle/wrapper/gradle-wrapper.jar" + if not download(jar_url, jar, "gradle-wrapper.jar"): + return None + + if jar.exists() and jar.stat().st_size > 0: + log.info(f"gradle-wrapper.jar ready ({log.file_info(jar)})") + else: + log.error("gradle-wrapper.jar is empty or missing") + return None + return gradlew + +# ============================================================================ +# Step 5: Output +# ============================================================================ +def step_output(): + log.step(5, 5, "OUTPUT") + OUTPUT.mkdir(parents=True, exist_ok=True) + apk_dir = PROJECT / "app/build/outputs/apk" + apk = None + for d in ["release", "debug"]: + for f in (apk_dir / d).glob("*.apk") if (apk_dir / d).exists() else []: + apk = f + break + if not apk: + log.error("APK not found in build outputs") + return False + final = OUTPUT / "app-release.apk" + shutil.copy2(apk, final) + size = final.stat().st_size / 1048576 + log.info(f"APK: {final}") + log.info(f"Size: {size:.1f} MB") + log.step_complete("APK READY!") + print(f"\n{'='*60}\n BUILD SUCCESSFUL!\n{'='*60}") + print(f" APK: {final}\n Size: {size:.1f} MB") + print(f"\n Install: adb install {final}\n{'='*60}\n") + return True + +# ============================================================================ +# Main +# ============================================================================ +def main(): + import argparse + p = argparse.ArgumentParser(description="WSA Kotlin WebDAV APK Builder") + p.add_argument("--release", action="store_true") + p.add_argument("--clean", action="store_true") + p.add_argument("--verbose", "-v", action="store_true") + args = p.parse_args() + global VERBOSE + VERBOSE = args.verbose or VERBOSE + if args.clean and BUILD.exists(): + shutil.rmtree(BUILD) + log.info("Cleaned build directory") + + print(f"\n{'='*60}") + print(f" WSA Native Kotlin WebDAV Server") + print(f" Headless APK Builder") + print(f"{'='*60}") + print(f" Start Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f" Platform: {platform.system()} {platform.machine()}") + print(f" Verbose: {'ON' if VERBOSE else 'OFF'}") + print(f"{'='*60}\n") + + steps = [ + ("Java Setup", step_java), + ("Android SDK", step_sdk), + ("Kotlin Project", step_project), + ("Build APK", lambda: step_build(args.release)), + ("Output", step_output), + ] + for name, fn in steps: + if not fn(): + log.error(f"FAILED at: {name}") + log.summary() + sys.exit(1) + log.summary() + log.info("All steps completed successfully!") + +if __name__ == "__main__": + main() diff --git a/builder/webui.py b/builder/webui.py new file mode 100644 index 0000000..8330db7 --- /dev/null +++ b/builder/webui.py @@ -0,0 +1,1639 @@ +#!/usr/bin/env python3 +"""Complete Web File Manager SPA - Embedded in build.py""" +# This module is imported by build.py to serve the file manager HTML + +def webui_html(): + # Inline SVG icons + icons = { + "folder": '''''', + "file": '''''', + "image": '''''', + "music": '''''', + "video": '''''', + "zip": '''''', + "code": '''''', + "search": '''''', + "grid": '''''', + "list": '''''', + "sort": '''''', + "sun": '''''', + "moon": '''''', + "refresh": '''''', + "home": '''''', + "back": '''''', + "forward": '''''', + "upload": '''''', + "download": '''''', + "plus": '''''', + "trash": '''''', + "rename": '''''', + "copy": '''''', + "clipboard": '''''', + "info": '''''', + "close": '''''', + "more": '''''', + "newFolder": '''''', + "root": '''''', + "drive": '''''', + "star": '''''', + "clock": '''''', + "pin": '''''', + "eye": '''''', + "checkbox": '''''', + "checkEmpty": '''''', + "paste": '''''', + "hex": '''''', + "lock": '''''', + "terminal": '''''', + "tools": '''''', + "hash": '''''', + "compare": '''''', + "bookmark": '''''', + "selectall": '''''', + } + + html = f''' + + + + +WSA WebDAV + + + +
+
+ + + +
+ + + + +
+ + + +
+ + +
+
+ +
+
+
User Storage{icons["close"]}
+
+
+
+
+
Drop files to upload
+
+
+ Items: 0 + + Selected: 0 +
+ +
+
+
+ + +
+ + + + + + + + + + + + + + +
+ + +
+ + + + + + + +
+ + + + + +
+
+
+ File + +
+
+
+
+ + +
+ + + +''' + return html diff --git a/config.json b/config.json new file mode 100644 index 0000000..7b5dc6d --- /dev/null +++ b/config.json @@ -0,0 +1,24 @@ +{ + "enabled": true, + "port": 8088, + "host": "127.0.0.1", + "auto_start": true, + "anonymous_access": true, + "enable_root_access": true, + "user_storage_path": "/storage/emulated/0", + "root_storage_path": "/", + "max_connections": 50, + "timeout": 300, + "log_level": "INFO", + "enable_ssl": false, + "ssl_cert_file": "", + "ssl_key_file": "", + "user": "", + "password": "", + "allowed_ips": [], + "blocked_ips": [], + "max_upload_size": 0, + "enable_directory_listing": true, + "enable_cors": false, + "cors_origins": ["*"] +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b32a933 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# WSA Embedded CPython WebDAV Server +# No external Python dependencies required - uses only stdlib +# Python 3.8+ compatible diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..6024540 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "WSAWebDAV" +include ':app' diff --git a/setup_python.bat b/setup_python.bat new file mode 100644 index 0000000..4e441aa --- /dev/null +++ b/setup_python.bat @@ -0,0 +1,104 @@ +@echo off +REM ============================================================================ +REM Windows Build Helper for WSA WebDAV Server +REM ============================================================================ +REM This script helps prepare the embedded Python for the APK on Windows. +REM For full CPython cross-compilation, use Linux or WSL. +REM ============================================================================ + +setlocal enabledelayedexpansion + +set PROJECT_DIR=%~dp0.. +set PYTHON_BUILD_DIR=%PROJECT_DIR%\python_build +set OUTPUT_DIR=%PYTHON_BUILD_DIR%\output +set ASSETS_DIR=%PROJECT_DIR%\app\src\main\assets\python + +echo ============================================ +echo WSA WebDAV - Embedded Python Setup +echo ============================================ +echo. + +REM Check for Python +python --version >nul 2>&1 +if errorlevel 1 ( + echo ERROR: Python not found. Please install Python 3.8+ + echo Download from: https://www.python.org/downloads/ + exit /b 1 +) + +REM Check for required tools +echo Checking prerequisites... +python -c "import urllib.request" >nul 2>&1 +if errorlevel 1 ( + echo ERROR: urllib not available + exit /b 1 +) + +echo. +echo Choose setup method: +echo 1. Download pre-built Python binaries (Recommended) +echo 2. Create minimal bootstrap (requires system Python on device) +echo 3. Build from source (requires Linux/WSL) +echo. +set /p choice="Enter choice (1-3): " + +if "%choice%"=="1" ( + echo. + echo Downloading pre-built Python binaries... + python "%PYTHON_BUILD_DIR%\scripts\download_python.py" --output "%OUTPUT_DIR%" + if errorlevel 1 ( + echo. + echo Download failed. Trying minimal bootstrap... + python "%PYTHON_BUILD_DIR%\scripts\download_python.py" --output "%OUTPUT_DIR%" --minimal + ) +) else if "%choice%"=="2" ( + echo. + echo Creating minimal Python bootstrap... + python "%PYTHON_BUILD_DIR%\scripts\download_python.py" --output "%OUTPUT_DIR%" --minimal +) else if "%choice%"=="3" ( + echo. + echo For source compilation, please use Linux or WSL. + echo See: python_build\README.md + echo. + echo Alternatively, run the Docker build: + echo cd %PYTHON_BUILD_DIR% + echo docker build -t cpython-android . + echo docker run -v %OUTPUT_DIR%:/output cpython-android + exit /b 0 +) else ( + echo Invalid choice + exit /b 1 +) + +REM Copy to assets +echo. +echo Copying Python to APK assets... +if exist "%ASSETS_DIR%" ( + rmdir /s /q "%ASSETS_DIR%" +) +mkdir "%ASSETS_DIR%" 2>nul + +if exist "%OUTPUT_DIR%\arm64-v8a" ( + echo Copying arm64-v8a... + xcopy /E /I /Y "%OUTPUT_DIR%\arm64-v8a" "%ASSETS_DIR%\arm64-v8a" +) +if exist "%OUTPUT_DIR%\x86_64" ( + echo Copying x86_64... + xcopy /E /I /Y "%OUTPUT_DIR%\x86_64" "%ASSETS_DIR%\x86_64" +) + +echo. +echo ============================================ +echo Setup Complete! +echo ============================================ +echo. +echo Next steps: +echo 1. Open project in Android Studio +echo 2. Build APK: Build > Build Bundle(s) / APK(s) +echo 3. Install APK on WSA device +echo. +echo APK output will be at: +echo app\build\outputs\apk\debug\app-debug.apk +echo. + +pause diff --git a/wsa_webdav.sh b/wsa_webdav.sh new file mode 100644 index 0000000..e61a88b --- /dev/null +++ b/wsa_webdav.sh @@ -0,0 +1,138 @@ +#!/bin/bash +# WSA WebDAV Server - Service Control Script +# Usage: ./wsa_webdav.sh {start|stop|restart|status|log|config|root|health|help} + +APP_NAME="WSA WebDAV Server" +LOG_DIR="/data/local/tmp/wsa_webdav" +PID_FILE="$LOG_DIR/server.pid" +LOG_FILE="$LOG_DIR/server.log" +PYTHON_DIR="$(dirname "$0")/python_runtime" +PYTHON_SCRIPT="$PYTHON_DIR/main.py" +CONFIG_FILE="$(dirname "$0")/config.json" +DEFAULT_PORT=8088 + +start() { + if [ -f "$PID_FILE" ]; then + pid=$(cat "$PID_FILE") + if kill -0 "$pid" 2>/dev/null; then + echo "$APP_NAME is already running (PID: $pid)" + return 0 + fi + fi + + echo "Starting $APP_NAME..." + mkdir -p "$LOG_DIR" + + nohup python3 "$PYTHON_SCRIPT" --config "$CONFIG_FILE" --log "$LOG_FILE" > /dev/null 2>&1 & + echo $! > "$PID_FILE" + echo "$APP_NAME started (PID: $!)" +} + +stop() { + if [ ! -f "$PID_FILE" ]; then + echo "$APP_NAME is not running" + return 1 + fi + + pid=$(cat "$PID_FILE") + if kill -0 "$pid" 2>/dev/null; then + echo "Stopping $APP_NAME (PID: $pid)..." + kill "$pid" + sleep 2 + if kill -0 "$pid" 2>/dev/null; then + kill -9 "$pid" + fi + rm -f "$PID_FILE" + echo "$APP_NAME stopped" + else + echo "$APP_NAME is not running (stale PID file)" + rm -f "$PID_FILE" + fi +} + +restart() { + stop + sleep 1 + start +} + +status() { + if [ -f "$PID_FILE" ]; then + pid=$(cat "$PID_FILE") + if kill -0 "$pid" 2>/dev/null; then + echo "$APP_NAME: RUNNING (PID: $pid)" + echo "Port: $DEFAULT_PORT" + echo "Log: $LOG_FILE" + else + echo "$APP_NAME: DEAD (stale PID file)" + fi + else + echo "$APP_NAME: STOPPED" + fi +} + +log() { + if [ -f "$LOG_FILE" ]; then + tail -${1:-50} "$LOG_FILE" + else + echo "No log file found" + fi +} + +config() { + if [ -f "$CONFIG_FILE" ]; then + cat "$CONFIG_FILE" + else + echo "No config file found" + fi +} + +root() { + if su -c "id" 2>/dev/null | grep -q "uid=0"; then + echo "Root: AVAILABLE" + else + echo "Root: NOT AVAILABLE" + fi +} + +health() { + if [ -f "$PID_FILE" ]; then + pid=$(cat "$PID_FILE") + if kill -0 "$pid" 2>/dev/null; then + echo "Health: OK" + else + echo "Health: PROCESS DEAD" + fi + else + echo "Health: NOT RUNNING" + fi +} + +help() { + echo "$APP_NAME - Control Script" + echo "" + echo "Usage: $0 {start|stop|restart|status|log|config|root|health|help}" + echo "" + echo "Commands:" + echo " start Start the server" + echo " stop Stop the server" + echo " restart Restart the server" + echo " status Show server status" + echo " log [N] Show last N lines of log (default: 50)" + echo " config Show configuration" + echo " root Check root access" + echo " health Check server health" + echo " help Show this help" +} + +case "$1" in + start) start ;; + stop) stop ;; + restart) restart ;; + status) status ;; + log) log "$2" ;; + config) config ;; + root) root ;; + health) health ;; + help|*) help ;; +esac