Skip to content
Open
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
45 changes: 45 additions & 0 deletions app/src/main/java/com/winlator/core/FileUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,21 @@
import java.io.RandomAccessFile;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Stack;
import java.util.UUID;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicLong;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;

Expand Down Expand Up @@ -179,6 +185,45 @@ public static boolean isEmpty(File targetFile) {
else return targetFile.length() == 0;
}

public static void mergeDirectoryRecursively(File srcFile, File dstFile, OnFileMergedListener onProgress) throws IOException {
Path source = srcFile.toPath();
Path target = dstFile.toPath();

Files.createDirectories(target);

AtomicLong totalFiles = new AtomicLong(0);
try (var stream = Files.walk(source)) {
stream.filter(Files::isRegularFile)
.forEach(p -> totalFiles.incrementAndGet());
}

long total = totalFiles.get();
AtomicLong processed = new AtomicLong(0);

Files.walkFileTree(source, new SimpleFileVisitor<>() {
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
Path targetDir = target.resolve(source.relativize(dir));
Files.createDirectories(targetDir);
return FileVisitResult.CONTINUE;
}

@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
Path targetFile = target.resolve(source.relativize(file));
Files.copy(file, targetFile, LinkOption.NOFOLLOW_LINKS, StandardCopyOption.REPLACE_EXISTING);

long done = processed.incrementAndGet();

if (onProgress != null) {
onProgress.onFileMerged(done, total);
}

return FileVisitResult.CONTINUE;
}
});
}

public static boolean copy(File srcFile, File dstFile) {
return copy(srcFile, dstFile, null);
}
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/com/winlator/core/OnFileMergedListener.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.winlator.core;

public interface OnFileMergedListener {
void onFileMerged(long done, long filesCount);
}
125 changes: 74 additions & 51 deletions app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import android.content.res.AssetManager;
import android.util.Log;

import androidx.annotation.NonNull;

import app.gamenative.R;
import app.gamenative.enums.Marker;
import app.gamenative.service.SteamService;
Expand Down Expand Up @@ -77,6 +79,7 @@ public static void installWineFromDownloads(final Context context) {
for (String version : versions) {
File downloaded = new File(imageFs.getFilesDir(), version + ".txz");
File outFile = new File(rootDir, "/opt/" + version);
if (outFile.exists()) continue;
outFile.mkdirs();
TarCompressorUtils.extract(
TarCompressorUtils.Type.XZ,
Expand All @@ -98,44 +101,7 @@ private static Future<Boolean> installFromAssetsFuture(final Context context, As
// dialog.show(R.string.installing_system_files);
return Executors.newSingleThreadExecutor().submit(() -> {
clearRootDir(context, rootDir);
final byte compressionRatio = 22;
String imagefsFile = containerVariant.equals(Container.GLIBC) ? "imagefs_gamenative.txz" : "imagefs_bionic.txz";
File downloaded = new File(imageFs.getFilesDir(), imagefsFile);

boolean success = false;

if (Arrays.asList(context.getAssets().list("")).contains(imagefsFile) == true){
final long contentLength = (long) (FileUtils.getSize(assetManager, imagefsFile) * (100.0f / compressionRatio));
AtomicLong totalSizeRef = new AtomicLong();
Log.d("Extraction", "extracting " + imagefsFile);

success = TarCompressorUtils.extract(TarCompressorUtils.Type.XZ, assetManager, imagefsFile, rootDir, (file, size) -> {
if (size > 0) {
long totalSize = totalSizeRef.addAndGet(size);
if (onProgress != null) {
final int progress = (int) (((float) totalSize / contentLength) * 100);
onProgress.call(progress);
}
}
return file;
});
}

else if (downloaded.exists()){
final long contentLength = (long) (FileUtils.getSize(downloaded) * (100.0f / compressionRatio));
AtomicLong totalSizeRef = new AtomicLong();
Log.d("Extraction", "extracting " + imagefsFile);
success = TarCompressorUtils.extract(TarCompressorUtils.Type.XZ, downloaded, rootDir, (file, size) -> {
if (size > 0) {
long totalSize = totalSizeRef.addAndGet(size);
if (onProgress != null) {
final int progress = (int) (((float) totalSize / contentLength) * 100);
onProgress.call(progress);
}
}
return file;
});
}
boolean success = extractImageFs(context, assetManager, containerVariant, onProgress, imageFs, rootDir);

if (success) {
Log.d("ImageFsInstaller", "Successfully installed system files");
Expand All @@ -158,6 +124,76 @@ else if (downloaded.exists()){
});
}

private static void mergeImageFsFolder(Context context, String containerVariant, File rootDir, Callback<Integer> onProgress, Boolean isCleanInstall) throws IOException {
File variantDir = getVariantDir(context, containerVariant);
FileUtils.mergeDirectoryRecursively(variantDir, rootDir, (done, filesCount) -> {
if (onProgress == null) return;

int progress = (int) (((float) done / filesCount) * 100);
if (isCleanInstall) {
onProgress.call(progress / 2 + 50);
} else {
onProgress.call(progress);
}
});
}

private static boolean extractImageFs(Context context, AssetManager assetManager, String containerVariant, Callback<Integer> onProgress, ImageFs imageFs, File rootDir) throws IOException {
final byte compressionRatio = 22;

String imagefsFile = containerVariant.equals(Container.GLIBC) ? "imagefs_gamenative.txz" : "imagefs_bionic.txz";
File downloaded = new File(context.getFilesDir(), imagefsFile);

File variantDir = getVariantDir(context, containerVariant);
if (variantDir.exists()) {
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Feb 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Cache reuse is based solely on variantDir.exists() without any integrity/marker check, so a partially extracted or failed cache can be treated as valid on subsequent runs and permanently merge corrupted data into the container.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java, line 146:

<comment>Cache reuse is based solely on `variantDir.exists()` without any integrity/marker check, so a partially extracted or failed cache can be treated as valid on subsequent runs and permanently merge corrupted data into the container.</comment>

<file context>
@@ -158,6 +124,74 @@ else if (downloaded.exists()){
+        File downloaded = new File(context.getFilesDir(), imagefsFile);
+
+        File variantDir = getVariantDir(context, containerVariant);
+        if (variantDir.exists()) {
+            mergeImageFsFolder(context, containerVariant, rootDir, onProgress, false);
+            return true;
</file context>
Fix with Cubic

mergeImageFsFolder(context, containerVariant, rootDir, onProgress, false);
return true;
}


boolean success = false;

if (Arrays.asList(context.getAssets().list("")).contains(imagefsFile) == true){
final long contentLength = (long) (FileUtils.getSize(assetManager, imagefsFile) * (100.0f / compressionRatio));
AtomicLong totalSizeRef = new AtomicLong();
Log.d("Extraction", "extracting " + imagefsFile);

success = TarCompressorUtils.extract(TarCompressorUtils.Type.XZ, assetManager, imagefsFile, variantDir, (file, size) -> {
if (size > 0) {
long totalSize = totalSizeRef.addAndGet(size);
if (onProgress != null) {
final int progress = (int) (((float) totalSize / contentLength) * 100 / 2);
onProgress.call(progress);
}
}
return file;
});
}

else if (downloaded.exists()){
final long contentLength = (long) (FileUtils.getSize(downloaded) * (100.0f / compressionRatio));
AtomicLong totalSizeRef = new AtomicLong();
Log.d("Extraction", "extracting " + imagefsFile);
success = TarCompressorUtils.extract(TarCompressorUtils.Type.XZ, downloaded, variantDir, (file, size) -> {
if (size > 0) {
long totalSize = totalSizeRef.addAndGet(size);
if (onProgress != null) {
final int progress = (int) (((float) totalSize / contentLength) * 100 / 2);
onProgress.call(progress);
}
}
return file;
});
}
mergeImageFsFolder(context, containerVariant, rootDir, onProgress, true);
return success;
}
Comment on lines +141 to +190
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

mergeImageFsFolder called even when extraction fails; potential merge on empty directory.

When neither the asset nor the downloaded file exists, success remains false, yet mergeImageFsFolder (line 188) is still called on variantDir. This will either:

  1. Fail with IOException if variantDir doesn't exist
  2. Merge an empty/incomplete directory if a previous partial extraction left artifacts

Consider guarding the merge call:

🐛 Proposed fix
         else if (downloaded.exists()){
             // ... extraction logic ...
         }
-        mergeImageFsFolder(context, containerVariant, rootDir, onProgress, true);
-        return success;
+
+        if (success) {
+            mergeImageFsFolder(context, containerVariant, rootDir, onProgress, true);
+        }
+        return success;
     }

Additionally, line 156 has a redundant == true comparison that can be simplified:

-        if (Arrays.asList(context.getAssets().list("")).contains(imagefsFile) == true){
+        if (Arrays.asList(context.getAssets().list("")).contains(imagefsFile)) {
🤖 Prompt for AI Agents
In `@app/src/main/java/com/winlator/xenvironment/ImageFsInstaller.java` around
lines 141 - 190, The extractImageFs method currently always calls
mergeImageFsFolder even when extraction failed; change it to only call
mergeImageFsFolder if extraction succeeded or the variantDir actually exists
(e.g., if (success || variantDir.exists()) { mergeImageFsFolder(...) }), so you
don't merge an empty/nonexistent folder; also simplify the contains check by
removing the redundant "== true" in the Arrays.asList(...).contains(imagefsFile)
condition. Ensure you reference extractImageFs, variantDir, success, and
mergeImageFsFolder when making the edits.


@NonNull
private static File getVariantDir(Context context, String containerVariant) {
return new File(context.getFilesDir(), containerVariant.equals(Container.GLIBC) ? "glibc/imagefs" : "bionic/imagefs");
}

private static void installGuestLibs(Context ctx) {
final String ASSET_TAR = "redirect.tzst"; // ➊ add this to assets/
File imagefs = new File(ctx.getFilesDir(), "imagefs");
Expand Down Expand Up @@ -216,19 +252,6 @@ private static boolean isImportedWineProton(Context context, String fileName) {
return false;
}

// Get bundled versions from resource arrays
String[] bionicWineEntries = context.getResources().getStringArray(R.array.bionic_wine_entries);
String[] glibcWineEntries = context.getResources().getStringArray(R.array.glibc_wine_entries);

// Check if it's a bundled version
for (String version : bionicWineEntries) {
if (lowerName.equals(version.toLowerCase())) return false;
}
for (String version : glibcWineEntries) {
if (lowerName.equals(version.toLowerCase())) return false;
}

// It's Wine/Proton but not bundled, so it's imported
return true;
}

Expand Down