From 00b94dc0fcc13f1ffc0e978fd3684f26e06ce503 Mon Sep 17 00:00:00 2001 From: weibolin-259 Date: Sun, 25 Oct 2026 00:44:52 +0800 Subject: [PATCH 1/2] perf(apm): persist module size cache and single du invocation - Persist id to (bytes, versionCode) size cache into SharedPreferences so cold start hits the cache immediately, removing the transient 0 KB flash on the module list before du finishes - Invalidate cache per module when versionCode changes, fixing stale sizes shown after a module update - Replace per-module for-loop du (one fork/exec per module) with a single busybox du -sb invocation for all module dirs - Skip size prefetch entirely when the show-more-module-info setting is disabled; measure on demand when the toggle is turned on - Add mutex to prevent overlapping du runs from rapid refresh or toggle --- .../apatch/ui/viewmodel/APModuleViewModel.kt | 127 ++++++++++++++---- 1 file changed, 100 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/me/bmax/apatch/ui/viewmodel/APModuleViewModel.kt b/app/src/main/java/me/bmax/apatch/ui/viewmodel/APModuleViewModel.kt index 5f157addf..37816f440 100644 --- a/app/src/main/java/me/bmax/apatch/ui/viewmodel/APModuleViewModel.kt +++ b/app/src/main/java/me/bmax/apatch/ui/viewmodel/APModuleViewModel.kt @@ -12,7 +12,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit import me.bmax.apatch.APApplication import me.bmax.apatch.apApp @@ -34,6 +36,7 @@ class APModuleViewModel : ViewModel() { private const val TAG = "ModuleViewModel" private const val CUSTOM_ORDER_ENABLED_KEY = "module_custom_order_enabled" private const val CUSTOM_ORDER_KEY = "module_custom_order" + private const val MODULE_SIZE_CACHE_KEY = "module_size_cache_v1" private var modules by mutableStateOf>(emptyList()) private val zygiskModuleIds = listOf( "zygisksu", @@ -65,6 +68,13 @@ class APModuleViewModel : ViewModel() { val isLSPosed: Boolean, ) + /** + * Persisted module size cache entry. + * [versionCode] is the module.prop versionCode at measurement time, + * used to invalidate the cache when a module gets updated. + */ + private data class CachedSize(val bytes: Long, val versionCode: Int) + data class ModuleUpdateInfo( val version: String, val versionCode: Int, @@ -88,18 +98,29 @@ class APModuleViewModel : ViewModel() { private var customOrderEnabled by mutableStateOf(prefs.getBoolean(CUSTOM_ORDER_ENABLED_KEY, false)) private var customOrder by mutableStateOf(readCustomOrder()) private val bannerCache = mutableStateMapOf() - private val moduleSizeCache = mutableStateMapOf() + private val moduleSizeCache = mutableStateMapOf() + private val sizePrefetchMutex = Mutex() private val updateSemaphore = Semaphore(3) val bannerSemaphore = Semaphore(4) private val preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> if (key == "module_sort_optimization") { sortOptimizationEnabled = prefs.getBoolean("module_sort_optimization", true) + } else if (key == "show_more_module_info") { + // Size display just turned on: measure modules that are not cached yet. + if (prefs.getBoolean("show_more_module_info", true)) { + viewModelScope.launch(Dispatchers.IO) { + prefetchModuleSizes(modules.map { it.id }) + } + } } } + private val collator = Collator.getInstance(Locale.getDefault()) + init { prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener) + loadSizeCacheFromDisk() } override fun onCleared() { @@ -107,7 +128,40 @@ class APModuleViewModel : ViewModel() { prefs.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) } - private val collator = Collator.getInstance(Locale.getDefault()) + /** + * Load the persisted size cache into memory so sizes are available + * immediately on cold start (no "0 KB" flash before du finishes). + */ + private fun loadSizeCacheFromDisk() { + runCatching { + val raw = prefs.getString(MODULE_SIZE_CACHE_KEY, null) ?: return + val obj = JSONObject(raw) + for (id in obj.keys()) { + val entry = obj.optJSONObject(id) ?: continue + val bytes = entry.optLong("bytes", -1L) + if (bytes < 0) continue + moduleSizeCache[id] = CachedSize(bytes, entry.optInt("vc", -1)) + } + }.onFailure { e -> + Log.e(TAG, "Failed to load module size cache", e) + } + } + + /** + * Persist the in-memory size cache. Called after measurement / pruning; + * the entry count is small (dozens) so the write cost is negligible. + */ + private fun persistSizeCache() { + runCatching { + val obj = JSONObject() + for ((id, cached) in moduleSizeCache) { + obj.put(id, JSONObject().put("bytes", cached.bytes).put("vc", cached.versionCode)) + } + prefs.edit().putString(MODULE_SIZE_CACHE_KEY, obj.toString()).apply() + }.onFailure { e -> + Log.e(TAG, "Failed to persist module size cache", e) + } + } val moduleList by derivedStateOf { if (customOrderEnabled) { @@ -208,7 +262,10 @@ class APModuleViewModel : ViewModel() { private fun pruneModuleSizeCache(validIds: Set) { val keysToRemove = moduleSizeCache.keys.filter { it !in validIds } - keysToRemove.forEach { moduleSizeCache.remove(it) } + if (keysToRemove.isNotEmpty()) { + keysToRemove.forEach { moduleSizeCache.remove(it) } + persistSizeCache() + } } private val updateCache = mutableStateMapOf>() @@ -275,7 +332,9 @@ class APModuleViewModel : ViewModel() { // Non-critical: run in background without blocking module display pruneBannerCache(ids.toSet()) pruneModuleSizeCache(ids.toSet()) - viewModelScope.launch(Dispatchers.IO) { prefetchModuleSizes(ids) } + if (prefs.getBoolean("show_more_module_info", true)) { + viewModelScope.launch(Dispatchers.IO) { prefetchModuleSizes(ids) } + } viewModelScope.launch(Dispatchers.IO) { batchCheckUpdates() } Log.i(TAG, "load cost: ${SystemClock.elapsedRealtime() - start}, modules: $modules") @@ -372,38 +431,52 @@ class APModuleViewModel : ViewModel() { private fun prefetchModuleSizes(moduleIds: List) { if (moduleIds.isEmpty()) return - val idsToFetch = moduleIds.filter { it !in moduleSizeCache } - if (idsToFetch.isEmpty()) return + // Guard against overlapping runs (rapid refresh / settings toggle) + // so du is not executed multiple times concurrently. + viewModelScope.launch(Dispatchers.IO) { + sizePrefetchMutex.withLock { + // Re-measure only when there is no entry, or the module was + // updated (versionCode changed) since the last measurement. + val versionCodeById = modules.associate { it.id to it.versionCode } + val idsToFetch = moduleIds.filter { id -> + val cached = moduleSizeCache[id] + cached == null || cached.versionCode != versionCodeById[id] + } + if (idsToFetch.isEmpty()) return@withLock - runCatching { - val sb = StringBuilder("for d in") - for (id in idsToFetch) { - sb.append(" /data/adb/modules/") - sb.append(id.replace(" ", "\\ ")) - } - sb.append("; do if [ -d \"\$d\" ]; then /data/adb/ap/bin/busybox du -sb \"\$d\" 2>/dev/null; fi; done") - val result = getRootShell().newJob().add(sb.toString()).to(ArrayList(), null).exec() - if (result.isSuccess) { - for (line in result.out) { - val parts = line.split("\t", limit = 2) - if (parts.size == 2) { - val size = parts[0].trim().toLongOrNull() ?: continue - val path = parts[1].trim() - val id = path.removePrefix("/data/adb/modules/") - if (id in idsToFetch) { - moduleSizeCache[id] = size + runCatching { + val sb = StringBuilder("/data/adb/ap/bin/busybox du -sb") + for (id in idsToFetch) { + sb.append(" /data/adb/modules/") + sb.append(id.replace(" ", "\\ ")) + } + // Single du invocation: one fork/exec for all modules, + // non-existent dirs only produce stderr (discarded). + val result = getRootShell().newJob().add(sb.toString()).to(ArrayList(), null).exec() + if (result.isSuccess) { + for (line in result.out) { + val parts = line.split("\t", limit = 2) + if (parts.size == 2) { + val size = parts[0].trim().toLongOrNull() ?: continue + val path = parts[1].trim() + val id = path.removePrefix("/data/adb/modules/") + if (id in idsToFetch) { + moduleSizeCache[id] = CachedSize(size, versionCodeById[id] ?: -1) + } + } } + persistSizeCache() } + }.onFailure { e -> + Log.e(TAG, "Error prefetching module sizes", e) } } - }.onFailure { e -> - Log.e(TAG, "Error prefetching module sizes", e) } } fun getModuleSize(moduleId: String): String { - moduleSizeCache[moduleId]?.let { cachedBytes -> - return formatFileSize(cachedBytes) + moduleSizeCache[moduleId]?.let { cached -> + return formatFileSize(cached.bytes) } return "0 KB" } From 1404e60188f38ed09acbb216b406f44e4bb8adf7 Mon Sep 17 00:00:00 2001 From: weibolin-259 Date: Sun, 25 Oct 2026 01:01:32 +0800 Subject: [PATCH 2/2] ci: pass event data via env instead of direct interpolation Commit messages containing quotes, backticks or dollar-expansions broke the bash scripts where head_commit.message and similar fields were interpolated directly into run blocks, failing the prepare job. - prepare: pass inputs and event fields through env, not string interpolation - upload: encode commit message, author and other caption fields with jq so Telegram captions survive arbitrary characters - keystore and Telegram secrets likewise moved to env vars --- .github/workflows/build.yml | 79 +++++++++++++++++++++++++++---------- 1 file changed, 59 insertions(+), 20 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a28c6c690..a8bbc5be8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -52,9 +52,12 @@ jobs: steps: - id: set + env: + INPUT_TYPE: ${{ inputs.build_type }} + EVENT_NAME: ${{ github.event_name }} run: | - TYPE="${{ inputs.build_type }}" - [ "${{ github.event_name }}" != "workflow_dispatch" ] && TYPE="both" + TYPE="$INPUT_TYPE" + [ "$EVENT_NAME" != "workflow_dispatch" ] && TYPE="both" DEBUG="false"; RELEASE="false"; LIST="" [ "$TYPE" = "both" -o "$TYPE" = "debug" ] && { DEBUG="true"; LIST="folkpatch-debug-${{ github.sha }}"; } @@ -69,14 +72,21 @@ jobs: echo "artifact_list=$LIST" >> "$GITHUB_OUTPUT" - id: msg + env: + EVENT_NAME: ${{ github.event_name }} + PUSH_MSG: ${{ github.event.head_commit.message }} + PUSH_AUTHOR: ${{ github.event.head_commit.author.name }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + ACTOR: ${{ github.actor }} run: | - case "${{ github.event_name }}" in - push) TEXT="${{ github.event.head_commit.message }}" - AUTHOR="${{ github.event.head_commit.author.name }}" ;; - pull_request) TEXT="PR: ${{ github.event.pull_request.title }}" - AUTHOR="${{ github.event.pull_request.user.login }}" ;; + case "$EVENT_NAME" in + push) TEXT="$PUSH_MSG" + AUTHOR="$PUSH_AUTHOR" ;; + pull_request) TEXT="PR: $PR_TITLE" + AUTHOR="$PR_AUTHOR" ;; *) TEXT="FolkPatch Build" - AUTHOR="${{ github.actor }}" ;; + AUTHOR="$ACTOR" ;; esac TEXT="${TEXT%%$'\n'*}" echo "text=$TEXT" >> "$GITHUB_OUTPUT" @@ -134,16 +144,22 @@ jobs: run: ./gradlew printVersion --no-daemon - name: Decode keystore + env: + KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} run: | - echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > FolkPatch.jks + echo "$KEYSTORE_BASE64" | base64 -d > FolkPatch.jks - name: Create keystore.properties + env: + KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} run: | - cat > keystore.properties << 'EOF' + cat > keystore.properties << EOF KEYSTORE_FILE=../FolkPatch.jks - KEYSTORE_PASSWORD=${{ secrets.KEYSTORE_PASSWORD }} - KEY_ALIAS=${{ secrets.KEY_ALIAS }} - KEY_PASSWORD=${{ secrets.KEY_PASSWORD }} + KEYSTORE_PASSWORD=${KEYSTORE_PASSWORD} + KEY_ALIAS=${KEY_ALIAS} + KEY_PASSWORD=${KEY_PASSWORD} EOF - run: ./gradlew assembleRelease --no-daemon @@ -172,8 +188,11 @@ jobs: steps: - name: Check Telegram config id: tg_check + env: + BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} run: | - if [ -n "${{ secrets.TELEGRAM_BOT_TOKEN }}" ] && [ -n "${{ secrets.TELEGRAM_CHAT_ID }}" ]; then + if [ -n "$BOT_TOKEN" ] && [ -n "$CHAT_ID" ]; then echo "enabled=true" >> "$GITHUB_OUTPUT" else echo "::notice::Telegram secrets not configured, skipping upload" @@ -189,6 +208,16 @@ jobs: - name: Send to Telegram if: steps.tg_check.outputs.enabled == 'true' + env: + COMMIT_MSG: ${{ needs.prepare.outputs.commit_msg }} + COMMIT_AUTHOR: ${{ needs.prepare.outputs.commit_author }} + REF_NAME: ${{ github.ref_name }} + SHA: ${{ github.sha }} + SERVER_URL: ${{ github.server_url }} + REPOSITORY: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} run: | FILES=$(find artifacts -type f 2>/dev/null | sort) FILE_COUNT=$(echo "$FILES" | grep -c . || true) @@ -198,11 +227,20 @@ jobs: exit 1 fi - CAPTION="${{ needs.prepare.outputs.commit_msg }}\nBranch: ${{ github.ref_name }}\nCommit: ${{ github.sha }}\nAuthor: ${{ needs.prepare.outputs.commit_author }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + # jq -Rs: safely JSON-escape arbitrary user-controlled strings + # (commit messages may contain quotes, backticks, $(), newlines) + MSG_JSON=$(printf '%s' "$COMMIT_MSG" | jq -Rs .) + AUTHOR_JSON=$(printf '%s' "$COMMIT_AUTHOR" | jq -Rs .) + REF_JSON=$(printf '%s' "$REF_NAME" | jq -Rs .) + SHA_JSON=$(printf '%s' "$SHA" | jq -Rs .) + RUN_JSON=$(printf '%s' "$SERVER_URL/$REPOSITORY/actions/runs/$RUN_ID" | jq -Rs .) + CAPTION=$(jq -jn --argjson m "$MSG_JSON" --argjson a "$AUTHOR_JSON" \ + --argjson r "$REF_JSON" --argjson s "$SHA_JSON" --argjson u "$RUN_JSON" \ + '$m + "\nBranch: " + $r + "\nCommit: " + $s + "\nAuthor: " + $a + "\nRun: " + $u') if [ "$FILE_COUNT" -eq 1 ]; then - curl -sf -X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendDocument" \ - -F "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}" \ + curl -sf -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendDocument" \ + -F "chat_id=${CHAT_ID}" \ -F "document=@$FILES" \ -F "caption=${CAPTION}" else @@ -219,15 +257,16 @@ jobs: MEDIA_JSON+="," fi if [ $((i + 1)) -eq $TOTAL ]; then - MEDIA_JSON+="{\"type\":\"document\",\"media\":\"attach://$FNAME\",\"caption\":\"${CAPTION}\"}" + CAPTION_JSON=$(printf '%s' "$CAPTION" | jq -Rs .) + MEDIA_JSON+="{\"type\":\"document\",\"media\":\"attach://$FNAME\",\"caption\":${CAPTION_JSON}}" else MEDIA_JSON+="{\"type\":\"document\",\"media\":\"attach://$FNAME\"}" fi done MEDIA_JSON+="]" - ARGS=(-X POST "https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMediaGroup") - ARGS+=(-F "chat_id=${{ secrets.TELEGRAM_CHAT_ID }}") + ARGS=(-X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMediaGroup") + ARGS+=(-F "chat_id=${CHAT_ID}") ARGS+=(-F "media=${MEDIA_JSON}") while IFS= read -r f; do