skainet-android-integration — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited skainet-android-integration (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Android-specific concerns for SKaiNET consumers: how to load model files from assets/, which ABIs to ship, where to call forward(...) so the UI stays responsive, and how to react to memory pressure.
.gguf / .safetensors / .onnx from src/main/assets/ (or a downloaded cache file).ViewModel, Service, WorkManager, or Composable.androidTarget, minSdk, ndk.abiFilters).kmp skill).skainet-consumer-setup.skainet-inference covers it without Android specifics.skainet-java-consumer for the Maven deps; threading still applies here.minSdk = 24, compileSdk = 36. Lower is unsupported; higher is fine.ndk.abiFilters accordingly to keep APK size down.java.lang.foreign, so the JVM's FFM kernel provider can never run on Android; without the JNI AAR, inference runs on the scalar reference floor. The AAR ships NDK-built kernel .sos (arm64-v8a + x86_64, 16 KB-page-aligned for Android 15+; two NEON tiers selected at load time from /proc/cpuinfo) and a JniKernelProvider that the Android ops factory discovers via ServiceLoader automatically — no bootstrap call. Consumer R8 rules keeping the ServiceLoader entry are shipped in the AAR.viewModelScope.launch { withContext(Dispatchers.Default) { ... } } from a ViewModel, or lifecycleScope.launch { ... } from an Activity/Fragment. The CPU backend can take hundreds of ms to seconds; the main thread has a 16 ms budget.context.cacheDir / context.filesDir once, then open it with openRandomAccessSource(path) or AndroidGguf.loader(path).largeHeap) no matter the device RAM. AndroidGguf.loader(path) defaults to WeightForm(residency = WeightResidency.MAPPED): weights are served from file-backed pages the OS pages in on demand and evicts under pressure — they never count against the heap cap. Check fit before loading with AndroidGguf.plan(path, ctx) + AndroidGguf.deviceMemory(context).level >= TRIM_MEMORY_BACKGROUND, drop the Module (and any cached tensors / KV cache); rebuild on next access. Mapped weight pages are evictable by the OS on their own, but heap-staged tensors are not.context.filesDir (or context.cacheDir) on first launch and load from there subsequently. Loading from assets/ repeatedly extracts on every cold start — and mmap needs a real file anyway.skainet-lang-core + skainet-backend-cpu + skainet-backend-jni-cpu (NEON kernels; see skainet-consumer-setup). For loaders, add skainet-io-core + the format-specific artifact.ExecutionContext and Module — usually a singleton tied to Application lifetime, or a Hilt/Koin app-scope binding.AndroidGguf.plan / fitOn).Dispatchers.Default.onTrimMemory to release the Module under pressure.Application-scoped model holder:
class SkainetApp : Application() {
private val ctx = DirectCpuExecutionContext.create()
private val modelMutex = Mutex()
private var module: Module<FP32, Float>? = null
fun executionContext(): ExecutionContext = ctx
suspend fun model(): Module<FP32, Float> = modelMutex.withLock {
module ?: loadModel().also { module = it }
}
private suspend fun loadModel(): Module<FP32, Float> = withContext(Dispatchers.IO) {
// see "Loading from assets" below
}
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
if (level >= TRIM_MEMORY_BACKGROUND) {
module = null // GC reclaims; reload next forward
}
}
}Loading a GGUF from assets via cacheDir (random-access loaders need a real file):
import sk.ainet.io.gguf.AndroidGguf
import sk.ainet.lang.types.FP32
private suspend fun copyAssetIfNeeded(context: Context, name: String): java.io.File =
withContext(Dispatchers.IO) {
val target = java.io.File(context.cacheDir, name)
if (!target.exists()) {
context.assets.open(name).use { input ->
target.outputStream().use { output -> input.copyTo(output) }
}
}
target
}
private suspend fun loadGguf(context: Context, ctx: ExecutionContext) = withContext(Dispatchers.IO) {
val file = copyAssetIfNeeded(context, "model-q4.gguf")
// Mapped residency by default: tensor payloads come from mmap'd pages, not the ART heap.
val loader = AndroidGguf.loader(file.absolutePath)
loader.load(ctx, FP32::class) { name, tensor ->
// bind 'name' -> 'tensor' into your Module's parameters
}
}AndroidGguf (in skainet-io-gguf, androidMain) also offers the pre-load fit check: AndroidGguf.plan(path, ctx) prices the model from the GGUF header alone (a few KB of reads), and AndroidGguf.deviceMemory(context) reports RAM + the ART heap cap — combine with fitOn to refuse a model before the OOM kill.
Inference in a ViewModel:
class ClassifyViewModel(
private val app: SkainetApp,
private val pre: Transform<Bitmap, Tensor<FP32, Float>>
) : AndroidViewModel(app) {
private val _result = MutableStateFlow<List<Float>?>(null)
val result: StateFlow<List<Float>?> = _result
fun classify(bitmap: Bitmap) {
viewModelScope.launch {
val tensor = pre(bitmap)
val module = app.model()
val out = withContext(Dispatchers.Default) {
module.forward(tensor, app.executionContext())
}
_result.value = out.toFloatList()
}
}
}viewModelScope ensures the launch is cancelled if the ViewModel is destroyed mid-inference. Dispatchers.Default is the right pool for CPU-bound forward passes.
Asset-resident SafeTensors via cache-first pattern (same idea as GGUF):
import sk.ainet.io.openRandomAccessSource
import sk.ainet.io.weights.WeightMapper
import sk.ainet.io.weights.WeightTensor
suspend fun loadSafeTensors(context: Context, ctx: ExecutionContext, module: Module<FP32, Float>) {
val file = copyAssetIfNeeded(context, "weights.safetensors")
val loader = SafeTensorsParametersLoader(
sourceProvider = { openRandomAccessSource(file.absolutePath)!! }
)
val loaded = mutableListOf<WeightTensor<FP32, Float>>()
loader.load(ctx, FP32::class) { name, tensor ->
loaded += WeightTensor(name, tensor.shape.dimensions.toList(), tensor)
}
WeightMapper.applyWeights(module, loaded) // name-based binding into the Module tree
}`build.gradle.kts` (consumer Android module) — ABI filters:
android {
namespace = "com.example.skainetapp"
compileSdk = 36
defaultConfig {
applicationId = "com.example.skainetapp"
minSdk = 24
targetSdk = 36
ndk { abiFilters += listOf("arm64-v8a") } // primary
// add "armeabi-v7a" only if you must support 32-bit ARM devices
}
}
dependencies {
implementation(platform(libs.skainet.bom))
implementation(libs.skainet.lang.core)
implementation(libs.skainet.backend.cpu)
implementation(libs.skainet.backend.jni.cpu) // AAR: NEON kernels via JNI; ServiceLoader auto-discovery
implementation(libs.skainet.io.core)
implementation(libs.skainet.io.gguf)
}../skainet-consumer-setup/SKILL.md.../skainet-inference/SKILL.md.../skainet-model-loading/SKILL.md.Bitmap — ../skainet-data-dsl/SKILL.md for the tensor side; preprocessing chains via skainet-data-transform.// WRONG — loading on every Activity onResume
override fun onResume() {
super.onResume()
val module = sequential<FP32, Float> { /* ... */ } // expensive each time
val ctx = DirectCpuExecutionContext.create()
}// RIGHT — Application-scoped singleton
val app = applicationContext as SkainetApp
val module = app.model()
val ctx = app.executionContext()// WRONG — assets opened as a File path (path doesn't exist inside the APK)
val loader = AndroidGguf.loader("file:///android_asset/model.gguf")// RIGHT — copy to cacheDir, load from there (mapped weights need a real file)
val file = copyAssetIfNeeded(context, "model.gguf")
val loader = AndroidGguf.loader(file.absolutePath)// WRONG — forward on the main thread / from a Composable directly
@Composable
fun Result(input: Tensor<FP32, Float>) {
val out = model.forward(input, ctx) // blocks UI for hundreds of ms
Text(out.toString())
}// RIGHT — collect from a StateFlow / Flow that runs forward on Default
@Composable
fun Result(viewModel: ClassifyViewModel) {
val r by viewModel.result.collectAsState()
Text(r?.toString() ?: "loading…")
}// WRONG — shipping every ABI inflates APK
android.defaultConfig { /* no abiFilters → all ABIs included */ }// RIGHT — only ARM64 (and optionally ARM32)
android.defaultConfig { ndk { abiFilters += "arm64-v8a" } }// WRONG — ignoring memory pressure
override fun onTrimMemory(level: Int) { super.onTrimMemory(level) }// RIGHT — drop the Module under pressure
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
if (level >= TRIM_MEMORY_BACKGROUND) module = null
}references/asset-loading.md — patterns for assets/ vs. downloaded files vs. APK-extracted caches; loader compatibility.references/lifecycle-and-threading.md — viewModelScope, lifecycleScope, WorkManager, Service patterns; onTrimMemory reference table; ABI filter recommendations.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.