diff --git a/examples/android/TERMUX.md b/examples/android/TERMUX.md new file mode 100644 index 000000000..2213e0970 --- /dev/null +++ b/examples/android/TERMUX.md @@ -0,0 +1,159 @@ +# Building on Termux (aarch64) + +Termux provides a full Linux environment on Android — you can clone, edit, +compile, and sideload APKs entirely on-device without a desktop machine. +This guide covers the extra steps needed to build the GenieX Android demo on +aarch64 Termux, where the standard Android build toolchain has pitfalls. + +## Why build on Termux? + +- **No desktop required** — develop and build directly on your phone or tablet. +- **Faster iteration** — edit a Kotlin file, `gradlew assembleDebug`, install + the APK, run it. All on the same device. +- **Keep everything on-device** — models, SDK, and source all live under one + filesystem; no adb push/pull dance. + +## Prerequisites + +### Packages + +``` +pkg install openjdk-21 gradle aapt2 +``` + +You also need `git` (usually pre-installed) and `python3` for scripting. + +### Android SDK + +Install the Android SDK at `$HOME/android-sdk`. The easiest path is +`cmdline-tools` + `sdkmanager`: + +```bash +mkdir -p ~/android-sdk/cmdline-tools +# Download latest command-line tools from developer.android.com +# Extract into ~/android-sdk/cmdline-tools/latest/ +export ANDROID_HOME=$HOME/android-sdk +~/android-sdk/cmdline-tools/latest/bin/sdkmanager --install \ + "platforms;android-34" \ + "platforms;android-35" \ + "platforms;android-36" \ + "build-tools;34.0.0" \ + "build-tools;34.0.4" \ + "build-tools;35.0.1" \ + "build-tools;36.0.0" +``` + +Exact contents of `$HOME/android-sdk/build-tools/` after setup: + +``` +34.0.0/ 34.0.4/ 35.0.0/ 35.0.1/ 36.0.0/ +``` + +And `$HOME/android-sdk/platforms/`: + +``` +android-33/ android-34/ android-35/ android-36/ +``` + +## Workarounds + +Three things must be changed for the build to succeed on aarch64 Termux. + +### 1. local.properties + +Create `local.properties` pointing at the SDK: + +```properties +sdk.dir=/data/data/com.termux/files/home/android-sdk +``` + +> **Never commit this file.** It is already in `.gitignore`. + +### 2. aapt2 override (gradle.properties) + +The aarch64 aapt2 binary shipped with build-tools older than 34.x **cannot +load platform JARs >= 35**. AGP also bundles an x86_64 aapt2 that won't run +on aarch64. The fix is to tell Gradle to use the build-tools 34.0.4 aapt2, +which is aarch64-native: + +```properties +android.aapt2FromMavenOverride=/data/data/com.termux/files/home/android-sdk/build-tools/34.0.4/aapt2 +``` + +The system `aapt2` from `pkg install aapt2` also works and is sometimes +newer — if your build fails during resource linking, try swapping. + +### 3. compileSdk must be 34 + +Because of the aapt2 limitation above, `compileSdk` and `targetSdk` in +`build.gradle` must be 34: + +```groovy +ANDROID_COMPILE_API = 34 +ANDROID_TARGET_API = 34 +``` + +> With compileSdk 35 or higher, processDebugResources fails with: +> `AAPT: error: failed to load include path .../android-35/android.jar` + +### 4. NDK version + +The `build.gradle` declares `ANDROID_NDK_VERSION = '29.0.14206865'`, but +the example has **no native code** — AGP skips the NDK entirely. If you +later add C/C++ code, install the matching NDK: + +```bash +sdkmanager --install "ndk;29.0.14206865" +``` + +## Build + +```bash +cd examples/android +ANDROID_HOME=$HOME/android-sdk ./gradlew assembleDebug +``` + +First build downloads Gradle 9.1.0 and all dependencies — ~2-3 minutes on +a warm cache, longer on first run. + +APK output: `build/outputs/apk/debug/app-debug.apk` + +### Incremental rebuilds + +```bash +./gradlew assembleDebug +``` + +### Clean rebuild (if you switch aapt2 or compileSdk) + +```bash +rm -rf ~/.gradle/caches/*/transforms/*aapt* +./gradlew assembleDebug --no-build-cache +``` + +## Troubleshooting + +| Symptom | Fix | +|---|---| +| `AAPT: error: failed to load include path .../android-35/android.jar` | Set compileSdk to 34 in `build.gradle` | +| `java.lang.UnsatisfiedLinkError` for aapt2 | Check `android.aapt2FromMavenOverride` points to the aarch64 aapt2 | +| Gradle can't find SDK | Create `local.properties` with correct `sdk.dir` | +| NDK not found | Remove or update `ANDROID_NDK_VERSION` in `build.gradle` (no native code = not needed) | +| Out of memory during dex | Reduce `-Xmx` in `gradle.properties` or close other apps | + +## Installing the APK + +```bash +# If you have the APK on the same device: +termux-open build/outputs/apk/debug/app-debug.apk + +# Or from adb (if you have a second device / PC): +adb install build/outputs/apk/debug/app-debug.apk +``` + +## Reference + +- [browseragent TERMUX.md](https://github.com/sigsegv0x0b/browseragent) — + similar aarch64 build setup, originally solved many of these issues. +- [Android SDK CLI tools](https://developer.android.com/studio#command-line-tools-only) — + official download page. diff --git a/examples/android/build.gradle b/examples/android/build.gradle index 4f8c2da16..5f0cf3f85 100644 --- a/examples/android/build.gradle +++ b/examples/android/build.gradle @@ -9,9 +9,9 @@ plugins { // can override these when this folder is consumed via ai-hub-apps. ext { ANDROID_NDK_VERSION = '29.0.14206865' - ANDROID_COMPILE_API = 35 + ANDROID_COMPILE_API = 34 ANDROID_MIN_API = 27 - ANDROID_TARGET_API = 35 + ANDROID_TARGET_API = 34 } if (file('_shared/android/common.gradle').exists()) { apply from: '_shared/android/common.gradle' diff --git a/examples/android/gradle.properties b/examples/android/gradle.properties index d97190a83..4fe0f4074 100644 --- a/examples/android/gradle.properties +++ b/examples/android/gradle.properties @@ -1,3 +1,4 @@ org.gradle.jvmargs=-Xmx4G -Dfile.encoding=UTF-8 android.useAndroidX=true android.nonTransitiveRClass=true +android.aapt2FromMavenOverride=/data/data/com.termux/files/home/android-sdk/build-tools/34.0.4/aapt2 diff --git a/examples/android/src/main/assets/model_list.json b/examples/android/src/main/assets/model_list.json index ad499a899..05df82bc9 100644 --- a/examples/android/src/main/assets/model_list.json +++ b/examples/android/src/main/assets/model_list.json @@ -6,7 +6,8 @@ "quant": "Q4_0", "hub": "HUGGINGFACE", "type": "chat", - "runtime": "llama_cpp" + "runtime": "llama_cpp", + "size": 382156480 }, { "id": "Qwen3-1.7B-GGUF", @@ -15,7 +16,8 @@ "quant": "Q4_0", "hub": "HUGGINGFACE", "type": "chat", - "runtime": "llama_cpp" + "runtime": "llama_cpp", + "size": 1056782912 }, { "id": "Llama-3.2-3B-Instruct-GGUF", @@ -24,7 +26,8 @@ "quant": "Q4_K_M", "hub": "HUGGINGFACE", "type": "chat", - "runtime": "llama_cpp" + "runtime": "llama_cpp", + "size": 2019377600 }, { "id": "Ministral-3-3B-Instruct-2512-GGUF", @@ -33,7 +36,8 @@ "quant": "Q4_0", "hub": "HUGGINGFACE", "type": "chat", - "runtime": "llama_cpp" + "runtime": "llama_cpp", + "size": 2046375200 }, { "id": "granite-4.0-micro-GGUF", @@ -42,7 +46,8 @@ "quant": "Q4_0", "hub": "HUGGINGFACE", "type": "chat", - "runtime": "llama_cpp" + "runtime": "llama_cpp", + "size": 1991163648 }, { "id": "Phi-4-mini-instruct-GGUF", @@ -51,7 +56,8 @@ "quant": "Q4_K_M", "hub": "HUGGINGFACE", "type": "chat", - "runtime": "llama_cpp" + "runtime": "llama_cpp", + "size": 2491874272 }, { "_note": "Gemma 4 E2B is actually a VLM, but VLM support is not wired up for llama.cpp in the Android demo yet — exposed as type=chat so the text path still works.", @@ -61,7 +67,8 @@ "quant": "Q4_0", "hub": "HUGGINGFACE", "type": "chat", - "runtime": "llama_cpp" + "runtime": "llama_cpp", + "size": 3041376384 }, { "id": "Qwen3.5-0.8B-GGUF", @@ -70,7 +77,8 @@ "quant": "Q4_0", "hub": "HUGGINGFACE", "type": "vlm", - "runtime": "llama_cpp" + "runtime": "llama_cpp", + "size": 507154688 }, { "id": "Qwen3.5-2B-GGUF", @@ -79,7 +87,8 @@ "quant": "Q4_0", "hub": "HUGGINGFACE", "type": "vlm", - "runtime": "llama_cpp" + "runtime": "llama_cpp", + "size": 1214873856 }, { "id": "Qwen3-VL-2B-Instruct-GGUF", @@ -88,7 +97,8 @@ "quant": "Q4_0", "hub": "HUGGINGFACE", "type": "vlm", - "runtime": "llama_cpp" + "runtime": "llama_cpp", + "size": 1056784064 }, { "id": "Qwen2.5-VL-7B-Instruct-GGUF", @@ -97,7 +107,8 @@ "quant": "Q4_0", "hub": "HUGGINGFACE", "type": "vlm", - "runtime": "llama_cpp" + "runtime": "llama_cpp", + "size": 4444119936 }, { "id": "gpt-oss-20b-GGUF", @@ -106,7 +117,8 @@ "quant": "Q4_0", "hub": "HUGGINGFACE", "type": "chat", - "runtime": "llama_cpp" + "runtime": "llama_cpp", + "size": 11501495488 }, { "id": "Qwen3-4B-Instruct-2507", @@ -115,7 +127,8 @@ "hub": "AUTO", "chipset": "SM8750", "type": "chat", - "runtime": "qairt" + "runtime": "qairt", + "size": 2532949886 }, { "id": "Qwen2.5-VL-7B-Instruct", @@ -124,6 +137,7 @@ "hub": "AUTO", "chipset": "SM8750", "type": "vlm", - "runtime": "qairt" + "runtime": "qairt", + "size": 4933782555 } ] diff --git a/examples/android/src/main/java/com/geniex/demo/MainActivity.kt b/examples/android/src/main/java/com/geniex/demo/MainActivity.kt index aa53db0a6..8dc0c7d55 100644 --- a/examples/android/src/main/java/com/geniex/demo/MainActivity.kt +++ b/examples/android/src/main/java/com/geniex/demo/MainActivity.kt @@ -37,6 +37,7 @@ import android.widget.ProgressBar import android.widget.SimpleAdapter import android.widget.Spinner import android.widget.TextView +import android.view.ViewGroup import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.app.ActivityCompat @@ -121,6 +122,7 @@ class MainActivity : FragmentActivity() { private var enableThinking = false private var isGenerating = false + private val cachedModelNames = mutableSetOf() private val savedImageFiles = mutableListOf() private val messages = arrayListOf() @@ -135,6 +137,10 @@ class MainActivity : FragmentActivity() { initData() initView() setListeners() + modelScope.launch { + cachedModelNames.addAll(ModelManagerWrapper.list()) + runOnUiThread { updateSpinnerAdapter() } + } } private fun resetLoadState() { @@ -142,6 +148,26 @@ class MainActivity : FragmentActivity() { isLoadVlmModel = false } + private fun updateSpinnerAdapter() { + val names = cachedModelNames + spModelList.adapter = object : SimpleAdapter(this, modelList.map { + val map = mutableMapOf() + map["displayName"] = it.displayName + map["sizeGb"] = it.size?.let { b -> "%.2f GB".format(b / 1e9) } ?: "" + map + }, R.layout.item_model, arrayOf("displayName", "sizeGb"), intArrayOf(R.id.tv_model_id, R.id.tv_model_size)) { + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + return super.getView(position, convertView, parent).also { v -> + v.findViewById(R.id.tv_downloaded).visibility = + if (position < modelList.size && modelList[position].modelName in names) View.VISIBLE else View.GONE + } + } + override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View { + return getView(position, convertView, parent) + } + } + } + private fun initView() { adapter = ChatAdapter(messages) binding.rvChat.adapter = adapter @@ -150,13 +176,7 @@ class MainActivity : FragmentActivity() { tvDownloadProgress = findViewById(R.id.tv_download_progress) pbDownloading = findViewById(R.id.pb_downloading) spModelList = findViewById(R.id.sp_model_list) - spModelList.adapter = object : SimpleAdapter(this, modelList.map { - val map = mutableMapOf() - map["displayName"] = it.displayName - map - }, R.layout.item_model, arrayOf("displayName"), intArrayOf(R.id.tv_model_id)) { - - } + updateSpinnerAdapter() spModelList.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { override fun onItemSelected( parent: AdapterView<*>?, view: View?, position: Int, id: Long @@ -491,18 +511,29 @@ Note: You must use the campaign_investigation function whenever a customer asks return@launch } + var lastProgressTime = System.currentTimeMillis() + var lastDoneBytes = 0L + ModelManagerWrapper.pullFlow(input).collect { event -> when (event) { is ModelManagerWrapper.PullEvent.Progress -> { val total = event.files.sumOf { if (it.total_bytes > 0) it.total_bytes else 0L } val done = event.files.sumOf { it.downloaded_bytes } val percent = if (total > 0) ((done * 100) / total).toInt() else 0 - runOnUiThread { tvDownloadProgress.text = "$percent%" } + val now = System.currentTimeMillis() + val dt = (now - lastProgressTime) / 1000.0 + val speedMb = if (dt > 0) (done - lastDoneBytes) / dt / 1_000_000.0 else 0.0 + lastProgressTime = now + lastDoneBytes = done + val speedStr = if (speedMb > 0) " (%.1f MB/s)".format(speedMb) else "" + runOnUiThread { tvDownloadProgress.text = "$percent%$speedStr" } } is ModelManagerWrapper.PullEvent.Completed -> { runOnUiThread { llDownloading.visibility = View.GONE Toaster.show("${selectModelData.displayName} downloaded") + cachedModelNames.add(selectModelData.modelName) + updateSpinnerAdapter() } } is ModelManagerWrapper.PullEvent.Error -> { diff --git a/examples/android/src/main/java/com/geniex/demo/bean/ModelData.kt b/examples/android/src/main/java/com/geniex/demo/bean/ModelData.kt index 592fd51de..5193a1273 100644 --- a/examples/android/src/main/java/com/geniex/demo/bean/ModelData.kt +++ b/examples/android/src/main/java/com/geniex/demo/bean/ModelData.kt @@ -48,6 +48,8 @@ data class ModelData( * pair with an explicit `chipset`. */ val chipset: String? = null, + /** Model file size in bytes (from HuggingFace / AI Hub). */ + val size: Long? = null, ) { var isSupport = true } diff --git a/examples/android/src/main/res/layout/item_model.xml b/examples/android/src/main/res/layout/item_model.xml index c665d3040..10c20f9d3 100644 --- a/examples/android/src/main/res/layout/item_model.xml +++ b/examples/android/src/main/res/layout/item_model.xml @@ -4,16 +4,40 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center_vertical" - android:orientation="vertical"> + android:orientation="horizontal" + android:paddingStart="15dp" + android:paddingEnd="15dp"> + tools:text="Qwen3-0.6B (GGUF)" /> + + + + \ No newline at end of file