diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicDownloadManager.java b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicDownloadManager.java index 8cd7324f9..4f219425b 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicDownloadManager.java +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicDownloadManager.java @@ -138,12 +138,10 @@ public final class EpicDownloadManager { File installDir = new File(installDirPath); installDir.mkdirs(); - // The native engine keeps its cache beside the game (its contract names /.chunks); - // the scratch path is honoured by the Java pool and the assembly. A path on a card and a - // native run therefore still cache on the card until the engine takes the parameter. - boolean scratch = chunkCacheDirPath != null && !chunkCacheDirPath.isEmpty(); - File engineCacheDir = new File(installDir, ".chunks"); - File chunkCacheDir = scratch ? new File(chunkCacheDirPath) : engineCacheDir; + // "" = the cache beside the game; a path = the scratch cache (the app's own storage when + // the game goes to a card). The engine and the Java loops read the same value. + final String cachePath = chunkCacheDirPath == null ? "" : chunkCacheDirPath; + final File chunkCacheDir = cachePath.isEmpty() ? new File(installDir, ".chunks") : new File(cachePath); chunkCacheDir.mkdirs(); List selected = resolveInstallFiles(manifest, installTags); @@ -185,12 +183,13 @@ public final class EpicDownloadManager { cb.onLog("epic: " + fmt(fTotalBytes) + " in " + totalChunks + " chunks"); boolean javaPool = true; - if (com.droiddeck.launcher.stores.StoresNative.INSTANCE.getAvailable()) { - NativeOutcome r = runNativePool(ctx, manifestBytes, manifest, pending, needed, fTotalBytes, installDirPath, cdnUrls, cancel, cb, + final int[] pendingIdx = pendingIndices(manifest, pending); + boolean engine = com.droiddeck.launcher.stores.StoresNative.INSTANCE.getAvailable() && pendingIdx != null; + if (engine) { + NativeOutcome r = runNativePool(ctx, manifestBytes, pendingIdx, needed, fTotalBytes, installDirPath, cachePath, cdnUrls, cancel, cb, completedBytes, completedCount, lastSpeedMs, lastSpeedBytes, speedBps); if (r.started) { javaPool = false; - chunkCacheDir = engineCacheDir; if (r.cancelled) return null; if (!r.success) { cb.onLog("epic: engine failed: " + r.error); failCount.incrementAndGet(); } } else cb.onLog("epic: engine not started (" + r.error + "); using the built-in pool"); @@ -198,12 +197,11 @@ public final class EpicDownloadManager { if (javaPool) { cb.onLog("epic: engine=built-in (8 threads)"); ExecutorService pool = Executors.newFixedThreadPool(8); - final File poolCacheDir = chunkCacheDir; for (ChunkInfo chunk : needed) { final ChunkInfo fc = chunk; pool.submit(() -> { if (cancel.get()) return; - File cached = new File(poolCacheDir, fc.guidStr()); + File cached = new File(chunkCacheDir, fc.guidStr()); if (!cached.exists() && !downloadChunkStreaming(fc, manifest.chunkDir, cdnUrls, cached)) { cb.onLog("FAIL chunk=" + fc.guidStr()); failCount.incrementAndGet(); @@ -230,6 +228,18 @@ public final class EpicDownloadManager { if (cancel.get()) return null; if (failCount.get() > 0) throw new InstallException(failCount.get() + " chunk(s) failed to download"); + // The engine's assembler when it starts (it drops each chunk after its last use and the + // cache at the end); the loop below when it does not. + if (engine) { + NativeOutcome r = runNativeAssembly(manifestBytes, pendingIdx, pending.size(), installDirPath, cachePath, cancel, cb); + if (r.started) { + if (r.cancelled) return null; + if (!r.success) throw new InstallException(r.error.isEmpty() ? "assembly failed" : r.error); + cb.onProgress("Complete", 100); + return new Result(manifest.launchExe, manifest.buildVersion, planned); + } + cb.onLog("epic: assembler not started (" + r.error + "); writing the files here"); + } int totalFiles = pending.size(), doneFiles = 0; for (FileInfo file : pending) { if (cancel.get()) return null; @@ -248,8 +258,7 @@ public final class EpicDownloadManager { } doneFiles++; } - deleteDir(engineCacheDir); - if (scratch) deleteDir(new File(chunkCacheDirPath)); + deleteDir(chunkCacheDir); cb.onProgress("Complete", 100); return new Result(manifest.launchExe, manifest.buildVersion, planned); } catch (InstallException e) { @@ -262,28 +271,33 @@ public final class EpicDownloadManager { private static final class NativeOutcome { boolean started, success, cancelled; String error = ""; } + /** The pending files as indices into the manifest's file list, what both native runs take; null when one is not in it. */ + private static int[] pendingIndices(Manifest manifest, List pending) { + java.util.IdentityHashMap index = new java.util.IdentityHashMap<>(); + for (int i = 0; i < manifest.files.size(); i++) index.put(manifest.files.get(i), i); + int[] pendingIdx = new int[pending.size()]; + for (int i = 0; i < pending.size(); i++) { + Integer k = index.get(pending.get(i)); + if (k == null) return null; + pendingIdx[i] = k; + } + return pendingIdx; + } + /** The native chunk pool on the same counters; never throws. */ - private static NativeOutcome runNativePool(Context ctx, byte[] manifestBytes, Manifest manifest, List pending, List needed, long totalBytes, - String installDirPath, List cdnUrls, AtomicBoolean cancel, Callback cb, + private static NativeOutcome runNativePool(Context ctx, byte[] manifestBytes, int[] pendingIdx, List needed, long totalBytes, + String installDirPath, String cachePath, List cdnUrls, AtomicBoolean cancel, Callback cb, AtomicLong completedBytes, AtomicInteger completedCount, AtomicLong lastSpeedMs, AtomicLong lastSpeedBytes, AtomicLong speedBps) { NativeOutcome r = new NativeOutcome(); try { - java.util.IdentityHashMap index = new java.util.IdentityHashMap<>(); - for (int i = 0; i < manifest.files.size(); i++) index.put(manifest.files.get(i), i); - int[] pendingIdx = new int[pending.size()]; - for (int i = 0; i < pending.size(); i++) { - Integer k = index.get(pending.get(i)); - if (k == null) { r.error = "pending file not in manifest"; return r; } - pendingIdx[i] = k; - } String[] prefixes = new String[cdnUrls.size()]; for (int i = 0; i < cdnUrls.size(); i++) prefixes[i] = cdnUrls.get(i).baseUrl + cdnUrls.get(i).cloudDir; final int totalChunks = needed.size(); StoreDownloadTier tier = StoreDownloadTier.Companion.current(ctx); int workers = Math.max(1, Math.min(128, tier.getNetworkWindow())); int process = Math.max(2, tier.getProcessWorkers()); - cb.onLog("epic: engine=native workers=" + workers + " process_workers=" + process + " tier=" + tier.getId()); - EpicNative.Result res = EpicNative.run(manifestBytes, installDirPath, prefixes, pendingIdx, totalChunks, totalBytes, "", workers, process, cancel, + cb.onLog("epic: engine=native workers=" + workers + " process_workers=" + process + " tier=" + tier.getId() + (cachePath.isEmpty() ? "" : " cache=scratch")); + EpicNative.Result res = EpicNative.run(manifestBytes, installDirPath, cachePath, prefixes, pendingIdx, totalChunks, totalBytes, "", workers, process, cancel, new EpicNative.Listener() { @Override public void onPlan(int chunksTotal, long bytesTotal, String chunkDir) { cb.onLog("epic: plan chunks=" + chunksTotal + " bytes=" + bytesTotal + " dir=" + chunkDir); } @Override public void onProgress(long bytesDone, long bytesTotal, int chunksDone, int chunksTotal) { @@ -304,6 +318,27 @@ public final class EpicDownloadManager { return r; } + /** The native assembler: the pending files written from the cache, the cache removed on success; never throws. */ + private static NativeOutcome runNativeAssembly(byte[] manifestBytes, int[] pendingIdx, int totalFiles, String installDirPath, String cachePath, AtomicBoolean cancel, Callback cb) { + NativeOutcome r = new NativeOutcome(); + try { + cb.onLog("epic: assembler=native files=" + totalFiles + (cachePath.isEmpty() ? "" : " cache=scratch")); + EpicNative.Result res = EpicNative.assemble(manifestBytes, installDirPath, cachePath, pendingIdx, cancel, new EpicNative.Listener() { + @Override public void onPlan(int chunksTotal, long bytesTotal, String chunkDir) {} + @Override public void onProgress(long bytesDone, long bytesTotal, int done, int total) { + cb.onProgress("Writing files (" + done + "/" + Math.max(total, totalFiles) + ")", 80 + (int) (done * 20L / Math.max(1, Math.max(total, totalFiles)))); + } + @Override public void onLog(String line) { cb.onLog(line); } + @Override public void onComplete(boolean success, String error, long bytes) {} + }); + r.started = res.started; r.success = res.success; r.cancelled = res.cancelled; r.error = res.error == null ? "" : res.error; + } catch (Throwable t) { + r.started = false; + r.error = t.getClass().getSimpleName() + ": " + t.getMessage(); + } + return r; + } + // ---- CDN list and manifest download --------------------------------------------------------- public static List parseCdnUrls(String json) { diff --git a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicNative.kt b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicNative.kt index 9d1a6c7a2..59849fd60 100644 --- a/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicNative.kt +++ b/app/src/main/java/com/droiddeck/launcher/stores/epic/EpicNative.kt @@ -9,41 +9,56 @@ import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference /** - * The native Epic chunk fetcher in `libdroiddeckstores.so`: it fills `/.chunks/` - * with verified, decompressed chunks for the files the manager says are pending. Everything - * around it (manifest, install tags, delta pass, assembly, post-install) stays in the manager. + * The native half of an Epic install in `libdroiddeckstores.so`: [run] fills the chunk cache with + * verified, decompressed chunks for the files the manager says are pending, [assemble] writes those + * files from the cache. Everything around them (manifest, install tags, delta pass, post-install) + * stays in the manager. * - * [run] blocks like the Java pool it replaces and polls [AtomicBoolean] `cancel` every 250 ms: on - * cancel it flips the native flag, waits up to 5 s for the run to wind down, then returns. + * The cache is `/.chunks` when `chunkCacheDir` is `""`, else the scratch folder given - + * the app's own cache when the game installs to a card, where the assembler drops each chunk right + * after its last use and the folder at the end. Both calls take the same value for one install. + * + * Both block like the Java loops they replace and poll [AtomicBoolean] `cancel` every 250 ms: on + * cancel they flip the native flag, wait up to 5 s for the run to wind down, then return. */ object EpicNative { private const val TAG = "EpicNative" - /** Callbacks from the native run; every method is called on a native thread. */ + /** Callbacks from a native run; every method is called on a native thread. One interface serves both runs. */ interface Listener { - /** Once, before any fetch: the plan the engine derived (the manager cross-checks it). */ + /** Fetch only, once before any fetch: the plan the engine derived (the manager cross-checks it). */ fun onPlan(chunksTotal: Int, bytesTotal: Long, chunkDir: String) - /** Per accounted chunk (cached-skip or fetched). */ - fun onProgress(bytesDone: Long, bytesTotal: Long, chunksDone: Int, chunksTotal: Int) + /** Fetch: per accounted chunk (cached-skip or fetched), done/total = chunks. Assembly: per file written, done/total = files. */ + fun onProgress(bytesDone: Long, bytesTotal: Long, done: Int, total: Int) /** Engine log line (already in logcat under `EpicNative`). */ fun onLog(line: String) - /** Terminal. */ - fun onComplete(success: Boolean, error: String, bytesCredited: Long) + /** Terminal. Fetch: bytes credited; assembly: bytes written. */ + fun onComplete(success: Boolean, error: String, bytes: Long) } - /** Outcome of [run]. `started == false`: nothing was fetched, the caller runs its own pool. */ + /** Outcome of a run. `started == false`: nothing happened on disk, the caller runs its own loop. */ class Result( @JvmField val started: Boolean, @JvmField val success: Boolean, @JvmField val cancelled: Boolean, - @JvmField val error: String, @JvmField val bytesCredited: Long, @JvmField val chunksDone: Int, @JvmField val chunksTotal: Int, + @JvmField val error: String, @JvmField val bytes: Long, @JvmField val done: Int, @JvmField val total: Int, ) private class Completion(val success: Boolean, val error: String, val bytes: Long) + /** The fetch: chunks for [pendingFileIdx] into the cache. */ @JvmStatic fun run( - manifest: ByteArray, installDir: String, cdnPrefixes: Array, pendingFileIdx: IntArray, expectedChunks: Int, expectedBytes: Long, + manifest: ByteArray, installDir: String, chunkCacheDir: String, cdnPrefixes: Array, pendingFileIdx: IntArray, expectedChunks: Int, expectedBytes: Long, caBundlePath: String, maxWorkers: Int, processWorkers: Int, cancel: AtomicBoolean?, listener: Listener, - ): Result { + ): Result = drive("nativeStart", cancel, listener) { inner -> + nativeStart(manifest, installDir, chunkCacheDir, cdnPrefixes, pendingFileIdx, expectedChunks, expectedBytes, caBundlePath, maxWorkers, processWorkers, inner) + } + + /** The assembly: the files [fileIdx] written from the cache, the cache removed on success. */ + @JvmStatic + fun assemble(manifest: ByteArray, installDir: String, chunkCacheDir: String, fileIdx: IntArray, cancel: AtomicBoolean?, listener: Listener): Result = + drive("nativeAssemble", cancel, listener) { inner -> nativeAssemble(manifest, installDir, chunkCacheDir, fileIdx, inner) } + + private fun drive(what: String, cancel: AtomicBoolean?, listener: Listener, start: (Listener) -> Long): Result { if (!StoresNative.ensureLoaded()) return Result(false, false, false, "engine not built", 0L, 0, 0) val latch = CountDownLatch(1) val completion = AtomicReference(null) @@ -51,19 +66,19 @@ object EpicNative { val totalRef = AtomicReference(0) val inner = object : Listener { override fun onPlan(chunksTotal: Int, bytesTotal: Long, chunkDir: String) { totalRef.set(chunksTotal); listener.onPlan(chunksTotal, bytesTotal, chunkDir) } - override fun onProgress(bytesDone: Long, bytesTotal: Long, chunksDone: Int, chunksTotal: Int) { doneRef.set(chunksDone); listener.onProgress(bytesDone, bytesTotal, chunksDone, chunksTotal) } + override fun onProgress(bytesDone: Long, bytesTotal: Long, done: Int, total: Int) { doneRef.set(done); totalRef.set(total); listener.onProgress(bytesDone, bytesTotal, done, total) } override fun onLog(line: String) = listener.onLog(line) - override fun onComplete(success: Boolean, error: String, bytesCredited: Long) { - completion.set(Completion(success, error, bytesCredited)) + override fun onComplete(success: Boolean, error: String, bytes: Long) { + completion.set(Completion(success, error, bytes)) latch.countDown() - runCatching { listener.onComplete(success, error, bytesCredited) } + runCatching { listener.onComplete(success, error, bytes) } } } val handle: Long = try { - nativeStart(manifest, installDir, cdnPrefixes, pendingFileIdx, expectedChunks, expectedBytes, caBundlePath, maxWorkers, processWorkers, inner) + start(inner) } catch (t: Throwable) { Log.w(TAG, "engine unavailable: ${t.javaClass.simpleName}: ${t.message}") - return Result(false, false, false, "nativeStart: ${t.javaClass.simpleName}", 0L, 0, 0) + return Result(false, false, false, "$what: ${t.javaClass.simpleName}", 0L, 0, 0) } if (handle == 0L) return Result(false, false, false, completion.get()?.error ?: "not started", 0L, 0, totalRef.get()) var cancelSent = false @@ -87,10 +102,13 @@ object EpicNative { @JvmStatic private external fun nativeStart( - manifest: ByteArray, installDir: String, cdnPrefixes: Array, pendingFileIdx: IntArray, expectedChunks: Int, expectedBytes: Long, + manifest: ByteArray, installDir: String, chunkCacheDir: String, cdnPrefixes: Array, pendingFileIdx: IntArray, expectedChunks: Int, expectedBytes: Long, caBundlePath: String, maxWorkers: Int, processWorkers: Int, listener: Listener, ): Long + @JvmStatic + private external fun nativeAssemble(manifest: ByteArray, installDir: String, chunkCacheDir: String, fileIdx: IntArray, listener: Listener): Long + @JvmStatic private external fun nativeCancel(handle: Long) diff --git a/docs/development/stores.md b/docs/development/stores.md index 7407578c8..4617042d2 100644 --- a/docs/development/stores.md +++ b/docs/development/stores.md @@ -24,9 +24,11 @@ scanned (`StoreInstallRoot.roots`): internal, every card, and the Steam library' installs an earlier build put there. `` is `GOG`, `Epic` or `Amazon`; `` is the title with unsafe characters dropped, at most 60 characters (`StoreInstallRoot.folderName`), chosen once; a rerun (repair, update) lands on the folder whose sidecar carries the game's id, wherever it is. -An Epic install on a card keeps its in-flight chunks in `cacheDir/stores/epic/<id>/` (the Java pool -and the assembly honour it; the native engine's own cache stays `<installDir>/.chunks` until its -contract takes a cache path); the scratch folder is removed on cancel and uninstall. +An Epic install on a card keeps its in-flight chunks in `cacheDir/stores/epic/<id>/`: the +`chunkCacheDir` of both native calls (`EpicNative.run` fetches into it, `EpicNative.assemble` +writes the files from it and drops each chunk after its last use, then the folder), and of the +manager's own loops when the engine is not there; `""` keeps the cache beside the game, as an +internal install has it. The scratch folder is removed on cancel-with-delete and on uninstall. ## The sidecar diff --git a/docs/progress-log.md b/docs/progress-log.md index e295fe9e8..e4cdfc879 100644 --- a/docs/progress-log.md +++ b/docs/progress-log.md @@ -111,9 +111,10 @@ without it and Setup says "Stores engine not built"). already registered there keep their appid); every root is scanned and uninstall works for either. The Downloads row shows where a game is going, with its art as a small 16:9 thumbnail; the game page names the - place. Epic installs on a card keep their in-flight chunks in the app's cache (Java pool and - assembly; the native engine's `chunkCacheDir` parameter is not in JNI.md yet and is wired the - moment it lands), cleaned on cancel and uninstall. + place. Epic installs on a card keep their in-flight chunks in the app's cache: the engine's + `chunkCacheDir` (fetch and the new `nativeAssemble`, which drops each chunk after its last use) + and the manager's own loops read the same path; internal installs pass `""` as before. Cleaned on + cancel-with-delete and uninstall. - **Device-proven on the AYANEO Pocket FIT, 2026-10-08 (GOG):** sign-in, the library (33 owned), DOOM I Enhanced installed through the GOG engine to the SD Games root, shown in Games with the GOG chip and real art, registered as a non-Steam game in the Steam client (grid art present), and