From 28535a703be12622157c9fcd4ea076b8120e0f09 Mon Sep 17 00:00:00 2001 From: lateautumn233 Date: Sat, 4 Jul 2026 20:54:04 +0800 Subject: [PATCH 1/5] feat(disk): preserve compression when optimizing images - Fix qemu-img convert: positional args must follow all options (was breaking -c placement) - Add ImageUtils.hasCompressedClusters() using qemu-img check's real compressed-clusters count - Implement applyKeepCompress() to preserve source compression algorithm during optimization - New "Keep compression when optimizing" setting (disabled by default) with UI controls - Add i18n strings (English, Simplified Chinese, Traditional Chinese) --- .../droidvm/lib/utils/ImageUtils.java | 27 +++++++++++++ .../ui/disk/action/DiskActionDialog.java | 1 + .../disk/operation/DiskOperationActivity.java | 40 +++++++++++++++++++ .../disk/operation/ImageCommandGenerate.java | 13 ++++-- .../main/settings/MainSettingsFragment.java | 9 +++++ .../res/layout/fragment_main_settings.xml | 8 ++++ app/src/main/res/values-zh-rCN/strings.xml | 2 + app/src/main/res/values-zh-rTW/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 9 files changed, 100 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/cn/classfun/droidvm/lib/utils/ImageUtils.java b/app/src/main/java/cn/classfun/droidvm/lib/utils/ImageUtils.java index af4f830..9e3b829 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/utils/ImageUtils.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/utils/ImageUtils.java @@ -28,4 +28,31 @@ public static JSONObject getImageInfo(String path) throws JSONException { } return new JSONObject(result.getOutString()); } + + /** + * Whether {@code path} actually stores compressed clusters, per + * {@code qemu-img check}'s {@code compressed-clusters} count. This is the + * real signal: the qcow2 header's {@code compression-type} reads "zlib" for + * every v3 image (even ones with no compressed data), so it cannot tell an + * uncompressed disk from a compressed one. Unlike lbx, qemu-img reads both + * zlib and zstd images, so a zstd disk is detected too. Any detection + * failure (raw images reject {@code check}, etc.) returns {@code false} -- + * an undetectable image is treated as uncompressed. + */ + public static boolean hasCompressedClusters(String path) { + try { + var result = runListQuiet( + getPrebuiltBinaryPath("qemu-img"), + "check", "--output=json", path); + var out = result.getOutString(); + // qemu-img check still writes its JSON report on non-zero exits + // (leaks/corruptions), so parse whatever it produced rather than + // gating on isSuccess(); a missing/garbage body throws -> false. + if (out == null || out.isEmpty()) + return false; + return new JSONObject(out).optLong("compressed-clusters", 0) > 0; + } catch (Exception e) { + return false; + } + } } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskActionDialog.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskActionDialog.java index 56850ee..99d7172 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskActionDialog.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskActionDialog.java @@ -121,6 +121,7 @@ public void tryOptimize(@NonNull DiskConfig config) { try { var info = ImageUtils.getImageInfo(config.getFullPath()); obj.put("action", "convert"); + obj.put("keep_compress", true); // preserve compression when optimizing obj.put("format", info.getString("format")); if (info.has("backing-filename")) obj.put("backing_path", info.getString("backing-filename")); diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/DiskOperationActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/DiskOperationActivity.java index 12a3b1e..677cf3b 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/DiskOperationActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/DiskOperationActivity.java @@ -3,6 +3,8 @@ import static android.view.View.GONE; import static android.view.View.VISIBLE; import static cn.classfun.droidvm.lib.utils.FileUtils.findExecute; +import static cn.classfun.droidvm.lib.utils.ImageUtils.getImageInfo; +import static cn.classfun.droidvm.lib.utils.ImageUtils.hasCompressedClusters; import static cn.classfun.droidvm.lib.utils.ProcessUtils.SIGHUP; import static cn.classfun.droidvm.lib.utils.ProcessUtils.shellKillProcess; import static cn.classfun.droidvm.lib.utils.StringUtils.basename; @@ -42,6 +44,7 @@ import cn.classfun.droidvm.lib.ui.termux.SimpleTerminalSessionClient; import cn.classfun.droidvm.lib.ui.termux.TerminalFonts; import cn.classfun.droidvm.lib.ui.termux.SimpleTerminalViewClient; +import cn.classfun.droidvm.ui.main.settings.MainSettingsFragment; public final class DiskOperationActivity extends AppCompatActivity { private static final String TAG = "DiskOperationActivity"; @@ -133,6 +136,7 @@ public static void startOptimize( try { var obj = new JSONObject(); obj.put("action", "convert"); + obj.put("keep_compress", true); // preserve compression when optimizing var intent = createIntent(context, diskId, obj); context.startActivity(intent); } catch (Exception e) { @@ -221,6 +225,7 @@ public void handleOnBackPressed() { final String cmd; try { var task = new JSONObject(taskJsonStr); + applyKeepCompress(getApplicationContext(), task, diskPath); var gen = new ImageCommandGenerate(diskStore); cmd = gen.buildCommand(task, diskPath); taskAction = task.optString("action", ""); @@ -235,6 +240,41 @@ public void handleOnBackPressed() { }); } + /** + * When a task opts into keep-compress (the user-facing "optimize", not the + * pre-start decompress in {@link #optimizeForResultIntent}), re-compress the + * rewritten image with the algorithm the source uses instead of rewriting it + * uncompressed. Whether the source is compressed at all is decided by + * qemu-img's real {@code compressed-clusters} count + * ({@code ImageUtils.hasCompressedClusters}); the qcow2 header's + * {@code compression-type} is only read to pick zlib vs + * zstd, because that header field reads "zlib" for every v3 image and so + * cannot on its own distinguish an uncompressed disk from a compressed one. + * An uncompressed source (raw, or a v3 qcow2 with no compressed data) is left + * alone. An explicit {@code compress} in the task always wins, the user can + * turn this off globally in settings, and any detection failure falls through + * to the default (uncompressed) rewrite. + */ + private static void applyKeepCompress( + @NonNull Context ctx, @NonNull JSONObject task, @NonNull String path) { + if (!task.optBoolean("keep_compress", false) || task.has("compress")) + return; + if (!MainSettingsFragment.isKeepCompressOnOptimizeEnabled(ctx)) + return; + try { + if (!hasCompressedClusters(path)) + return; // nothing actually compressed -- rewrite uncompressed + var info = getImageInfo(path); + var fmtSpecific = info.optJSONObject("format-specific"); + var data = fmtSpecific == null ? null : fmtSpecific.optJSONObject("data"); + var type = data == null ? "" : data.optString("compression-type", ""); + // qemu compression-type zstd stays zstd; zlib (and anything else) -> deflate. + task.put("compress", "zstd".equals(type) ? "zstd" : "deflate"); + } catch (Exception e) { + Log.w(TAG, "keep-compress detection failed", e); + } + } + private void startTerminalSession(String cmd) { var shell = findExecute("su", "/system/bin/su"); var cwd = getFilesDir().getAbsolutePath(); diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/ImageCommandGenerate.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/ImageCommandGenerate.java index 8059020..33aaef6 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/ImageCommandGenerate.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/ImageCommandGenerate.java @@ -118,7 +118,6 @@ private void appendConvert() throws JSONException { task.put("format", format); } else throw new RuntimeException("No format specified in task or image info"); sb.append(" --target-format ").append(format); - sb.append(" ").append(eDiskPath).append(" "); if (task.has("output")) { outputPath = task.getString("output"); } else { @@ -129,11 +128,17 @@ private void appendConvert() throws JSONException { if (!opts.isEmpty()) sb.append(" --target-format-options ").append(opts); if (!task.optString("compress", "none").equals("none")) - sb.append(" --compress"); - sb.append(" "); + // -c (short form): the bundled qemu-img rejects the --compress long + // option even though it accepts the other long options. + sb.append(" -c"); + // Positional args go last. qemu-img stops parsing options at the first + // SRC_FILE (convert accepts multiple source files), so any option after + // it -- e.g. --compress -- is misread as an extra source file. All + // options must precede SRC_FILE. realPath = escapedString(outputPath); tmpPath = escapedString(fmt("%s.tmp", diskPath)); - sb.append(useTempPath ? tmpPath : realPath); + sb.append(" ").append(eDiskPath); + sb.append(" ").append(useTempPath ? tmpPath : realPath); } @NonNull diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/settings/MainSettingsFragment.java b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/MainSettingsFragment.java index e65053e..e09bfb6 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/settings/MainSettingsFragment.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/MainSettingsFragment.java @@ -65,6 +65,7 @@ public final class MainSettingsFragment extends MainBaseFragment { private static final String PREFS_NAME = "droidvm_prefs"; public static final String KEY_VM_AUTO_CONSOLE = "vm_auto_console"; public static final String KEY_VM_CLEAR_LOGS_BEFORE_START = "vm_clear_logs_before_start"; + public static final String KEY_VM_KEEP_COMPRESS_ON_OPTIMIZE = "vm_keep_compress_on_optimize"; public static final String KEY_AUTO_CHECK_UPDATE = "auto_check_update"; private final Handler mainHandler = new Handler(Looper.getMainLooper()); private final Runnable daemonStatusRefreshRunnable = this::periodRefreshDaemonStatus; @@ -78,6 +79,7 @@ public final class MainSettingsFragment extends MainBaseFragment { private TextRowWidget itemDaemonRestart; private SwitchRowWidget itemVMAutoConsole; private SwitchRowWidget itemVMClearLogsBeforeStart; + private SwitchRowWidget itemVMKeepCompressOnOptimize; private TextRowWidget itemLicense; private SwitchRowWidget itemAutoCheckUpdate; private TextRowWidget itemCheckUpdate; @@ -130,6 +132,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat itemDaemonRestart = view.findViewById(R.id.item_daemon_restart); itemVMAutoConsole = view.findViewById(R.id.item_vm_auto_console); itemVMClearLogsBeforeStart = view.findViewById(R.id.item_vm_clear_logs_before_start); + itemVMKeepCompressOnOptimize = view.findViewById(R.id.item_vm_keep_compress_on_optimize); itemLicense = view.findViewById(R.id.item_license); itemAutoCheckUpdate = view.findViewById(R.id.item_auto_check_update); itemCheckUpdate = view.findViewById(R.id.item_check_update); @@ -160,6 +163,7 @@ private void initSettings() { bindOnClick(itemDaemonRestart, daemon::asyncRestartDaemon); bindOnChecked(itemVMAutoConsole, KEY_VM_AUTO_CONSOLE, false); bindOnChecked(itemVMClearLogsBeforeStart, KEY_VM_CLEAR_LOGS_BEFORE_START, false); + bindOnChecked(itemVMKeepCompressOnOptimize, KEY_VM_KEEP_COMPRESS_ON_OPTIMIZE, false); bindOnChecked(itemAutoCheckUpdate, KEY_AUTO_CHECK_UPDATE, true); bindOnClick(itemCheckUpdate, this::checkUpdate); bindOnClick(itemPrivacy, this::showPrivacyPolicy); @@ -203,6 +207,11 @@ public static boolean isClearLogsBeforeStartEnabled(@NonNull Context context) { return prefs.getBoolean(KEY_VM_CLEAR_LOGS_BEFORE_START, false); } + public static boolean isKeepCompressOnOptimizeEnabled(@NonNull Context context) { + var prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + return prefs.getBoolean(KEY_VM_KEEP_COMPRESS_ON_OPTIMIZE, false); + } + public static boolean isAutoCheckUpdateEnabled(@NonNull Context context) { var prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); return prefs.getBoolean(KEY_AUTO_CHECK_UPDATE, true); diff --git a/app/src/main/res/layout/fragment_main_settings.xml b/app/src/main/res/layout/fragment_main_settings.xml index 948cdf8..250c048 100644 --- a/app/src/main/res/layout/fragment_main_settings.xml +++ b/app/src/main/res/layout/fragment_main_settings.xml @@ -63,6 +63,14 @@ android:subtitle="@string/settings_vm_clear_logs_before_start_summary" android:text="@string/settings_vm_clear_logs_before_start_title" /> + + 启动虚拟机时自动打开控制台 启动前清理日志 启动虚拟机前清理该虚拟机的所有控制台日志 + 优化时保持压缩 + 优化磁盘时保留其原有压缩而非解压;关闭则重写为未压缩 关于 问题反馈 在 GitHub 上报告问题 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index ca90d62..13af394 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -363,6 +363,8 @@ 啟動虛擬機器時自動開啟控制台 啟動前清理日誌 啟動虛擬機器前清理該虛擬機器的所有控制台日誌 + 最佳化時保持壓縮 + 最佳化磁碟時保留其原有壓縮而非解壓縮;關閉則重寫為未壓縮 關於 問題反饋 在 GitHub 上報告問題 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7692a34..3ca8687 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -369,6 +369,8 @@ Automatically open the console when a VM is started Clear logs before start Clear all console logs for the VM before it starts + Keep compression when optimizing + Preserve a disk\'s existing compression when optimizing instead of decompressing it; off rewrites the disk uncompressed About Feedback Report issues on GitHub From fc837326edcca0e8aafdb97c77b0f8305e0759bd Mon Sep 17 00:00:00 2001 From: lateautumn233 Date: Sun, 5 Jul 2026 22:05:15 +0800 Subject: [PATCH 2/5] feat(disk): add compression option to convert dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add compression selector (visible only for QCOW2 format) - Allow QCOW2→QCOW2 conversion to support re-compression/decompression - Handle in-place re-compress via temp file path to avoid read lock collision --- .../ui/disk/action/DiskSetFormatDialog.java | 24 +++++++++++++++++-- .../disk/operation/DiskOperationActivity.java | 5 +++- .../disk/operation/ImageCommandGenerate.java | 5 ++++ .../res/layout/dialog_disk_set_format.xml | 8 +++++++ 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskSetFormatDialog.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskSetFormatDialog.java index 37043d3..358f486 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskSetFormatDialog.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskSetFormatDialog.java @@ -7,6 +7,10 @@ import static cn.classfun.droidvm.lib.utils.ThreadUtils.runOnPool; import static cn.classfun.droidvm.ui.disk.operation.DiskOperationActivity.startConvert; +import static android.view.View.GONE; +import static android.view.View.VISIBLE; +import static cn.classfun.droidvm.lib.store.disk.DiskConfig.supportsCompress; + import android.content.Context; import android.os.Handler; import android.os.Looper; @@ -23,6 +27,7 @@ import cn.classfun.droidvm.R; import cn.classfun.droidvm.lib.store.disk.DiskConfig; import cn.classfun.droidvm.lib.utils.ImageUtils; +import cn.classfun.droidvm.ui.disk.create.DiskCompress; import cn.classfun.droidvm.ui.disk.create.DiskFormat; import cn.classfun.droidvm.ui.widgets.row.ChooseRowWidget; import cn.classfun.droidvm.ui.widgets.row.TextInputRowWidget; @@ -34,6 +39,7 @@ public final class DiskSetFormatDialog { private final DiskConfig config; private TextInputRowWidget inputName; private ChooseRowWidget chooseFormat; + private ChooseRowWidget chooseCompress; private TextView tvCurrentFormat; private DiskFormat currentFormat = null; @@ -47,14 +53,18 @@ public void show() { tvCurrentFormat = view.findViewById(R.id.tv_current_format); inputName = view.findViewById(R.id.input_name); chooseFormat = view.findViewById(R.id.choose_format); + chooseCompress = view.findViewById(R.id.choose_compress); chooseFormat.setItems(DiskFormat.class); + chooseCompress.configure(DiskCompress.class, DiskCompress.DEFLATE); inputName.setText(config.getName()); tvCurrentFormat.setText(R.string.disk_set_format_loading); runOnPool(this::loadImageInfo); chooseFormat.setOnValueChangedListener(() -> { DiskFormat fmt = chooseFormat.getSelectedItem(); updateFileName(inputName, config.getName(), fmt.getExt()); + updateCompressVisibility(fmt); }); + updateCompressVisibility(chooseFormat.getSelectedItem()); var dialog = new MaterialAlertDialogBuilder(context) .setTitle(R.string.disk_set_format_title) .setView(view) @@ -70,16 +80,25 @@ public void show() { } inputName.setError(null); DiskFormat format = chooseFormat.getSelectedItem(); - if (format.equals(currentFormat)) { + // Same format is still a valid conversion when it carries a + // compression choice (qcow2): the user may only be re-compressing. + if (format.equals(currentFormat) && !supportsCompress(format)) { Toast.makeText(context, R.string.disk_set_format_error_same, LENGTH_LONG).show(); return; } dialog.dismiss(); var output = pathJoin(config.item.optString("folder", ""), name); - startConvert(context, config.getId(), format.name().toLowerCase(), output); + var compress = supportsCompress(format) + ? chooseCompress.getSelectedItem().name().toLowerCase() + : "none"; + startConvert(context, config.getId(), format.name().toLowerCase(), output, compress); }); } + private void updateCompressVisibility(DiskFormat fmt) { + chooseCompress.setVisibility(supportsCompress(fmt) ? VISIBLE : GONE); + } + private void loadImageInfo() { try { currentFormat = null; @@ -100,6 +119,7 @@ private void loadImageInfo() { currentFormat = chooseFormat.getSelectedItem(); } updateFileName(inputName, config.getName(), currentFormat.getExt()); + updateCompressVisibility(chooseFormat.getSelectedItem()); }); } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/DiskOperationActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/DiskOperationActivity.java index 677cf3b..5a04126 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/DiskOperationActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/DiskOperationActivity.java @@ -148,13 +148,16 @@ public static void startConvert( @NonNull Context context, @NonNull UUID diskId, @NonNull String format, - @NonNull String output + @NonNull String output, + @NonNull String compress ) { try { var obj = new JSONObject(); obj.put("action", "convert"); obj.put("format", format); obj.put("output", output); + if (!compress.equals("none")) + obj.put("compress", compress); var intent = createIntent(context, diskId, obj); context.startActivity(intent); } catch (Exception e) { diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/ImageCommandGenerate.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/ImageCommandGenerate.java index 33aaef6..157ae6f 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/ImageCommandGenerate.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/ImageCommandGenerate.java @@ -120,6 +120,11 @@ private void appendConvert() throws JSONException { sb.append(" --target-format ").append(format); if (task.has("output")) { outputPath = task.getString("output"); + // In-place re-compress (output == source): writing straight to the + // target collides with the source's own read lock, so route through + // the .tmp + rename path even though an output was given. + if (outputPath.equals(diskPath)) + useTempPath = true; } else { useTempPath = true; } diff --git a/app/src/main/res/layout/dialog_disk_set_format.xml b/app/src/main/res/layout/dialog_disk_set_format.xml index eeca2d5..e6cf784 100644 --- a/app/src/main/res/layout/dialog_disk_set_format.xml +++ b/app/src/main/res/layout/dialog_disk_set_format.xml @@ -31,4 +31,12 @@ android:icon="@drawable/ic_disk_format" android:text="@string/disk_set_format_target" /> + + From 1ffa0f4a16b6d664c552e5716f4b5cf536da49e7 Mon Sep 17 00:00:00 2001 From: lateautumn233 Date: Sun, 5 Jul 2026 23:37:28 +0800 Subject: [PATCH 3/5] feat(cpu): add CPU affinity support for disk operations - New CpuUtils utility to enumerate cores and identify frequency tiers - Add CPU affinity settings UI with multi-core selection dialog - Bind disk operations (qemu-img, clone) to selected cores via taskset - Support automatic big-core filtering on heterogeneous CPU layouts - Reserve KEY_CROSVM_CPU_AFFINITY for future VM affinity support --- .../vm/backend/CrosvmBackendInstance.java | 5 + .../classfun/droidvm/lib/utils/CpuUtils.java | 214 ++++++++++++++++++ .../disk/operation/DiskOperationActivity.java | 2 + .../disk/operation/ImageCommandGenerate.java | 17 ++ .../main/settings/MainSettingsFragment.java | 106 +++++++++ .../res/layout/fragment_main_settings.xml | 8 + app/src/main/res/values-zh-rCN/strings.xml | 7 + app/src/main/res/values-zh-rTW/strings.xml | 7 + app/src/main/res/values/strings.xml | 7 + 9 files changed, 373 insertions(+) create mode 100644 app/src/main/java/cn/classfun/droidvm/lib/utils/CpuUtils.java diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/CrosvmBackendInstance.java b/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/CrosvmBackendInstance.java index f83505f..756bba5 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/CrosvmBackendInstance.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/CrosvmBackendInstance.java @@ -135,6 +135,11 @@ private List buildCommand() { var item = config.item; var args = new ArrayList(); prepareStraceArguments(args); + // TODO(cpu-affinity): to bind the VM to selected host cores, prepend a + // system-taskset wrapper here (mirroring prepareStraceArguments): + // "taskset -a ", with the mask from CpuUtils.coresCsvToHexMask + // reading a future MainSettingsFragment.KEY_CROSVM_CPU_AFFINITY setting. + // Note toybox taskset takes a hex mask (no "0x"), not a -c core list. args.add(getPrebuiltBinaryPath("crosvm")); // Top-level flag (must precede `run`): makes crosvm surface CommandStatus // exit codes (see CrosvmExit) so runVM() can tell reset/crash/panic apart. diff --git a/app/src/main/java/cn/classfun/droidvm/lib/utils/CpuUtils.java b/app/src/main/java/cn/classfun/droidvm/lib/utils/CpuUtils.java new file mode 100644 index 0000000..fdb94c0 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/utils/CpuUtils.java @@ -0,0 +1,214 @@ +package cn.classfun.droidvm.lib.utils; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.TreeSet; +import java.util.regex.Pattern; + +/** + * Host CPU topology helper: enumerates cores, reads each core's max frequency + * from sysfs and groups them into frequency tiers so callers can tell the + * little cluster apart from the big/prime clusters (for CPU-affinity binding). + * Reads are best-effort; a core whose frequency cannot be read is reported with + * {@code maxFreqKHz == 0} and treated as tier 0. + */ +public final class CpuUtils { + private static final String TAG = "CpuUtils"; + private static final String CPU_ROOT = "/sys/devices/system/cpu"; + private static final Pattern CPU_DIR = Pattern.compile("cpu\\d+"); + + private CpuUtils() { + } + + /** A single host CPU core and its cluster classification. */ + public static final class CpuCore { + public final int index; + public final long maxFreqKHz; // 0 when unknown + public final int tier; // 0 = lowest-freq cluster, ascending + public final boolean big; // true when not in the lowest-freq cluster + + CpuCore(int index, long maxFreqKHz, int tier, boolean big) { + this.index = index; + this.maxFreqKHz = maxFreqKHz; + this.tier = tier; + this.big = big; + } + } + + /** + * Enumerate host cores sorted by index, each tagged with its frequency tier. + * Never returns null; falls back to {@link Runtime#availableProcessors()} + * with unknown frequencies if sysfs cannot be read. + */ + @NonNull + public static List getCores() { + var indices = listCoreIndices(); + var freqs = new long[indices.size()]; + var distinct = new TreeSet(); + for (int i = 0; i < indices.size(); i++) { + freqs[i] = readMaxFreqKHz(indices.get(i)); + if (freqs[i] > 0) distinct.add(freqs[i]); + } + // Ascending tier index per distinct frequency; unknown (0) stays tier 0. + var tierOf = new ArrayList<>(distinct); + var cores = new ArrayList(indices.size()); + for (int i = 0; i < indices.size(); i++) { + int tier = freqs[i] > 0 ? tierOf.indexOf(freqs[i]) : 0; + cores.add(new CpuCore(indices.get(i), freqs[i], tier, tier > 0)); + } + return cores; + } + + /** Number of distinct frequency clusters (1 when frequencies are unknown). */ + public static int tierCount(@NonNull List cores) { + int max = 0; + for (var c : cores) max = Math.max(max, c.tier); + return max + 1; + } + + /** + * Default selection for "filter out the little cores": every core that is + * not in the lowest-frequency cluster. When there is only a single cluster + * (or detection failed) all cores are returned, since filtering is moot. + */ + @NonNull + public static String defaultBigCoresCsv() { + var cores = getCores(); + boolean single = tierCount(cores) <= 1; + var sb = new StringBuilder(); + for (var c : cores) { + if (single || c.big) { + if (sb.length() > 0) sb.append(','); + sb.append(c.index); + } + } + return sb.toString(); + } + + /** Format a KHz frequency as e.g. "2.60 GHz"; empty string when unknown. */ + @NonNull + public static String formatFreq(long khz) { + if (khz <= 0) return ""; + return String.format(Locale.US, "%.2f GHz", khz / 1_000_000.0); + } + + /** + * Collapse a core-index CSV into a compact range string, e.g. + * "4,5,6,7" -> "4-7", "0,4,5,6,7" -> "0,4-7". Returns "" for empty input. + */ + @NonNull + public static String compactRanges(@NonNull String csv) { + var nums = parseCsv(csv); + if (nums.isEmpty()) return ""; + var sb = new StringBuilder(); + int start = nums.get(0), prev = start; + for (int i = 1; i <= nums.size(); i++) { + int cur = i < nums.size() ? nums.get(i) : Integer.MIN_VALUE; + if (cur == prev + 1) { + prev = cur; + continue; + } + if (sb.length() > 0) sb.append(','); + if (start == prev) sb.append(start); + else sb.append(start).append('-').append(prev); + start = prev = cur; + } + return sb.toString(); + } + + /** + * Convert a core-index CSV into the hex CPU mask that toybox {@code taskset} + * expects (no "0x" prefix), e.g. "4,5,6,7" -> "f0", "0,1" -> "3". Returns + * "" for empty input. Bit N set means core N is allowed. + */ + @NonNull + public static String coresCsvToHexMask(@NonNull String csv) { + var nums = parseCsv(csv); + if (nums.isEmpty()) return ""; + var mask = java.math.BigInteger.ZERO; + for (int idx : nums) { + if (idx >= 0) mask = mask.setBit(idx); + } + return mask.signum() == 0 ? "" : mask.toString(16); + } + + @NonNull + private static List parseCsv(@NonNull String csv) { + var out = new ArrayList(); + if (csv.isEmpty()) return out; + for (var part : csv.split(",")) { + part = part.trim(); + if (part.isEmpty()) continue; + try { + out.add(Integer.parseInt(part)); + } catch (NumberFormatException ignored) { + } + } + return out; + } + + @NonNull + private static List listCoreIndices() { + var out = new ArrayList(); + var root = new File(CPU_ROOT); + var dirs = root.listFiles(); + if (dirs != null) { + for (var d : dirs) { + if (!d.isDirectory() || !CPU_DIR.matcher(d.getName()).matches()) continue; + try { + out.add(Integer.parseInt(d.getName().substring(3))); + } catch (NumberFormatException ignored) { + } + } + } + if (out.isEmpty()) { + // sysfs unreadable -- fall back to the runtime-reported count. + int n = Math.max(1, Runtime.getRuntime().availableProcessors()); + for (int i = 0; i < n; i++) out.add(i); + } else { + out.sort(Integer::compareTo); + } + return out; + } + + private static long readMaxFreqKHz(int index) { + // cpuinfo_max_freq is the hardware ceiling; scaling_max_freq is the + // policy ceiling (usually equal). Try the direct read first, then a + // root-backed read, before giving up on this core. + long v = tryReadFreq(fmt("%s/cpu%d/cpufreq/cpuinfo_max_freq", CPU_ROOT, index)); + if (v > 0) return v; + return tryReadFreq(fmt("%s/cpu%d/cpufreq/scaling_max_freq", CPU_ROOT, index)); + } + + private static long tryReadFreq(@NonNull String path) { + String raw = null; + try { + raw = FileUtils.readFile(path); + } catch (Exception directFailed) { + try { + raw = FileUtils.shellReadFile(path); + } catch (Exception rootFailed) { + Log.d(TAG, "freq read failed: " + path); + } + } + if (raw == null) return 0; + raw = raw.trim(); + if (raw.isEmpty()) return 0; + try { + return Long.parseLong(raw); + } catch (NumberFormatException e) { + return 0; + } + } + + @NonNull + private static String fmt(@NonNull String f, Object... args) { + return String.format(Locale.US, f, args); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/DiskOperationActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/DiskOperationActivity.java index 5a04126..6edf43a 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/DiskOperationActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/DiskOperationActivity.java @@ -230,6 +230,8 @@ public void handleOnBackPressed() { var task = new JSONObject(taskJsonStr); applyKeepCompress(getApplicationContext(), task, diskPath); var gen = new ImageCommandGenerate(diskStore); + gen.setCpuAffinity( + MainSettingsFragment.getQemuImgCpuAffinity(getApplicationContext())); cmd = gen.buildCommand(task, diskPath); taskAction = task.optString("action", ""); outputPath = gen.getOutputPath(); diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/ImageCommandGenerate.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/ImageCommandGenerate.java index 157ae6f..825285c 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/ImageCommandGenerate.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/ImageCommandGenerate.java @@ -14,6 +14,7 @@ import java.util.UUID; import cn.classfun.droidvm.lib.store.disk.DiskStore; +import cn.classfun.droidvm.lib.utils.CpuUtils; import cn.classfun.droidvm.ui.disk.create.DiskFormat; public final class ImageCommandGenerate { @@ -25,6 +26,7 @@ public final class ImageCommandGenerate { private String realPath; private String tmpPath; private String outputPath; + private String cpuAffinity = ""; private final DiskStore diskStore; public ImageCommandGenerate(@NonNull DiskStore diskStore) { @@ -35,6 +37,14 @@ public String getOutputPath() { return outputPath; } + /** + * Restrict the worker (qemu-img, or pv for clone) to the given host cores. + * Value is a taskset -c list such as "4,5,6,7"; empty means no binding. + */ + public void setCpuAffinity(String cpuAffinity) { + this.cpuAffinity = cpuAffinity == null ? "" : cpuAffinity; + } + @NonNull public String buildCommand(@NonNull JSONObject task, String diskPath) throws JSONException { this.task = task; @@ -50,6 +60,13 @@ public String buildCommand(@NonNull JSONObject task, String diskPath) throws JSO sb.append("set -x; "); sb.append(fmt("mkdir -pv %s; ", escapedString(dirname(diskPath)))); sb.append("if "); + // Bind the worker to selected host cores. The prefix goes before both + // the qemu-img and the clone/pv binary, so it covers every action. + // Uses Android's system taskset (toybox), which takes a hex CPU mask + // with no "0x" prefix -- the bundled busybox has no taskset applet. + var affinityMask = CpuUtils.coresCsvToHexMask(cpuAffinity); + if (!affinityMask.isEmpty()) + sb.append("taskset ").append(affinityMask).append(" "); if (!task.getString("action").equals("clone")) sb.append(escapedString(qemuImg)); appendAction(); diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/settings/MainSettingsFragment.java b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/MainSettingsFragment.java index e09bfb6..cd5b617 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/settings/MainSettingsFragment.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/MainSettingsFragment.java @@ -50,6 +50,7 @@ import cn.classfun.droidvm.lib.store.network.NetworkStore; import cn.classfun.droidvm.lib.store.vm.VMStore; import cn.classfun.droidvm.lib.ui.UIContext; +import cn.classfun.droidvm.lib.utils.CpuUtils; import cn.classfun.droidvm.ui.hugepage.HugePageActivity; import cn.classfun.droidvm.ui.main.base.MainBaseFragment; import cn.classfun.droidvm.ui.setup.SetupActivity; @@ -66,6 +67,10 @@ public final class MainSettingsFragment extends MainBaseFragment { public static final String KEY_VM_AUTO_CONSOLE = "vm_auto_console"; public static final String KEY_VM_CLEAR_LOGS_BEFORE_START = "vm_clear_logs_before_start"; public static final String KEY_VM_KEEP_COMPRESS_ON_OPTIMIZE = "vm_keep_compress_on_optimize"; + public static final String KEY_QEMU_IMG_CPU_AFFINITY = "qemu_img_cpu_affinity"; + // Reserved for a future crosvm/qemu VM affinity setting; not read or + // written yet, and intentionally has no UI in this release. + // public static final String KEY_CROSVM_CPU_AFFINITY = "crosvm_cpu_affinity"; public static final String KEY_AUTO_CHECK_UPDATE = "auto_check_update"; private final Handler mainHandler = new Handler(Looper.getMainLooper()); private final Runnable daemonStatusRefreshRunnable = this::periodRefreshDaemonStatus; @@ -80,6 +85,7 @@ public final class MainSettingsFragment extends MainBaseFragment { private SwitchRowWidget itemVMAutoConsole; private SwitchRowWidget itemVMClearLogsBeforeStart; private SwitchRowWidget itemVMKeepCompressOnOptimize; + private TextRowWidget itemCpuAffinity; private TextRowWidget itemLicense; private SwitchRowWidget itemAutoCheckUpdate; private TextRowWidget itemCheckUpdate; @@ -133,6 +139,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat itemVMAutoConsole = view.findViewById(R.id.item_vm_auto_console); itemVMClearLogsBeforeStart = view.findViewById(R.id.item_vm_clear_logs_before_start); itemVMKeepCompressOnOptimize = view.findViewById(R.id.item_vm_keep_compress_on_optimize); + itemCpuAffinity = view.findViewById(R.id.item_cpu_affinity); itemLicense = view.findViewById(R.id.item_license); itemAutoCheckUpdate = view.findViewById(R.id.item_auto_check_update); itemCheckUpdate = view.findViewById(R.id.item_check_update); @@ -164,6 +171,8 @@ private void initSettings() { bindOnChecked(itemVMAutoConsole, KEY_VM_AUTO_CONSOLE, false); bindOnChecked(itemVMClearLogsBeforeStart, KEY_VM_CLEAR_LOGS_BEFORE_START, false); bindOnChecked(itemVMKeepCompressOnOptimize, KEY_VM_KEEP_COMPRESS_ON_OPTIMIZE, false); + bindOnClick(itemCpuAffinity, this::showCpuAffinityDialog); + refreshCpuAffinitySummary(); bindOnChecked(itemAutoCheckUpdate, KEY_AUTO_CHECK_UPDATE, true); bindOnClick(itemCheckUpdate, this::checkUpdate); bindOnClick(itemPrivacy, this::showPrivacyPolicy); @@ -217,6 +226,16 @@ public static boolean isAutoCheckUpdateEnabled(@NonNull Context context) { return prefs.getBoolean(KEY_AUTO_CHECK_UPDATE, true); } + /** + * qemu-img CPU affinity as a taskset -c core list (e.g. "4,5,6,7"). + * Empty string means no binding (qemu-img may use every core). + */ + @NonNull + public static String getQemuImgCpuAffinity(@NonNull Context context) { + var prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + return prefs.getString(KEY_QEMU_IMG_CPU_AFFINITY, ""); + } + private void onRefreshDaemonStatus(boolean r) { itemDaemonStatus.setSubtitle(r ? R.string.settings_daemon_running : R.string.settings_daemon_stopped); @@ -276,6 +295,93 @@ private void showLanguageDialog() { .show(); } + private void refreshCpuAffinitySummary() { + if (itemCpuAffinity == null) return; + var csv = getQemuImgCpuAffinity(requireContext()); + if (csv.isEmpty()) { + itemCpuAffinity.setSubtitle(R.string.settings_cpu_affinity_all_cores); + } else { + itemCpuAffinity.setSubtitle(getString( + R.string.settings_cpu_affinity_summary_fmt, CpuUtils.compactRanges(csv))); + } + } + + private void showCpuAffinityDialog() { + var ctx = requireContext(); + var cores = CpuUtils.getCores(); + int tiers = CpuUtils.tierCount(cores); + var labels = new String[cores.size()]; + for (int i = 0; i < cores.size(); i++) + labels[i] = cpuCoreLabel(cores.get(i), tiers); + + // Saved selection, or the "filter out little cores" default when unset. + var savedCsv = getQemuImgCpuAffinity(ctx); + var selectedIdx = parseCsvToSet( + savedCsv.isEmpty() ? CpuUtils.defaultBigCoresCsv() : savedCsv); + var checked = new boolean[cores.size()]; + for (int i = 0; i < cores.size(); i++) + checked[i] = selectedIdx.contains(cores.get(i).index); + + var dialog = new MaterialAlertDialogBuilder(ctx) + .setTitle(R.string.settings_cpu_affinity_title) + .setMultiChoiceItems(labels, checked, (d, which, isChecked) -> + checked[which] = isChecked) + .setNeutralButton(R.string.settings_cpu_affinity_big_only, null) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, (d, w) -> { + var sb = new StringBuilder(); + for (int i = 0; i < cores.size(); i++) { + if (!checked[i]) continue; + if (sb.length() > 0) sb.append(','); + sb.append(cores.get(i).index); + } + ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit().putString(KEY_QEMU_IMG_CPU_AFFINITY, sb.toString()).apply(); + refreshCpuAffinitySummary(); + }) + .create(); + dialog.show(); + // Re-check only the big cores without dismissing the dialog. + dialog.getButton(android.app.AlertDialog.BUTTON_NEUTRAL).setOnClickListener(v -> { + var bigIdx = parseCsvToSet(CpuUtils.defaultBigCoresCsv()); + var list = dialog.getListView(); + for (int i = 0; i < cores.size(); i++) { + checked[i] = bigIdx.contains(cores.get(i).index); + list.setItemChecked(i, checked[i]); + } + }); + } + + @NonNull + private String cpuCoreLabel(@NonNull CpuUtils.CpuCore core, int tiers) { + var freq = CpuUtils.formatFreq(core.maxFreqKHz); + int tierRes; + if (tiers <= 1) tierRes = 0; + else if (core.tier == 0) tierRes = R.string.settings_cpu_affinity_tier_little; + else if (tiers >= 3 && core.tier == tiers - 1) + tierRes = R.string.settings_cpu_affinity_tier_prime; + else tierRes = R.string.settings_cpu_affinity_tier_big; + var sb = new StringBuilder(fmt("CPU%d", core.index)); + if (!freq.isEmpty()) sb.append(" ").append(freq); + if (tierRes != 0) sb.append(" ").append(getString(tierRes)); + return sb.toString(); + } + + @NonNull + private static java.util.Set parseCsvToSet(@NonNull String csv) { + var set = new java.util.HashSet(); + if (csv.isEmpty()) return set; + for (var part : csv.split(",")) { + part = part.trim(); + if (part.isEmpty()) continue; + try { + set.add(Integer.parseInt(part)); + } catch (NumberFormatException ignored) { + } + } + return set; + } + private void showLicenseDialog() { new SoftwareLicenseDialog(requireContext()).show(); } diff --git a/app/src/main/res/layout/fragment_main_settings.xml b/app/src/main/res/layout/fragment_main_settings.xml index 250c048..5deeeb4 100644 --- a/app/src/main/res/layout/fragment_main_settings.xml +++ b/app/src/main/res/layout/fragment_main_settings.xml @@ -71,6 +71,14 @@ android:subtitle="@string/settings_vm_keep_compress_on_optimize_summary" android:text="@string/settings_vm_keep_compress_on_optimize_title" /> + + 启动虚拟机前清理该虚拟机的所有控制台日志 优化时保持压缩 优化磁盘时保留其原有压缩而非解压;关闭则重写为未压缩 + CPU 亲和性 + 磁盘操作运行于核心 %1$s + 磁盘操作可使用全部核心 + 仅大核 + 小核 + 大核 + 超大核 关于 问题反馈 在 GitHub 上报告问题 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 13af394..a12832a 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -365,6 +365,13 @@ 啟動虛擬機器前清理該虛擬機器的所有控制台日誌 最佳化時保持壓縮 最佳化磁碟時保留其原有壓縮而非解壓縮;關閉則重寫為未壓縮 + CPU 親和性 + 磁碟操作執行於核心 %1$s + 磁碟操作可使用全部核心 + 僅大核 + 小核 + 大核 + 超大核 關於 問題反饋 在 GitHub 上報告問題 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3ca8687..f8b427d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -371,6 +371,13 @@ Clear all console logs for the VM before it starts Keep compression when optimizing Preserve a disk\'s existing compression when optimizing instead of decompressing it; off rewrites the disk uncompressed + CPU affinity + Disk ops run on cores %1$s + Disk ops may use all cores + Big cores only + little + big + prime About Feedback Report issues on GitHub From 5b4d041a141e6512d7a950444c18a1042c371f21 Mon Sep 17 00:00:00 2001 From: lateautumn233 Date: Mon, 6 Jul 2026 13:49:52 +0800 Subject: [PATCH 4/5] refactor(disk): address review feedback on CPU affinity and image utils - CrosvmBackendInstance: drop the no-op cpu-affinity TODO comment - CpuUtils: reuse SizeUtils.findFloatUnit for frequency formatting and StringUtils.fmt instead of a local fmt helper - ImageUtils: split out getImageCheck() mirroring getImageInfo() - MainSettingsFragment: import Set/HashSet, drop commented-out reserved key --- .../vm/backend/CrosvmBackendInstance.java | 5 ---- .../classfun/droidvm/lib/utils/CpuUtils.java | 23 +++++++++------- .../droidvm/lib/utils/ImageUtils.java | 26 ++++++++++++------- .../main/settings/MainSettingsFragment.java | 9 +++---- 4 files changed, 34 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/CrosvmBackendInstance.java b/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/CrosvmBackendInstance.java index 756bba5..f83505f 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/CrosvmBackendInstance.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/CrosvmBackendInstance.java @@ -135,11 +135,6 @@ private List buildCommand() { var item = config.item; var args = new ArrayList(); prepareStraceArguments(args); - // TODO(cpu-affinity): to bind the VM to selected host cores, prepend a - // system-taskset wrapper here (mirroring prepareStraceArguments): - // "taskset -a ", with the mask from CpuUtils.coresCsvToHexMask - // reading a future MainSettingsFragment.KEY_CROSVM_CPU_AFFINITY setting. - // Note toybox taskset takes a hex mask (no "0x"), not a -c core list. args.add(getPrebuiltBinaryPath("crosvm")); // Top-level flag (must precede `run`): makes crosvm surface CommandStatus // exit codes (see CrosvmExit) so runVM() can tell reset/crash/panic apart. diff --git a/app/src/main/java/cn/classfun/droidvm/lib/utils/CpuUtils.java b/app/src/main/java/cn/classfun/droidvm/lib/utils/CpuUtils.java index fdb94c0..72c0937 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/utils/CpuUtils.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/utils/CpuUtils.java @@ -1,5 +1,7 @@ package cn.classfun.droidvm.lib.utils; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + import android.util.Log; import androidx.annotation.NonNull; @@ -7,10 +9,11 @@ import java.io.File; import java.util.ArrayList; import java.util.List; -import java.util.Locale; import java.util.TreeSet; import java.util.regex.Pattern; +import cn.classfun.droidvm.lib.size.SizeUtils; + /** * Host CPU topology helper: enumerates cores, reads each core's max frequency * from sysfs and groups them into frequency tiers so callers can tell the @@ -91,11 +94,18 @@ public static String defaultBigCoresCsv() { return sb.toString(); } - /** Format a KHz frequency as e.g. "2.60 GHz"; empty string when unknown. */ + // Hz-family labels indexed by SizeUtils tier (B/KB/MB/GB/... -> Hz/kHz/...). + private static final String[] FREQ_UNITS = {"Hz", "kHz", "MHz", "GHz", "THz"}; + + /** Format a KHz frequency as e.g. "2.42 GHz"; empty string when unknown. */ @NonNull public static String formatFreq(long khz) { if (khz <= 0) return ""; - return String.format(Locale.US, "%.2f GHz", khz / 1_000_000.0); + var pair = SizeUtils.findFloatUnit(khz * 1000L); + int tier = pair.getUnit().ordinal(); + String unit = tier < FREQ_UNITS.length + ? FREQ_UNITS[tier] : pair.getUnit().getString(); + return fmt("%s %s", pair.getNumberFloat(2), unit); } /** @@ -194,7 +204,7 @@ private static long tryReadFreq(@NonNull String path) { try { raw = FileUtils.shellReadFile(path); } catch (Exception rootFailed) { - Log.d(TAG, "freq read failed: " + path); + Log.d(TAG, fmt("freq read failed: %s", path)); } } if (raw == null) return 0; @@ -206,9 +216,4 @@ private static long tryReadFreq(@NonNull String path) { return 0; } } - - @NonNull - private static String fmt(@NonNull String f, Object... args) { - return String.format(Locale.US, f, args); - } } diff --git a/app/src/main/java/cn/classfun/droidvm/lib/utils/ImageUtils.java b/app/src/main/java/cn/classfun/droidvm/lib/utils/ImageUtils.java index 9e3b829..ba4d304 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/utils/ImageUtils.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/utils/ImageUtils.java @@ -29,6 +29,21 @@ public static JSONObject getImageInfo(String path) throws JSONException { return new JSONObject(result.getOutString()); } + @NonNull + public static JSONObject getImageCheck(String path) throws JSONException { + var result = runListQuiet( + getPrebuiltBinaryPath("qemu-img"), + "check", "--output=json", path + ); + // qemu-img check still writes its JSON report on non-zero exits + // (leaks/corruptions), so parse whatever it produced rather than + // gating on isSuccess(); a missing/garbage body throws. + var out = result.getOutString(); + if (out == null || out.isEmpty()) + throw new JSONException("qemu-img check produced no output"); + return new JSONObject(out); + } + /** * Whether {@code path} actually stores compressed clusters, per * {@code qemu-img check}'s {@code compressed-clusters} count. This is the @@ -41,16 +56,7 @@ public static JSONObject getImageInfo(String path) throws JSONException { */ public static boolean hasCompressedClusters(String path) { try { - var result = runListQuiet( - getPrebuiltBinaryPath("qemu-img"), - "check", "--output=json", path); - var out = result.getOutString(); - // qemu-img check still writes its JSON report on non-zero exits - // (leaks/corruptions), so parse whatever it produced rather than - // gating on isSuccess(); a missing/garbage body throws -> false. - if (out == null || out.isEmpty()) - return false; - return new JSONObject(out).optLong("compressed-clusters", 0) > 0; + return getImageCheck(path).optLong("compressed-clusters", 0) > 0; } catch (Exception e) { return false; } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/settings/MainSettingsFragment.java b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/MainSettingsFragment.java index cd5b617..72f80ba 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/settings/MainSettingsFragment.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/MainSettingsFragment.java @@ -36,7 +36,9 @@ import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; import java.util.Date; +import java.util.HashSet; import java.util.Locale; +import java.util.Set; import cn.classfun.droidvm.BuildConfig; import cn.classfun.droidvm.R; @@ -68,9 +70,6 @@ public final class MainSettingsFragment extends MainBaseFragment { public static final String KEY_VM_CLEAR_LOGS_BEFORE_START = "vm_clear_logs_before_start"; public static final String KEY_VM_KEEP_COMPRESS_ON_OPTIMIZE = "vm_keep_compress_on_optimize"; public static final String KEY_QEMU_IMG_CPU_AFFINITY = "qemu_img_cpu_affinity"; - // Reserved for a future crosvm/qemu VM affinity setting; not read or - // written yet, and intentionally has no UI in this release. - // public static final String KEY_CROSVM_CPU_AFFINITY = "crosvm_cpu_affinity"; public static final String KEY_AUTO_CHECK_UPDATE = "auto_check_update"; private final Handler mainHandler = new Handler(Looper.getMainLooper()); private final Runnable daemonStatusRefreshRunnable = this::periodRefreshDaemonStatus; @@ -368,8 +367,8 @@ else if (tiers >= 3 && core.tier == tiers - 1) } @NonNull - private static java.util.Set parseCsvToSet(@NonNull String csv) { - var set = new java.util.HashSet(); + private static Set parseCsvToSet(@NonNull String csv) { + var set = new HashSet(); if (csv.isEmpty()) return set; for (var part : csv.split(",")) { part = part.trim(); From 2a3aead25393d694181496cbddd298bae01f7bb9 Mon Sep 17 00:00:00 2001 From: lateautumn233 Date: Mon, 6 Jul 2026 14:14:57 +0800 Subject: [PATCH 5/5] fix(cpu): format CPU frequency with decimal (SI) units findFloatUnit uses binary 1024 steps, which underreports clock speeds (2.60 GHz shown as 2.42 GHz). Frequencies are SI/decimal, so pick the Hz/kHz/MHz/GHz tier by dividing by 1000. --- .../classfun/droidvm/lib/utils/CpuUtils.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/cn/classfun/droidvm/lib/utils/CpuUtils.java b/app/src/main/java/cn/classfun/droidvm/lib/utils/CpuUtils.java index 72c0937..571414a 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/utils/CpuUtils.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/utils/CpuUtils.java @@ -12,8 +12,6 @@ import java.util.TreeSet; import java.util.regex.Pattern; -import cn.classfun.droidvm.lib.size.SizeUtils; - /** * Host CPU topology helper: enumerates cores, reads each core's max frequency * from sysfs and groups them into frequency tiers so callers can tell the @@ -94,18 +92,20 @@ public static String defaultBigCoresCsv() { return sb.toString(); } - // Hz-family labels indexed by SizeUtils tier (B/KB/MB/GB/... -> Hz/kHz/...). + // Frequency uses decimal (SI) steps of 1000, unlike SizeUtils' binary units. private static final String[] FREQ_UNITS = {"Hz", "kHz", "MHz", "GHz", "THz"}; - /** Format a KHz frequency as e.g. "2.42 GHz"; empty string when unknown. */ + /** Format a KHz frequency as e.g. "2.60 GHz"; empty string when unknown. */ @NonNull public static String formatFreq(long khz) { if (khz <= 0) return ""; - var pair = SizeUtils.findFloatUnit(khz * 1000L); - int tier = pair.getUnit().ordinal(); - String unit = tier < FREQ_UNITS.length - ? FREQ_UNITS[tier] : pair.getUnit().getString(); - return fmt("%s %s", pair.getNumberFloat(2), unit); + double value = khz * 1000.0; + int tier = 0; + while (value >= 1000.0 && tier < FREQ_UNITS.length - 1) { + value /= 1000.0; + tier++; + } + return fmt("%.2f %s", value, FREQ_UNITS[tier]); } /**