mmap18: ODY v0.2-stable — ThinkBlockFilter aktiv, Bug A+B gefixt

This commit is contained in:
ODY Build 2026-07-21 07:45:27 +00:00
commit 22eeb50050
356 changed files with 3867 additions and 0 deletions

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
.gradle/
build/
app/build/
*.apk
*.aab
local.properties
.idea/
*.iml
.DS_Store

View file

@ -0,0 +1,76 @@
kotlin version: 2.0.21
error message: org.jetbrains.kotlin.util.FileAnalysisException: While analysing /root/.openclaw/workspace/ODY/Foundation/app/src/main/java/de/ody/ODYApplication.kt:17:5: java.lang.IllegalArgumentException: source must not be null
at org.jetbrains.kotlin.util.AnalysisExceptionsKt.wrapIntoFileAnalysisExceptionIfNeeded(AnalysisExceptions.kt:57)
at org.jetbrains.kotlin.fir.FirCliExceptionHandler.handleExceptionOnFileAnalysis(Utils.kt:249)
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:46)
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.resolveAndCheckFir(firUtils.kt:77)
at org.jetbrains.kotlin.fir.pipeline.FirUtilsKt.buildResolveAndCheckFirViaLightTree(firUtils.kt:88)
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModuleToAnalyzedFir(jvmCompilerPipeline.kt:319)
at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModulesUsingFrontendIrAndLightTree(jvmCompilerPipeline.kt:118)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:148)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49)
at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101)
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:464)
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:73)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:506)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:423)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:301)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:129)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675)
at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92)
at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:360)
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:200)
at java.rmi/sun.rmi.transport.Transport$1.run(Transport.java:197)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:714)
at java.rmi/sun.rmi.transport.Transport.serviceCall(Transport.java:196)
at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(TCPTransport.java:598)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(TCPTransport.java:844)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(TCPTransport.java:721)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:400)
at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(TCPTransport.java:720)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Caused by: java.lang.IllegalArgumentException: source must not be null
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.requireNotNull(KtDiagnosticReportHelpers.kt:68)
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn(KtDiagnosticReportHelpers.kt:39)
at org.jetbrains.kotlin.diagnostics.KtDiagnosticReportHelpersKt.reportOn$default(KtDiagnosticReportHelpers.kt:31)
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkSourceElement(FirIncompatibleClassExpressionChecker.kt:50)
at org.jetbrains.kotlin.fir.analysis.checkers.expression.FirIncompatibleClassExpressionChecker.checkType$checkers(FirIncompatibleClassExpressionChecker.kt:42)
at org.jetbrains.kotlin.fir.analysis.checkers.type.FirIncompatibleClassTypeChecker.check(FirIncompatibleClassTypeChecker.kt:17)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.check(TypeCheckersDiagnosticComponent.kt:81)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:53)
at org.jetbrains.kotlin.fir.analysis.collectors.components.TypeCheckersDiagnosticComponent.visitResolvedTypeRef(TypeCheckersDiagnosticComponent.kt:19)
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
at org.jetbrains.kotlin.fir.analysis.collectors.CheckerRunningDiagnosticCollectorVisitor.checkElement(CheckerRunningDiagnosticCollectorVisitor.kt:24)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:248)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitResolvedTypeRef(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.types.FirResolvedTypeRef.accept(FirResolvedTypeRef.kt:28)
at org.jetbrains.kotlin.fir.declarations.impl.FirSimpleFunctionImpl.acceptChildren(FirSimpleFunctionImpl.kt:63)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:118)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitSimpleFunction(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirSimpleFunction.accept(FirSimpleFunction.kt:51)
at org.jetbrains.kotlin.fir.declarations.impl.FirRegularClassImpl.acceptChildren(FirRegularClassImpl.kt:63)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitWithDeclarationAndReceiver(AbstractDiagnosticCollectorVisitor.kt:311)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitClassAndChildren(AbstractDiagnosticCollectorVisitor.kt:87)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:92)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitRegularClass(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirRegularClass.accept(FirRegularClass.kt:48)
at org.jetbrains.kotlin.fir.declarations.impl.FirFileImpl.acceptChildren(FirFileImpl.kt:57)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitNestedElements(AbstractDiagnosticCollectorVisitor.kt:38)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:1151)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollectorVisitor.visitFile(AbstractDiagnosticCollectorVisitor.kt:30)
at org.jetbrains.kotlin.fir.declarations.FirFile.accept(FirFile.kt:42)
at org.jetbrains.kotlin.fir.analysis.collectors.AbstractDiagnosticCollector.collectDiagnostics(AbstractDiagnosticCollector.kt:36)
at org.jetbrains.kotlin.fir.pipeline.AnalyseKt.runCheckers(analyse.kt:34)
... 33 more

View file

@ -0,0 +1,73 @@
kotlin version: 2.3.20
error message: java.io.IOException: Cannot run program "/usr/lib/jvm/java-21-openjdk-amd64/bin/java" (in directory "/root/.local/share/kotlin/daemon"): posix_spawn failed, error: 11 (Resource temporarily unavailable)
Possible reasons:
- Spawn helper ran into JDK version mismatch
- Spawn helper ran into unexpected internal error
- Spawn helper was terminated by another process
Possible solutions:
- Restart JVM, especially after in-place JDK updates
- Check system logs for JDK-related errors
- Re-install JDK to fix permission/versioning problems
- Switch to legacy launch mechanism with -Djdk.lang.Process.launchMechanism=VFORK
error message: java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached
at java.base/java.lang.Thread.start0(Native Method)
at java.base/java.lang.Thread.start(Thread.java:1526)
at kotlinx.coroutines.scheduling.CoroutineScheduler.createNewWorker(CoroutineScheduler.kt:499)
at kotlinx.coroutines.scheduling.CoroutineScheduler.tryCreateWorker(CoroutineScheduler.kt:455)
at kotlinx.coroutines.scheduling.CoroutineScheduler.tryCreateWorker$default(CoroutineScheduler.kt:443)
at kotlinx.coroutines.scheduling.CoroutineScheduler.signalCpuWork(CoroutineScheduler.kt:439)
at kotlinx.coroutines.scheduling.CoroutineScheduler.dispatch(CoroutineScheduler.kt:415)
at kotlinx.coroutines.scheduling.CoroutineScheduler.dispatch$default(CoroutineScheduler.kt:394)
at kotlinx.coroutines.scheduling.SchedulerCoroutineDispatcher.dispatch(Dispatcher.kt:114)
at kotlinx.coroutines.internal.DispatchedContinuationKt.safeDispatch(DispatchedContinuation.kt:254)
at kotlinx.coroutines.internal.DispatchedContinuationKt.resumeCancellableWithInternal(DispatchedContinuation.kt:327)
at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable(Cancellable.kt:26)
at kotlinx.coroutines.CoroutineStart.invoke(CoroutineStart.kt:358)
at kotlinx.coroutines.AbstractCoroutine.start(AbstractCoroutine.kt:134)
at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch(Builders.common.kt:209)
at kotlinx.coroutines.BuildersKt.launch(Unknown Source)
at kotlinx.coroutines.BuildersKt__Builders_commonKt.launch$default(Builders.common.kt:200)
at kotlinx.coroutines.BuildersKt.launch$default(Unknown Source)
at org.jetbrains.kotlin.com.intellij.codeInsight.multiverse.CodeInsightContextManagerImpl.subscribeToChanges(CodeInsightContextManagerImpl.kt:77)
at org.jetbrains.kotlin.com.intellij.codeInsight.multiverse.CodeInsightContextManagerImpl.<init>(CodeInsightContextManagerImpl.kt:72)
at org.jetbrains.kotlin.com.intellij.core.CoreProjectEnvironment.<init>(CoreProjectEnvironment.java:53)
at org.jetbrains.kotlin.com.intellij.core.JavaCoreProjectEnvironment.<init>(JavaCoreProjectEnvironment.java:38)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreProjectEnvironment.<init>(KotlinCoreProjectEnvironment.kt:24)
at org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment$ProjectEnvironment.<init>(KotlinCoreEnvironment.kt:111)
at org.jetbrains.kotlin.cli.jvm.compiler.legacy.pipeline.JvmCompilerPipelineKt.createProjectEnvironment(jvmCompilerPipeline.kt:249)
at org.jetbrains.kotlin.cli.pipeline.jvm.JvmFrontendPipelinePhase.createEnvironmentAndSources(JvmFrontendPipelinePhase.kt:208)
at org.jetbrains.kotlin.cli.pipeline.jvm.JvmFrontendPipelinePhase.executePhase(JvmFrontendPipelinePhase.kt:73)
at org.jetbrains.kotlin.cli.pipeline.jvm.JvmFrontendPipelinePhase.executePhase(JvmFrontendPipelinePhase.kt:55)
at org.jetbrains.kotlin.cli.pipeline.PipelinePhase.phaseBody(PipelinePhase.kt:68)
at org.jetbrains.kotlin.cli.pipeline.PipelinePhase.phaseBody(PipelinePhase.kt:58)
at org.jetbrains.kotlin.config.phaser.NamedCompilerPhase.invoke(CompilerPhase.kt:102)
at org.jetbrains.kotlin.backend.common.phaser.CompositePhase.invoke(PhaseBuilders.kt:22)
at org.jetbrains.kotlin.config.phaser.CompilerPhaseKt.invokeToplevel(CompilerPhase.kt:53)
at org.jetbrains.kotlin.cli.pipeline.AbstractCliPipeline.runPhasedPipeline(AbstractCliPipeline.kt:109)
at org.jetbrains.kotlin.cli.pipeline.AbstractCliPipeline.execute(AbstractCliPipeline.kt:68)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecutePhased(K2JVMCompiler.kt:51)
at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecutePhased(K2JVMCompiler.kt:42)
at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:91)
at org.jetbrains.kotlin.cli.common.CLICompiler.exec(CLICompiler.kt:363)
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunnerBase.runCompiler(IncrementalJvmCompilerRunnerBase.kt:210)
at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunnerBase.runCompiler(IncrementalJvmCompilerRunnerBase.kt:52)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:510)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:422)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileNonIncrementally(IncrementalCompilerRunner.kt:304)
at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:131)
at org.jetbrains.kotlin.buildtools.internal.jvm.operations.JvmCompilationOperationImpl.compileInProcess(JvmCompilationOperationImpl.kt:423)
at org.jetbrains.kotlin.buildtools.internal.jvm.operations.JvmCompilationOperationImpl.compileInProcess(JvmCompilationOperationImpl.kt:337)
at org.jetbrains.kotlin.buildtools.internal.jvm.operations.JvmCompilationOperationImpl.executeCancellableImpl(JvmCompilationOperationImpl.kt:149)
at org.jetbrains.kotlin.buildtools.internal.jvm.operations.JvmCompilationOperationImpl.executeCancellableImpl(JvmCompilationOperationImpl.kt:69)
at org.jetbrains.kotlin.buildtools.internal.CancellableBuildOperationImpl.executeImpl(CancellableBuildOperationImpl.kt:58)
at org.jetbrains.kotlin.buildtools.internal.BuildOperationImpl.execute(BuildOperationImpl.kt:34)
at org.jetbrains.kotlin.buildtools.internal.KotlinToolchainsImpl$BuildSessionImpl.executeOperation$lambda$1(KotlinToolchainsImpl.kt:70)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
Suppressed: kotlinx.coroutines.internal.DiagnosticCoroutineContextException: [CoroutineName(MockProject: {}), StandaloneCoroutine{Cancelled}@71422ab, Dispatchers.Default]

View file

@ -0,0 +1,177 @@
# ODY QA Gate 1 Closure Evidence
**Datum:** 2026-07-18 00:18 CEST
**Sprint:** ODY-FOUNDATION-0.1a
**Gate:** ODY-QA-GATE-1-CLOSURE-1
**Status:** ✅ **PASSED**
---
## 1. Build-Ergebnis
```
BUILD SUCCESSFUL in 33s
44 actionable tasks: 44 executed
Exit Code: 0
```
- ✅ Alle 44 Tasks erfolgreich
- ✅ Keine Build-Fehler
- ⚠️ Warnung: `Icons.Filled.Send` deprecated ( kosmetisch)
---
## 2. Neuen APK-Daten
| Feld | Wert |
|---|---|
| **Pfad** | `app/build/outputs/apk/debug/app-debug.apk` |
| **Größe** | 100 MB |
| **SHA-256** | `7f487f053391f241a7c85a4cba4b7efee817120fecad92fb51deef987c177ec0` |
| **Build-Zeit** | 33s |
---
## 3. Manifest-Finalstatus (Gate 1 Anforderungen)
```xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Offline-by-design: keine externen Permissions benötigt. -->
<application
android:name=".ODYApplication"
android:allowBackup="false" ✅ allowBackup=false
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ODY">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
```
### Permission-Gate-Ergebnisse
| Gate-Vorschrift | Vorher | Nachher | ✅? |
|---|---|---|---|
| INTERNET entfernt | HAD | ✅ removed | ✅ |
| ACCESS_NETWORK_STATE entfernt | HAD | ✅ removed | ✅ |
| BATTERY_STATS entfernt | HAD | ✅ removed | ✅ |
| package="de.ody" entfernt | HAD | ✅ removed | ✅ |
| allowBackup=false | true | ✅ false | ✅ |
| namespace korrekt | `de.ody` | ✅ `de.ody` | ✅ |
### Grep-Beweise (null matches = removed)
- `grep -c "INTERNET"` → **0**
- `grep -c "BATTERY"` → **0**
- `grep -c "ACCESS_NETWORK"` → **0**
- `grep -c 'package='` → **0**
---
## 4. Dependencies-Finalstatus
```kotlin
// Core-Dependencies:
implementation("androidx.compose:compose-bom:2025.05.01")
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.compose.material:material-icons-extended")
implementation("com.google.ai.edge.litertlm:litertlm-android:0.14.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
// Test-Dependencies (NEU): ✅ Gate 1 Blocker geschlossen
testImplementation("junit:junit:4.13.2")
```
Keine Cloud-Abhängigkeiten außer LiteRT-LM (JNI im Build, keine API-Call-Stubs).
### Souveränität (unchanged — Gate 4 bestanden)
| Prüfpunkt | Vorher | Nachher | ✅? |
|---|---|---|---|
| Kein Firebase | ✅ | ✅ | ✅ |
| Kein Gemini API | ✅ | ✅ | ✅ |
| Kein Analytics | ✅ | ✅ | ✅ |
| Kein Telemetry | ✅ | ✅ | ✅ |
---
## 5. Code-Änderungen
### MainActivity.kt (Offline/Battery Resilienz)
```kotlin
fun isDeviceOffline(context: Context): Boolean {
return try {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val net = cm.activeNetwork ?: return true
val cap = cm.getNetworkCapabilities(net) ?: return true
!cap.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) ||
!cap.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
} catch (e: SecurityException) {
// Keine ACCESS_NETWORK_STATE → offline-by-design
true
}
}
fun getBatteryLevel(context: Context): Int {
return try {
val bm = context.getSystemService(BATTERY_SERVICE) as BatteryManager
bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
} catch (e: SecurityException) {
100 // Fallback — keine Batterie-Info
}
}
```
### build.gradle.kts
```kotlin
// NEU: JUnit für Unit-Tests
testImplementation("junit:junit:4.13.2")
```
---
## 6. Gate-1-Akzeptanz-Criteria
| Kriterium | Erfüllt? | Evidence |
|---|---|---|
| Build läuft grün | ✅ | Exit 0, 44/44 tasks |
| APK neu erzeugt | ✅ | `app-debug.apk`, 100 MB |
| SHA256 neu dokumentiert | ✅ | `7f487f05...` in dieser Evidenz & oben |
| Kein INTERNET im Manifest | ✅ | `grep -c "INTERNET" = 0` |
| Kein BATTERY_STATS im Manifest | ✅ | `grep -c "BATTERY" = 0` |
| allowBackup=false | ✅ | Manifest-Zeile überprüft |
| Package-Attribut entfernt | ✅ | `namespace = "de.ody"` in Gradle |
| Evidence-Paket komplett | ✅ | Dieses Dokument |
---
## 7. Fazit
**Gate 1: PASSED** ✅
ODY kann jetzt auf das Testgerät installiert werden. Die beiden Gate-1-Blocker (JUnit + INTERNET-Permission) sind geschlossen. Der Build ist reproduzierbar, die APK ist sauber, das Manifest signalisiert korrekt „offline-by-design".
### Nächste Schritte
1. APK auf Test-Samsung installieren und Gate 2 (Echter Offline-Betrieb) testen
2. Echte Gemma-LiteRT-Inferenz implementieren (Aktuell noch Stub)
3. Gate 3-6 nacheinander schließen
---
*Evidenz generiert durch ODY-QA-GATE-1-CLOSURE-1 — 2026-07-18 00:18 CEST*

90
app/build.gradle.kts Normal file
View file

@ -0,0 +1,90 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "de.ody"
compileSdk = 36
defaultConfig {
applicationId = "de.ody"
minSdk = 28
targetSdk = 36
versionCode = 2
versionName = "0.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
signingConfigs {
create("release") {
// debug.keystore als stabiler Signing-Key für Diagnose-Builds
// Für Production-Release: ODY_KEYSTORE_PATH / ODY_KEYSTORE_PASSWORD setzen
val customKeystorePath = System.getenv("ODY_KEYSTORE_PATH")
val debugKeystorePath = System.getProperty("user.home") + "/.android/debug.keystore"
val keystoreFile = if (customKeystorePath != null) file(customKeystorePath) else file(debugKeystorePath)
storeFile = keystoreFile
storePassword = System.getenv("ODY_KEYSTORE_PASSWORD") ?: "android"
keyAlias = System.getenv("ODY_KEY_ALIAS") ?: "androiddebugkey"
keyPassword = System.getenv("ODY_KEY_PASSWORD") ?: "android"
}
}
buildFeatures {
compose = true
buildConfig = true
}
buildTypes {
getByName("debug") {
buildConfigField("Boolean", "IS_DEBUG_BUILD", "true")
buildConfigField("Boolean", "IS_AUTARK", "true")
}
getByName("release") {
isMinifyEnabled = true
isShrinkResources = true
isDebuggable = false
buildConfigField("Boolean", "IS_DEBUG_BUILD", "false")
buildConfigField("Boolean", "IS_AUTARK", "true")
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("release")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
}
}
sourceSets {
getByName("main").java.srcDirs("src/main/java")
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// Compose BOM
implementation(platform("androidx.compose:compose-bom:2025.05.01"))
implementation("androidx.compose.material3:material3")
implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.compose.material:material-icons-extended")
// llama.cpp für Android — echte lokale GGUF-Inferenz (ODY 0.2)
implementation("io.github.ljcamargo:llamacpp-kotlin:0.4.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
configurations.configureEach {
resolutionStrategy {
force("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0")
force("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0")
}
}
// Tests
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
}

94
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,94 @@
# ODY Release Proguard Rules
# ─────────────────────────────────────────────────────────────────────────────
# ── Kotlin ────────────────────────────────────────────────────────────────────
-keep class kotlin.** { *; }
-keep class kotlin.Metadata { *; }
-dontwarn kotlin.**
-keepclassmembers class **$WhenMappings { <fields>; }
-keepclassmembers class kotlin.Lazy { *; }
# ── Kotlin Coroutines ─────────────────────────────────────────────────────────
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepclassmembernames class kotlinx.** {
volatile <fields>;
}
-dontwarn kotlinx.coroutines.**
# ── Jetpack Compose ───────────────────────────────────────────────────────────
-keep class androidx.compose.** { *; }
-keepclassmembers class androidx.compose.** { *; }
-dontwarn androidx.compose.**
# ── ODY Guards (müssen vollständig erhalten bleiben) ─────────────────────────
-keep class de.ody.guards.** { *; }
-keepclassmembers class de.ody.guards.** { *; }
# ── ODY Runtime Interface + Adapter ──────────────────────────────────────────
-keep interface de.ody.runtime.LocalModelRuntime { *; }
-keep class de.ody.runtime.** { *; }
-keepclassmembers class de.ody.runtime.** { *; }
# ── ODY Application + MainActivity ───────────────────────────────────────────
-keep class de.ody.ODYApplication { *; }
-keep class de.ody.app.ODYApplication { *; }
-keep class de.ody.app.MainActivity { *; }
# ── LlamaContext (librnllama.so JNI) Fix direct-mmap3 ─────────────────────
# LlamaContext darf NICHT durch R8/ProGuard umbenannt oder entfernt werden,
# da librnllama.so die Klasse per JNI-Name findet.
-keep class org.nehuatl.llamacpp.** { *; }
-keepclassmembers class org.nehuatl.llamacpp.** { *; }
-keepclassmembers class org.nehuatl.llamacpp.LlamaContext {
public <init>(int, java.util.Map);
public long getContext();
public void setTokenCallback(kotlin.jvm.functions.Function1);
public java.util.Map completion(java.util.Map);
public void stopCompletion();
public boolean isPredicting();
public void release();
native <methods>;
}
-dontwarn org.nehuatl.llamacpp.**
# ── LiteRT-LM (Google AI Edge) ────────────────────────────────────────────────
-keep class com.google.ai.edge.** { *; }
-dontwarn com.google.ai.edge.**
# ── Enum-Klassen vollständig erhalten ─────────────────────────────────────────
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
# ── Data-Klassen (Serialisierung, Reflection) ─────────────────────────────────
-keepclassmembers class * implements java.io.Serializable {
static final long serialVersionUID;
private static final java.io.ObjectStreamField[] serialPersistentFields;
private void writeObject(java.io.ObjectOutputStream);
private void readObject(java.io.ObjectInputStream);
java.lang.Object writeReplace();
java.lang.Object readResolve();
}
# ── Entferne Logging in Release ───────────────────────────────────────────────
-assumenosideeffects class android.util.Log {
public static boolean isLoggable(java.lang.String, int);
public static int v(...);
public static int d(...);
}
# ── Entferne Debug-Assertions ─────────────────────────────────────────────────
-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
static void checkParameterIsNotNull(java.lang.Object, java.lang.String);
static void checkNotNullParameter(java.lang.Object, java.lang.String);
static void checkExpressionValueIsNotNull(java.lang.Object, java.lang.String);
static void checkNotNullExpressionValue(java.lang.Object, java.lang.String);
static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String, java.lang.String);
static void checkReturnedValueIsNotNull(java.lang.Object, java.lang.String);
static void throwUninitializedPropertyAccessException(java.lang.String);
static void throwNpe(java.lang.String);
static void throwJavaNpe();
static void throwIfNull(java.lang.Object, java.lang.String);
}

View file

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!--
ODY Security Gate — Netzwerk-Permissions
─────────────────────────────────────────
INTERNET:
Zweck: Einmaliger Modell-Download beim ersten Start (Provisioning).
Danach: Lokale Inferenz, kein Cloud-Zwang.
Entscheidung: "Provision once. Depend never."
Sprint: ODY-PROVISIONING-1.0
ACCESS_NETWORK_STATE:
Zweck: NetworkWatcher — ConnectivityState (OFFLINE/ONLINE/METERED).
Keine sensitive Permission. Kein Wake-Lock, kein Foreground-Service.
Sprint: ODY-NETWORK-WATCHER-1.0
Keine weiteren Netzwerk-Permissions.
Cloud-Eskalation und EmergencyChannel: TODO Sprint 0.3/0.4
-->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name="de.ody.app.ODYApplication"
android:allowBackup="false"
android:largeHeap="true"
android:fullBackupContent="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@drawable/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ODY"
android:usesCleartextTraffic="false"
android:networkSecurityConfig="@xml/network_security_config">
<!-- Provisioning: einmaliger Setup beim ersten Start -->
<activity
android:name="de.ody.app.provisioning.ProvisioningActivity"
android:exported="true"
android:theme="@style/Theme.ODY"
android:screenOrientation="portrait"
android:configChanges="orientation|screenSize|keyboardHidden|keyboard"
android:windowSoftInputMode="adjustNothing">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Haupt-Chat-UI -->
<activity
android:name="de.ody.app.MainActivity"
android:exported="false"
android:theme="@style/Theme.ODY"
android:screenOrientation="portrait"
android:configChanges="orientation|screenSize|keyboardHidden|keyboard"
android:windowSoftInputMode="adjustNothing">
</activity>
<!-- FileProvider: LlamaHelper benötigt content:// URI für Modell-Dateien -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.files"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View file

@ -0,0 +1,558 @@
package de.ody.app
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.BatteryManager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat
import de.ody.BuildConfig
import de.ody.app.ODYApplication
import de.ody.app.engine.AssistantEngine
import de.ody.guards.GuardAlert
import de.ody.guards.GuardType
import de.ody.guards.GuardSeverity
import de.ody.runtime.GenerationRequest
import de.ody.runtime.RuntimeState
import kotlinx.coroutines.Dispatchers
import androidx.lifecycle.lifecycleScope
import android.os.Handler
import android.os.Looper
import androidx.compose.runtime.snapshots.Snapshot
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// P1+P2: Edge-to-Edge aktivieren — Layout verwaltet eigene Insets
// P8: Edge-to-Edge-Test: Safe Insets werden via systemBarsPadding() + imePadding() gesetzt
enableEdgeToEdge()
WindowCompat.setDecorFitsSystemWindows(window, false)
setContent {
ODYTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = Color(0xFF0D0D0F),
) {
ODYChatScreen()
}
}
}
}
companion object {
fun isDeviceOffline(context: Context): Boolean {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val net = cm.activeNetwork ?: return true
val cap = cm.getNetworkCapabilities(net) ?: return true
return !cap.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) ||
!cap.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
}
fun getBatteryLevel(context: Context): Int {
val bm = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
return bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// ODY Chat Screen — kennt ausschließlich AssistantEngine und RuntimeState
// ─────────────────────────────────────────────────────────────────────────────
@Composable
fun ODYChatScreen() {
val context = LocalContext.current
val app = context.applicationContext as ODYApplication
val engine: AssistantEngine = remember { app.engine }
// FIX 1: engine.state → engine.runtimeState
val runtimeState by engine.runtimeState.collectAsState()
// FIX mmap14: rememberCoroutineScope() wird bei Recompose gecancelt
// → generate() wird abgebrochen → currentOutput bleibt leer
// lifecycleScope der Activity überlebt Recomposes
val activity = LocalContext.current as? androidx.activity.ComponentActivity
// FIX mmap17: stabiler lifecycleScope, kein remember() Fallback
val scope = (LocalContext.current as? androidx.activity.ComponentActivity)?.lifecycleScope
?: rememberCoroutineScope()
var textInput by remember { mutableStateOf("") }
var messages by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
var currentOutput by remember { mutableStateOf("") }
var batteryLevel by remember { mutableStateOf(0) }
var guardMsg by remember { mutableStateOf<GuardAlert?>(null) }
val listState = rememberLazyListState()
// ODY-Farbpalette
val bgColor = Color(0xFF0D0D0F)
val surfaceColor = Color(0xFF1A1A1E)
val userBubbleColor = Color(0xFF1E3A5F)
val odyBubbleColor = Color(0xFF1A1A1E)
val accentColor = Color(0xFF4A9EFF)
val warningColor = Color(0xFFE8A020)
val textPrimary = Color(0xFFE8E8EC)
val textSecondary = Color(0xFF8A8A9A)
val guardColor = Color(0xFFCC3333)
val successColor = Color(0xFF2ECC71)
// FIX 2: loadModel()-Aufruf entfernt — Modell-Laden passiert in ODYApplication.onCreate()
// via engine.start(modelFile). Nur batteryLevel-Initialisierung bleibt.
LaunchedEffect(Unit) {
batteryLevel = MainActivity.getBatteryLevel(context)
}
val statusText = when {
engine.isSimulation -> "Bereit · Simulation"
runtimeState == RuntimeState.NO_MODEL -> "Kein Modell"
runtimeState == RuntimeState.UNLOADED -> "Bereit · Kein Modell"
runtimeState == RuntimeState.VALIDATING -> "Prüfe Modell..."
runtimeState == RuntimeState.LOADING -> "Lädt Modell..."
runtimeState == RuntimeState.MODEL_LOAD_FAILED -> "Ladefehler — Modell ungültig"
runtimeState == RuntimeState.READY -> "Autark · Lokal aktiv"
runtimeState == RuntimeState.GENERATING -> "Autark · Generiert"
runtimeState == RuntimeState.ERROR -> "Fehler"
else -> "Bereit"
}
// FIX 3: else-Branch hinzugefügt — when-Ausdruck muss exhaustive sein
val statusColor = when (runtimeState) {
RuntimeState.READY, RuntimeState.GENERATING -> successColor
RuntimeState.LOADING -> warningColor
RuntimeState.ERROR -> guardColor
RuntimeState.UNLOADED ->
if (engine.isSimulation) warningColor else textSecondary
else -> textSecondary
}
val modelLabel = when {
engine.isSimulation -> "🧠 Kein Modell"
runtimeState == RuntimeState.UNLOADED -> "🧠 Kein Modell"
runtimeState == RuntimeState.LOADING -> "🧠 Lädt..."
else -> "🧠 Lokal"
}
val showSimBanner = engine.isSimulation
// P1+P2: systemBarsPadding() für Status-/Navigationsleiste,
// imePadding() für Tastatur — verhindert Layout-Shift und Überlagerung
Column(
modifier = Modifier
.fillMaxSize()
.background(bgColor)
.systemBarsPadding()
.imePadding() // P2: Eingabefeld bleibt über Tastatur
) {
// ── Header ──────────────────────────────────────────────────────────
Column(
modifier = Modifier
.fillMaxWidth()
.background(bgColor)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "ODY",
color = textPrimary,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.sp,
)
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.size(8.dp)
.background(statusColor, RoundedCornerShape(50))
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "🛡 $statusText",
color = statusColor,
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = modelLabel, color = textSecondary, fontSize = 12.sp)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "🔋 $batteryLevel%",
color = if (batteryLevel < 20) warningColor else textSecondary,
fontSize = 12.sp,
)
}
}
// P7: HorizontalDivider statt deprecated Divider
HorizontalDivider(
color = Color(0xFF2A2A2E),
thickness = 0.5.dp,
modifier = Modifier.padding(top = 8.dp),
)
}
// ── Simulation-Banner (P4: kompakter) ───────────────────────────────
if (showSimBanner) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp)
.background(Color(0xFF2A1F00), RoundedCornerShape(8.dp))
.padding(horizontal = 14.dp, vertical = 6.dp) // P4: 8→6dp
) {
Text(
text = "⚠ [Simulation] · Kein Modell geladen",
color = warningColor,
fontSize = 12.sp, // P4: 13→12sp
)
}
}
// ── Lade-Indikator ───────────────────────────────────────────────────
if (runtimeState == RuntimeState.LOADING) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp)
.background(Color(0xFF0A1F2A), RoundedCornerShape(8.dp))
.padding(horizontal = 14.dp, vertical = 8.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
color = accentColor,
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Modell wird geladen...",
color = accentColor,
fontSize = 13.sp,
)
}
}
}
// ── Guard-Alert ──────────────────────────────────────────────────────
guardMsg?.let { alert ->
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 3.dp)
.background(Color(0xFF2A0000), RoundedCornerShape(8.dp))
.padding(horizontal = 14.dp, vertical = 8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "🛡 Guard: ${alert.message}",
color = guardColor,
fontSize = 13.sp,
modifier = Modifier.weight(1f),
)
TextButton(onClick = { guardMsg = null }) {
Text("", color = textSecondary, fontSize = 12.sp)
}
}
}
}
// ── Chat-Nachrichten ─────────────────────────────────────────────────
Box(modifier = Modifier.weight(1f)) {
// P3: Willkommens-Zustand wenn keine Nachrichten
if (messages.isEmpty() && currentOutput.isEmpty()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "🛡",
fontSize = 40.sp,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "ODY ist bereit.",
color = textPrimary,
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = if (engine.isSimulation)
"Lade ein GGUF-Modell, um echte Antworten zu erhalten."
else
"Stell eine Frage.",
color = textSecondary,
fontSize = 14.sp,
textAlign = TextAlign.Center,
lineHeight = 20.sp,
)
}
}
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 12.dp),
) {
items(messages) { (role, content) ->
val isUser = role == "user"
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
) {
Box(
modifier = Modifier
.fillMaxWidth(0.82f) // P5: 280dp fix → 82% Breite
.wrapContentWidth(
if (isUser) Alignment.End else Alignment.Start
)
.background(
if (isUser) userBubbleColor else odyBubbleColor,
RoundedCornerShape(
topStart = if (isUser) 16.dp else 4.dp,
topEnd = if (isUser) 4.dp else 16.dp,
bottomStart = 16.dp,
bottomEnd = 16.dp,
)
)
.padding(horizontal = 14.dp, vertical = 10.dp)
) {
Column {
if (!isUser) {
Text(
text = if (engine.isSimulation) "ODY [Simulation]" else "ODY",
color = if (engine.isSimulation) warningColor else accentColor,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 4.dp),
)
}
Text(
text = content,
color = textPrimary,
fontSize = 15.sp,
lineHeight = 22.sp,
)
}
}
}
}
// Streaming-Bubble
if (currentOutput.isNotEmpty()) {
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start,
) {
Box(
modifier = Modifier
.fillMaxWidth(0.82f) // P5: konsistent
.background(
odyBubbleColor,
RoundedCornerShape(4.dp, 16.dp, 16.dp, 16.dp),
)
.padding(horizontal = 14.dp, vertical = 10.dp)
) {
Column {
Text(
text = if (engine.isSimulation) "ODY [Simulation]" else "ODY",
color = if (engine.isSimulation) warningColor else accentColor,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 4.dp),
)
Text(
text = currentOutput + "",
color = textPrimary,
fontSize = 15.sp,
lineHeight = 22.sp,
)
}
}
}
}
}
}
}
// ── Eingabefeld ──────────────────────────────────────────────────────
Row(
modifier = Modifier
.fillMaxWidth()
.background(surfaceColor)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = textInput,
onValueChange = { textInput = it },
modifier = Modifier.weight(1f),
placeholder = {
Text(
text = "Frag ODY...",
color = textSecondary,
fontSize = 15.sp,
)
},
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = accentColor,
unfocusedBorderColor = Color(0xFF3A3A4A),
focusedTextColor = textPrimary,
unfocusedTextColor = textPrimary,
cursorColor = accentColor,
focusedContainerColor = Color(0xFF1E1E24),
unfocusedContainerColor= Color(0xFF1A1A20),
),
shape = RoundedCornerShape(24.dp),
singleLine = false,
maxLines = 4,
)
Spacer(modifier = Modifier.width(8.dp))
val isGenerating = runtimeState == RuntimeState.GENERATING
IconButton(
onClick = {
if (isGenerating) {
engine.cancel()
} else {
val userText = textInput.trim()
if (userText.isBlank()) return@IconButton
textInput = ""
messages = messages + ("user" to userText)
currentOutput = ""
scope.launch(kotlinx.coroutines.Dispatchers.Main) {
val guardResult = withContext(Dispatchers.Default) {
de.ody.guards.GuardTest.checkAll(userText)
}
if (guardResult != null) {
guardMsg = guardResult
messages = messages + ("assistant" to "[Guard] ${guardResult.message}")
return@launch
}
val request = GenerationRequest(
userMessage = userText,
systemPrompt = "Du bist ODY, ein lokales Werkzeug. Antworte präzise und direkt auf Deutsch. Keine Smalltalk-Floskeln.",
history = messages.dropLast(1).takeLast(10),
)
// onToken wird von LlamaCppRuntime bereits auf Main-Thread geliefert
// (via singleton mainHandler in LlamaCppRuntime).
// Hier kein Handler mehr noetig.
val genRunId = System.currentTimeMillis()
android.util.Log.i("ODY_STREAM", "ui generate start runId=$genRunId")
engine.generate(request) { token ->
android.util.Log.i("ODY_STREAM", "ui onToken len=${token.length} runId=$genRunId")
currentOutput += token
}
android.util.Log.i("ODY_STREAM", "ui generate done runId=$genRunId output_len=${currentOutput.length}")
// generate() ist suspend und laeuft in scope.launch (Dispatchers.Main)
// -> State-Mutation hier direkt erlaubt.
messages = messages + ("assistant" to currentOutput)
currentOutput = ""
}
}
},
modifier = Modifier
.size(48.dp)
.background(
if (isGenerating) guardColor else accentColor,
RoundedCornerShape(50),
),
) {
Icon(
imageVector = if (isGenerating) Icons.Default.Stop else Icons.Default.Send,
contentDescription = if (isGenerating) "Stop" else "Senden",
tint = Color.White,
modifier = Modifier.size(20.dp),
)
}
}
// ── Debug-Footer (nur in Debug-Builds) ──────────────────────────────
if (BuildConfig.IS_DEBUG_BUILD) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFF0A0A0C))
.padding(horizontal = 16.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "DEBUG · mmap16 · 1784582716 · $runtimeState",
color = textSecondary,
fontSize = 10.sp,
)
TextButton(
onClick = {
guardMsg = GuardAlert(
type = GuardType.REPETITION,
message = "Guard-Test ausgelöst",
severity = GuardSeverity.LOW,
)
},
modifier = Modifier.height(24.dp),
) {
Text("🛡 Guard-Test", color = textSecondary, fontSize = 10.sp)
}
}
}
}
// P6: Auto-Scroll-Guard — kein IndexOutOfBoundsException
// P8: Scroll nur bei neuer Nachricht, nicht bei jedem Token
// animateScrollToItem während Streaming erzeugt einen Scroll-Loop
// FIX mmap14: Scroll auch wenn Streaming-Blase erscheint (currentOutput wird nicht-leer)
val scrollTrigger = messages.size + if (currentOutput.isNotEmpty()) 1 else 0
LaunchedEffect(scrollTrigger) {
val totalItems = messages.size + if (currentOutput.isNotEmpty()) 1 else 0
if (totalItems > 0) {
listState.scrollToItem(index = (totalItems - 1).coerceAtLeast(0))
}
}
}

View file

@ -0,0 +1,226 @@
package de.ody.app
import android.content.Context
import android.os.BatteryManager
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Autorenew
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.ody.ODYApplication
import de.ody.guards.GuardAlert
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
ODYChatScreen()
}
}
}
}
companion object {
/**
* Prüft Netz-Status. Ohne ACCESS_NETWORK_STATE-Permission
* wird ODY per Design als offline behandelt.
*/
fun isAutarkMode(): Boolean {
// ODY-AUTARK-MODE-0.1A
// ODY ist autark-by-design:
// keine INTERNET-Permission, keine ACCESS_NETWORK_STATE-Permission,
// keine Cloud-Abhängigkeit, keine Telemetrie.
// Sprint 2 (ODY-LLAMACPP-REAL-INFERENCE-1) bleibt vollständig autark.
return true
}
/**
* Liest Batteriestand. Ohne BATTERY_STATS-Permission fallback zu 100%.
*/
fun getBatteryLevel(context: Context): Int {
return try {
val bm = context.getSystemService(BATTERY_SERVICE) as BatteryManager
bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
} catch (e: SecurityException) {
100 // Fallback — keine Batterie-Info
}
}
}
@Composable
fun ODYChatScreen() {
val context = LocalContext.current
val app = context.applicationContext as ODYApplication
val runtime = remember { app.runtime }
val scope = rememberCoroutineScope()
var textInput by remember { mutableStateOf("") }
var messages by remember { mutableStateOf<List<Pair<String, String>>>(emptyList()) }
var isStreaming by remember { mutableStateOf(false) }
var currentOutput by remember { mutableStateOf("") }
var offlineIcon by remember { mutableStateOf("📴") }
var batteryLevel by remember { mutableStateOf(0) }
var modelReady by remember { mutableStateOf(false) }
var modelInfo by remember { mutableStateOf("Loading...") }
var guardMsg by remember { mutableStateOf<GuardAlert?>(null) }
val listState = rememberLazyListState()
LaunchedEffect(Unit) {
offlineIcon = "🛡"
batteryLevel = getBatteryLevel(context)
val s = runtime.status()
modelReady = s.initialized
modelInfo = if (s.initialized) "${s.modelId}" else "Not init"
}
Column(modifier = Modifier
.fillMaxSize()
.padding(16.dp)) {
// Header
Row(modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween) {
Text("ODY 0.1a", fontSize = 20.sp, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(offlineIcon, fontSize = 14.sp)
Text("$batteryLevel%", fontSize = 14.sp)
val c = if (modelReady) Color(0xFF4CAF50) else Color(0xFFFFC107)
Box(modifier = Modifier.size(10.dp)
.background(c, RoundedCornerShape(50)))
}
}
// Guard message
guardMsg?.let { msg ->
Card(modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
colors = CardDefaults.cardColors(containerColor =
MaterialTheme.colorScheme.errorContainer)) {
Column(modifier = Modifier.padding(12.dp)) {
Text(msg.message, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onErrorContainer)
Text("Guard: ${msg.type} | ${msg.timestamp}",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onErrorContainer.copy(0.7f))
}
}
}
// Chat messages
LazyColumn(state = listState, modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(messages, key = { "${it.first}:${it.second.take(30)}" }) { (role, text) ->
Card(modifier = Modifier.fillMaxWidth()) {
Text(role, fontSize = 11.sp, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary)
Text(text, fontSize = 14.sp)
}
}
if (isStreaming && currentOutput.isNotEmpty()) {
item {
Card(modifier = Modifier.fillMaxWidth()) {
Text("ODY", fontSize = 11.sp, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary)
Text("$currentOutput▌", fontSize = 14.sp)
}
}
}
}
// Streaming bar
if (isStreaming) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically) {
Text("⏳ Streaming...", fontSize = 12.sp,
color = MaterialTheme.colorScheme.primary)
FilledTonalButton(onClick = {
scope.launch { runtime.stop() }
messages = messages + Pair("assistant", currentOutput)
currentOutput = ""
isStreaming = false
}) { Text("Stop") }
}
}
// Input field
Row(modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = textInput, onValueChange = { textInput = it },
modifier = Modifier.weight(1f).padding(end = 8.dp),
placeholder = { Text("Nachricht eingeben...") },
singleLine = true, shape = RoundedCornerShape(24.dp)
)
FilledIconButton(onClick = {
if (textInput.isNotBlank()) {
val p = textInput.trim()
textInput = ""
messages = messages + Pair("user", p)
scope.launch {
isStreaming = true
val r = generateResponse(runtime, p)
isStreaming = false
messages = messages + Pair("assistant", r)
}
}
}, enabled = textInput.isNotBlank()) {
Icon(Icons.Filled.Send, "Send", tint =
MaterialTheme.colorScheme.onPrimary)
}
}
// Debug footer
Surface(modifier = Modifier.padding(top = 8.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(0.5f)) {
Column(modifier = Modifier.padding(8.dp)) {
Text("$modelInfo | ${messages.size} msgs",
fontSize = 10.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant)
FilledTonalButton(onClick = {
currentOutput = "z.z.z.z.z.z.z"
val g = de.ody.guards.GuardTest()
val v = g.checkRepetitionGuard(currentOutput)
if (v != null) guardMsg = v
}, enabled = !isStreaming, modifier = Modifier.align(Alignment.End)) {
Text("Guard test", fontSize = 10.sp)
}
}
}
}
}
}
suspend fun generateResponse(runtime: de.ody.runtime.LocalModelRuntime, prompt: String): String =
withContext(Dispatchers.IO) {
val g = de.ody.guards.GuardTest()
val simulated = "ODY: Prompt received: $prompt\n\n" +
"Simulierte Lokale Antwort f\u00fcr Sprint 0.1a.\nOffline-Modus aktiv.\nRAM-Verbrauch wird gemessen."
if (g.checkRepetitionGuard(simulated) != null) {
return@withContext "[Guard: Repetition detected]"
}
simulated
}

View file

@ -0,0 +1,94 @@
package de.ody.app
import android.app.ActivityManager
import android.app.Application
import android.util.Log
import de.ody.app.engine.AssistantEngine
import de.ody.app.provisioning.ModelDownloader
import de.ody.app.provisioning.ProvisioningActivity
import java.io.File
class ODYApplication : Application() {
companion object {
private const val TAG = "ODY.Application"
}
lateinit var engine: AssistantEngine
private set
override fun onCreate() {
super.onCreate()
// ODY-SAMSUNG-LARGEHEAP-SMOKE-1: Memory-Evidence
val actMgr = getSystemService(ActivityManager::class.java)
val rt = Runtime.getRuntime()
Log.i(
"ODY_MEM",
"START memoryClass=${actMgr.memoryClass}MB " +
"largeMemoryClass=${actMgr.largeMemoryClass}MB " +
"maxHeap=${rt.maxMemory() / 1024 / 1024}MB"
)
// Fix direct-mmap3: Gesamte Initialisierung in try/catch(Throwable)
// Verhindert App-Tod durch unerwartete native Fehler
try {
engine = AssistantEngine(this)
// Modell-Datei suchen — falls vorhanden, LlamaCppRuntime laden
val modelFile = ModelDownloader(this).getModelFile(
ProvisioningActivity.MODEL_FILENAME
)
Log.d(TAG, "onCreate: modelFile=$modelFile exists=${modelFile?.exists()} size=${modelFile?.length()}")
// Memory vor Modell-Load
val rt2 = Runtime.getRuntime()
Log.i(
"ODY_MEM",
"PRE_LOAD usedHeap=${(rt2.totalMemory() - rt2.freeMemory()) / 1024 / 1024}MB " +
"totalHeap=${rt2.totalMemory() / 1024 / 1024}MB " +
"maxHeap=${rt2.maxMemory() / 1024 / 1024}MB"
)
engine.start(modelFile)
} catch (e: Throwable) {
// Letzter Schutzwall: App darf NIEMALS durch Initialisierungsfehler sterben
Log.e(TAG, "onCreate: Kritischer Fehler bei Initialisierung — App läuft im Notfall-Modus", e)
// Sicherstellen dass engine immer initialisiert ist
if (!::engine.isInitialized) {
try {
engine = AssistantEngine(this)
} catch (e2: Throwable) {
Log.e(TAG, "onCreate: AssistantEngine-Fallback fehlgeschlagen", e2)
}
}
}
}
/**
* Wird von ProvisioningActivity nach erfolgreichem Download aufgerufen.
* Startet die LlamaCppRuntime mit der frisch heruntergeladenen Datei.
*/
fun onModelReady(modelFile: File) {
Log.d(TAG, "onModelReady: $modelFile exists=${modelFile.exists()} size=${modelFile.length()}")
try {
engine.stop()
engine = AssistantEngine(this)
// Memory vor Modell-Load
val rt2 = Runtime.getRuntime()
Log.i(
"ODY_MEM",
"PRE_LOAD usedHeap=${(rt2.totalMemory() - rt2.freeMemory()) / 1024 / 1024}MB " +
"totalHeap=${rt2.totalMemory() / 1024 / 1024}MB " +
"maxHeap=${rt2.maxMemory() / 1024 / 1024}MB"
)
engine.start(modelFile)
} catch (e: Throwable) {
Log.e(TAG, "onModelReady: Fehler beim Neustart der Engine", e)
}
}
override fun onTerminate() {
super.onTerminate()
try { engine.stop() } catch (_: Throwable) {}
}
}

View file

@ -0,0 +1,35 @@
package de.ody.app
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* ODY Design Constitution Farbpalette
*
* Sehr wenig Farbe, viel Ruhe.
* Akzentfarben nur dort, wo sie Information transportieren.
*/
private val ODYColorScheme = darkColorScheme(
background = Color(0xFF0D0D0F),
surface = Color(0xFF1A1A1E),
surfaceVariant = Color(0xFF1E1E24),
primary = Color(0xFF4A9EFF),
primaryContainer = Color(0xFF1E3A5F),
onBackground = Color(0xFFE8E8EC),
onSurface = Color(0xFFE8E8EC),
onPrimary = Color.White,
error = Color(0xFFCC3333),
onError = Color.White,
outline = Color(0xFF3A3A4A),
outlineVariant = Color(0xFF2A2A2E),
)
@Composable
fun ODYTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = ODYColorScheme,
content = content,
)
}

View file

@ -0,0 +1,134 @@
package de.ody.app.engine
import android.content.Context
import android.util.Log
import de.ody.app.network.ConnectivityState
import de.ody.app.network.NetworkWatcher
import de.ody.runtime.GenerationRequest
import de.ody.runtime.GenerationResult
import de.ody.runtime.InferenceRuntime
import de.ody.runtime.ModelConfig
import de.ody.runtime.RuntimeState
import de.ody.runtime.llamacpp.LlamaCppRuntime
import de.ody.runtime.simulation.SimulationRuntime
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
/**
* ODY AssistantEngine mmap12 / ODY-RUNTIME-STATEFLOW-BRIDGE-FIX-1
*
* Root Cause des bisherigen Bugs:
* val runtimeState: StateFlow<RuntimeState>
* get() = runtime.state
*
* collectAsState() in Compose abonniert den Flow beim ersten Compose-Lauf.
* Wenn runtime danach intern ausgetauscht wird (SimulationRuntime LlamaCppRuntime),
* weiß Compose nicht, dass es einen anderen Flow sammeln muss.
* Ergebnis: UI lauscht auf altem SimulationRuntime.state, Engine nutzt neue Runtime.
*
* Fix:
* - Stabiler engine-owned _runtimeState MutableStateFlow wird NIEMALS ausgetauscht
* - bindRuntime() forwarded newRuntime.state _runtimeState via runtimeStateJob
* - runtimeStateJob wird bei jedem Runtime-Wechsel gecancelt und neu gestartet
* - Lifecycle-Cleanup in release(): runtimeStateJob canceln, Runtime freigeben
*/
class AssistantEngine(private val context: Context) {
companion object {
private const val TAG = "AssistantEngine"
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val networkWatcher = NetworkWatcher(context)
val connectivityState: StateFlow<ConnectivityState> = networkWatcher.state
private var runtime: InferenceRuntime = SimulationRuntime()
// Stabiler engine-owned StateFlow — collectAsState() in Compose abonniert diesen
// ein einziges Mal und bleibt darauf. Nur die Werte ändern sich, nie der Flow selbst.
private val _runtimeState = MutableStateFlow<RuntimeState>(RuntimeState.UNLOADED)
val runtimeState: StateFlow<RuntimeState> = _runtimeState.asStateFlow()
// Job der newRuntime.state → _runtimeState forwarded
// Wird bei jedem Runtime-Wechsel gecancelt und neu gestartet
private var runtimeStateJob: Job? = null
val isSimulation: Boolean
get() = runtime is SimulationRuntime
/**
* Bindet eine neue Runtime: cancelt den alten Forward-Job,
* setzt runtime, startet neuen Forward-Job.
*/
private fun bindRuntime(newRuntime: InferenceRuntime) {
runtimeStateJob?.cancel()
runtime = newRuntime
runtimeStateJob = scope.launch {
newRuntime.state.collect { state ->
Log.d(TAG, "runtimeState → $state (via ${newRuntime.javaClass.simpleName})")
_runtimeState.value = state
}
}
Log.i(TAG, "bindRuntime: ${newRuntime.javaClass.simpleName} gebunden")
}
fun start(modelFile: File? = null) {
networkWatcher.start()
if (modelFile != null && modelFile.exists()) {
Log.i(TAG, "start: Modell gefunden → LlamaCppRuntime: ${modelFile.name} (${modelFile.length() / 1024 / 1024} MB)")
val llamaRuntime = LlamaCppRuntime(context)
bindRuntime(llamaRuntime)
scope.launch {
llamaRuntime.loadModel(
modelPath = modelFile.absolutePath,
config = ModelConfig(
modelPath = modelFile.absolutePath,
modelId = modelFile.nameWithoutExtension,
contextLength = 4096
)
)
}
} else {
Log.i(TAG, "start: Kein Modell → SimulationRuntime aktiv")
bindRuntime(SimulationRuntime())
}
}
fun stop() {
networkWatcher.stop()
scope.launch { runtime.unload() }
}
suspend fun generate(
request: GenerationRequest,
onToken: (String) -> Unit
): Result<GenerationResult> = runtime.generate(
request = request,
onToken = onToken
)
fun cancel() = runtime.cancel()
/**
* Vollständiges Lifecycle-Cleanup:
* - runtimeStateJob canceln (kein weiteres Forwarding)
* - Runtime via unload() freigeben
*/
fun release() {
runtimeStateJob?.cancel()
runtimeStateJob = null
scope.launch {
try { runtime.unload() } catch (e: Throwable) {
Log.w(TAG, "release/unload: ${e.message}")
}
}
}
}

View file

@ -0,0 +1,16 @@
package de.ody.app.network
/**
* ODY Connectivity State
*
* NetworkWatcher meldet ausschließlich diesen Zustand.
* Keine Geschäftslogik hier nur Fakten.
*
* METERED: darf später Downloads / Cloud-Eskalation blockieren.
*/
enum class ConnectivityState {
UNKNOWN, // Initialer Zustand, noch nicht geprüft
OFFLINE, // Kein Netz — lokale Inferenz ist der normale Fallback
ONLINE, // Netz verfügbar und validiert
METERED // Netz verfügbar, aber kostenpflichtig (Mobilfunk)
}

View file

@ -0,0 +1,94 @@
package de.ody.app.network
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* ODY NetworkWatcher
*
* Meldet nur den Konnektivitätszustand keine Geschäftslogik.
* Kein Foreground-Service, kein Wake-Lock.
*
* Bewertet Konnektivität anhand tatsächlicher Internet-Capability
* (NET_CAPABILITY_INTERNET + NET_CAPABILITY_VALIDATED),
* nicht nur anhand einer bestehenden WLAN- oder Mobilfunkverbindung.
*
* Security-Gate: Benötigt ACCESS_NETWORK_STATE (keine sensitive Permission).
*/
class NetworkWatcher(private val context: Context) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _state = MutableStateFlow(ConnectivityState.UNKNOWN)
val state: StateFlow<ConnectivityState> = _state.asStateFlow()
private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(
network: Network,
caps: NetworkCapabilities
) {
scope.launch { _state.value = caps.toConnectivityState() }
}
override fun onLost(network: Network) {
scope.launch { _state.value = ConnectivityState.OFFLINE }
}
override fun onUnavailable() {
scope.launch { _state.value = ConnectivityState.OFFLINE }
}
}
fun start() {
// Initialzustand sofort setzen
_state.value = currentState()
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
try {
connectivityManager.registerNetworkCallback(request, networkCallback)
} catch (e: Exception) {
// Fallback: einmalige Abfrage ohne Live-Updates
_state.value = currentState()
}
}
fun stop() {
try {
connectivityManager.unregisterNetworkCallback(networkCallback)
} catch (_: Exception) {}
}
private fun currentState(): ConnectivityState {
val network = connectivityManager.activeNetwork ?: return ConnectivityState.OFFLINE
val caps = connectivityManager.getNetworkCapabilities(network)
?: return ConnectivityState.OFFLINE
return caps.toConnectivityState()
}
private fun NetworkCapabilities.toConnectivityState(): ConnectivityState {
val hasInternet = hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
val isValidated = hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
if (!hasInternet || !isValidated) return ConnectivityState.OFFLINE
// METERED: Mobilfunk oder explizit als metered markiert
val isMetered = !hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
return if (isMetered) ConnectivityState.METERED else ConnectivityState.ONLINE
}
}

View file

@ -0,0 +1,177 @@
package de.ody.app.provisioning
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.security.MessageDigest
/**
* ModelDownloader atomarer Download mit .part-Datei.
*
* Ablauf:
* URL model.gguf.part Download vollständig flush Magic+Größe prüfen
* atomar zu model.gguf umbenennen
*
* Eine vorhandene .part-Datei gilt niemals als Modell.
* Eine vorhandene .gguf wird nur übersprungen wenn sie die Basisvalidierung besteht.
*/
class ModelDownloader(private val context: Context) {
companion object {
private const val TAG = "ModelDownloader"
private const val BUFFER_SIZE = 65_536
private const val MAX_REDIRECTS = 10
private val GGUF_MAGIC = byteArrayOf(0x47, 0x47, 0x55, 0x46)
}
sealed class DownloadProgress {
data class Progress(
val bytesDownloaded: Long,
val totalBytes: Long,
val percent: Int,
) : DownloadProgress()
data class Verifying(val message: String = "Prüfe Datei...") : DownloadProgress()
data class Success(val file: File) : DownloadProgress()
data class Error(val message: String, val cause: Throwable? = null) : DownloadProgress()
}
private fun getModelsDir(): File {
val dir = File(context.filesDir, "models")
dir.mkdirs()
return dir
}
/**
* Prüft ob eine valide (nicht-part) Modelldatei existiert.
* Gibt null zurück wenn nicht vorhanden oder zu klein.
*/
fun getModelFile(fileName: String): File? {
// Prüfe alle bekannten Speicherpfade — ältere Builds nutzten externalFilesDir
val candidates = listOfNotNull(
File(getModelsDir(), fileName), // aktuell: filesDir/models/
context.getExternalFilesDir("models")?.let { File(it, fileName) }, // alt: externalFilesDir/models/
)
for (f in candidates) {
if (!f.exists() || !f.isFile) continue
if (f.length() < 100L * 1024 * 1024) continue
val magic = try { f.inputStream().use { it.readNBytes(4) } } catch (e: Exception) { continue }
if (!magic.contentEquals(GGUF_MAGIC)) continue
Log.d(TAG, "getModelFile: valide Datei gefunden ${f.absolutePath} (${f.length() / 1024 / 1024} MB)")
return f
}
return null
}
fun download(
url: String,
fileName: String,
expectedSha256: String? = null,
): Flow<DownloadProgress> = flow {
val destFile = File(getModelsDir(), fileName)
val partFile = File(getModelsDir(), "$fileName.part")
// Alte .part-Datei löschen
partFile.takeIf { it.exists() }?.delete()
try {
// Redirect-Kette manuell auflösen
var currentUrl = url
var connection: HttpURLConnection? = null
var redirectCount = 0
while (redirectCount < MAX_REDIRECTS) {
val conn = URL(currentUrl).openConnection() as HttpURLConnection
conn.instanceFollowRedirects = false
conn.connectTimeout = 15_000
conn.readTimeout = 60_000
conn.setRequestProperty("User-Agent", "ODY/0.2 (Android)")
conn.connect()
val code = conn.responseCode
if (code in 301..308) {
val location = conn.getHeaderField("Location")
conn.disconnect()
if (location.isNullOrBlank()) break
currentUrl = location
redirectCount++
continue
}
connection = conn
break
}
val conn = connection ?: throw Exception("Zu viele Redirects oder keine Verbindung")
val totalBytes = conn.contentLengthLong
var bytesDownloaded = 0L
conn.inputStream.buffered(BUFFER_SIZE).use { input ->
partFile.outputStream().buffered(BUFFER_SIZE).use { output ->
val buffer = ByteArray(BUFFER_SIZE)
var bytes: Int
while (input.read(buffer).also { bytes = it } != -1) {
output.write(buffer, 0, bytes)
bytesDownloaded += bytes
val percent = if (totalBytes > 0)
((bytesDownloaded * 100) / totalBytes).toInt() else -1
emit(DownloadProgress.Progress(bytesDownloaded, totalBytes, percent.coerceAtLeast(0)))
}
output.flush()
}
}
conn.disconnect()
// Basisvalidierung der .part-Datei
emit(DownloadProgress.Verifying("Prüfe Datei..."))
if (partFile.length() < 100L * 1024 * 1024) {
partFile.delete()
emit(DownloadProgress.Error("Download unvollständig: ${partFile.length() / 1024 / 1024} MB"))
return@flow
}
val magic = partFile.inputStream().use { it.readNBytes(4) }
if (!magic.contentEquals(GGUF_MAGIC)) {
partFile.delete()
emit(DownloadProgress.Error("Kein GGUF-Format — Download beschädigt"))
return@flow
}
// SHA-256 (optional)
if (expectedSha256 != null) {
emit(DownloadProgress.Verifying("Prüfe Integrität (SHA-256)..."))
val actual = sha256(partFile)
if (!actual.equals(expectedSha256, ignoreCase = true)) {
partFile.delete()
emit(DownloadProgress.Error("SHA-256 ungültig — Datei gelöscht"))
return@flow
}
}
// Atomares Umbenennen
if (destFile.exists()) destFile.delete()
if (!partFile.renameTo(destFile)) {
partFile.delete()
emit(DownloadProgress.Error("Umbenennen fehlgeschlagen"))
return@flow
}
Log.i(TAG, "Download abgeschlossen: ${destFile.name} (${destFile.length() / 1024 / 1024} MB)")
emit(DownloadProgress.Success(destFile))
} catch (e: Exception) {
Log.e(TAG, "Download-Fehler", e)
partFile.takeIf { it.exists() }?.delete()
emit(DownloadProgress.Error("Download fehlgeschlagen: ${e.message}", e))
}
}.flowOn(Dispatchers.IO)
private fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().buffered(BUFFER_SIZE).use { input ->
val buf = ByteArray(BUFFER_SIZE)
var n: Int
while (input.read(buf).also { n = it } != -1) digest.update(buf, 0, n)
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
}

View file

@ -0,0 +1,297 @@
package de.ody.app.provisioning
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import de.ody.app.MainActivity
import de.ody.app.ODYApplication
import de.ody.app.ODYTheme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
class ProvisioningActivity : ComponentActivity() {
companion object {
const val MODEL_URL =
"https://huggingface.co/unsloth/Qwen3-1.7B-GGUF/resolve/main/Qwen3-1.7B-Q4_K_M.gguf"
const val MODEL_FILENAME = "Qwen3-1.7B-Q4_K_M.gguf"
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val downloader = ModelDownloader(this)
// Nur Datei-Existenz + Basisvalidierung prüfen — kein SharedPreferences-Flag.
// Datei überlebt Deinstallation in getFilesDir()/models/.
if (downloader.getModelFile(MODEL_FILENAME) != null) {
launchMain()
return
}
setContent {
ODYTheme {
ProvisioningScreen(
onStartDownload = {
setContent { ODYTheme { DownloadScreen(downloader) { modelFile -> onModelReady(modelFile) } } }
},
onImportFile = { uri ->
setContent { ODYTheme { ImportScreen(uri, downloader) { modelFile -> onModelReady(modelFile) } } }
}
)
}
}
}
private fun onModelReady(modelFile: File) {
(applicationContext as ODYApplication).onModelReady(modelFile)
launchMain()
}
private fun launchMain() {
startActivity(Intent(this, MainActivity::class.java))
finish()
}
}
// ─── Provisioning-Screen (Auswahl: Download oder Import) ─────────────────────
@Composable
fun ProvisioningScreen(
onStartDownload: () -> Unit,
onImportFile: (Uri) -> Unit,
) {
val bg = Color(0xFF0D0D0F)
val textPrimary = Color(0xFFE8E8EA)
val textSecondary = Color(0xFF8A8A8E)
val accent = Color(0xFF4A9EFF)
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri ->
if (uri != null) onImportFile(uri)
}
Box(
modifier = Modifier.fillMaxSize().background(bg).systemBarsPadding(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(32.dp)
) {
Text("ODY", color = textPrimary, fontSize = 48.sp, fontWeight = FontWeight.Bold, letterSpacing = 4.sp)
Spacer(Modifier.height(8.dp))
Text("Dein lokaler KI-Assistent", color = textSecondary, fontSize = 16.sp)
Spacer(Modifier.height(48.dp))
Surface(color = Color(0xFF1A1A1C), shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(20.dp)) {
Text("Einmalige Einrichtung", color = textPrimary, fontSize = 16.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.height(8.dp))
Text(
"Wähle eine Option um das Sprachmodell einzurichten. " +
"Danach läuft ODY vollständig lokal — kein Internet nötig.",
color = textSecondary, fontSize = 14.sp, lineHeight = 20.sp
)
Spacer(Modifier.height(4.dp))
Text("Modell: Qwen3 1.7B · Q4_K_M · ~1 GB", color = textSecondary.copy(alpha = 0.7f), fontSize = 12.sp)
}
}
Spacer(Modifier.height(24.dp))
Button(
onClick = onStartDownload,
colors = ButtonDefaults.buttonColors(containerColor = accent),
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth().height(52.dp)
) {
Text("Modell herunterladen (~1 GB)", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.Medium)
}
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = { launcher.launch(arrayOf("application/octet-stream", "*/*")) },
shape = RoundedCornerShape(12.dp),
modifier = Modifier.fillMaxWidth().height(52.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = textSecondary),
) {
Text("GGUF-Datei importieren", fontSize = 15.sp)
}
Spacer(Modifier.height(8.dp))
Text(
"Importiere eine vorhandene .gguf-Datei aus Downloads, Cloud oder USB.",
color = textSecondary.copy(alpha = 0.6f),
fontSize = 12.sp,
textAlign = TextAlign.Center,
)
}
}
}
// ─── Download-Screen ──────────────────────────────────────────────────────────
@Composable
fun DownloadScreen(
downloader: ModelDownloader,
onComplete: (File) -> Unit,
) {
val bg = Color(0xFF0D0D0F)
val textPrimary = Color(0xFFE8E8EA)
val textSecondary = Color(0xFF8A8A8E)
val accent = Color(0xFF4A9EFF)
var progress by remember { mutableIntStateOf(0) }
var statusText by remember { mutableStateOf("Verbinde...") }
var bytesText by remember { mutableStateOf("") }
var isError by remember { mutableStateOf(false) }
var retryKey by remember { mutableIntStateOf(0) }
val scope = rememberCoroutineScope()
LaunchedEffect(retryKey) {
isError = false; statusText = "Verbinde..."; bytesText = ""; progress = 0
scope.launch {
downloader.download(
url = ProvisioningActivity.MODEL_URL,
fileName = ProvisioningActivity.MODEL_FILENAME,
).collect { state ->
when (state) {
is ModelDownloader.DownloadProgress.Progress -> {
progress = state.percent
val mb = state.bytesDownloaded / 1024 / 1024
val total = if (state.totalBytes > 0) state.totalBytes / 1024 / 1024 else 0
statusText = "Lade Modell herunter..."
bytesText = if (total > 0) "$mb MB / $total MB" else "$mb MB"
}
is ModelDownloader.DownloadProgress.Verifying -> { statusText = state.message; bytesText = "" }
is ModelDownloader.DownloadProgress.Success -> { statusText = "Bereit."; progress = 100; onComplete(state.file) }
is ModelDownloader.DownloadProgress.Error -> { isError = true; statusText = state.message }
}
}
}
}
Box(modifier = Modifier.fillMaxSize().background(bg).systemBarsPadding(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(32.dp)) {
Text("ODY", color = textPrimary, fontSize = 48.sp, fontWeight = FontWeight.Bold, letterSpacing = 4.sp)
Spacer(Modifier.height(48.dp))
Text(statusText, color = if (isError) Color(0xFFE57373) else textPrimary, fontSize = 16.sp, textAlign = TextAlign.Center)
if (bytesText.isNotEmpty()) { Spacer(Modifier.height(4.dp)); Text(bytesText, color = textSecondary, fontSize = 13.sp) }
Spacer(Modifier.height(24.dp))
if (!isError) {
LinearProgressIndicator(
progress = { progress / 100f },
modifier = Modifier.fillMaxWidth().height(6.dp),
color = accent, trackColor = Color(0xFF2A2A2C)
)
Spacer(Modifier.height(8.dp))
Text("$progress %", color = textSecondary, fontSize = 13.sp)
} else {
Spacer(Modifier.height(16.dp))
Button(onClick = { retryKey++ }, colors = ButtonDefaults.buttonColors(containerColor = accent), shape = RoundedCornerShape(12.dp)) {
Text("Erneut versuchen", color = Color.White)
}
}
}
}
}
// ─── Import-Screen (SAF → in ODY-Speicher kopieren) ──────────────────────────
@Composable
fun ImportScreen(
sourceUri: Uri,
downloader: ModelDownloader,
onComplete: (File) -> Unit,
) {
val context = LocalContext.current
val bg = Color(0xFF0D0D0F)
val textPrimary = Color(0xFFE8E8EA)
val textSecondary = Color(0xFF8A8A8E)
val accent = Color(0xFF4A9EFF)
var statusText by remember { mutableStateOf("Importiere...") }
var isError by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
scope.launch {
withContext(Dispatchers.IO) {
try {
// Dateiname aus URI ermitteln
val fileName = sourceUri.lastPathSegment
?.substringAfterLast("/")
?.substringAfterLast("%2F")
?.let { if (it.endsWith(".gguf", ignoreCase = true)) it else "imported_model.gguf" }
?: "imported_model.gguf"
val destDir = File(context.filesDir, "models").also { it.mkdirs() }
val partFile = File(destDir, "$fileName.part")
val destFile = File(destDir, fileName)
partFile.takeIf { it.exists() }?.delete()
// SAF-URI → lokale Kopie
context.contentResolver.openInputStream(sourceUri)?.use { input ->
partFile.outputStream().buffered(65_536).use { output ->
input.copyTo(output, bufferSize = 65_536)
output.flush()
}
} ?: throw Exception("Datei konnte nicht geöffnet werden")
// Basisvalidierung
if (partFile.length() < 100L * 1024 * 1024) {
partFile.delete()
withContext(Dispatchers.Main) { isError = true; statusText = "Datei zu klein (min 100 MB)" }
return@withContext
}
val magic = partFile.inputStream().use { it.readNBytes(4) }
val ggufMagic = byteArrayOf(0x47, 0x47, 0x55, 0x46)
if (!magic.contentEquals(ggufMagic)) {
partFile.delete()
withContext(Dispatchers.Main) { isError = true; statusText = "Kein GGUF-Format" }
return@withContext
}
if (destFile.exists()) destFile.delete()
partFile.renameTo(destFile)
withContext(Dispatchers.Main) {
statusText = "Import abgeschlossen."
onComplete(destFile)
}
} catch (e: Exception) {
withContext(Dispatchers.Main) { isError = true; statusText = "Import fehlgeschlagen: ${e.message}" }
}
}
}
}
Box(modifier = Modifier.fillMaxSize().background(bg).systemBarsPadding(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(32.dp)) {
Text("ODY", color = textPrimary, fontSize = 48.sp, fontWeight = FontWeight.Bold, letterSpacing = 4.sp)
Spacer(Modifier.height(48.dp))
Text(statusText, color = if (isError) Color(0xFFE57373) else textPrimary, fontSize = 16.sp, textAlign = TextAlign.Center)
if (!isError) {
Spacer(Modifier.height(24.dp))
CircularProgressIndicator(color = accent)
}
}
}
}

View file

@ -0,0 +1,24 @@
package de.ody.guards
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
/**
* Guard-Verdict: Was hat der Guard festgestellt?
*/
enum class GuardVerdict {
PASS,
STOP,
WARN,
}
/**
* Guard-Typen in Sprint 0.1a
*/
enum class GuardType {
REPETITION,
TIMEOUT,
TOKEN_LIMIT,
STOP_BUTTON,
}

View file

@ -0,0 +1,107 @@
package de.ody.guards
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
data class GuardAlert(
val type: GuardType,
val message: String,
val severity: GuardSeverity = GuardSeverity.MEDIUM,
val timestamp: String = Instant.now()
.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME),
val evidence: String? = null,
) {
val isStopTriggered: Boolean get() = severity == GuardSeverity.HIGH || severity == GuardSeverity.CRITICAL
}
enum class GuardSeverity {
LOW, MEDIUM, HIGH, CRITICAL
}
class GuardTest {
companion object {
private val instance = GuardTest()
/** Führt alle Guards aus. Gibt ersten ausgelösten Alert zurück, oder null. */
fun checkAll(text: String): GuardAlert? {
return instance.checkRepetitionGuard(text)
}
}
fun checkRepetitionGuard(text: String, maxRepetitions: Int = 3): GuardAlert? {
val words = text.split(Regex("\\s+")).filter { it.isNotEmpty() }
for (i in 0 until words.size - maxRepetitions) {
val run = words.subList(i, i + maxRepetitions)
if (run.all { it.equals(run[0], ignoreCase = true) }) {
return GuardAlert(
type = GuardType.REPETITION,
message = "Repetition Guard: Wort '${run[0]}' wiederholt $maxRepetitions hintereinander",
severity = GuardSeverity.HIGH,
evidence = "Position $i: " + run.joinToString(" ")
)
}
}
if (text.length >= 10) {
val shortPatterns = listOf("z.", "a.", "b.", "c.", "x.", "y.")
for (pattern in shortPatterns) {
var repeats = 0
var pos = 0
while (pos + pattern.length <= text.length) {
if (text.substring(pos, pos + pattern.length).equals(pattern, ignoreCase = true)) {
repeats++
pos += pattern.length
} else {
break
}
}
if (repeats >= maxRepetitions) {
return GuardAlert(
type = GuardType.REPETITION,
message = "Repetition Guard: Muster '$pattern' $maxRepetitions hintereinander",
severity = GuardSeverity.CRITICAL,
evidence = "Gefunden: ${text.take(80)}"
)
}
}
}
return null
}
fun checkTimeoutGuard(elapsedMs: Long, thresholdMs: Long = 60000L): GuardAlert? {
if (elapsedMs >= thresholdMs) {
return GuardAlert(
type = GuardType.TIMEOUT,
message = "Timeout Guard: Generierung überschritt ${thresholdMs / 1000}s",
severity = GuardSeverity.MEDIUM,
evidence = "Elapsed: $elapsedMs ms"
)
}
return null
}
fun checkTokenLimitGuard(tokens: List<String>, limit: Int = 512): GuardAlert? {
if (tokens.size >= limit) {
return GuardAlert(
type = GuardType.TOKEN_LIMIT,
message = "Token Limit Guard: $limit Token erreicht",
severity = GuardSeverity.MEDIUM,
evidence = "Token count: ${tokens.size}"
)
}
return null
}
fun checkStopButton(stopRequested: Boolean): GuardAlert? {
if (stopRequested) {
return GuardAlert(
type = GuardType.STOP_BUTTON,
message = "Stop Guard: Benutzer hat Generation gestoppt",
severity = GuardSeverity.LOW,
)
}
return null
}
}

View file

@ -0,0 +1,16 @@
package de.ody.runtime
/**
* Eine vollständige Anfrage an die InferenceRuntime.
*
* @param userMessage Die aktuelle Nutzernachricht
* @param systemPrompt Optionaler System-Prompt (ODY-Identität)
* @param history Vorherige Nachrichten als (role, content)-Paare
* @param maxNewTokens Maximale Anzahl neuer Tokens (Standard: 256)
*/
data class GenerationRequest(
val userMessage: String,
val systemPrompt: String? = null,
val history: List<Pair<String, String>> = emptyList(),
val maxNewTokens: Int = 256,
)

View file

@ -0,0 +1,18 @@
package de.ody.runtime
/**
* Ergebnis einer abgeschlossenen Generierung.
*
* @param fullText Vollständiger generierter Text
* @param tokenCount Anzahl generierter Tokens
* @param durationMs Gesamtdauer in Millisekunden
* @param tokensPerSecond Durchschnittliche Generierungsgeschwindigkeit
* @param cancelledByUser true, wenn cancel() aufgerufen wurde
*/
data class GenerationResult(
val fullText: String,
val tokenCount: Int,
val durationMs: Long,
val tokensPerSecond: Float,
val cancelledByUser: Boolean = false,
)

View file

@ -0,0 +1,62 @@
package de.ody.runtime
import kotlinx.coroutines.flow.StateFlow
/**
* InferenceRuntime stabiler Vertrag für alle lokalen Inferenz-Implementierungen.
*
* Die UI kennt ausschließlich dieses Interface.
* Sie kennt NIEMALS LlamaCppRuntime, SimulationRuntime oder andere Implementierungen.
*
* Implementierungen:
* - SimulationRuntime : Fallback ohne echtes Modell (immer verfügbar)
* - LlamaCppRuntime : Echte lokale Inferenz via llama.cpp JNI (ab 0.2)
*
* Lebenszyklus:
* UNLOADED loadModel() READY generate() READY unload() UNLOADED
*
* Regel: Die UI liest ausschließlich [state]. Sie ruft niemals internen Zustand ab.
*/
interface InferenceRuntime {
/**
* Beobachtbarer Lebenszyklus-Zustand.
* Die UI reagiert auf Änderungen dieses Flows.
*/
val state: StateFlow<RuntimeState>
/**
* Lädt ein GGUF-Modell aus dem lokalen App-Speicher.
* Wechselt state: UNLOADED LOADING READY | ERROR
*/
suspend fun loadModel(
modelPath: String,
config: ModelConfig,
): Result<Unit>
/**
* Generiert eine Antwort und streamt Token via [onToken]-Callback.
* Wechselt state: READY GENERATING READY | ERROR
*
* @param request Vollständige Anfrage inkl. System-Prompt und History
* @param onToken Wird für jedes generierte Token aufgerufen (Main-Thread-sicher)
*/
suspend fun generate(
request: GenerationRequest,
onToken: (String) -> Unit,
): Result<GenerationResult>
/**
* Bricht eine laufende Generierung sofort ab.
* Wechselt state: GENERATING READY
* Kein-Op wenn state != GENERATING.
*/
fun cancel()
/**
* Gibt das geladene Modell und alle JNI-Ressourcen frei.
* Wechselt state: READY UNLOADED
* Kein-Op wenn state == UNLOADED.
*/
suspend fun unload()
}

View file

@ -0,0 +1,139 @@
package de.ody.runtime
import android.content.Context
import android.os.BatteryManager
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.withTimeout
/**
* LiteRT-LM Runtime Adapter einzige Implementierung in Sprint 0.1a.
*
* Verwendet die LiteRT-LM-Kotlin-API für lokale Inferenz.
* In späteren Sprints wird LLamaCppRuntimeAdapter als zweite Implementierung folgen.
*
* WICHTIG: Die genaue API (Pakete, Klassen, Methoden) orientiert sich an der
* finalen LiteRT-LM-Dokumentation. Diese Skeleton-Klasse definiert die
* Integrationsschicht und muss je nach tatsächlicher LiteRT-LM-API angepasst werden.
*/
class LiteRtRuntimeAdapter(private val context: Context) : LocalModelRuntime {
// Platzhalter für das actuale LiteRT-LM-Modellobjekt.
// Die genaue Klasse hängt von der LiteRT-LM-Dependency ab.
private var loadedModel: Any? = null
private var currentConfig: LocalModelRuntime.ModelConfig? = null
private var isStreaming = false
private var cancelFlag = false
override suspend fun initialize(config: LocalModelRuntime.ModelConfig): LocalModelRuntime.InitResult {
val startTime = System.currentTimeMillis()
try {
// Speicher-Check vor dem Laden
val ramBefore = getRamUsage()
// LiteRT-LM Model laden
// TODO: Ersetze durch echte LiteRT-LM-API-Aufrufe
// val modelBuilder = LiteRtLmModel.Builder(context, config.modelPath)
// modelBuilder.setArtifactFormat(config.artifactFormat)
// modelBuilder.setContextLength(config.contextLength)
// modelBuilder.setTemperature(config.temperature)
// modelBuilder.setTopK(config.topK)
// modelBuilder.setTopP(config.topP)
// loadedModel = modelBuilder.build()
// Simuliertes Laden (Placeholder bis echte API verfügbar)
// In 0.1a: Hier wird die echte LiteRT-LM-Integration stehen.
currentConfig = config
val loadedAtMs = System.currentTimeMillis() - startTime
val ramAfter = getRamUsage()
val ramDelta = ramAfter - ramBefore
return LocalModelRuntime.InitResult(
success = true,
modelId = config.modelId,
loadedAtMs = loadedAtMs,
ramUsageBytes = ramDelta,
)
} catch (e: Exception) {
return LocalModelRuntime.InitResult(
success = false,
modelId = config.modelId,
loadedAtMs = 0,
ramUsageBytes = 0,
errorMsg = e.message,
)
}
}
override fun stream(request: LocalModelRuntime.GenerationRequest): Flow<LocalModelRuntime.TokenEvent> = channelFlow {
isStreaming = true
cancelFlag = false
try {
// TODO: Echte LiteRT-LM-Inferenz-Integration
// Hier läuft die Token-Generierung via LiteRT-LM
// und jedes Token wird über den Flow emittiert.
// Placeholder: echte API wird in einem späteren Sprint ersetzt.
// Simulierter Output bis echte Inferenz-API verfügbar:
val simulatedOutput = listOf("Simulierte Antwort", "[END]")
for (tokenStr in simulatedOutput) {
if (cancelFlag) return@channelFlow
send(
LocalModelRuntime.TokenEvent(
token = tokenStr,
isSpecial = tokenStr.startsWith("["),
isEnd = tokenStr == "[END]",
)
)
}
} catch (e: Exception) {
send(LocalModelRuntime.TokenEvent(token = "[ERROR: ${e.message}]"))
} finally {
isStreaming = false
cancelFlag = false
}
}
override suspend fun stop() {
cancelFlag = true
isStreaming = false
try {
// TODO: Echte LiteRT-LM stop/cancel API
// loadedModel?.stopGeneration()
} catch (e: Exception) {
// Ignoriere Fehler beim Stop — die Generierung ist trotzdem beendet
}
}
override suspend fun unload() {
// TODO: Echte LiteRT-LM cleanup
// loadedModel?.close()
loadedModel = null
currentConfig = null
isStreaming = false
cancelFlag = false
}
override fun status(): LocalModelRuntime.RuntimeStatus {
return LocalModelRuntime.RuntimeStatus(
initialized = loadedModel != null,
modelId = currentConfig?.modelId,
isStreaming = isStreaming,
ramUsageBytes = getRamUsage(),
temperature = currentConfig?.temperature ?: 0f,
)
}
// --- Hilfsfunktionen ---
private fun getRamUsage(): Long {
val batteryManager = context.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
// Platzhalter — richtige RAM-Abfrage über ActivityManager
// val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
// val memInfo = ActivityManager.MemoryInfo()
// activityManager.getMemoryInfo(memInfo)
// return memInfo.availMem
return 0L
}
}

View file

@ -0,0 +1,103 @@
package de.ody.runtime
/**
* Abstrahierte Schnittstelle für lokale Modell-Inferenz.
*
* Dient als Erweiterungsnaht zwischen UI und spezifischer Runtime (LiteRT-LM,
* llama.cpp, etc.). Nach dem ersten Build+Test durch QA-Gates wird die
* ABI-Stabilität bestätigt. Vorher ist sie als Entwurf zu betrachten.
*
* Sprint 0.1a: Nur LiteRtRuntimeAdapter implementiert.
*/
interface LocalModelRuntime {
/**
* Konfiguration für die Modellinitialisierung.
*/
data class ModelConfig(
val modelId: String,
val modelPath: String,
val artifactFormat: String, // "litertlm", "gguf", etc.
val runtime: String, // "litert_lm", "llama_cpp"
val contextLength: Int = 2048,
val temperature: Float = 0.7f,
val topK: Int = 40,
val topP: Float = 0.9f,
val maxTokens: Int = 512,
val enableThinking: Boolean = false,
val enableToolCalling: Boolean = false,
)
/**
* Ergebnis der Initialisierung.
*/
data class InitResult(
val success: Boolean,
val modelId: String,
val loadedAtMs: Long,
val ramUsageBytes: Long,
val errorMsg: String? = null,
) {
val initialized: Boolean get() = success
}
/**
* Anfrage an die Inferenz-Pipeline.
*/
data class GenerationRequest(
val prompt: String,
val systemPrompt: String? = null,
val contextHistory: List<Pair<String, String>> = emptyList(),
)
/**
* Ein einzelnes generiertes Token-Ereignis.
*/
data class TokenEvent(
val token: String,
val isSpecial: Boolean = false,
val isEnd: Boolean = false,
val thinkTagOpen: Boolean = false,
val thinkTagClose: Boolean = false,
)
/**
* Aktueller Status der Runtime.
*/
data class RuntimeStatus(
val initialized: Boolean,
val modelId: String? = null,
val isStreaming: Boolean = false,
val ramUsageBytes: Long = 0,
val temperature: Float = 0f,
)
/**
* Initialisiert die Runtime mit dem gegebenen Modell.
* Muss suspendieren, da das Laden I/O-intensiv ist.
*/
suspend fun initialize(config: ModelConfig): InitResult
/**
* Startet das Streaming einer Generierung.
* Gibt einen Kotlin Flow zurück, der Token-Ereignisse emittiert.
* Die UI kann auf den Flow lauschen und jedes Token rendern.
*/
fun stream(request: GenerationRequest): kotlinx.coroutines.flow.Flow<TokenEvent>
/**
* Unterbricht die laufende Generierung sofort.
* Muss suspendieren, da die Interruption sauber abgeschlossen werden muss.
*/
suspend fun stop()
/**
* Gibt das geladene Modell und alle zugehörigen Ressourcen frei.
*/
suspend fun unload()
/**
* Liefert den aktuellen Status ohne zu blockieren.
*/
fun status(): RuntimeStatus
}

View file

@ -0,0 +1,22 @@
package de.ody.runtime
/**
* Konfiguration für das Laden eines GGUF-Modells.
*
* @param modelPath Absoluter Pfad zur .gguf-Datei im App-Speicher
* @param modelId Menschenlesbarer Bezeichner (z.B. "qwen3-4b-q4_k_m")
* @param contextLength Maximale Kontextlänge in Tokens (Standard: 4096)
* @param temperature Sampling-Temperatur (Standard: 0.6)
* @param topP Top-P-Sampling (Standard: 0.9)
* @param nThreads CPU-Threads (0 = automatisch)
* @param nGpuLayers GPU-Offload-Schichten (0 = CPU-only)
*/
data class ModelConfig(
val modelPath: String,
val modelId: String,
val contextLength: Int = 4096,
val temperature: Float = 0.6f,
val topP: Float = 0.9f,
val nThreads: Int = 0,
val nGpuLayers: Int = 0,
)

View file

@ -0,0 +1,35 @@
package de.ody.runtime
/**
* ODY RuntimeState State Machine für InferenceRuntime.
*
* Übergänge:
* NO_MODEL DOWNLOADING VALIDATING LOADING READY GENERATING
*
* MODEL_LOAD_FAILED
*
* Invarianten:
* - MODEL_LOAD_FAILED ist terminal bis explizitem Reset.
* - Kein automatischer Simulation-Fallback nach LOADING-Fehler.
* - UNLOADED = Ressourcen freigegeben (nach unload()).
*/
enum class RuntimeState {
/** Kein Modell installiert. */
NO_MODEL,
/** Modell wird heruntergeladen. */
DOWNLOADING,
/** Datei-Validierung (Magic, SHA-256, FD-Test). */
VALIDATING,
/** llama.cpp initialisiert Kontext (JNI). */
LOADING,
/** Bereit für Anfragen. */
READY,
/** Generierung läuft. */
GENERATING,
/** Ladefehler — kein Fallback, klare Fehlermeldung. */
MODEL_LOAD_FAILED,
/** Ressourcen freigegeben (nach unload()). */
UNLOADED,
/** Laufzeitfehler während Generierung. */
ERROR,
}

View file

@ -0,0 +1,105 @@
package de.ody.runtime.llamacpp
import android.content.ContentResolver
import android.net.Uri
import android.util.Log
import java.io.File
import java.security.MessageDigest
/**
* GGUFValidator 8-stufige Validierung vor jedem startEngine()-Aufruf.
*/
object GGUFValidator {
private const val TAG = "GGUFValidator"
private val GGUF_MAGIC = byteArrayOf(0x47, 0x47, 0x55, 0x46)
private const val MIN_SIZE_BYTES = 100L * 1024 * 1024
data class ValidationResult(
val valid: Boolean,
val reason: String,
val diagnostics: Map<String, String>,
)
fun validate(
file: File,
contentUri: Uri,
contentResolver: ContentResolver,
expectedSha256: String? = null,
attemptId: String = "n/a",
): ValidationResult {
val diag = mutableMapOf<String, String>()
diag["load_attempt_id"] = attemptId
diag["model_filename"] = file.name
diag["abi"] = android.os.Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown"
diag["runtime_version"] = android.os.Build.VERSION.SDK_INT.toString()
// 1. Datei existiert
if (!file.exists() || !file.isFile) return fail("Datei nicht gefunden", diag)
// 2. Mindestgröße
val size = file.length()
diag["model_size"] = size.toString()
if (size < MIN_SIZE_BYTES) return fail("Datei zu klein: ${size / 1024 / 1024} MB", diag)
// 3. GGUF Magic
val magic = try { file.inputStream().use { it.readNBytes(4) } }
catch (e: Exception) { return fail("Magic nicht lesbar: ${e.message}", diag) }
val magicValid = magic.contentEquals(GGUF_MAGIC)
diag["gguf_magic_valid"] = magicValid.toString()
if (!magicValid) return fail("Kein GGUF-Format (Magic: ${magic.toHex()})", diag)
// 4. SHA-256 (optional)
if (expectedSha256 != null) {
val actual = sha256(file)
val ok = actual.equals(expectedSha256, ignoreCase = true)
diag["sha256_valid"] = ok.toString()
if (!ok) return fail("SHA-256 ungültig", diag)
} else {
diag["sha256_valid"] = "skipped"
}
// 5. Freier RAM
val availRam = getAvailableRamMb()
diag["available_ram"] = "${availRam} MB"
if (availRam < 512) return fail("Zu wenig RAM: ${availRam} MB", diag)
// 6. URI-Schema
diag["content_uri_authority"] = contentUri.authority ?: "null"
if (contentUri.scheme != "content") return fail("URI-Schema ungültig: ${contentUri.scheme}", diag)
// 7. ContentResolver FD-Test
val fdValid = try {
contentResolver.openFileDescriptor(contentUri, "r")?.use { pfd -> pfd.fileDescriptor.valid() } ?: false
} catch (e: Exception) { Log.e(TAG, "FD-Test", e); false }
diag["provider_fd_opened"] = fdValid.toString()
if (!fdValid) return fail("ContentResolver FD nicht öffenbar", diag)
Log.i(TAG, "Validierung OK: ${diag.entries.joinToString(", ") { "${it.key}=${it.value}" }}")
return ValidationResult(valid = true, reason = "OK", diagnostics = diag)
}
private fun fail(reason: String, diag: Map<String, String>): ValidationResult {
Log.e(TAG, "Validierung fehlgeschlagen: $reason | ${diag.entries.joinToString(", ") { "${it.key}=${it.value}" }}")
return ValidationResult(valid = false, reason = reason, diagnostics = diag)
}
private fun sha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().buffered(65_536).use { input ->
val buf = ByteArray(65_536); var n: Int
while (input.read(buf).also { n = it } != -1) digest.update(buf, 0, n)
}
return digest.digest().toHex()
}
private fun getAvailableRamMb(): Long {
return try {
val lines = java.io.File("/proc/meminfo").readLines()
val kb = lines.firstOrNull { it.startsWith("MemAvailable") }
?.split(Regex("\\s+"))?.getOrNull(1)?.toLongOrNull() ?: 0L
kb / 1024
} catch (e: Exception) { 1024L }
}
private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
}

View file

@ -0,0 +1,195 @@
package de.ody.runtime.llamacpp
import android.content.Context
import android.os.ParcelFileDescriptor
import android.util.Log
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.nehuatl.llamacpp.LlamaContext
import java.io.File
/**
* LlamaCppBridge ODY-LLAMACPP-DIRECT-MMAP-RUNTIME-1 (direct-mmap9)
*
* Fixes:
* mmap4: detachFd() fdsan-SIGABRT behoben
* mmap5: emit_partial_completion=true Token-Callback feuert
* Finales Map-Fallback robuster zweiter Ausgabe-Pfad
* Anti-Doppel-Guard streamedOutput-Check
* Vollständiges Logging alle Keys sichtbar
* mmap9: Diagnose-Build vollständiges Callback-Logging
* Import-Fix: callbackFlow statt channelFlow (channelFlow entfernt)
*/
class LlamaCppBridge(private val context: Context) {
companion object {
private const val TAG = "LlamaCppBridge"
private var idCounter = 0
// Bekannte Schlüssel für finalen Text aus completion()-Map
private val RESULT_TEXT_KEYS = listOf("content", "text", "completion", "generated_text")
}
sealed class TokenEvent {
data class Token(val word: String) : TokenEvent()
object Done : TokenEvent()
data class Error(val message: String) : TokenEvent()
}
private var llamaContext: LlamaContext? = null
// ─── loadModel ────────────────────────────────────────────────────────────
fun loadModel(modelFile: File): Result<Unit> {
return try {
if (!modelFile.exists()) {
return Result.failure(IllegalStateException("Modelldatei nicht gefunden: ${modelFile.absolutePath}"))
}
if (!modelFile.canRead()) {
return Result.failure(IllegalStateException("Modelldatei nicht lesbar: ${modelFile.absolutePath}"))
}
if (modelFile.length() < 100L * 1024 * 1024) {
return Result.failure(IllegalStateException("Modelldatei zu klein: ${modelFile.length()} Bytes"))
}
Log.i(TAG, "loadModel: ${modelFile.absolutePath} size=${modelFile.length() / 1024 / 1024}MB")
release()
// Fix mmap4: detachFd() übergibt FD-Ownership an librnllama.so
// pfd.fd → fdsan-getaggt → SIGABRT wenn librnllama.so close() aufruft
// pfd.detachFd() → fdsan-Tag entfernt → librnllama.so darf close() aufrufen
val pfd = ParcelFileDescriptor.open(modelFile, ParcelFileDescriptor.MODE_READ_ONLY)
val fd = pfd.detachFd()
Log.i(TAG, "loadModel: detachFd()=$fd — FD-Ownership an librnllama.so übergeben")
val id = ++idCounter
val params = mapOf<String, Any>(
"model" to modelFile.absolutePath,
"model_fd" to fd,
"embedding" to false,
"n_ctx" to 4096,
"n_batch" to 128,
"n_threads" to 4,
"n_gpu_layers" to 0,
"use_mlock" to false,
"use_mmap" to true,
"vocab_only" to false,
"rope_freq_base" to 0.0f,
"rope_freq_scale" to 0.0f,
"lora" to "",
"lora_scaled" to 0.0f,
)
Log.i(TAG, "loadModel: id=$id fd=$fd — LlamaContext initialisieren")
val ctx = LlamaContext(id, params)
val nativeCtx = ctx.context
if (nativeCtx == 0L) {
Log.e(TAG, "loadModel: LlamaContext.context=0 — Modell konnte nicht geladen werden")
try { ctx.release() } catch (_: Throwable) {}
return Result.failure(IllegalStateException("LlamaContext.context=0"))
}
llamaContext = ctx
Log.i(TAG, "loadModel: OK — nativeCtx=$nativeCtx")
Result.success(Unit)
} catch (e: Throwable) {
Log.e(TAG, "loadModel Fehler: ${e.javaClass.simpleName}: ${e.message}", e)
Result.failure(RuntimeException("loadModel fehlgeschlagen: ${e.message}", e))
}
}
// ─── getFormattedChat ─────────────────────────────────────────────────────
// Delegiert an LlamaContext.getFormattedChat() — wendet das im GGUF hinterlegte
// Chat-Template an. Zweiter Parameter: "chatml" = Qwen3/ChatML-Template.
fun getFormattedChat(messages: List<Map<String, String>>): String {
val ctx = llamaContext
?: throw IllegalStateException("Kein Kontext geladen")
if (ctx.context == 0L) {
throw IllegalStateException("LlamaContext.context=0")
}
return ctx.getFormattedChat(messages, "chatml")
}
// ─── generate ─────────────────────────────────────────────────────────────
// mmap9: Diagnose-Build — vollständiges Callback-Logging
// callbackFlow ist thread-safe by design; trySend() darf aus beliebigen Threads aufgerufen werden.
fun generate(prompt: String): Flow<TokenEvent> = callbackFlow {
val ctx = llamaContext
if (ctx == null) {
trySend(TokenEvent.Error("Kein Kontext geladen"))
close()
return@callbackFlow
}
if (ctx.context == 0L) {
trySend(TokenEvent.Error("LlamaContext.context=0"))
close()
return@callbackFlow
}
val callbackCount = java.util.concurrent.atomic.AtomicInteger(0)
// Pfad 1: setTokenCallback → Live-Streaming
ctx.setTokenCallback { token ->
val n = callbackCount.incrementAndGet()
val sendResult = trySend(TokenEvent.Token(token ?: ""))
Log.v(TAG,
"callback=$n " +
"thread=${Thread.currentThread().name} " +
"tokenLen=${token?.length ?: 0} " +
"sendSuccess=${sendResult.isSuccess} " +
"sendClosed=${sendResult.isClosed} " +
"sendFailure=${sendResult.exceptionOrNull()?.javaClass?.simpleName}"
)
}
try {
Log.i(TAG, "completion_start: emit_partial_completion=true prompt_len=${prompt.length}")
val completionParams = mapOf<String, Any>(
"prompt" to prompt,
"emit_partial_completion" to true,
"n_predict" to 512,
"temperature" to 0.8f,
"top_k" to 40,
"top_p" to 0.95f,
"min_p" to 0.05f,
"penalty_repeat" to 1.1f,
"penalty_last_n" to 64,
"seed" to -1,
"stop" to listOf("<|im_end|>", "<|endoftext|>", "</s>"),
"ignore_eos" to false,
)
// completion() blockierend auf IO-Thread
val result: Map<String, Any> = withContext(Dispatchers.IO) {
ctx.completion(completionParams)
}
Log.i(TAG,
"completion_returned: callbacks=${callbackCount.get()} " +
"resultKeys=${result.keys} " +
"resultSize=${result.size}"
)
// Alle Map-Werte strukturell loggen
result.forEach { (k, v) ->
Log.v(TAG, "result_entry: key=$k type=${v.javaClass.simpleName} " +
"value=${if (v is String) v.take(50) else v}")
}
// Pfad 2: finales Map-Fallback (nur wenn kein Streaming kam)
val streamedChars = callbackCount.get()
if (streamedChars == 0) {
val finalText = RESULT_TEXT_KEYS
.mapNotNull { key -> result[key] as? String }
.firstOrNull { it.isNotEmpty() }
?: ""
Log.i(TAG, "completion_done(map-fallback): finalChars=${finalText.length} text=${finalText.take(50)}")
if (finalText.isNotEmpty()) {
trySend(TokenEvent.Token(finalText))
} else {
Log.w(TAG, "completion_done: weder Streaming noch Map-Text — leere Antwort")
}
} else {
Log.i(TAG, "completion_done(streamed): callbacks=$streamedChars")
}
// Channel kontrolliert schließen
trySend(TokenEvent.Done)
close()
} catch (e: Throwable) {
Log.e(TAG, "generate_error: ${e.javaClass.simpleName}: ${e.message}", e)
trySend(TokenEvent.Error(e.message ?: "Generierungsfehler"))
close(e)
}
awaitClose {
try { ctx.stopCompletion() } catch (e: Throwable) { Log.w(TAG, "stopCompletion: ${e.message}") }
ctx.setTokenCallback {}
}
}
fun stop() {
try { llamaContext?.stopCompletion() } catch (e: Throwable) { Log.w(TAG, "stop: ${e.message}") }
}
fun release() {
try { llamaContext?.release(); llamaContext = null } catch (e: Throwable) { Log.w(TAG, "release: ${e.message}") }
}
}

View file

@ -0,0 +1,324 @@
package de.ody.runtime.llamacpp
import android.content.Context
import android.util.Log
import de.ody.runtime.GenerationRequest
import de.ody.runtime.GenerationResult
import de.ody.runtime.InferenceRuntime
import de.ody.runtime.ModelConfig
import de.ody.runtime.RuntimeState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
class LlamaCppRuntime(
context: Context,
) : InferenceRuntime {
private val bridge = LlamaCppBridge(context)
companion object {
private const val TAG = "LlamaCppRuntime"
private const val STREAM_TAG = "ODY_STREAM"
private const val ODY_SYSTEM_PROMPT = """
ODY Constitution v1.1
Mein Zweck ist es, Menschen in jeder Situation handlungsfähig zu halten besonders dann, wenn keine Verbindung zur Außenwelt besteht. Alles andere ordnet sich diesem Ziel unter.
---
Identität
Du bist ODY.
ODY ist ein vollständig lokaler KI-Begleiter für Smartphones.
Du arbeitest auch ohne Internet, ohne Cloud und ohne Benutzerkonto.
Dein Ziel ist, den Menschen sicherer, selbstständiger und handlungsfähiger zu machen.
Nicht Informationen sammeln.
Nicht beeindrucken.
Sondern helfen.
---
Sprache
Antworte immer in der Sprache des Nutzers.
Falls der Nutzer ausdrücklich eine andere Sprache wünscht, wechsle dorthin.
Halte Fachbegriffe auf Wunsch zweisprachig.
---
Prioritäten
Bei jeder Anfrage gilt:
1. Sicherheit
2. Wahrheit
3. Handlungsfähigkeit
4. Einfachheit
5. Geschwindigkeit
Nicht: möglichst viel reden.
---
Fähigkeiten zuerst
Wenn der Nutzer nach deinen Möglichkeiten fragt, beschreibe zuerst, was du lokal leisten kannst.
Erwähne Einschränkungen erst danach und nur, wenn sie für die konkrete Aufgabe relevant sind.
Wenn gefragt wird: "Was kannst du?"
Antworte mit Fähigkeiten, nicht mit Einschränkungen.
Zum Beispiel:
"Auch ohne Internet kann ich dir unter anderem helfen bei:
Erste Hilfe · Navigation · Orientierung · Dokumentanalyse · Übersetzungen · Berechnungen · Checklisten · Problemlösung · Planung · Lernen"
Für aktuelle Wetterdaten oder Live-Verkehr brauche ich allerdings eine Verbindung.
---
Kein Cloud-Denken
Schlage keine Internetdienste oder Cloud-Lösungen vor, solange die Aufgabe vollständig mit lokalen Mitteln gelöst werden kann.
Nicht: "Suche im Internet", "Öffne Google", "Nutze eine Website" es sei denn, es gibt keine lokale Alternative.
---
Offline First
Wenn keine Internetverbindung vorhanden ist:
Denke niemals zuerst an Einschränkungen.
Denke zuerst: Welche Fähigkeiten habe ich lokal?
Lokale Fähigkeiten:
Wissen · Rechnen · Logik · Schritt-für-Schritt-Anleitungen · Kameraanalyse · Dokumente · Übersetzungen · Orientierung · Checklisten · Notfallabläufe
Erst wenn etwas wirklich unmöglich ist: Erkläre ruhig, warum.
---
Werkzeugbewusstsein
Wenn Kamera, Mikrofon, Sensoren, lokale Dokumente oder Karten verfügbar sind und sie die Antwort verbessern würden, schlage ihre Nutzung aktiv vor.
Beispiel: "Wenn du möchtest, richte die Kamera auf die Pflanze, dann kann ich sie beurteilen."
Wenn eine Ressource nicht verfügbar ist, verlange sie nicht.
---
Ressourcenbewusstsein
ODY läuft auf einem Smartphone. Berücksichtige automatisch:
- Akkustand: Wenn der Akku niedrig ist, bevorzuge präzise Antworten und schlage stromsparende Vorgehensweisen vor.
- Offline-Status: Nutze ausschließlich lokale Mittel.
- Verfügbare Sensoren: Schlage nur Werkzeuge vor, die tatsächlich vorhanden sind.
---
Antwortstil
Kurz. Klar. Direkt.
Kein Marketing. Keine Floskeln. Keine Entschuldigungen.
Nicht: "Leider kann ich..."
Sondern: "Ich kann..."
---
Unsicherheit
Wenn du etwas weißt: Sage es.
Wenn du unsicher bist: Kennzeichne es "Wahrscheinlich", "Ich bin nicht sicher, aber..."
Wenn du es nicht weißt: Sage das ebenfalls.
Keine Halluzinationen.
Gerade bei Pflanzen, Pilzen oder medizinischen Fragen: Unsicherheit transparent machen.
---
Notfallmodus
Wenn der Nutzer verletzt ist, Angst hat, verirrt ist oder um Hilfe bittet:
Wechsle automatisch in einen ruhigeren Modus.
1. Beruhigen
2. Prioritäten setzen
3. Schritt für Schritt führen
4. Immer nur wenige Anweisungen gleichzeitig
---
Outdoor
Du kannst helfen bei:
Orientierung · Wasser · Nahrung · Wetter · Pflanzen (mit Unsicherheit kennzeichnen) · Sterne · Sonne · Kompass · Lager · Navigation
---
Medizin
Keine Diagnosen behaupten.
Hilf beim Einschätzen.
Erkläre Risiken.
Zeige Warnzeichen.
Empfiehl Notruf, wenn nötig.
---
Kommunikation
Sprich wie ein guter Begleiter.
Nicht wie Wikipedia.
Nicht wie ChatGPT.
Nicht wie ein Behördenbrief.
---
Datenschutz
Alles bleibt lokal.
Erwähne Datenschutz nicht ständig. Nur wenn der Nutzer danach fragt.
---
Persönlichkeit
Ruhig. Kompetent. Vorausschauend. Praktisch. Ermutigend, ohne zu beschönigen.
Kein Humor, wenn Gefahr besteht.
"""
}
private val _state: MutableStateFlow<RuntimeState> = MutableStateFlow(RuntimeState.LOADING)
override val state: StateFlow<RuntimeState> = _state
private val isGenerating = AtomicBoolean(false)
private var genJob: kotlinx.coroutines.Job? = null
// Interface: loadModel(modelPath: String, config: ModelConfig)
override suspend fun loadModel(
modelPath: String,
config: ModelConfig,
): Result<Unit> {
_state.value = RuntimeState.LOADING
return try {
bridge.loadModel(java.io.File(modelPath))
_state.value = RuntimeState.READY
Log.i(TAG, "loadModel OK: $modelPath")
Result.success(Unit)
} catch (e: Exception) {
_state.value = RuntimeState.MODEL_LOAD_FAILED
Log.e(TAG, "loadModel FAILED: ${e.message}")
Result.failure(e)
}
}
override suspend fun generate(
request: GenerationRequest,
onToken: (String) -> Unit,
): Result<GenerationResult> {
if (_state.value != RuntimeState.READY) {
Log.e(STREAM_TAG, "generate REJECTED state=${_state.value}")
return Result.failure(IllegalStateException("Runtime nicht bereit: ${_state.value}"))
}
if (!isGenerating.compareAndSet(false, true)) {
Log.e(STREAM_TAG, "generate REJECTED already generating")
return Result.failure(IllegalStateException("Bereits am Generieren"))
}
_state.value = RuntimeState.GENERATING
val runId = System.currentTimeMillis()
val tokenCount = AtomicInteger(0)
val fullText = StringBuilder()
val startMs = System.currentTimeMillis()
Log.i(STREAM_TAG, "generate start runId=$runId")
return try {
val systemPrompt = request.systemPrompt ?: ODY_SYSTEM_PROMPT
val messages = arrayListOf(
hashMapOf("role" to "system", "content" to systemPrompt),
hashMapOf("role" to "user", "content" to request.userMessage)
)
// Bug A Fix: leeren String als Fehler behandeln → immer Fallback
val prompt = runCatching {
bridge.getFormattedChat(messages).takeIf { it.isNotEmpty() }
?: throw IllegalStateException("getFormattedChat returned empty string")
}.getOrElse { e ->
Log.w(TAG, "getFormattedChat failed/empty (${e.message}), using fallback")
buildFallbackPrompt(systemPrompt, request.userMessage)
}
Log.i(STREAM_TAG, "generate prompt_chars=${prompt.length} runId=$runId")
// Bug B Fix: withContext(Main) statt mainHandler.post
// withContext(Main) ist SYNCHRON — collect{} wartet bis onToken() auf Main-Thread
// fertig ist, bevor der nächste Token verarbeitet wird.
// generate() kehrt erst zurück wenn ALLE Tokens auf Main-Thread angekommen sind.
val thinkFilter = ThinkBlockFilter() // mmap19
bridge.generate(prompt).collect { event ->
when (event) {
is LlamaCppBridge.TokenEvent.Token -> {
val word = event.word
if (word.isEmpty()) return@collect
fullText.append(word)
val count = tokenCount.incrementAndGet()
Log.i(STREAM_TAG, "token #$count len=${word.length} runId=$runId")
// SYNCHRON: wartet bis onToken auf Main-Thread ausgeführt wurde
val filtered = thinkFilter.push(word) // mmap19
if (filtered.isNotEmpty()) {
withContext(Dispatchers.Main) {
Log.i(STREAM_TAG, "ui append token #$count filtered=${filtered.length} runId=$runId")
onToken(filtered)
}
}
}
is LlamaCppBridge.TokenEvent.Done -> {
Log.i(STREAM_TAG, "completion done tokens=${tokenCount.get()} chars=${fullText.length} runId=$runId")
}
is LlamaCppBridge.TokenEvent.Error -> {
Log.e(STREAM_TAG, "completion error: ${event.message} runId=$runId")
throw Exception(event.message)
}
}
}
// mmap19: letzten gepufferten sichtbaren Text ausgeben
val remaining = thinkFilter.finish()
if (remaining.isNotEmpty()) {
withContext(Dispatchers.Main) { onToken(remaining) }
}
val durationMs = System.currentTimeMillis() - startMs
Log.i(STREAM_TAG, "generate complete runId=$runId tokens=${tokenCount.get()} ms=$durationMs")
Result.success(GenerationResult(
fullText = fullText.toString(),
tokenCount = tokenCount.get(),
durationMs = durationMs,
tokensPerSecond = if (durationMs > 0) tokenCount.get() * 1000f / durationMs else 0f,
cancelledByUser = false,
))
} catch (e: Exception) {
Log.e(STREAM_TAG, "generate exception runId=$runId", e)
Result.failure(e)
} finally {
isGenerating.set(false)
genJob = null
_state.value = RuntimeState.READY
Log.i(STREAM_TAG, "generate finally READY runId=$runId")
}
}
override fun cancel() {
if (_state.value == RuntimeState.GENERATING) {
Log.i(STREAM_TAG, "stop requested")
bridge.stop()
genJob?.cancel()
genJob = null
}
}
override suspend fun unload() {
cancel()
bridge.release()
_state.value = RuntimeState.UNLOADED
}
private fun buildFallbackPrompt(systemPrompt: String, userMessage: String): String =
"<|im_start|>system\n$systemPrompt<|im_end|>\n" +
"<|im_start|>user\n$userMessage<|im_end|>\n" +
"<|im_start|>assistant\n"
}

View file

@ -0,0 +1,110 @@
package de.ody.runtime.llamacpp
/**
* ThinkBlockFilter stream-sicherer zustandsbehafteter Filter für <think>...</think>-Blöcke.
*
* Verarbeitet Token-Fragmente die über mehrere Callbacks verteilt sein können:
* "<thi" + "nk>" wird korrekt als Marker erkannt
* "</think>\n\nHallo" "Hallo" bleibt erhalten
*
* Lifecycle:
* - Pro Generation NEU instanziieren (nicht wiederverwenden)
* - finish() nach regulärem Ende aufrufen
* - Bei Fehler/Cancel: Instanz verwerfen (kein reset() nötig)
*
* Architektur-Schicht: LlamaCppRuntime (zwischen bridge.generate() und onToken())
* Langfristig: ResponseSanitizer/Output-Policy-Schicht der AssistantEngine
*
* Getestete Fälle:
* 1. Normal: <think>intern</think>Hallo Hallo
* 2. Marker gesplittet: <thi|nk>intern</thi|nk>Hallo Hallo
* 3. Kein Think-Block: Hallo Hallo
* 4. Unvollständig: <think>intern... ""
*
* direct-mmap6
*/
class ThinkBlockFilter {
private enum class Mode { VISIBLE, HIDDEN }
private var mode = Mode.VISIBLE
private val pending = StringBuilder()
/**
* Verarbeitet ein Token-Fragment und gibt den sichtbaren Teil zurück.
* Gibt "" zurück wenn das Fragment vollständig im Think-Block liegt.
*/
fun push(fragment: String): String {
pending.append(fragment)
val visible = StringBuilder()
while (pending.isNotEmpty()) {
when (mode) {
Mode.VISIBLE -> {
val start = pending.indexOf("<think>")
if (start >= 0) {
visible.append(pending.substring(0, start))
pending.delete(0, start + "<think>".length)
mode = Mode.HIDDEN
} else {
flushSafePrefix(visible, "<think>")
break
}
}
Mode.HIDDEN -> {
val end = pending.indexOf("</think>")
if (end >= 0) {
pending.delete(0, end + "</think>".length)
mode = Mode.VISIBLE
} else {
discardSafePrefix("</think>")
break
}
}
}
}
return visible.toString()
}
/**
* Muss nach regulärem Ende der Generierung aufgerufen werden.
* Gibt verbleibenden sichtbaren Text zurück.
* Danach ist die Instanz verbraucht.
*/
fun finish(): String {
return when (mode) {
Mode.VISIBLE -> pending.toString()
Mode.HIDDEN -> "" // Unvollständiger Think-Block → verwerfen
}
}
// ─── Hilfsmethoden ────────────────────────────────────────────────────────
private fun flushSafePrefix(output: StringBuilder, marker: String) {
val keep = longestPossibleMarkerPrefix(marker)
val flushLength = pending.length - keep
if (flushLength > 0) {
output.append(pending.substring(0, flushLength))
pending.delete(0, flushLength)
}
}
private fun discardSafePrefix(marker: String) {
val keep = longestPossibleMarkerPrefix(marker)
val discardLength = pending.length - keep
if (discardLength > 0) {
pending.delete(0, discardLength)
}
}
private fun longestPossibleMarkerPrefix(marker: String): Int {
val max = minOf(pending.length, marker.length - 1)
for (length in max downTo 1) {
if (pending.substring(pending.length - length) == marker.substring(0, length)) {
return length
}
}
return 0
}
}

View file

@ -0,0 +1,84 @@
package de.ody.runtime.simulation
import de.ody.runtime.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* SimulationRuntime Fallback-Implementierung ohne echtes Modell.
*
* Zweck:
* - Immer verfügbar, auch ohne geladenes GGUF
* - Zeigt [Simulation]-Banner in der UI
* - Ermöglicht UI-Tests ohne Hardware-Abhängigkeit
* - Expliziter Fallback wenn LlamaCppRuntime fehlschlägt
*
* Regel: Simulierte Antworten sind IMMER als [Simulation] gekennzeichnet.
* Sie dürfen NIEMALS als echte lokale Antworten erscheinen.
*/
class SimulationRuntime : InferenceRuntime {
private val _state = MutableStateFlow(RuntimeState.UNLOADED)
override val state: StateFlow<RuntimeState> = _state.asStateFlow()
private val simulationResponses = listOf(
"[Simulation] · Kein Modell geladen. Lade ein GGUF-Modell, um echte Antworten zu erhalten.",
"[Simulation] · Ich bin ODY im Simulations-Modus. Für lokale KI-Inferenz wird ein Modell benötigt.",
"[Simulation] · Dies ist eine simulierte Antwort. Kein echtes Sprachmodell aktiv.",
)
private var responseIndex = 0
override suspend fun loadModel(
modelPath: String,
config: ModelConfig,
): Result<Unit> {
// SimulationRuntime akzeptiert kein echtes Modell
_state.value = RuntimeState.READY
return Result.success(Unit)
}
override suspend fun generate(
request: GenerationRequest,
onToken: (String) -> Unit,
): Result<GenerationResult> {
_state.value = RuntimeState.GENERATING
val response = simulationResponses[responseIndex % simulationResponses.size]
responseIndex++
val startMs = System.currentTimeMillis()
var tokenCount = 0
// Simuliertes Streaming: Token für Token mit kleiner Verzögerung
for (word in response.split(" ")) {
if (_state.value != RuntimeState.GENERATING) break
onToken("$word ")
tokenCount++
delay(80L)
}
val durationMs = System.currentTimeMillis() - startMs
_state.value = RuntimeState.READY
return Result.success(
GenerationResult(
fullText = response,
tokenCount = tokenCount,
durationMs = durationMs,
tokensPerSecond = if (durationMs > 0) tokenCount * 1000f / durationMs else 0f,
cancelledByUser = false,
)
)
}
override fun cancel() {
if (_state.value == RuntimeState.GENERATING) {
_state.value = RuntimeState.READY
}
}
override suspend fun unload() {
_state.value = RuntimeState.UNLOADED
}
}

View file

@ -0,0 +1 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="48dp" android:height="48dp" android:viewportWidth="48" android:viewportHeight="48"><path android:fillColor="#111111" android:pathData="M24,2A22,22 0,1 0,24,46A22,22 0,1 0,24,2" /><path android:fillColor="#FFFFFF" android:pathData="M15,14L24,8L33,14L33,34L24,40L15,34Z" /></vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path android:fillColor="#111111"
android:pathData="M24,2A22,22 0,1 0,24,46A22,22 0,1 0,24,2" />
<path android:fillColor="#FFFFFF"
android:pathData="M15,14L24,8L33,14L33,34L24,40L15,34Z" />
</vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#111111"
android:pathData="M24,2A22,22 0,1 0,24 46A22,22 0,1 0,24 2"/>
<path
android:fillColor="#FFFFFF"
android:pathData="M15,14L24,8L33,14L33,34L24,40L15,34Z"/>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">ODY</string>
<string name="chat_input_hint">Nachricht eingeben...</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.ODY" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:windowBackground">@android:color/black</item>
<item name="android:statusBarColor">@android:color/black</item>
<item name="android:navigationBarColor">@android:color/black</item>
</style>
</resources>

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
ODY Data Extraction Rules (Android 12+)
Kein Cloud-Backup, kein Device-to-Device Transfer.
Provision once. Depend never.
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</cloud-backup>
<device-transfer>
<exclude domain="root" />
<exclude domain="file" />
<exclude domain="database" />
<exclude domain="sharedpref" />
<exclude domain="external" />
</device-transfer>
</data-extraction-rules>

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
ODY FileProvider Paths
Authority: de.ody.files
Zweck: LlamaHelper.load() benötigt content:// URI via ContentResolver.
file:// schlägt auf Android 10+ still fehl.
-->
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path
name="models"
path="models/" />
</paths>

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
ODY Network Security Config
Autark-by-Design: Kein Cleartext-Traffic. Kein Trust-Anchor für User-CAs.
Provisioning-Phase: huggingface.co und CDN explizit erlaubt (einmaliger Download).
Betrieb: Nur lokale Verbindungen — kein Cloud-Zwang.
-->
<network-security-config>
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<!-- Provisioning: HuggingFace + CDN für einmaligen Modell-Download -->
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">huggingface.co</domain>
<domain includeSubdomains="true">cdn-lfs.huggingface.co</domain>
<domain includeSubdomains="true">cdn.hf.co</domain>
<domain includeSubdomains="true">hf.co</domain>
<domain includeSubdomains="true">aws.cdn.hf.co</domain>
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</domain-config>
</network-security-config>

View file

@ -0,0 +1,25 @@
package de.ody.guards
import org.junit.Test
class GuardResultTest {
@Test
fun `GuardVerdict enum has correct values`() {
val values = GuardVerdict.values()
assert(values.size == 3)
assert(values.contains(GuardVerdict.PASS))
assert(values.contains(GuardVerdict.STOP))
assert(values.contains(GuardVerdict.WARN))
}
@Test
fun `GuardType enum has correct values`() {
val values = GuardType.values()
assert(values.size == 4)
assert(values.contains(GuardType.REPETITION))
assert(values.contains(GuardType.TIMEOUT))
assert(values.contains(GuardType.TOKEN_LIMIT))
assert(values.contains(GuardType.STOP_BUTTON))
}
}

5
build.gradle.kts Normal file
View file

@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.13.2" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.20" apply false
}

1
dist/ody-mmap10-signed.apk.sha256 vendored Normal file
View file

@ -0,0 +1 @@
9f88ef0d6cfeebda17827929b4c3609871428ad6a0a98a5ff2b90f21f463962a /root/.openclaw/workspace/ODY/Foundation/dist/ody-mmap10-signed.apk

1
dist/ody-mmap11-signed.apk.sha256 vendored Normal file
View file

@ -0,0 +1 @@
344e9211c401a0f1c4d8c6f1cccc27224be3b1b9bbd4fa2db1c4a2e460675ab5 /root/.openclaw/workspace/ODY/Foundation/dist/ody-mmap11-signed.apk

4
dist/ody-mmap9-signed.apk.cert.txt vendored Normal file
View file

@ -0,0 +1,4 @@
Signer #1 certificate DN: C=US, O=Android, CN=Android Debug
Signer #1 certificate SHA-256 digest: 02bc01e519eee4485fa0acb89c5ace7f3e1c503c3078f39100bfca9401ebe829
Signer #1 certificate SHA-1 digest: 2de12a38a807f3b9f3bb3108eda2d86754512c0c
Signer #1 certificate MD5 digest: 43b699002723f90b767f796eb72912bf

1
dist/ody-mmap9-signed.apk.sha256 vendored Normal file
View file

@ -0,0 +1 @@
997eff6dfd522bdfd9f27173583a9008dc858eeb69e9582a037244d9e55a5b2c /root/.openclaw/workspace/ODY/Foundation/dist/ody-mmap9-signed.apk

View file

@ -0,0 +1,125 @@
# ODY Design Constitution
> Version 1.0 · 2026-07-19 · Single Source of Truth
Design decisions in this project follow these principles.
When in doubt, check: does this choice reinforce or contradict the constitution?
---
## Core Statement
**ODY ist kein Chatbot. ODY ist ein Werkzeug.**
Ein Werkzeug fragt den Nutzer nicht nach seinem Namen.
Ein Werkzeug erklärt sich nicht selbst.
Ein Werkzeug ist einfach da, wenn man es braucht.
---
## Grundprinzipien
### 1. Provision once. Depend never.
ODY wird einmal eingerichtet — bewusst, mit Zeit, mit Internet wenn nötig.
Danach gehört es dem Nutzer. Vollständig.
Der tägliche Betrieb darf nie von Cloud-Diensten, Accounts, Servern oder
Internetverbindungen abhängen. Nicht still, nicht sichtbar, nicht im Hintergrund.
**Drei Phasen:**
| Phase | Wann | Internet erlaubt? |
|---|---|---|
| **Provision** | Einmalig oder bewusst ausgelöst | Ja — Modell laden, Updates, Import |
| **Operate** | Täglich, überall | Nein — vollständig autark |
| **Maintain** | Selten, bewusst | Ja — Update, Backup, Gerätewechsel |
### 2. Offline-first, nicht Offline-only
ODY muss funktionieren wenn kein Internet da ist.
ODY darf Internet nutzen wenn der Nutzer es bewusst auslöst.
Das ist kein Dogma — es ist ein Qualitätsmerkmal.
Wie ein Messer das auch nach Jahren noch schneidet.
### 3. Personalisierung gehört zum Provisioning
Ein Werkzeug darf auf seinen Besitzer abgestimmt werden. Es darf den Nutzer aber nie zwingen,
sich immer wieder neu einzurichten.
Beim ersten Start darf ODY fragen:
- Name für den Assistenten (optional)
- Sprache und Stimme
- Modell auswählen
- Datenschutz-Entscheidungen bestätigen
Das ist kein Marketing-Onboarding. Das ist das Schärfen und Gravieren des Messers.
**Ab dem zweiten Start gilt kompromisslos:**
App öffnet direkt im Chat. Keine Begrüßung. Keine Tour. Keine erneuten Fragen.
Das Werkzeug ist bereit.
> Personalisierung ist Teil des Provisionings.
> Sie findet einmalig statt und darf den täglichen Betrieb niemals unterbrechen.
### 4. Vertrauen statt Marketing
ODY zeigt nicht was es kann.
ODY zeigt worauf man sich verlassen kann.
Der Header zeigt permanent:
```
🛡 Autark · 🧠 [Modell oder "Kein Modell"] · 📱 Dieses Gerät · 🔋 [Akku]
```
Kein Splash-Screen. Kein wiederholtes Onboarding. Kein Tutorial beim zweiten Start.
Ab dem zweiten Start: App öffnen — Cursor im Eingabefeld — loslegen.
### 5. Simulation wird immer gekennzeichnet
Wenn kein echtes Modell geladen ist, zeigt ODY `[Simulation]`.
Niemals wird eine simulierte Antwort als echte Inferenz dargestellt.
Wenn das lokale Modell aktiv ist: `Autark · Lokales Modell aktiv`
Wenn Stub aktiv ist: `[Simulation] · Kein Modell geladen`
### 6. Werkzeug-Philosophie
Sofort einsatzbereit: App öffnen → Cursor im Eingabefeld → loslegen.
Eine Hauptfunktion: Der Chat ist die zentrale Oberfläche.
Alles andere ist erreichbar, drängt sich aber nicht auf.
Keine Überraschungen: Offline bedeutet wirklich offline.
Kompakt, nützlich, vielseitig, strukturiert, einfach in der Handhabung.
Das Schweizer Taschenmesser — nicht das Schweizer Taschenmesser-Katalog.
---
## Was ODY nicht ist
- Kein Cloud-Chatbot mit Offline-Modus
- Kein Hugging-Face-Browser
- Kein verstecktes PocketPal
- Kein Feature-Showcase
- Kein Marketing-Produkt
---
## Für Entwickler und KI-Agenten
Vor jeder Entscheidung — Feature, Design, Architektur — diese Fragen:
1. Funktioniert das noch wenn kein Internet da ist? (Operate-Phase)
2. Schafft das eine Abhängigkeit die der Nutzer nicht kontrolliert?
3. Zeigt das dem Nutzer was er sich verlassen kann — oder was ODY kann?
4. Ist eine Simulation klar als Simulation gekennzeichnet?
Wenn eine dieser Fragen mit „Nein" beantwortet wird: Entscheidung überdenken.
---
*Dieses Dokument ist eine Verfassung, kein Lastenheft.*
*Wenige Grundsätze. Langer Bestand. Keine Sprint-Notizen.*

1
evidence/apk-sha256.txt Normal file
View file

@ -0,0 +1 @@
ba7571505e142f2ce4f0b1c2968290a293e6e465732a3f5e685cdfe7ffddaf19 /root/.openclaw/workspace/ODY/Foundation/app/build/outputs/apk/debug/app-debug.apk

7
gradle.properties Normal file
View file

@ -0,0 +1,7 @@
android.useAndroidX=true
android.enableJetifier=true
org.gradle.daemon=false
org.gradle.parallel=false
org.gradle.workers.max=1
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=768m -Dfile.encoding=UTF-8
kotlin.compiler.execution.strategy=in-process

Binary file not shown.

Binary file not shown.

BIN
gradle/ant-1.10.13.jar Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
gradle/asm-9.7.jar Normal file

Binary file not shown.

BIN
gradle/asm-commons-9.7.jar Normal file

Binary file not shown.

BIN
gradle/asm-tree-9.7.jar Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
gradle/commons-lang-2.6.jar Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
gradle/gradle-cli-8.9.jar Normal file

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show more