Scaffold tsjetpiti: on-device Qwen3.5-2B chat app
Android app that runs an uncensored Qwen3.5-2B GGUF fully on-device via an embedded llama.cpp (pinned b10333, built through the NDK) behind a barebones WebView chat UI. Model is downloaded on first launch. "new tsjet" wipes the conversation and resets the KV cache. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
# Android / Gradle
|
||||
.gradle/
|
||||
build/
|
||||
/local.properties
|
||||
/captures/
|
||||
.cxx/
|
||||
.externalNativeBuild/
|
||||
|
||||
# IDE
|
||||
*.iml
|
||||
.idea/
|
||||
|
||||
# Artifacts
|
||||
*.apk
|
||||
*.aab
|
||||
|
||||
# Never commit model weights
|
||||
*.gguf
|
||||
79
README.md
Normal file
79
README.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# tsjetpiti
|
||||
|
||||
A dead-simple Android chat app that runs an **uncensored Qwen3.5-2B** model
|
||||
fully **on-device** via an embedded [llama.cpp](https://github.com/ggml-org/llama.cpp),
|
||||
wrapped in an **extremely barebones WebView UI**.
|
||||
|
||||
Talk to the tsjet. Hit **new tsjet** to wipe the conversation and start fresh.
|
||||
|
||||
---
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ MainActivity (Kotlin) │
|
||||
│ • full-screen WebView ── UI ──────────┼──> assets/web/{index.html,app.js,styles.css}
|
||||
│ • JS bridge "TsjetNative" │ (the whole chat UI, ~200 lines)
|
||||
│ • model download + prompt formatting │
|
||||
└──────────────┬──────────────────────────┘
|
||||
│ JNI (LlamaBridge)
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ cpp/llama-jni.cpp → libtsjet.so │
|
||||
│ talks to llama.cpp C API (pinned) │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Inference:** llama.cpp compiled from source (pinned tag `b10333`) through the
|
||||
NDK. Pulled automatically at build time via CMake `FetchContent` — no submodule
|
||||
to init.
|
||||
- **Model:** `Qwen3.5-2B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf` (~1.2 GB) is
|
||||
**downloaded on first launch** from Hugging Face into the app's private storage.
|
||||
It is *not* bundled in the APK.
|
||||
- **Conversation:** the web layer holds the full history and sends it each turn;
|
||||
native rebuilds the ChatML prompt and clears the KV cache before every reply, so
|
||||
"new tsjet" is just: clear JS state + reset cache.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **A build machine** with the Android SDK + NDK. Easiest path: open the project
|
||||
in **Android Studio** (it will offer to install the matching NDK `27.2.12479018`
|
||||
and CMake `3.22.1`).
|
||||
- **A phone:** 64-bit ARM (`arm64-v8a`), Android 8.0+ (minSdk 26), and enough free
|
||||
RAM to hold a ~1.2 GB Q4 model (~3–4 GB RAM device recommended).
|
||||
- Network on first launch to download the model.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
The APK lands in `app/build/outputs/apk/debug/`. Or just Run ▶ from Android Studio.
|
||||
|
||||
> First build compiles llama.cpp from source, so it takes a while and needs
|
||||
> network (CMake fetches the pinned llama.cpp).
|
||||
|
||||
## Where things live
|
||||
|
||||
| What | Where |
|
||||
|------|-------|
|
||||
| Chat UI (HTML/CSS/JS) | `app/src/main/assets/web/` |
|
||||
| Android glue + model download | `app/src/main/java/monster/autisme/tsjetpiti/` |
|
||||
| Native llama.cpp bridge | `app/src/main/cpp/llama-jni.cpp` |
|
||||
| Pinned llama.cpp version | `app/src/main/cpp/CMakeLists.txt` (`GIT_TAG b10333`) |
|
||||
| Model URL / filename | `ModelDownloader.kt` |
|
||||
| Generation params (ctx, temp, tokens) | `MainActivity.kt` + `llama-jni.cpp` |
|
||||
|
||||
## TODO / notes
|
||||
|
||||
- **Logo:** juli is on it. The header logo is a placeholder `🐟` in
|
||||
`assets/web/index.html` (`#logo`), and there's no launcher icon yet — add an
|
||||
`android:icon` in `AndroidManifest.xml` + a `mipmap` when the artwork is ready.
|
||||
- The model may emit `<think>…</think>` blocks; the UI strips them from the
|
||||
display and from history (`stripThink` in `app.js`).
|
||||
- Bumping the llama.cpp tag? Re-check the C API calls in `llama-jni.cpp` against
|
||||
that tag's `include/llama.h` — it uses the raw C API directly.
|
||||
- Not yet compiled end-to-end in CI; first real build is the acid test for the
|
||||
native layer.
|
||||
62
app/build.gradle.kts
Normal file
62
app/build.gradle.kts
Normal file
@@ -0,0 +1,62 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "monster.autisme.tsjetpiti"
|
||||
compileSdk = 35
|
||||
|
||||
// NDK is needed to compile the llama.cpp JNI bridge. Android Studio will
|
||||
// offer to install this exact version; adjust if you have a different one.
|
||||
ndkVersion = "27.2.12479018"
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "monster.autisme.tsjetpiti"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
|
||||
// On-device LLM => 64-bit ARM only. Keeps the APK from ballooning.
|
||||
ndk {
|
||||
abiFilters += "arm64-v8a"
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
arguments += "-DANDROID_STL=c++_shared"
|
||||
cppFlags += "-std=c++17"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path = file("src/main/cpp/CMakeLists.txt")
|
||||
version = "3.22.1"
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Intentionally dependency-free: framework WebView + org.json + native lib.
|
||||
}
|
||||
10
app/proguard-rules.pro
vendored
Normal file
10
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Keep the JNI bridge: native method names must not be renamed.
|
||||
-keepclasseswithmembernames class * {
|
||||
native <methods>;
|
||||
}
|
||||
-keep class monster.autisme.tsjetpiti.LlamaBridge { *; }
|
||||
|
||||
# Keep @JavascriptInterface methods reachable from the WebView.
|
||||
-keepclassmembers class monster.autisme.tsjetpiti.MainActivity$Bridge {
|
||||
@android.webkit.JavascriptInterface <methods>;
|
||||
}
|
||||
24
app/src/main/AndroidManifest.xml
Normal file
24
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<!-- Needs a 64-bit ARM device with enough RAM to run a ~1.2 GB model. -->
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:label="tsjet"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.Material.NoActionBar">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden|uiMode"
|
||||
android:exported="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
</manifest>
|
||||
130
app/src/main/assets/web/app.js
Normal file
130
app/src/main/assets/web/app.js
Normal file
@@ -0,0 +1,130 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var chat = document.getElementById("chat");
|
||||
var overlay = document.getElementById("overlay");
|
||||
var statusEl = document.getElementById("status");
|
||||
var form = document.getElementById("composer");
|
||||
var input = document.getElementById("input");
|
||||
var sendBtn = document.getElementById("sendBtn");
|
||||
var newBtn = document.getElementById("newBtn");
|
||||
|
||||
var messages = []; // conversation history: {role, content}
|
||||
var ready = false; // model loaded?
|
||||
var streamActive = false;// a reply is currently generating
|
||||
var curEl = null; // the assistant bubble being streamed into
|
||||
|
||||
function native() { return window.TsjetNative; }
|
||||
|
||||
// Qwen may emit <think>...</think>. Hide it from the chat and from history.
|
||||
function stripThink(t) {
|
||||
return t
|
||||
.replace(/<think>[\s\S]*?<\/think>/g, "")
|
||||
.replace(/<think>[\s\S]*$/, "")
|
||||
.replace(/^\s+/, "");
|
||||
}
|
||||
|
||||
function addBubble(role, text) {
|
||||
var el = document.createElement("div");
|
||||
el.className = "msg " + role;
|
||||
el.textContent = text;
|
||||
chat.appendChild(el);
|
||||
scrollDown();
|
||||
return el;
|
||||
}
|
||||
|
||||
function scrollDown() { chat.scrollTop = chat.scrollHeight; }
|
||||
|
||||
function setGenerating(on) {
|
||||
streamActive = on;
|
||||
sendBtn.textContent = on ? "stop" : "send";
|
||||
sendBtn.classList.toggle("stop", on);
|
||||
sendBtn.disabled = !ready;
|
||||
}
|
||||
|
||||
function autoGrow() {
|
||||
input.style.height = "auto";
|
||||
input.style.height = Math.min(input.scrollHeight, 160) + "px";
|
||||
}
|
||||
|
||||
function send() {
|
||||
if (!ready) return;
|
||||
if (streamActive) { native().stop(); return; } // send doubles as "stop"
|
||||
|
||||
var text = input.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
messages.push({ role: "user", content: text });
|
||||
addBubble("user", text);
|
||||
input.value = "";
|
||||
autoGrow();
|
||||
|
||||
setGenerating(true);
|
||||
curEl = addBubble("assistant", "…");
|
||||
try {
|
||||
native().generate(JSON.stringify(messages));
|
||||
} catch (e) {
|
||||
window.__tsjet.onError("" + e);
|
||||
}
|
||||
}
|
||||
|
||||
function newChat() {
|
||||
if (native()) native().newChat();
|
||||
messages = [];
|
||||
curEl = null;
|
||||
chat.innerHTML = "";
|
||||
setGenerating(false);
|
||||
if (ready) input.focus();
|
||||
}
|
||||
|
||||
// Callbacks invoked from the native side.
|
||||
window.__tsjet = {
|
||||
onStatus: function (s) {
|
||||
overlay.style.display = "flex";
|
||||
statusEl.textContent = s;
|
||||
},
|
||||
onReady: function () {
|
||||
ready = true;
|
||||
overlay.style.display = "none";
|
||||
sendBtn.disabled = false;
|
||||
input.focus();
|
||||
},
|
||||
onStart: function () {
|
||||
if (streamActive && curEl) curEl.textContent = "…";
|
||||
},
|
||||
onUpdate: function (full) {
|
||||
if (!streamActive || !curEl) return;
|
||||
var shown = stripThink(full);
|
||||
curEl.textContent = shown.length ? shown : "…";
|
||||
scrollDown();
|
||||
},
|
||||
onDone: function (full) {
|
||||
if (curEl && curEl.isConnected && streamActive) {
|
||||
var finalText = stripThink(full).trim();
|
||||
curEl.textContent = finalText.length ? finalText : "(no reply)";
|
||||
messages.push({ role: "assistant", content: finalText });
|
||||
}
|
||||
curEl = null;
|
||||
setGenerating(false);
|
||||
if (ready) input.focus();
|
||||
},
|
||||
onError: function (msg) {
|
||||
overlay.style.display = "none";
|
||||
if (curEl && curEl.isConnected) {
|
||||
curEl.textContent = "⚠ " + msg;
|
||||
curEl.classList.add("error");
|
||||
} else {
|
||||
addBubble("assistant", "⚠ " + msg).classList.add("error");
|
||||
}
|
||||
curEl = null;
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
form.addEventListener("submit", function (e) { e.preventDefault(); send(); });
|
||||
newBtn.addEventListener("click", newChat);
|
||||
input.addEventListener("input", autoGrow);
|
||||
input.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
|
||||
});
|
||||
})();
|
||||
35
app/src/main/assets/web/index.html
Normal file
35
app/src/main/assets/web/index.html
Normal file
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<title>tsjet</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<header id="bar">
|
||||
<div id="brand">
|
||||
<!-- juli's logo goes here -->
|
||||
<span id="logo" aria-hidden="true">🐟</span>
|
||||
<span id="title">tsjet</span>
|
||||
</div>
|
||||
<button id="newBtn" type="button">new tsjet</button>
|
||||
</header>
|
||||
|
||||
<main id="chat"></main>
|
||||
|
||||
<div id="overlay">
|
||||
<div class="spinner" aria-hidden="true"></div>
|
||||
<div id="status">Waking the tsjet…</div>
|
||||
</div>
|
||||
|
||||
<form id="composer">
|
||||
<textarea id="input" rows="1" placeholder="talk to the tsjet…"
|
||||
autocomplete="off" autocapitalize="sentences"></textarea>
|
||||
<button id="sendBtn" type="submit" disabled>send</button>
|
||||
</form>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
148
app/src/main/assets/web/styles.css
Normal file
148
app/src/main/assets/web/styles.css
Normal file
@@ -0,0 +1,148 @@
|
||||
:root {
|
||||
--bg: #0e0f13;
|
||||
--bar: #16181f;
|
||||
--line: #262a35;
|
||||
--text: #e8e9ee;
|
||||
--muted: #8a90a2;
|
||||
--user: #2b6cff;
|
||||
--assistant: #1d2230;
|
||||
--accent: #ff4d6d;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 16px/1.45 -apple-system, "Roboto", "Segoe UI", system-ui, sans-serif;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
/* --- top bar --- */
|
||||
#bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: max(env(safe-area-inset-top), 10px) 14px 10px;
|
||||
background: var(--bar);
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
#brand { display: flex; align-items: center; gap: 8px; font-weight: 700; }
|
||||
#logo { font-size: 22px; }
|
||||
#title { letter-spacing: 0.5px; }
|
||||
|
||||
#newBtn {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 7px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
#newBtn:active { background: var(--line); color: var(--text); }
|
||||
|
||||
/* --- messages --- */
|
||||
#chat {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.msg {
|
||||
max-width: 85%;
|
||||
padding: 10px 13px;
|
||||
border-radius: 16px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.msg.user {
|
||||
align-self: flex-end;
|
||||
background: var(--user);
|
||||
color: #fff;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
.msg.assistant {
|
||||
align-self: flex-start;
|
||||
background: var(--assistant);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
.msg.error { background: #3a1420; color: #ffb3c1; }
|
||||
|
||||
/* --- composer --- */
|
||||
#composer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: flex-end;
|
||||
padding: 10px 12px calc(env(safe-area-inset-bottom) + 10px);
|
||||
background: var(--bar);
|
||||
border-top: 1px solid var(--line);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
#input {
|
||||
flex: 1 1 auto;
|
||||
resize: none;
|
||||
max-height: 160px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
outline: none;
|
||||
}
|
||||
#input::placeholder { color: var(--muted); }
|
||||
|
||||
#sendBtn {
|
||||
flex: 0 0 auto;
|
||||
border: none;
|
||||
border-radius: 18px;
|
||||
padding: 11px 18px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: var(--user);
|
||||
}
|
||||
#sendBtn:disabled { opacity: 0.45; }
|
||||
#sendBtn.stop { background: var(--accent); }
|
||||
|
||||
/* --- loading overlay --- */
|
||||
#overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
background: rgba(14, 15, 19, 0.96);
|
||||
z-index: 10;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
#status { color: var(--muted); font-size: 15px; }
|
||||
|
||||
.spinner {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 3px solid var(--line);
|
||||
border-top-color: var(--user);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
31
app/src/main/cpp/CMakeLists.txt
Normal file
31
app/src/main/cpp/CMakeLists.txt
Normal file
@@ -0,0 +1,31 @@
|
||||
cmake_minimum_required(VERSION 3.22.1)
|
||||
|
||||
project(tsjet LANGUAGES C CXX)
|
||||
|
||||
include(FetchContent)
|
||||
|
||||
# --- llama.cpp (pinned) -----------------------------------------------------
|
||||
# Pinned to a specific release so the JNI wrapper below always matches the API
|
||||
# it was written against. Bump GIT_TAG deliberately, not casually.
|
||||
set(LLAMA_BUILD_TESTS OFF CACHE BOOL "" FORCE)
|
||||
set(LLAMA_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
|
||||
set(LLAMA_BUILD_TOOLS OFF CACHE BOOL "" FORCE)
|
||||
set(LLAMA_BUILD_SERVER OFF CACHE BOOL "" FORCE)
|
||||
set(LLAMA_CURL OFF CACHE BOOL "" FORCE)
|
||||
set(GGML_OPENMP OFF CACHE BOOL "" FORCE)
|
||||
|
||||
FetchContent_Declare(
|
||||
llama
|
||||
GIT_REPOSITORY https://github.com/ggml-org/llama.cpp.git
|
||||
GIT_TAG b10333
|
||||
GIT_SHALLOW TRUE
|
||||
)
|
||||
FetchContent_MakeAvailable(llama)
|
||||
|
||||
# --- our JNI bridge ---------------------------------------------------------
|
||||
add_library(tsjet SHARED llama-jni.cpp)
|
||||
|
||||
find_library(log-lib log)
|
||||
|
||||
target_compile_features(tsjet PRIVATE cxx_std_17)
|
||||
target_link_libraries(tsjet PRIVATE llama ${log-lib})
|
||||
205
app/src/main/cpp/llama-jni.cpp
Normal file
205
app/src/main/cpp/llama-jni.cpp
Normal file
@@ -0,0 +1,205 @@
|
||||
// JNI bridge between the Android app and llama.cpp.
|
||||
//
|
||||
// Written against the llama.cpp C API as of tag b10333 (see CMakeLists.txt).
|
||||
// If you bump the pinned tag, re-check the signatures used here against
|
||||
// include/llama.h — this file talks to the raw C API, no `common` helpers.
|
||||
|
||||
#include <jni.h>
|
||||
#include <android/log.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "llama.h"
|
||||
|
||||
#define LOG_TAG "tsjet-native"
|
||||
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
|
||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||
|
||||
namespace {
|
||||
|
||||
// Everything needed to run one model + generate from it.
|
||||
struct tsjet_state {
|
||||
llama_model * model = nullptr;
|
||||
llama_context * ctx = nullptr;
|
||||
const llama_vocab * vocab = nullptr;
|
||||
llama_sampler * smpl = nullptr;
|
||||
int n_decoded = 0;
|
||||
int n_predict = 512;
|
||||
bool done = false;
|
||||
};
|
||||
|
||||
inline tsjet_state *as_state(jlong handle) {
|
||||
return reinterpret_cast<tsjet_state *>(static_cast<uintptr_t>(handle));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" {
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_monster_autisme_tsjetpiti_LlamaBridge_backendInit(JNIEnv *, jclass) {
|
||||
llama_backend_init();
|
||||
}
|
||||
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_monster_autisme_tsjetpiti_LlamaBridge_loadModel(
|
||||
JNIEnv *env, jclass, jstring jpath, jint n_ctx, jint n_threads) {
|
||||
|
||||
const char *path = env->GetStringUTFChars(jpath, nullptr);
|
||||
if (path == nullptr) return 0;
|
||||
|
||||
auto *s = new tsjet_state();
|
||||
|
||||
llama_model_params mparams = llama_model_default_params();
|
||||
mparams.n_gpu_layers = 0; // CPU only on-device
|
||||
|
||||
s->model = llama_model_load_from_file(path, mparams);
|
||||
env->ReleaseStringUTFChars(jpath, path);
|
||||
|
||||
if (s->model == nullptr) {
|
||||
LOGE("failed to load model");
|
||||
delete s;
|
||||
return 0;
|
||||
}
|
||||
|
||||
s->vocab = llama_model_get_vocab(s->model);
|
||||
|
||||
llama_context_params cparams = llama_context_default_params();
|
||||
cparams.n_ctx = static_cast<uint32_t>(n_ctx);
|
||||
cparams.n_batch = 512;
|
||||
cparams.n_ubatch = 512;
|
||||
cparams.n_threads = n_threads;
|
||||
cparams.n_threads_batch = n_threads;
|
||||
|
||||
s->ctx = llama_init_from_model(s->model, cparams);
|
||||
if (s->ctx == nullptr) {
|
||||
LOGE("failed to create context");
|
||||
llama_model_free(s->model);
|
||||
delete s;
|
||||
return 0;
|
||||
}
|
||||
|
||||
llama_sampler_chain_params sp = llama_sampler_chain_default_params();
|
||||
s->smpl = llama_sampler_chain_init(sp);
|
||||
llama_sampler_chain_add(s->smpl, llama_sampler_init_top_k(40));
|
||||
llama_sampler_chain_add(s->smpl, llama_sampler_init_top_p(0.95f, 1));
|
||||
llama_sampler_chain_add(s->smpl, llama_sampler_init_temp(0.7f));
|
||||
llama_sampler_chain_add(s->smpl, llama_sampler_init_dist(LLAMA_DEFAULT_SEED));
|
||||
|
||||
LOGI("model loaded, n_ctx=%d threads=%d", n_ctx, n_threads);
|
||||
return static_cast<jlong>(reinterpret_cast<uintptr_t>(s));
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_monster_autisme_tsjetpiti_LlamaBridge_resetMemory(JNIEnv *, jclass, jlong handle) {
|
||||
tsjet_state *s = as_state(handle);
|
||||
if (s == nullptr) return;
|
||||
llama_memory_clear(llama_get_memory(s->ctx), true);
|
||||
llama_sampler_reset(s->smpl);
|
||||
s->n_decoded = 0;
|
||||
s->done = false;
|
||||
}
|
||||
|
||||
JNIEXPORT jint JNICALL
|
||||
Java_monster_autisme_tsjetpiti_LlamaBridge_startCompletion(
|
||||
JNIEnv *env, jclass, jlong handle, jbyteArray jprompt, jint max_tokens) {
|
||||
|
||||
tsjet_state *s = as_state(handle);
|
||||
if (s == nullptr) return -1;
|
||||
|
||||
jbyte *data = env->GetByteArrayElements(jprompt, nullptr);
|
||||
jsize len = env->GetArrayLength(jprompt);
|
||||
std::string prompt(reinterpret_cast<const char *>(data), static_cast<size_t>(len));
|
||||
env->ReleaseByteArrayElements(jprompt, data, JNI_ABORT);
|
||||
|
||||
// Tokenize (parse ChatML special tokens; let the model decide on BOS).
|
||||
const int32_t n_needed = -llama_tokenize(
|
||||
s->vocab, prompt.c_str(), (int32_t) prompt.size(),
|
||||
nullptr, 0, /*add_special=*/true, /*parse_special=*/true);
|
||||
if (n_needed <= 0) return -2;
|
||||
|
||||
std::vector<llama_token> tokens(n_needed);
|
||||
const int32_t n_tokens = llama_tokenize(
|
||||
s->vocab, prompt.c_str(), (int32_t) prompt.size(),
|
||||
tokens.data(), (int32_t) tokens.size(),
|
||||
/*add_special=*/true, /*parse_special=*/true);
|
||||
if (n_tokens < 0) return -3;
|
||||
tokens.resize(n_tokens);
|
||||
|
||||
s->n_decoded = 0;
|
||||
s->n_predict = max_tokens;
|
||||
s->done = false;
|
||||
|
||||
// Feed the prompt in n_batch-sized chunks; positions are tracked automatically.
|
||||
const int n_batch = 512;
|
||||
for (int i = 0; i < (int) tokens.size(); i += n_batch) {
|
||||
int count = std::min(n_batch, (int) tokens.size() - i);
|
||||
llama_batch batch = llama_batch_get_one(tokens.data() + i, count);
|
||||
if (llama_decode(s->ctx, batch) != 0) {
|
||||
LOGE("prompt decode failed at offset %d", i);
|
||||
return -4;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Returns the raw UTF-8 bytes of the next generated token, or null when the
|
||||
// generation is finished (end-of-generation token or predict limit reached).
|
||||
JNIEXPORT jbyteArray JNICALL
|
||||
Java_monster_autisme_tsjetpiti_LlamaBridge_nextToken(JNIEnv *env, jclass, jlong handle) {
|
||||
tsjet_state *s = as_state(handle);
|
||||
if (s == nullptr || s->done) return nullptr;
|
||||
|
||||
if (s->n_decoded >= s->n_predict) {
|
||||
s->done = true;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
llama_token id = llama_sampler_sample(s->smpl, s->ctx, -1);
|
||||
if (llama_vocab_is_eog(s->vocab, id)) {
|
||||
s->done = true;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// token -> bytes
|
||||
char buf[256];
|
||||
int n = llama_token_to_piece(s->vocab, id, buf, sizeof(buf), 0, /*special=*/false);
|
||||
std::string piece;
|
||||
if (n < 0) {
|
||||
std::vector<char> big(-n);
|
||||
int n2 = llama_token_to_piece(s->vocab, id, big.data(), (int) big.size(), 0, false);
|
||||
if (n2 > 0) piece.assign(big.data(), n2);
|
||||
} else if (n > 0) {
|
||||
piece.assign(buf, n);
|
||||
}
|
||||
|
||||
// Advance the context so the next sample has fresh logits.
|
||||
llama_batch batch = llama_batch_get_one(&id, 1);
|
||||
if (llama_decode(s->ctx, batch) != 0) {
|
||||
LOGE("decode failed during generation");
|
||||
s->done = true;
|
||||
}
|
||||
s->n_decoded++;
|
||||
|
||||
jbyteArray arr = env->NewByteArray((jsize) piece.size());
|
||||
if (!piece.empty()) {
|
||||
env->SetByteArrayRegion(arr, 0, (jsize) piece.size(),
|
||||
reinterpret_cast<const jbyte *>(piece.data()));
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_monster_autisme_tsjetpiti_LlamaBridge_freeModel(JNIEnv *, jclass, jlong handle) {
|
||||
tsjet_state *s = as_state(handle);
|
||||
if (s == nullptr) return;
|
||||
if (s->smpl) llama_sampler_free(s->smpl);
|
||||
if (s->ctx) llama_free(s->ctx);
|
||||
if (s->model) llama_model_free(s->model);
|
||||
delete s;
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
32
app/src/main/java/monster/autisme/tsjetpiti/LlamaBridge.kt
Normal file
32
app/src/main/java/monster/autisme/tsjetpiti/LlamaBridge.kt
Normal file
@@ -0,0 +1,32 @@
|
||||
package monster.autisme.tsjetpiti
|
||||
|
||||
/**
|
||||
* Thin Kotlin front for the native llama.cpp bridge (see cpp/llama-jni.cpp).
|
||||
*
|
||||
* Generation is a two-step stream: [startCompletion] feeds the prompt, then
|
||||
* [nextToken] is called in a loop until it returns null.
|
||||
*/
|
||||
object LlamaBridge {
|
||||
|
||||
init {
|
||||
System.loadLibrary("tsjet")
|
||||
}
|
||||
|
||||
/** Initialise the llama.cpp backend once per process. */
|
||||
@JvmStatic external fun backendInit()
|
||||
|
||||
/** Load a GGUF model. Returns an opaque handle, or 0 on failure. */
|
||||
@JvmStatic external fun loadModel(path: String, nCtx: Int, nThreads: Int): Long
|
||||
|
||||
/** Clear the KV cache + sampler so the next completion starts clean. */
|
||||
@JvmStatic external fun resetMemory(handle: Long)
|
||||
|
||||
/** Tokenize + decode [promptUtf8]. Returns 0 on success, negative on error. */
|
||||
@JvmStatic external fun startCompletion(handle: Long, promptUtf8: ByteArray, maxTokens: Int): Int
|
||||
|
||||
/** Next token's raw UTF-8 bytes, or null when generation is finished. */
|
||||
@JvmStatic external fun nextToken(handle: Long): ByteArray?
|
||||
|
||||
/** Free the model + context. The handle must not be used afterwards. */
|
||||
@JvmStatic external fun freeModel(handle: Long)
|
||||
}
|
||||
173
app/src/main/java/monster/autisme/tsjetpiti/MainActivity.kt
Normal file
173
app/src/main/java/monster/autisme/tsjetpiti/MainActivity.kt
Normal file
@@ -0,0 +1,173 @@
|
||||
package monster.autisme.tsjetpiti
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* The whole app: a full-screen WebView (the barebones chat UI) wired to an
|
||||
* on-device llama.cpp model through [LlamaBridge].
|
||||
*
|
||||
* The web layer owns the conversation. Each turn it hands the full message
|
||||
* history to [Bridge.generate]; native rebuilds the prompt from scratch, so
|
||||
* "new tsjet" is just: clear the JS state + reset the KV cache.
|
||||
*/
|
||||
class MainActivity : Activity() {
|
||||
|
||||
private lateinit var webView: WebView
|
||||
private val setupExecutor = Executors.newSingleThreadExecutor()
|
||||
private val genExecutor = Executors.newSingleThreadExecutor()
|
||||
private val stopFlag = AtomicBoolean(false)
|
||||
private val busy = AtomicBoolean(false)
|
||||
|
||||
@Volatile private var handle: Long = 0L
|
||||
@Volatile private var setupStarted = false
|
||||
|
||||
companion object {
|
||||
private const val N_CTX = 4096
|
||||
private const val MAX_TOKENS = 512
|
||||
|
||||
// Optional system prompt. Blank keeps the model's own persona.
|
||||
private const val SYSTEM_PROMPT = ""
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
webView = WebView(this)
|
||||
setContentView(webView)
|
||||
|
||||
webView.settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
cacheMode = WebSettings.LOAD_NO_CACHE
|
||||
}
|
||||
webView.addJavascriptInterface(Bridge(), "TsjetNative")
|
||||
webView.webViewClient = object : WebViewClient() {
|
||||
override fun onPageFinished(view: WebView?, url: String?) {
|
||||
if (!setupStarted) {
|
||||
setupStarted = true
|
||||
startSetup()
|
||||
}
|
||||
}
|
||||
}
|
||||
webView.loadUrl("file:///android_asset/web/index.html")
|
||||
}
|
||||
|
||||
/** Fire a callback into the web layer: window.__tsjet.<fn>("<arg>"). */
|
||||
private fun callJs(fn: String, arg: String) {
|
||||
val quoted = JSONObject.quote(arg)
|
||||
runOnUiThread {
|
||||
webView.evaluateJavascript("window.__tsjet && window.__tsjet.$fn($quoted);", null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startSetup() = setupExecutor.execute {
|
||||
try {
|
||||
callJs("onStatus", "Waking the tsjet…")
|
||||
val modelFile = ModelDownloader.ensureModel(this) { pct ->
|
||||
callJs("onStatus", "Downloading the tsjet brain… $pct%")
|
||||
}
|
||||
callJs("onStatus", "Loading model…")
|
||||
LlamaBridge.backendInit()
|
||||
val threads = (Runtime.getRuntime().availableProcessors() - 2).coerceAtLeast(2)
|
||||
val h = LlamaBridge.loadModel(modelFile.absolutePath, N_CTX, threads)
|
||||
if (h == 0L) {
|
||||
callJs("onError", "Failed to load the model.")
|
||||
return@execute
|
||||
}
|
||||
handle = h
|
||||
callJs("onReady", "")
|
||||
} catch (e: Exception) {
|
||||
callJs("onError", e.message ?: "Setup failed.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildPrompt(messages: JSONArray): ByteArray {
|
||||
val sb = StringBuilder()
|
||||
if (SYSTEM_PROMPT.isNotBlank()) {
|
||||
sb.append("<|im_start|>system\n").append(SYSTEM_PROMPT).append("<|im_end|>\n")
|
||||
}
|
||||
for (i in 0 until messages.length()) {
|
||||
val m = messages.getJSONObject(i)
|
||||
val role = m.optString("role", "user")
|
||||
val content = m.optString("content", "")
|
||||
sb.append("<|im_start|>").append(role).append('\n')
|
||||
.append(content).append("<|im_end|>\n")
|
||||
}
|
||||
sb.append("<|im_start|>assistant\n")
|
||||
return sb.toString().toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
inner class Bridge {
|
||||
|
||||
@JavascriptInterface
|
||||
fun generate(messagesJson: String) {
|
||||
if (handle == 0L) {
|
||||
callJs("onError", "Model not ready yet.")
|
||||
return
|
||||
}
|
||||
if (!busy.compareAndSet(false, true)) return
|
||||
stopFlag.set(false)
|
||||
|
||||
genExecutor.execute {
|
||||
try {
|
||||
val messages = JSONArray(messagesJson)
|
||||
val prompt = buildPrompt(messages)
|
||||
LlamaBridge.resetMemory(handle)
|
||||
val rc = LlamaBridge.startCompletion(handle, prompt, MAX_TOKENS)
|
||||
if (rc != 0) {
|
||||
callJs("onError", "Inference failed to start ($rc).")
|
||||
return@execute
|
||||
}
|
||||
callJs("onStart", "")
|
||||
val out = ByteArrayOutputStream()
|
||||
while (!stopFlag.get()) {
|
||||
val piece = LlamaBridge.nextToken(handle) ?: break
|
||||
if (piece.isNotEmpty()) {
|
||||
out.write(piece)
|
||||
callJs("onUpdate", out.toString("UTF-8"))
|
||||
}
|
||||
}
|
||||
callJs("onDone", out.toString("UTF-8"))
|
||||
} catch (e: Exception) {
|
||||
callJs("onError", e.message ?: "Inference error.")
|
||||
} finally {
|
||||
busy.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun stop() {
|
||||
stopFlag.set(true)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun newChat() {
|
||||
stopFlag.set(true)
|
||||
val h = handle
|
||||
if (h != 0L) genExecutor.execute { LlamaBridge.resetMemory(h) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopFlag.set(true)
|
||||
val h = handle
|
||||
handle = 0L
|
||||
genExecutor.execute { if (h != 0L) LlamaBridge.freeModel(h) }
|
||||
setupExecutor.shutdown()
|
||||
genExecutor.shutdown()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package monster.autisme.tsjetpiti
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* Downloads the GGUF model on first launch and caches it in the app's private
|
||||
* files dir. ~1.2 GB, so it only happens once.
|
||||
*/
|
||||
object ModelDownloader {
|
||||
|
||||
const val MODEL_FILENAME = "Qwen3.5-2B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf"
|
||||
|
||||
private const val MODEL_URL =
|
||||
"https://huggingface.co/HauhauCS/Qwen3.5-2B-Uncensored-HauhauCS-Aggressive" +
|
||||
"/resolve/main/$MODEL_FILENAME?download=true"
|
||||
|
||||
/**
|
||||
* Returns the local model file, downloading it if needed.
|
||||
* [onProgress] is called with 0..100 during download.
|
||||
*/
|
||||
fun ensureModel(context: Context, onProgress: (Int) -> Unit): File {
|
||||
val dir = File(context.filesDir, "models").apply { mkdirs() }
|
||||
val target = File(dir, MODEL_FILENAME)
|
||||
if (target.exists() && target.length() > 0) return target
|
||||
|
||||
val part = File(dir, "$MODEL_FILENAME.part")
|
||||
if (part.exists()) part.delete()
|
||||
|
||||
var url = URL(MODEL_URL)
|
||||
var conn = open(url)
|
||||
|
||||
// Follow redirects manually as a safety net (HF -> CDN).
|
||||
var redirects = 0
|
||||
while (conn.responseCode in 300..399 && redirects < 5) {
|
||||
val location = conn.getHeaderField("Location") ?: break
|
||||
conn.disconnect()
|
||||
url = URL(url, location)
|
||||
conn = open(url)
|
||||
redirects++
|
||||
}
|
||||
|
||||
if (conn.responseCode !in 200..299) {
|
||||
val code = conn.responseCode
|
||||
conn.disconnect()
|
||||
throw RuntimeException("Download failed: HTTP $code")
|
||||
}
|
||||
|
||||
val total = conn.contentLengthLong
|
||||
conn.inputStream.use { input ->
|
||||
part.outputStream().use { output ->
|
||||
val buf = ByteArray(1 shl 16)
|
||||
var downloaded = 0L
|
||||
var lastPct = -1
|
||||
while (true) {
|
||||
val read = input.read(buf)
|
||||
if (read < 0) break
|
||||
output.write(buf, 0, read)
|
||||
downloaded += read
|
||||
if (total > 0) {
|
||||
val pct = (downloaded * 100 / total).toInt()
|
||||
if (pct != lastPct) {
|
||||
lastPct = pct
|
||||
onProgress(pct)
|
||||
}
|
||||
}
|
||||
}
|
||||
output.flush()
|
||||
}
|
||||
}
|
||||
conn.disconnect()
|
||||
|
||||
if (!part.renameTo(target)) {
|
||||
throw RuntimeException("Could not finalize model file")
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
private fun open(url: URL): HttpURLConnection =
|
||||
(url.openConnection() as HttpURLConnection).apply {
|
||||
instanceFollowRedirects = true
|
||||
connectTimeout = 30_000
|
||||
readTimeout = 30_000
|
||||
setRequestProperty("User-Agent", "tsjetpiti")
|
||||
connect()
|
||||
}
|
||||
}
|
||||
4
build.gradle.kts
Normal file
4
build.gradle.kts
Normal file
@@ -0,0 +1,4 @@
|
||||
plugins {
|
||||
id("com.android.application") version "8.7.3" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||
}
|
||||
5
gradle.properties
Normal file
5
gradle.properties
Normal file
@@ -0,0 +1,5 @@
|
||||
org.gradle.jvmargs=-Xmx2560m -Dfile.encoding=UTF-8
|
||||
org.gradle.caching=true
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
252
gradlew
vendored
Executable file
252
gradlew
vendored
Executable file
@@ -0,0 +1,252 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||
' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
gradlew.bat
vendored
Normal file
94
gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
18
settings.gradle.kts
Normal file
18
settings.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "tsjetpiti"
|
||||
include(":app")
|
||||
Reference in New Issue
Block a user