feat(speaker): rendre les Speakers GGUF sélectionnables in-app (Qwen3.5-4B, Qwen2.5-7B)
ModelRegistry devient backend-aware (PTE | GGUF) : enum Backend, ggufFile + ggufPath() sous MODELS_DIR, fileExists() par backend, et un type de template ChatTemplate (QWEN35_THINKOFF | PLAIN_CHATML). Ajout de deux entrées Speaker GGUF servies par le moteur lib : Qwen3.5-4B (q35-lmq4, le défaut lib jusqu'ici codé en dur, désormais visible) et Qwen2.5-7B (Q4_K_M, arch qwen2 dense). KazeiaService : le path lib route le GGUF du Speaker sélectionné (sp.ggufPath()) au lieu de q35-lmq4 en dur, et passe plainChatml selon le template. Pour les modèles sans thinking (Qwen2.5), EngineLlmAdapter construit un ChatML standard sans bloc <think> via generateRaw (le natif l'injecterait sinon). Affordances de test/maintenance (pilotage adb sans UI) : - llm_text / llm_max : génération LLM pure loggée (tok/s, chars), hors pipeline TTS. - cfg_set : persiste engine+model+prompt via am --es (contourne content --bind qui casse sur ':'); cfg_sys=DEFAULT résout le prompt thérapeutique en interne. Provider /models : expose le chemin GGUF pour les entrées GGUF. Validé device : les deux GGUF chargent, FR cohérent, templates propres. Bench : Qwen3.5-4B ~13.5 tok/s, Qwen2.5-7B ~6.8 tok/s (CPU-only) ; qualité FR thérapeutique nettement en faveur du Qwen3.5-4B. Note : KazeiaService.kt et les 3 fichiers nouveaux portent aussi du travail antérieur non commité (migration moteur lib + app admin) ; ce commit en capture l'état courant en plus de la fonctionnalité Speaker GGUF. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
8a3151681d
commit
06b333f0f0
|
|
@ -0,0 +1,135 @@
|
||||||
|
package com.kazeia.config
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catalogue figé des modèles LLM qu'on sait charger via ExecuTorch QNN HTP.
|
||||||
|
* Filtrer par `availableOnDisk()` pour ne proposer dans l'UI admin que ceux
|
||||||
|
* réellement présents sur la tablette.
|
||||||
|
*
|
||||||
|
* L'admin app lit cette liste via le ContentProvider URI `/models` ;
|
||||||
|
* KazeiaService la consulte pour résoudre un `model_id` → (pte, tokenizer).
|
||||||
|
*/
|
||||||
|
object ModelRegistry {
|
||||||
|
|
||||||
|
// Racine .pte LLM — résolue par KazeiaPaths (legacy /data/local/tmp/kazeia-et
|
||||||
|
// sur tablette dev, stockage externe app sur tablette de production).
|
||||||
|
private val ET_DIR: String get() = com.kazeia.KazeiaApplication.LLM_DIR
|
||||||
|
|
||||||
|
// Racine GGUF (moteur kazeia-engine "lib"). Les GGUF (q35-lmq4, qwen2.5-7b…)
|
||||||
|
// vivent sous MODELS_DIR, pas LLM_DIR.
|
||||||
|
private val GGUF_DIR: String get() = com.kazeia.KazeiaApplication.MODELS_DIR
|
||||||
|
|
||||||
|
enum class Role { SPEAKER, THINKER, BOTH }
|
||||||
|
|
||||||
|
// PTE = runner ExecuTorch QNN HTP ; GGUF = moteur kazeia-engine "lib" (CPU).
|
||||||
|
enum class Backend { PTE, GGUF }
|
||||||
|
|
||||||
|
// Template ChatML du modèle. QWEN35_THINKOFF injecte le bloc <think></think>
|
||||||
|
// (thinking-off déterministe Qwen3.5). PLAIN_CHATML = ChatML standard sans
|
||||||
|
// <think> (Qwen2.5 et autres modèles dense sans raisonnement).
|
||||||
|
enum class ChatTemplate { QWEN35_THINKOFF, PLAIN_CHATML }
|
||||||
|
|
||||||
|
data class ModelInfo(
|
||||||
|
val id: String,
|
||||||
|
val displayName: String,
|
||||||
|
val pteFile: String,
|
||||||
|
val tokenizerFile: String,
|
||||||
|
val role: Role,
|
||||||
|
val sizeMb: Int,
|
||||||
|
val maxSeqLen: Int,
|
||||||
|
val notes: String = "",
|
||||||
|
val backend: Backend = Backend.PTE,
|
||||||
|
// Nom du fichier GGUF (sous GGUF_DIR) quand backend = GGUF. Le tokenizer
|
||||||
|
// est embarqué dans le GGUF, pas de fichier séparé.
|
||||||
|
val ggufFile: String = "",
|
||||||
|
val chatTemplate: ChatTemplate = ChatTemplate.QWEN35_THINKOFF
|
||||||
|
) {
|
||||||
|
fun ptePath() = "$ET_DIR/$pteFile"
|
||||||
|
fun tokenizerPath() = "$ET_DIR/$tokenizerFile"
|
||||||
|
fun ggufPath() = "$GGUF_DIR/$ggufFile"
|
||||||
|
fun fileExists(): Boolean = when (backend) {
|
||||||
|
Backend.PTE -> File(ptePath()).exists()
|
||||||
|
Backend.GGUF -> File(ggufPath()).exists()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val ALL: List<ModelInfo> = listOf(
|
||||||
|
ModelInfo(
|
||||||
|
id = "qwen3-4b-seq512",
|
||||||
|
displayName = "Qwen3-4B (seq=512)",
|
||||||
|
pteFile = "hybrid_llama_qnn_4b.pte",
|
||||||
|
tokenizerFile = "tokenizer.json",
|
||||||
|
role = Role.SPEAKER,
|
||||||
|
sizeMb = 3000,
|
||||||
|
maxSeqLen = 512,
|
||||||
|
notes = "Speaker historique, budget contexte serré"
|
||||||
|
),
|
||||||
|
ModelInfo(
|
||||||
|
id = "qwen3-4b-seq1024",
|
||||||
|
displayName = "Qwen3-4B (seq=1024) ★",
|
||||||
|
pteFile = "hybrid_llama_qnn_4b_seq1024.pte",
|
||||||
|
tokenizerFile = "tokenizer.json",
|
||||||
|
role = Role.SPEAKER,
|
||||||
|
sizeMb = 3000,
|
||||||
|
maxSeqLen = 1024,
|
||||||
|
notes = "Speaker recommandé — production stable 2026-05-14"
|
||||||
|
),
|
||||||
|
ModelInfo(
|
||||||
|
id = "qwen3.5-4b",
|
||||||
|
displayName = "Qwen3.5-4B (GGUF) ★",
|
||||||
|
pteFile = "",
|
||||||
|
tokenizerFile = "",
|
||||||
|
role = Role.SPEAKER,
|
||||||
|
sizeMb = 2380,
|
||||||
|
maxSeqLen = 4096,
|
||||||
|
notes = "Speaker GGUF par défaut du moteur lib (q35-lmq4). Thinking-off Qwen3.5.",
|
||||||
|
backend = Backend.GGUF,
|
||||||
|
ggufFile = "q35-lmq4.gguf",
|
||||||
|
chatTemplate = ChatTemplate.QWEN35_THINKOFF
|
||||||
|
),
|
||||||
|
ModelInfo(
|
||||||
|
id = "qwen2.5-7b-instruct",
|
||||||
|
displayName = "Qwen2.5-7B Instruct (GGUF)",
|
||||||
|
pteFile = "",
|
||||||
|
tokenizerFile = "",
|
||||||
|
role = Role.SPEAKER,
|
||||||
|
sizeMb = 4683,
|
||||||
|
maxSeqLen = 4096,
|
||||||
|
notes = "Speaker GGUF dense (arch qwen2) via moteur lib CPU. Q4_K_M ~4.7 GB.",
|
||||||
|
backend = Backend.GGUF,
|
||||||
|
ggufFile = "Qwen2.5-7B-Instruct-Q4_K_M.gguf",
|
||||||
|
chatTemplate = ChatTemplate.PLAIN_CHATML
|
||||||
|
),
|
||||||
|
ModelInfo(
|
||||||
|
id = "guard06b",
|
||||||
|
displayName = "Qwen3Guard 0.6B",
|
||||||
|
pteFile = "hybrid_llama_qnn_guard06b.pte",
|
||||||
|
tokenizerFile = "tokenizer_guard06b.json",
|
||||||
|
role = Role.THINKER,
|
||||||
|
sizeMb = 672,
|
||||||
|
maxSeqLen = 512,
|
||||||
|
notes = "Thinker léger, ION ~700 MB, cascade safe"
|
||||||
|
),
|
||||||
|
ModelInfo(
|
||||||
|
id = "guard4b",
|
||||||
|
displayName = "Qwen3Guard 4B",
|
||||||
|
pteFile = "hybrid_llama_qnn_guard4b.pte",
|
||||||
|
tokenizerFile = "tokenizer_guard.json",
|
||||||
|
role = Role.THINKER,
|
||||||
|
sizeMb = 3000,
|
||||||
|
maxSeqLen = 512,
|
||||||
|
notes = "Thinker analytique. ⚠ Cascade Guard-4B + Speaker-4B = risque OOM."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
fun availableOnDisk(): List<ModelInfo> = ALL.filter { it.fileExists() }
|
||||||
|
|
||||||
|
fun byId(id: String): ModelInfo? = ALL.firstOrNull { it.id == id }
|
||||||
|
|
||||||
|
fun defaultSpeaker(): ModelInfo = byId("qwen3-4b-seq1024")
|
||||||
|
?: ALL.first { it.role == Role.SPEAKER && it.fileExists() }
|
||||||
|
|
||||||
|
fun defaultThinker(): ModelInfo = byId("guard06b")
|
||||||
|
?: ALL.first { it.role == Role.THINKER && it.fileExists() }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
package com.kazeia.llm
|
||||||
|
|
||||||
|
import com.kazeia.core.GenerationResult
|
||||||
|
import com.kazeia.core.LlmConfig
|
||||||
|
import com.kazeia.core.LlmEngine
|
||||||
|
import com.kazeia.core.SamplingParams
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter qui expose `com.kazeia.llm.EngineLlmEngine` (façade dist Kazeia-Engine,
|
||||||
|
* GGUF Qwen3.5/Qwen3 via libkazeia_engine.so + libllama fork ql + Hexagon)
|
||||||
|
* derrière l'interface prod `com.kazeia.core.LlmEngine`. Sert au swap réversible
|
||||||
|
* piloté par le flag config `llmEngine` ("prod" = ExecuTorch .pte | "lib" = engine GGUF).
|
||||||
|
*
|
||||||
|
* Pré-requis runtime : `libllama.so` fork ql + `libggml*.so` cohérents dans jniLibs.
|
||||||
|
* Si la chaîne libllama est encore en legacy (f86d), `System.loadLibrary("kazeia_engine")`
|
||||||
|
* jette UnsatisfiedLinkError au 1er accès à `EngineJni` → KazeiaService doit catch
|
||||||
|
* et tomber en repli sur le path prod.
|
||||||
|
*
|
||||||
|
* Modèle par défaut : Qwen3.5-4B `q35-lmq4.gguf` (upgrade vs Qwen3 dense en prod).
|
||||||
|
*/
|
||||||
|
class EngineLlmAdapter(
|
||||||
|
private val modelPath: String,
|
||||||
|
private val systemPrompt: String,
|
||||||
|
private val ctxLen: Int = 4096,
|
||||||
|
private val nThreads: Int = 6,
|
||||||
|
// true = ChatML standard sans <think> (Qwen2.5…) via generateRaw ;
|
||||||
|
// false = bloc thinking-off Qwen3.5 injecté côté natif (generate).
|
||||||
|
private val plainChatml: Boolean = false,
|
||||||
|
private val onLog: ((String) -> Unit)? = null
|
||||||
|
) : LlmEngine {
|
||||||
|
private var engine: EngineLlmEngine? = null
|
||||||
|
|
||||||
|
override suspend fun load(modelPath: String, config: LlmConfig) = withContext(Dispatchers.IO) {
|
||||||
|
if (engine != null) return@withContext
|
||||||
|
val t0 = System.currentTimeMillis()
|
||||||
|
engine = EngineLlmEngine(this@EngineLlmAdapter.modelPath, ctx = ctxLen, nThreads = nThreads)
|
||||||
|
onLog?.invoke("[ENGINE] load OK (${System.currentTimeMillis() - t0}ms) path=${this@EngineLlmAdapter.modelPath} t=${nThreads}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isLoaded(): Boolean = engine != null
|
||||||
|
|
||||||
|
override suspend fun generate(
|
||||||
|
prompt: String,
|
||||||
|
params: SamplingParams,
|
||||||
|
onToken: ((String) -> Boolean)?
|
||||||
|
): GenerationResult = withContext(Dispatchers.IO) {
|
||||||
|
val e = engine ?: throw IllegalStateException("EngineLlmAdapter not loaded")
|
||||||
|
val t0 = System.currentTimeMillis()
|
||||||
|
val out = if (plainChatml) {
|
||||||
|
// ChatML standard, sans bloc <think> (le natif generate l'injecterait).
|
||||||
|
val p = buildString {
|
||||||
|
append("<|im_start|>system\n").append(systemPrompt).append("<|im_end|>\n")
|
||||||
|
append("<|im_start|>user\n").append(prompt).append("<|im_end|>\n")
|
||||||
|
append("<|im_start|>assistant\n")
|
||||||
|
}
|
||||||
|
e.generateRaw(p, params.maxNewTokens).trim()
|
||||||
|
} else {
|
||||||
|
e.generate(systemPrompt, prompt, params.maxNewTokens).trim()
|
||||||
|
}
|
||||||
|
val ms = System.currentTimeMillis() - t0
|
||||||
|
onToken?.invoke(out)
|
||||||
|
val n = out.split(Regex("\\s+")).size
|
||||||
|
GenerationResult(out, n, ms, if (ms > 0) n * 1000f / ms else 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun release() { engine?.release(); engine = null }
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,576 @@
|
||||||
|
package com.kazeia.telemetry
|
||||||
|
|
||||||
|
import android.content.ContentProvider
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.UriMatcher
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.database.MatrixCursor
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Debug
|
||||||
|
import android.os.Process
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expose à l'admin app (com.kazeia.admin) les données runtime du process
|
||||||
|
* Kazeia patient via une URI standard.
|
||||||
|
*
|
||||||
|
* Tables (read-only sauf voices delete) :
|
||||||
|
* - content://com.kazeia.provider/state — 1 row : état process courant
|
||||||
|
* - content://com.kazeia.provider/turns — N rows : derniers tours pipeline
|
||||||
|
* - content://com.kazeia.provider/crashes — N rows : derniers crashes
|
||||||
|
* - content://com.kazeia.provider/voices — N rows : voix présentes sur disque
|
||||||
|
* - content://com.kazeia.provider/voices/<id> — delete : supprime la voix
|
||||||
|
*
|
||||||
|
* Pas de permission custom en MVP — données non sensibles (système + meta voix).
|
||||||
|
* À sécuriser en production via signature-based permission une fois le keystore
|
||||||
|
* partagé mis en place (cf project_kazeia_admin_app.md).
|
||||||
|
*/
|
||||||
|
class KazeiaTelemetryProvider : ContentProvider() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val AUTHORITY = "com.kazeia.provider"
|
||||||
|
const val PATH_STATE = "state"
|
||||||
|
const val PATH_TURNS = "turns"
|
||||||
|
const val PATH_CRASHES = "crashes"
|
||||||
|
const val PATH_VOICES = "voices"
|
||||||
|
const val PATH_CONFIG = "config"
|
||||||
|
const val PATH_MODELS = "models"
|
||||||
|
const val PATH_PROFILES = "profiles"
|
||||||
|
const val PATH_PROFILE_ACTIVE = "profiles/active"
|
||||||
|
const val PATH_CONVERSATIONS = "conversations"
|
||||||
|
const val PATH_DIST_CONFIG = "dist_config"
|
||||||
|
const val ACTION_RELOAD_CONFIG = "com.kazeia.action.RELOAD_CONFIG"
|
||||||
|
const val ACTION_RELOAD_PROFILES = "com.kazeia.action.RELOAD_PROFILES"
|
||||||
|
|
||||||
|
private const val CODE_STATE = 1
|
||||||
|
private const val CODE_TURNS = 2
|
||||||
|
private const val CODE_CRASHES = 3
|
||||||
|
private const val CODE_VOICES = 4
|
||||||
|
private const val CODE_VOICE_ITEM = 5
|
||||||
|
private const val CODE_CONFIG = 6
|
||||||
|
private const val CODE_MODELS = 7
|
||||||
|
private const val CODE_PROFILES = 8
|
||||||
|
private const val CODE_PROFILE_ITEM = 9
|
||||||
|
private const val CODE_PROFILE_ACTIVE = 10
|
||||||
|
private const val CODE_CONVERSATIONS = 11
|
||||||
|
private const val CODE_CONVERSATION_SESSION = 12
|
||||||
|
private const val CODE_CONVERSATION_PROFILE = 13
|
||||||
|
private const val CODE_DIST_CONFIG = 14
|
||||||
|
|
||||||
|
// Emplacements canoniques — résolus par KazeiaPaths (legacy /data/local/tmp
|
||||||
|
// sur tablette dev, stockage externe app sinon). Non-const.
|
||||||
|
private val VOICE_WAV_DIR: String
|
||||||
|
get() = "${com.kazeia.KazeiaApplication.MODELS_DIR}/../voix/voix"
|
||||||
|
private val VOICE_EMB_DIR: String
|
||||||
|
get() = "${com.kazeia.KazeiaApplication.MODELS_DIR}/qwen3-tts-npu"
|
||||||
|
private const val PREFIX_SUFFIX = "_voice_prefix.bin"
|
||||||
|
private const val SUFFIX_SUFFIX = "_voice_suffix.bin"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val matcher = UriMatcher(UriMatcher.NO_MATCH).apply {
|
||||||
|
addURI(AUTHORITY, PATH_STATE, CODE_STATE)
|
||||||
|
addURI(AUTHORITY, PATH_TURNS, CODE_TURNS)
|
||||||
|
addURI(AUTHORITY, PATH_CRASHES, CODE_CRASHES)
|
||||||
|
addURI(AUTHORITY, PATH_VOICES, CODE_VOICES)
|
||||||
|
addURI(AUTHORITY, "$PATH_VOICES/*", CODE_VOICE_ITEM)
|
||||||
|
addURI(AUTHORITY, PATH_CONFIG, CODE_CONFIG)
|
||||||
|
addURI(AUTHORITY, PATH_MODELS, CODE_MODELS)
|
||||||
|
// Profiles : enum + active + per-id (CRUD)
|
||||||
|
addURI(AUTHORITY, PATH_PROFILES, CODE_PROFILES)
|
||||||
|
addURI(AUTHORITY, PATH_PROFILE_ACTIVE, CODE_PROFILE_ACTIVE)
|
||||||
|
addURI(AUTHORITY, "$PATH_PROFILES/*", CODE_PROFILE_ITEM)
|
||||||
|
// Conversations : timeline + filter by session/profile
|
||||||
|
addURI(AUTHORITY, PATH_CONVERSATIONS, CODE_CONVERSATIONS)
|
||||||
|
addURI(AUTHORITY, "$PATH_CONVERSATIONS/session/*", CODE_CONVERSATION_SESSION)
|
||||||
|
addURI(AUTHORITY, "$PATH_CONVERSATIONS/profile/*", CODE_CONVERSATION_PROFILE)
|
||||||
|
// Config WebDAV de distribution (éditable depuis l'app admin)
|
||||||
|
addURI(AUTHORITY, PATH_DIST_CONFIG, CODE_DIST_CONFIG)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(): Boolean = true
|
||||||
|
|
||||||
|
override fun query(
|
||||||
|
uri: Uri,
|
||||||
|
projection: Array<out String>?,
|
||||||
|
selection: String?,
|
||||||
|
selectionArgs: Array<out String>?,
|
||||||
|
sortOrder: String?
|
||||||
|
): Cursor? = when (matcher.match(uri)) {
|
||||||
|
CODE_STATE -> stateCursor()
|
||||||
|
CODE_TURNS -> turnsCursor()
|
||||||
|
CODE_CRASHES -> crashesCursor()
|
||||||
|
CODE_VOICES -> voicesCursor()
|
||||||
|
CODE_CONFIG -> configCursor()
|
||||||
|
CODE_MODELS -> modelsCursor()
|
||||||
|
CODE_PROFILES -> profilesCursor()
|
||||||
|
CODE_PROFILE_ACTIVE -> activeProfileCursor()
|
||||||
|
CODE_CONVERSATIONS -> sessionsCursor(profileId = null)
|
||||||
|
CODE_CONVERSATION_PROFILE -> sessionsCursor(profileId = uri.lastPathSegment)
|
||||||
|
CODE_CONVERSATION_SESSION -> turnsForSessionCursor(uri.lastPathSegment ?: "")
|
||||||
|
CODE_DIST_CONFIG -> distConfigCursor()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun profilesCursor(): Cursor {
|
||||||
|
val ctx = context ?: return MatrixCursor(arrayOf<String>())
|
||||||
|
val store = com.kazeia.profiles.ProfileStore.get(ctx)
|
||||||
|
val cols = arrayOf(
|
||||||
|
"id", "display_name", "avatar_color", "voice_id",
|
||||||
|
"system_prompt_override", "has_pin", "created_at",
|
||||||
|
"last_used_at", "notes", "is_default", "turn_count"
|
||||||
|
)
|
||||||
|
val cursor = MatrixCursor(cols)
|
||||||
|
val dao = com.kazeia.profiles.ConversationDao(
|
||||||
|
com.kazeia.profiles.ConversationDb.get(ctx)
|
||||||
|
)
|
||||||
|
for (p in store.all()) {
|
||||||
|
val count = try { dao.countTurns(p.id) } catch (_: Exception) { 0 }
|
||||||
|
cursor.addRow(arrayOf(
|
||||||
|
p.id, p.displayName, p.avatarColor, p.voiceId,
|
||||||
|
p.systemPromptOverride, if (p.hasPin()) 1 else 0,
|
||||||
|
p.createdAt, p.lastUsedAt, p.notes,
|
||||||
|
if (p.isDefault) 1 else 0, count
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun activeProfileCursor(): Cursor {
|
||||||
|
val ctx = context ?: return MatrixCursor(arrayOf<String>())
|
||||||
|
val cursor = MatrixCursor(arrayOf("active_profile_id"))
|
||||||
|
val active = com.kazeia.profiles.ProfileStore.get(ctx).activeProfile()
|
||||||
|
cursor.addRow(arrayOf(active?.id))
|
||||||
|
return cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sessionsCursor(profileId: String?): Cursor {
|
||||||
|
val ctx = context ?: return MatrixCursor(arrayOf<String>())
|
||||||
|
val cols = arrayOf(
|
||||||
|
"profile_id", "session_id", "started_at", "ended_at", "turn_count"
|
||||||
|
)
|
||||||
|
val cursor = MatrixCursor(cols)
|
||||||
|
try {
|
||||||
|
val dao = com.kazeia.profiles.ConversationDao(
|
||||||
|
com.kazeia.profiles.ConversationDb.get(ctx)
|
||||||
|
)
|
||||||
|
for (s in dao.listSessions(profileId)) {
|
||||||
|
cursor.addRow(arrayOf(s.profileId, s.sessionId,
|
||||||
|
s.startedAt, s.endedAt, s.turnCount))
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { /* DB locked or new : retourne vide */ }
|
||||||
|
return cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun turnsForSessionCursor(sessionId: String): Cursor {
|
||||||
|
val ctx = context ?: return MatrixCursor(arrayOf<String>())
|
||||||
|
val cols = arrayOf(
|
||||||
|
"id", "profile_id", "session_id", "timestamp", "role",
|
||||||
|
"text", "ttft_ms", "total_ms", "voice_used", "model_used"
|
||||||
|
)
|
||||||
|
val cursor = MatrixCursor(cols)
|
||||||
|
try {
|
||||||
|
val dao = com.kazeia.profiles.ConversationDao(
|
||||||
|
com.kazeia.profiles.ConversationDb.get(ctx)
|
||||||
|
)
|
||||||
|
for (t in dao.listTurns(sessionId = sessionId)) {
|
||||||
|
cursor.addRow(arrayOf(
|
||||||
|
t.id, t.profileId, t.sessionId, t.timestamp, t.role.name,
|
||||||
|
t.text, t.ttftMs, t.totalMs, t.voiceUsed, t.modelUsed
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
return cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configCursor(): Cursor {
|
||||||
|
val ctx = context ?: return MatrixCursor(arrayOf<String>())
|
||||||
|
val cfg = com.kazeia.config.ConfigStore.get(ctx).current()
|
||||||
|
val cols = arrayOf(
|
||||||
|
"cascade_enabled", "tts_enabled",
|
||||||
|
"stt_engine", "llm_engine", "tts_engine",
|
||||||
|
"speaker_model_id", "speaker_system_prompt", "speaker_temperature",
|
||||||
|
"thinker_model_id", "thinker_system_prompt", "thinker_temperature"
|
||||||
|
)
|
||||||
|
val cursor = MatrixCursor(cols)
|
||||||
|
cursor.addRow(arrayOf(
|
||||||
|
if (cfg.cascadeEnabled) 1 else 0,
|
||||||
|
if (cfg.ttsEnabled) 1 else 0,
|
||||||
|
cfg.sttEngine, cfg.llmEngine, cfg.ttsEngine,
|
||||||
|
cfg.speaker.modelId, cfg.speaker.systemPrompt, cfg.speaker.temperature,
|
||||||
|
cfg.thinker.modelId, cfg.thinker.systemPrompt, cfg.thinker.temperature
|
||||||
|
))
|
||||||
|
return cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun modelsCursor(): Cursor {
|
||||||
|
val cols = arrayOf(
|
||||||
|
"id", "display_name", "pte_path", "tokenizer_path",
|
||||||
|
"role", "size_mb", "max_seq_len", "file_exists", "notes"
|
||||||
|
)
|
||||||
|
val cursor = MatrixCursor(cols)
|
||||||
|
for (m in com.kazeia.config.ModelRegistry.ALL) {
|
||||||
|
val path = if (m.backend == com.kazeia.config.ModelRegistry.Backend.GGUF) m.ggufPath() else m.ptePath()
|
||||||
|
cursor.addRow(arrayOf(
|
||||||
|
m.id, m.displayName, path, m.tokenizerPath(),
|
||||||
|
m.role.name, m.sizeMb, m.maxSeqLen,
|
||||||
|
if (m.fileExists()) 1 else 0, m.notes
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun update(
|
||||||
|
uri: Uri,
|
||||||
|
values: ContentValues?,
|
||||||
|
selection: String?,
|
||||||
|
selectionArgs: Array<out String>?
|
||||||
|
): Int {
|
||||||
|
if (values == null) return 0
|
||||||
|
return when (matcher.match(uri)) {
|
||||||
|
CODE_CONFIG -> updateConfig(uri, values)
|
||||||
|
CODE_PROFILES -> upsertProfile(values)
|
||||||
|
CODE_PROFILE_ITEM -> upsertProfile(values.apply {
|
||||||
|
if (!containsKey("id")) put("id", uri.lastPathSegment)
|
||||||
|
})
|
||||||
|
CODE_PROFILE_ACTIVE -> setActiveProfile(values.getAsString("active_profile_id"))
|
||||||
|
CODE_DIST_CONFIG -> updateDistConfig(uri, values)
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config WebDAV : 1 ligne. Le mot de passe n'est jamais renvoyé en clair —
|
||||||
|
* seul `has_password` indique s'il est défini.
|
||||||
|
*/
|
||||||
|
private fun distConfigCursor(): Cursor {
|
||||||
|
val ctx = context ?: return MatrixCursor(arrayOf<String>())
|
||||||
|
val cfg = com.kazeia.dist.DistConfigStore.get(ctx)
|
||||||
|
val cursor = MatrixCursor(arrayOf(
|
||||||
|
"webdav_base", "webdav_user", "has_password", "is_customized"))
|
||||||
|
cursor.addRow(arrayOf(
|
||||||
|
cfg.base,
|
||||||
|
cfg.user,
|
||||||
|
if (cfg.pass.isNotBlank()) 1 else 0,
|
||||||
|
if (com.kazeia.dist.DistConfigStore.isCustomized(ctx)) 1 else 0
|
||||||
|
))
|
||||||
|
return cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateDistConfig(uri: Uri, values: ContentValues): Int {
|
||||||
|
val ctx = context ?: return 0
|
||||||
|
// reset=true → retour au défaut d'usine (BuildConfig).
|
||||||
|
if (values.getAsBoolean("reset") == true) {
|
||||||
|
com.kazeia.dist.DistConfigStore.reset(ctx)
|
||||||
|
ctx.contentResolver.notifyChange(uri, null)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
val cur = com.kazeia.dist.DistConfigStore.get(ctx)
|
||||||
|
val base = values.getAsString("webdav_base") ?: cur.base
|
||||||
|
val user = values.getAsString("webdav_user") ?: cur.user
|
||||||
|
// Mot de passe : absent ou vide → inchangé ; non-vide → nouveau.
|
||||||
|
val pass = values.getAsString("webdav_pass")?.takeIf { it.isNotEmpty() }
|
||||||
|
com.kazeia.dist.DistConfigStore.update(ctx, base, user, pass)
|
||||||
|
ctx.contentResolver.notifyChange(uri, null)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateConfig(uri: Uri, values: ContentValues): Int {
|
||||||
|
val ctx = context ?: return 0
|
||||||
|
val store = com.kazeia.config.ConfigStore.get(ctx)
|
||||||
|
val cur = store.current()
|
||||||
|
val newCfg = com.kazeia.config.ConfigStore.RuntimeConfig(
|
||||||
|
cascadeEnabled = if (values.containsKey("cascade_enabled"))
|
||||||
|
values.getAsBoolean("cascade_enabled") else cur.cascadeEnabled,
|
||||||
|
ttsEnabled = if (values.containsKey("tts_enabled"))
|
||||||
|
values.getAsBoolean("tts_enabled") else cur.ttsEnabled,
|
||||||
|
sttEngine = values.getAsString("stt_engine") ?: cur.sttEngine,
|
||||||
|
llmEngine = values.getAsString("llm_engine") ?: cur.llmEngine,
|
||||||
|
ttsEngine = values.getAsString("tts_engine") ?: cur.ttsEngine,
|
||||||
|
speaker = com.kazeia.config.ConfigStore.ModelConfig(
|
||||||
|
modelId = values.getAsString("speaker_model_id") ?: cur.speaker.modelId,
|
||||||
|
systemPrompt = values.getAsString("speaker_system_prompt")
|
||||||
|
?: cur.speaker.systemPrompt,
|
||||||
|
temperature = values.getAsFloat("speaker_temperature")
|
||||||
|
?: cur.speaker.temperature
|
||||||
|
),
|
||||||
|
thinker = com.kazeia.config.ConfigStore.ModelConfig(
|
||||||
|
modelId = values.getAsString("thinker_model_id") ?: cur.thinker.modelId,
|
||||||
|
systemPrompt = values.getAsString("thinker_system_prompt")
|
||||||
|
?: cur.thinker.systemPrompt,
|
||||||
|
temperature = values.getAsFloat("thinker_temperature")
|
||||||
|
?: cur.thinker.temperature
|
||||||
|
)
|
||||||
|
)
|
||||||
|
store.save(newCfg)
|
||||||
|
ctx.contentResolver.notifyChange(uri, null)
|
||||||
|
ctx.sendBroadcast(android.content.Intent(ACTION_RELOAD_CONFIG))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun upsertProfile(values: ContentValues): Int {
|
||||||
|
val ctx = context ?: return 0
|
||||||
|
val store = com.kazeia.profiles.ProfileStore.get(ctx)
|
||||||
|
val id = values.getAsString("id") ?: com.kazeia.profiles.ProfileStore.newProfileId()
|
||||||
|
val existing = store.byId(id)
|
||||||
|
val displayName = values.getAsString("display_name") ?: existing?.displayName ?: id
|
||||||
|
val pinRaw = values.getAsString("pin") // PIN en clair pour set/clear
|
||||||
|
val newPinHash = when {
|
||||||
|
pinRaw == null -> existing?.pinHash // pas de changement
|
||||||
|
pinRaw.isEmpty() -> null // efface PIN
|
||||||
|
else -> com.kazeia.profiles.Profile.hashPin(pinRaw)
|
||||||
|
}
|
||||||
|
val p = com.kazeia.profiles.Profile(
|
||||||
|
id = id,
|
||||||
|
displayName = displayName,
|
||||||
|
avatarColor = values.getAsInteger("avatar_color")
|
||||||
|
?: existing?.avatarColor
|
||||||
|
?: com.kazeia.profiles.Profile.defaultColorFor(id),
|
||||||
|
voiceId = values.getAsString("voice_id") ?: existing?.voiceId,
|
||||||
|
systemPromptOverride = values.getAsString("system_prompt_override")
|
||||||
|
?: existing?.systemPromptOverride,
|
||||||
|
pinHash = newPinHash,
|
||||||
|
createdAt = existing?.createdAt ?: System.currentTimeMillis(),
|
||||||
|
lastUsedAt = existing?.lastUsedAt ?: System.currentTimeMillis(),
|
||||||
|
notes = values.getAsString("notes") ?: existing?.notes,
|
||||||
|
isDefault = values.getAsBoolean("is_default") ?: existing?.isDefault ?: false
|
||||||
|
)
|
||||||
|
store.upsert(p)
|
||||||
|
ctx.sendBroadcast(android.content.Intent(ACTION_RELOAD_PROFILES))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setActiveProfile(profileId: String?): Int {
|
||||||
|
val ctx = context ?: return 0
|
||||||
|
com.kazeia.profiles.ProfileStore.get(ctx).setActiveProfile(profileId)
|
||||||
|
ctx.sendBroadcast(android.content.Intent(ACTION_RELOAD_PROFILES))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun voicesCursor(): Cursor {
|
||||||
|
val cols = arrayOf(
|
||||||
|
"id", "name", "state",
|
||||||
|
"wav_exists", "prefix_exists", "suffix_exists",
|
||||||
|
"wav_size_bytes", "wav_duration_seconds", "wav_path", "created_at"
|
||||||
|
)
|
||||||
|
val cursor = MatrixCursor(cols)
|
||||||
|
|
||||||
|
// Set des ids = union (wav basenames) ∪ (prefix bin basenames)
|
||||||
|
val wavDir = File(VOICE_WAV_DIR)
|
||||||
|
val embDir = File(VOICE_EMB_DIR)
|
||||||
|
val wavIds = (wavDir.listFiles { f -> f.name.endsWith(".wav") } ?: emptyArray())
|
||||||
|
.associateBy { it.nameWithoutExtension.lowercase() }
|
||||||
|
val prefixIds = (embDir.listFiles { f -> f.name.endsWith(PREFIX_SUFFIX) } ?: emptyArray())
|
||||||
|
.associateBy { it.name.removeSuffix(PREFIX_SUFFIX).lowercase() }
|
||||||
|
val ids = (wavIds.keys + prefixIds.keys).sorted()
|
||||||
|
|
||||||
|
for (id in ids) {
|
||||||
|
val wav = wavIds[id]
|
||||||
|
val prefix = File(VOICE_EMB_DIR, "${id}$PREFIX_SUFFIX")
|
||||||
|
val suffix = File(VOICE_EMB_DIR, "${id}$SUFFIX_SUFFIX")
|
||||||
|
val state = when {
|
||||||
|
prefix.exists() && suffix.exists() -> "ready"
|
||||||
|
wav != null -> "recorded"
|
||||||
|
else -> "error"
|
||||||
|
}
|
||||||
|
val durSec = wav?.let { wavDurationSeconds(it) } ?: 0f
|
||||||
|
cursor.addRow(arrayOf(
|
||||||
|
id,
|
||||||
|
id.replaceFirstChar { it.uppercase() },
|
||||||
|
state,
|
||||||
|
if (wav != null) 1 else 0,
|
||||||
|
if (prefix.exists()) 1 else 0,
|
||||||
|
if (suffix.exists()) 1 else 0,
|
||||||
|
wav?.length() ?: 0L,
|
||||||
|
durSec,
|
||||||
|
wav?.absolutePath ?: "",
|
||||||
|
wav?.lastModified() ?: prefix.lastModified()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Décode l'en-tête WAV pour estimer la durée. Très tolérant. */
|
||||||
|
private fun wavDurationSeconds(f: File): Float = try {
|
||||||
|
java.io.RandomAccessFile(f, "r").use { raf ->
|
||||||
|
// Saute le RIFF header (12 bytes), parcourt les chunks
|
||||||
|
raf.seek(12)
|
||||||
|
var sampleRate = 0
|
||||||
|
var channels = 0
|
||||||
|
var bitsPerSample = 0
|
||||||
|
var dataSize = 0L
|
||||||
|
while (raf.filePointer < raf.length() - 8) {
|
||||||
|
val chunkId = ByteArray(4).also { raf.readFully(it) }
|
||||||
|
val sizeBytes = ByteArray(4).also { raf.readFully(it) }
|
||||||
|
val size = ((sizeBytes[0].toInt() and 0xff)
|
||||||
|
or ((sizeBytes[1].toInt() and 0xff) shl 8)
|
||||||
|
or ((sizeBytes[2].toInt() and 0xff) shl 16)
|
||||||
|
or ((sizeBytes[3].toInt() and 0xff) shl 24)).toLong() and 0xffffffffL
|
||||||
|
val id = String(chunkId)
|
||||||
|
if (id == "fmt ") {
|
||||||
|
val pos = raf.filePointer
|
||||||
|
raf.skipBytes(2) // audio format
|
||||||
|
channels = raf.readUnsignedByte() or (raf.readUnsignedByte() shl 8)
|
||||||
|
sampleRate = raf.readUnsignedByte() or
|
||||||
|
(raf.readUnsignedByte() shl 8) or
|
||||||
|
(raf.readUnsignedByte() shl 16) or
|
||||||
|
(raf.readUnsignedByte() shl 24)
|
||||||
|
raf.seek(pos + size)
|
||||||
|
} else if (id == "data") {
|
||||||
|
dataSize = size
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
raf.skipBytes(size.toInt())
|
||||||
|
}
|
||||||
|
if (raf.filePointer % 2L != 0L) raf.skipBytes(1)
|
||||||
|
}
|
||||||
|
// bitsPerSample : lu rapidement par estimation sinon 16 par défaut
|
||||||
|
if (sampleRate <= 0 || channels <= 0) 0f
|
||||||
|
else {
|
||||||
|
// bytes per sample = 2 par défaut
|
||||||
|
val bytesPerSample = 2
|
||||||
|
val totalSamples = dataSize / (channels * bytesPerSample)
|
||||||
|
totalSamples.toFloat() / sampleRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { 0f }
|
||||||
|
|
||||||
|
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
|
||||||
|
val matched = matcher.match(uri)
|
||||||
|
android.util.Log.i("KazeiaProvider", "delete uri=$uri matched=$matched lastSeg=${uri.lastPathSegment}")
|
||||||
|
when (matched) {
|
||||||
|
CODE_VOICE_ITEM -> return deleteVoiceFiles(uri.lastPathSegment?.lowercase() ?: return 0)
|
||||||
|
CODE_PROFILE_ITEM -> return deleteProfile(uri.lastPathSegment ?: return 0)
|
||||||
|
CODE_CONVERSATION_PROFILE -> return deleteConversationsForProfile(
|
||||||
|
uri.lastPathSegment ?: return 0
|
||||||
|
)
|
||||||
|
CODE_CONVERSATION_SESSION -> return deleteConversationSession(
|
||||||
|
uri.lastPathSegment ?: return 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteVoiceFiles(id: String): Int {
|
||||||
|
var removed = 0
|
||||||
|
listOf(
|
||||||
|
File(VOICE_EMB_DIR, "${id}$PREFIX_SUFFIX"),
|
||||||
|
File(VOICE_EMB_DIR, "${id}$SUFFIX_SUFFIX"),
|
||||||
|
File(VOICE_WAV_DIR, "${id}.wav")
|
||||||
|
).forEach { f ->
|
||||||
|
val exists = f.exists()
|
||||||
|
val ok = if (exists) f.delete() else false
|
||||||
|
android.util.Log.i("KazeiaProvider", " ${f.absolutePath} exists=$exists deleted=$ok")
|
||||||
|
if (exists && ok) removed++
|
||||||
|
}
|
||||||
|
context?.contentResolver?.notifyChange(
|
||||||
|
Uri.parse("content://$AUTHORITY/$PATH_VOICES"), null
|
||||||
|
)
|
||||||
|
context?.sendBroadcast(android.content.Intent("com.kazeia.action.RELOAD_VOICES"))
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteProfile(id: String): Int {
|
||||||
|
val ctx = context ?: return 0
|
||||||
|
// Supprime aussi tout l'historique de conversation associé
|
||||||
|
try {
|
||||||
|
com.kazeia.profiles.ConversationDao(
|
||||||
|
com.kazeia.profiles.ConversationDb.get(ctx)
|
||||||
|
).deleteProfile(id)
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
val ok = com.kazeia.profiles.ProfileStore.get(ctx).delete(id)
|
||||||
|
if (ok) ctx.sendBroadcast(android.content.Intent(ACTION_RELOAD_PROFILES))
|
||||||
|
return if (ok) 1 else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteConversationsForProfile(id: String): Int {
|
||||||
|
val ctx = context ?: return 0
|
||||||
|
return try {
|
||||||
|
com.kazeia.profiles.ConversationDao(
|
||||||
|
com.kazeia.profiles.ConversationDb.get(ctx)
|
||||||
|
).deleteProfile(id)
|
||||||
|
} catch (_: Exception) { 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteConversationSession(id: String): Int {
|
||||||
|
val ctx = context ?: return 0
|
||||||
|
return try {
|
||||||
|
com.kazeia.profiles.ConversationDao(
|
||||||
|
com.kazeia.profiles.ConversationDb.get(ctx)
|
||||||
|
).deleteSession(id)
|
||||||
|
} catch (_: Exception) { 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stateCursor(): Cursor {
|
||||||
|
val cols = arrayOf(
|
||||||
|
"pid", "uptime_seconds", "pss_mb", "ion_mb",
|
||||||
|
"anon_pages_mb", "mem_available_mb"
|
||||||
|
)
|
||||||
|
val cursor = MatrixCursor(cols)
|
||||||
|
|
||||||
|
val info = Debug.MemoryInfo()
|
||||||
|
Debug.getMemoryInfo(info)
|
||||||
|
val pssMb = info.totalPss / 1024
|
||||||
|
|
||||||
|
val ionMb = readMeminfoKb("IonTotalUsed") / 1024
|
||||||
|
val anonMb = readMeminfoKb("AnonPages") / 1024
|
||||||
|
val availMb = readMeminfoKb("MemAvailable") / 1024
|
||||||
|
|
||||||
|
val uptimeSec = (System.currentTimeMillis() - TelemetryHolder.processStartedAt) / 1000
|
||||||
|
|
||||||
|
cursor.addRow(arrayOf(
|
||||||
|
Process.myPid(),
|
||||||
|
uptimeSec,
|
||||||
|
pssMb,
|
||||||
|
ionMb,
|
||||||
|
anonMb,
|
||||||
|
availMb
|
||||||
|
))
|
||||||
|
return cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun turnsCursor(): Cursor {
|
||||||
|
val cols = arrayOf(
|
||||||
|
"timestamp", "input_kind", "status", "total_ms",
|
||||||
|
"stt_ms", "llm_ttft_ms", "llm_gen_ms", "llm_tokens",
|
||||||
|
"time_to_first_audio_ms", "audio_playback_ms"
|
||||||
|
)
|
||||||
|
val cursor = MatrixCursor(cols)
|
||||||
|
for (t in TelemetryHolder.recentTurns()) {
|
||||||
|
cursor.addRow(arrayOf(
|
||||||
|
t.timestamp, t.inputKind, t.status, t.totalMs,
|
||||||
|
t.sttDurationMs, t.llmTtftMs, t.llmGenerationMs, t.llmTokens,
|
||||||
|
t.timeToFirstAudioMs, t.audioPlaybackMs
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun crashesCursor(): Cursor {
|
||||||
|
val cols = arrayOf("timestamp", "component", "message")
|
||||||
|
val cursor = MatrixCursor(cols)
|
||||||
|
for (c in TelemetryHolder.recentCrashes()) {
|
||||||
|
cursor.addRow(arrayOf(c.timestamp, c.component, c.message))
|
||||||
|
}
|
||||||
|
return cursor
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readMeminfoKb(key: String): Long = try {
|
||||||
|
File("/proc/meminfo").bufferedReader().useLines { lines ->
|
||||||
|
lines.firstOrNull { it.startsWith("$key:") }
|
||||||
|
?.split(Regex("\\s+"))?.get(1)?.toLong() ?: 0L
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { 0L }
|
||||||
|
|
||||||
|
override fun getType(uri: Uri): String? = when (matcher.match(uri)) {
|
||||||
|
CODE_STATE -> "vnd.android.cursor.item/vnd.com.kazeia.state"
|
||||||
|
CODE_TURNS -> "vnd.android.cursor.dir/vnd.com.kazeia.turns"
|
||||||
|
CODE_CRASHES -> "vnd.android.cursor.dir/vnd.com.kazeia.crashes"
|
||||||
|
CODE_VOICES -> "vnd.android.cursor.dir/vnd.com.kazeia.voices"
|
||||||
|
CODE_VOICE_ITEM -> "vnd.android.cursor.item/vnd.com.kazeia.voice"
|
||||||
|
CODE_CONFIG -> "vnd.android.cursor.item/vnd.com.kazeia.config"
|
||||||
|
CODE_MODELS -> "vnd.android.cursor.dir/vnd.com.kazeia.models"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue