Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 59 additions & 20 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"; }
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down
127 changes: 100 additions & 27 deletions app/src/main/java/me/bmax/apatch/ui/viewmodel/APModuleViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<List<ModuleInfo>>(emptyList())
private val zygiskModuleIds = listOf(
"zygisksu",
Expand Down Expand Up @@ -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,
Expand All @@ -88,26 +98,70 @@ 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<String, BannerInfo>()
private val moduleSizeCache = mutableStateMapOf<String, Long>()
private val moduleSizeCache = mutableStateMapOf<String, CachedSize>()
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() {
super.onCleared()
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) {
Expand Down Expand Up @@ -208,7 +262,10 @@ class APModuleViewModel : ViewModel() {

private fun pruneModuleSizeCache(validIds: Set<String>) {
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<String, Triple<String, String, String>>()
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -372,38 +431,52 @@ class APModuleViewModel : ViewModel() {

private fun prefetchModuleSizes(moduleIds: List<String>) {
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"
}
Expand Down