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..571414a --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/utils/CpuUtils.java @@ -0,0 +1,219 @@ +package cn.classfun.droidvm.lib.utils; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +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(); + } + + // 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.60 GHz"; empty string when unknown. */ + @NonNull + public static String formatFreq(long khz) { + if (khz <= 0) return ""; + 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]); + } + + /** + * 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, fmt("freq read failed: %s", path)); + } + } + if (raw == null) return 0; + raw = raw.trim(); + if (raw.isEmpty()) return 0; + try { + return Long.parseLong(raw); + } catch (NumberFormatException e) { + return 0; + } + } +} 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..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 @@ -28,4 +28,37 @@ 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 + * 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 { + return getImageCheck(path).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/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 12a3b1e..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 @@ -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) { @@ -144,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) { @@ -221,7 +228,10 @@ public void handleOnBackPressed() { final String cmd; try { 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(); @@ -235,6 +245,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..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(); @@ -118,9 +135,13 @@ 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"); + // 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; } @@ -129,11 +150,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..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; @@ -50,6 +52,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; @@ -65,6 +68,8 @@ 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_QEMU_IMG_CPU_AFFINITY = "qemu_img_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; @@ -78,6 +83,8 @@ public final class MainSettingsFragment extends MainBaseFragment { private TextRowWidget itemDaemonRestart; private SwitchRowWidget itemVMAutoConsole; private SwitchRowWidget itemVMClearLogsBeforeStart; + private SwitchRowWidget itemVMKeepCompressOnOptimize; + private TextRowWidget itemCpuAffinity; private TextRowWidget itemLicense; private SwitchRowWidget itemAutoCheckUpdate; private TextRowWidget itemCheckUpdate; @@ -130,6 +137,8 @@ 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); + 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); @@ -160,6 +169,9 @@ 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); + bindOnClick(itemCpuAffinity, this::showCpuAffinityDialog); + refreshCpuAffinitySummary(); bindOnChecked(itemAutoCheckUpdate, KEY_AUTO_CHECK_UPDATE, true); bindOnClick(itemCheckUpdate, this::checkUpdate); bindOnClick(itemPrivacy, this::showPrivacyPolicy); @@ -203,11 +215,26 @@ 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); } + /** + * 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); @@ -267,6 +294,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 Set parseCsvToSet(@NonNull String csv) { + var set = new 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/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" /> + + diff --git a/app/src/main/res/layout/fragment_main_settings.xml b/app/src/main/res/layout/fragment_main_settings.xml index 948cdf8..5deeeb4 100644 --- a/app/src/main/res/layout/fragment_main_settings.xml +++ b/app/src/main/res/layout/fragment_main_settings.xml @@ -63,6 +63,22 @@ android:subtitle="@string/settings_vm_clear_logs_before_start_summary" android:text="@string/settings_vm_clear_logs_before_start_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 ca90d62..a12832a 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -363,6 +363,15 @@ 啟動虛擬機器時自動開啟控制台 啟動前清理日誌 啟動虛擬機器前清理該虛擬機器的所有控制台日誌 + 最佳化時保持壓縮 + 最佳化磁碟時保留其原有壓縮而非解壓縮;關閉則重寫為未壓縮 + CPU 親和性 + 磁碟操作執行於核心 %1$s + 磁碟操作可使用全部核心 + 僅大核 + 小核 + 大核 + 超大核 關於 問題反饋 在 GitHub 上報告問題 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7692a34..f8b427d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -369,6 +369,15 @@ 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 + 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