Skip to content
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
a5dfbb1
added initial download view w/steam support
eredisg Mar 12, 2026
d05ce1e
Merge branch 'master' of https://github.com/eredisg/GameNative
eredisg Mar 12, 2026
cb6e979
added initial epic games support
eredisg Mar 13, 2026
aa9e92a
added initial GOG support
eredisg Mar 13, 2026
3a8a03e
added initial amazon games support
eredisg Mar 13, 2026
f62d3e0
updated DownloadsViewModel.kt
eredisg Mar 13, 2026
11a80c4
added partial_install column to db and updated amazon, gog, and epic
eredisg Mar 14, 2026
94aa7b1
moved downloads screen to sidebar
eredisg Mar 14, 2026
d579277
added the ability to resume downloading
eredisg Mar 14, 2026
3bca522
removed duplicative code
eredisg Mar 14, 2026
fa86c33
Add global container storage manager
xXJSONDeruloXx Mar 11, 2026
9ecbfd2
Refine container manager UI and logging
xXJSONDeruloXx Mar 11, 2026
ea6c9fd
fix: add edge cases for container without install, install without co…
xXJSONDeruloXx Mar 11, 2026
cac5164
feat: allow move to ext or int if enabled
xXJSONDeruloXx Mar 12, 2026
800d134
feat: move storage manager into downloads tab
xXJSONDeruloXx Mar 14, 2026
0b65053
Refine downloads and storage manager sidebar UI
xXJSONDeruloXx Mar 14, 2026
c4d58d4
Merge remote-tracking branch 'upstream/master' into feat/downloads-st…
xXJSONDeruloXx Mar 14, 2026
23749c3
Merge remote-tracking branch 'upstream/master' into feat/downloads-st…
xXJSONDeruloXx Mar 15, 2026
9833f61
Merge remote-tracking branch 'origin/feat/downloads-storage-manager-t…
xXJSONDeruloXx Mar 15, 2026
646fd66
Fixed portrait mode issue with download view
eredisg Mar 17, 2026
7fc4416
Merge pull request #12 from eredisg/feat/downloads-storage-manager-tab
xXJSONDeruloXx Mar 17, 2026
7ebc53e
Merge remote-tracking branch 'upstream/master' into feat/downloads-st…
xXJSONDeruloXx Mar 20, 2026
81341c3
feat: improve downloads manager
xXJSONDeruloXx Mar 20, 2026
83dde8d
chore: make focus colors consistent purple, update header names and t…
xXJSONDeruloXx Mar 22, 2026
ee1c6f2
Refine downloads and storage manager UI
xXJSONDeruloXx Mar 22, 2026
274240d
Merge remote-tracking branch 'upstream/master' into feat/downloads-st…
xXJSONDeruloXx Mar 22, 2026
b320045
fix: move downloads pause cancel off main thread
xXJSONDeruloXx Mar 22, 2026
fe422a8
Fix manifest driver id for CI
xXJSONDeruloXx Mar 22, 2026
780bb31
Merge remote-tracking branch 'upstream/master' into feat/downloads-st…
xXJSONDeruloXx Mar 25, 2026
193c451
fix: restore inbound moves and amazon download details
xXJSONDeruloXx Mar 25, 2026
fd3d2ed
fix: remove stale friends home destination
xXJSONDeruloXx Mar 25, 2026
0ec73b7
fix: drop download speed from downloads manager
xXJSONDeruloXx Mar 25, 2026
79841f9
refactor: dedupe partial download detection
xXJSONDeruloXx Mar 25, 2026
1525e13
fix: add translations for downloads and storage
xXJSONDeruloXx Mar 25, 2026
611c1b2
fix(downloads): remove manager polling and reuse known sizes
xXJSONDeruloXx Mar 27, 2026
96ff5ae
fix(downloads): avoid full library partial-download scans
xXJSONDeruloXx Mar 27, 2026
a1c6ea3
fix(downloads): address cubic follow-up findings
xXJSONDeruloXx Mar 27, 2026
d43a6d9
fix(downloads): coalesce refreshes and estimate Steam sizes
xXJSONDeruloXx Mar 27, 2026
508333b
fix(downloads): preserve initial refresh and filter Steam size estimates
xXJSONDeruloXx Mar 27, 2026
8a067ac
fix(storage): use lightweight Steam depot size estimates
xXJSONDeruloXx Mar 27, 2026
f5e607e
fix(storage): estimate Steam sizes from installed depots
xXJSONDeruloXx Mar 27, 2026
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
3 changes: 3 additions & 0 deletions app/src/main/java/app/gamenative/db/dao/AmazonGameDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ interface AmazonGameDao {
@Query("SELECT * FROM amazon_games ORDER BY title ASC")
suspend fun getAllAsList(): List<AmazonGame>

@Query("SELECT * FROM amazon_games WHERE is_installed = 0")
suspend fun getNonInstalledGames(): List<AmazonGame>

@Query(
"UPDATE amazon_games SET is_installed = 1, install_path = :path, install_size = :size, version_id = :versionId WHERE product_id = :productId",
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ interface DownloadingAppInfoDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(appInfo: DownloadingAppInfo)

@Query("SELECT * FROM downloading_app_info")
suspend fun getAll(): List<DownloadingAppInfo>

@Query("SELECT * FROM downloading_app_info WHERE appId = :appId")
suspend fun getDownloadingApp(appId: Int): DownloadingAppInfo?

@Query("DELETE from downloading_app_info WHERE appId = :appId")
suspend fun deleteApp(appId: Int)

@Query("SELECT * FROM downloading_app_info")
suspend fun getAll(): List<DownloadingAppInfo>

@Query("DELETE from downloading_app_info")
suspend fun deleteAll()
}
6 changes: 6 additions & 0 deletions app/src/main/java/app/gamenative/db/dao/EpicGameDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,15 @@ interface EpicGameDao {
@Query("SELECT * FROM epic_games WHERE is_dlc = 0 AND namespace != 'ue' AND namespace != '89efe5924d3d467c839449ab6ab52e7f' ORDER BY title ASC")
fun getAll(): Flow<List<EpicGame>>

@Query("SELECT * FROM epic_games WHERE is_dlc = 0 AND namespace != 'ue' AND namespace != '89efe5924d3d467c839449ab6ab52e7f' ORDER BY title ASC")
suspend fun getAllAsList(): List<EpicGame>

@Query("SELECT * FROM epic_games WHERE is_installed = :isInstalled ORDER BY title ASC")
fun getByInstallStatus(isInstalled: Boolean): Flow<List<EpicGame>>

@Query("SELECT * FROM epic_games WHERE is_installed = 0 ORDER BY title ASC")
suspend fun getNonInstalledGames(): List<EpicGame>

@Query("SELECT * FROM epic_games WHERE base_game_app_name = (SELECT catalog_id FROM epic_games WHERE id = :appId)")
fun getDLCForTitle(appId: Int): Flow<List<EpicGame>>

Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/app/gamenative/db/dao/GOGGameDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ interface GOGGameDao {
@Query("SELECT * FROM gog_games WHERE is_installed = :isInstalled AND exclude = 0 ORDER BY title ASC")
fun getByInstallStatus(isInstalled: Boolean): Flow<List<GOGGame>>

@Query("SELECT * FROM gog_games WHERE is_installed = 0 AND exclude = 0")
suspend fun getNonInstalledGames(): List<GOGGame>

@Query("SELECT * FROM gog_games WHERE exclude = 0 AND title LIKE '%' || :searchQuery || '%' ORDER BY title ASC")
fun searchByTitle(searchQuery: String): Flow<List<GOGGame>>

Expand Down
11 changes: 11 additions & 0 deletions app/src/main/java/app/gamenative/db/dao/SteamAppDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,17 @@ interface SteamAppDao {
flow { emit(_getAllOwnedAppsPaged(invalidPkgId)) }
}

@Query(
"SELECT * FROM steam_app " +
"WHERE id != 480 " +
Comment thread
xXJSONDeruloXx marked this conversation as resolved.
"AND package_id != :invalidPkgId " +
"AND type != 0 " +
"ORDER BY LOWER(name)",
)
suspend fun getAllOwnedAppsAsList(
invalidPkgId: Int = INVALID_PKG_ID,
): List<SteamApp>

@Query("SELECT * FROM steam_app WHERE received_pics = 0 AND package_id != :invalidPkgId AND owner_account_id = :ownerId")
fun getAllOwnedAppsWithoutPICS(
ownerId: Int,
Expand Down
11 changes: 10 additions & 1 deletion app/src/main/java/app/gamenative/service/SteamService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ class SteamService : Service(), IChallengeUrlChanged {
}

val dirPath = getAppDirPath(appId)
return File(dirPath).exists() && !MarkerUtils.hasMarker(dirPath, Marker.DOWNLOAD_COMPLETE_MARKER)
return MarkerUtils.hasPartialInstall(dirPath)
}

private val syncInProgressApps = ConcurrentHashMap<Int, AtomicBoolean>()
Expand Down Expand Up @@ -564,6 +564,15 @@ class SteamService : Service(), IChallengeUrlChanged {
return downloadJobs[appId]
}

fun getActiveDownloads(): Map<Int, DownloadInfo> = HashMap(downloadJobs)

suspend fun getPartialDownloads(): List<Int> {
return instance?.downloadingAppInfoDao?.getAll()
Comment thread
xXJSONDeruloXx marked this conversation as resolved.
?.map { it.appId }
?.filter { appId -> !downloadJobs.containsKey(appId) }
?: emptyList()
}
Comment thread
xXJSONDeruloXx marked this conversation as resolved.

fun isAppInstalled(appId: Int): Boolean {
return MarkerUtils.hasMarker(getAppDirPath(appId), Marker.DOWNLOAD_COMPLETE_MARKER)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,26 @@ object AmazonConstants {
// ── SDK / Launcher channel ──────────────────────────────────────────────
const val LAUNCHER_CHANNEL_ID = "87d38116-4cbf-4af0-a371-a5b498975346"

fun internalAmazonGamesPath(context: Context): String {
val path = Paths.get(context.dataDir.path, "Amazon").toString()
File(path).mkdirs()
return path
}

fun externalAmazonGamesPath(): String {
val path = Paths.get(PrefManager.externalStoragePath, "Amazon", "games").toString()
File(path).mkdirs()
return path
}

fun defaultAmazonGamesPath(context: Context): String {
return if (PrefManager.useExternalStorage && File(PrefManager.externalStoragePath).exists()) {
val path = Paths.get(PrefManager.externalStoragePath, "Amazon", "games").toString()
val path = externalAmazonGamesPath()
Timber.i("Amazon using external storage: $path")
File(path).mkdirs()
path
} else {
val path = Paths.get(context.dataDir.path, "Amazon").toString()
val path = internalAmazonGamesPath(context)
Timber.i("Amazon using internal storage: $path")
File(path).mkdirs()
path
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ class AmazonManager @Inject constructor(
amazonGameDao.getAllAsList()
}

/** Return non-installed Amazon games from DB. */
suspend fun getNonInstalledGames(): List<AmazonGame> = withContext(Dispatchers.IO) {
amazonGameDao.getNonInstalledGames()
}

/** Mark a game as installed and persist install metadata. */
suspend fun markInstalled(productId: String, installPath: String, installSize: Long, versionId: String = "") =
withContext(Dispatchers.IO) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,41 @@ class AmazonService : Service() {
fun getDownloadInfo(productId: String): DownloadInfo? =
getInstance()?.activeDownloads?.get(productId)

fun getActiveDownloads(): Map<String, DownloadInfo> =
getInstance()?.activeDownloads?.let { HashMap(it) } ?: emptyMap()

private fun getPartialInstallPaths(context: Context): Set<String> {
val roots = buildList {
add(AmazonConstants.internalAmazonGamesPath(context))
if (app.gamenative.PrefManager.externalStoragePath.isNotBlank()) {
add(AmazonConstants.externalAmazonGamesPath())
}
}.distinct()

return roots.asSequence()
.flatMap { root -> MarkerUtils.findResumablePartialInstalls(root).asSequence() }
.toSet()
}

suspend fun getPartialDownloads(context: Context): List<String> {
val instance = getInstance() ?: return emptyList()
Comment thread
xXJSONDeruloXx marked this conversation as resolved.
val partialInstallPaths = getPartialInstallPaths(context)
if (partialInstallPaths.isEmpty()) return emptyList()

return instance.amazonManager.getNonInstalledGames()
.asSequence()
.filter { game -> !instance.activeDownloads.containsKey(game.productId) }
.filter { game ->
val expectedPaths = buildList {
game.installPath.takeIf { it.isNotBlank() }?.let(::add)
add(AmazonConstants.getGameInstallPath(context, game.title))
}
expectedPaths.any(partialInstallPaths::contains)
}
.map { it.productId }
.toList()
}

/** Returns the active [DownloadInfo] for [appId], or null if not downloading. */
fun getDownloadInfoByAppId(appId: Int): DownloadInfo? {
val productId = getProductIdByAppId(appId) ?: return null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,7 @@ class EpicDownloadManager @Inject constructor(
downloadInfo.updateStatusMessage("Complete")
// Ensure bytes-based progress shows 100% completion
downloadInfo.updateBytesDownloaded(downloadInfo.getTotalExpectedBytes() - downloadInfo.getBytesDownloaded())
downloadInfo.clearPersistedBytesDownloaded(installPath)
downloadInfo.setProgress(1.0f)
downloadInfo.setActive(false)
downloadInfo.emitProgressChange()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ class EpicManager @Inject constructor(
}

// Skip invalid platform (such as Android versions)
if(platforms.isNotEmpty() && !platforms.contains("Win32") && !platforms.contains("Windows")){
if(platforms.isNotEmpty() && !platforms.contains("Win32") && !platforms.contains("Windows")){
Timber.tag("Epic").d("Skipping due to invalid platform: $appName (namespace=$namespace, sandbox=$sandboxType)")
continue
}
Expand Down Expand Up @@ -718,6 +718,12 @@ class EpicManager @Inject constructor(
}
}

suspend fun getNonInstalledGames(): List<EpicGame> {
return withContext(Dispatchers.IO) {
epicGameDao.getNonInstalledGames()
}
}

/**
* Start background sync (called after login)
*/
Expand Down
47 changes: 47 additions & 0 deletions app/src/main/java/app/gamenative/service/epic/EpicService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,47 @@ class EpicService : Service() {
return getInstance()?.activeDownloads?.get(appId)
}

fun getActiveDownloads(): Map<Int, DownloadInfo> =
getInstance()?.activeDownloads?.let { HashMap(it) } ?: emptyMap()

fun hasPartialDownload(context: Context, appId: Int): Boolean {
val game = getEpicGameOf(appId) ?: return false
Comment thread
xXJSONDeruloXx marked this conversation as resolved.
if (game.isInstalled) return false
val appName = game.appName.ifBlank { return false }
val installPath = EpicConstants.getGameInstallPath(context, appName)
Comment thread
xXJSONDeruloXx marked this conversation as resolved.
return MarkerUtils.hasPartialInstall(installPath)
}

private fun getPartialInstallPaths(context: Context): Set<String> {
val roots = buildList {
add(EpicConstants.internalEpicGamesPath(context))
if (app.gamenative.PrefManager.externalStoragePath.isNotBlank()) {
add(EpicConstants.externalEpicGamesPath())
}
}.distinct()

return roots.asSequence()
.flatMap { root -> MarkerUtils.findResumablePartialInstalls(root).asSequence() }
.toSet()
}

suspend fun getPartialDownloads(): List<Int> {
val instance = getInstance() ?: return emptyList()
val context = instance.applicationContext
val partialInstallPaths = getPartialInstallPaths(context)
if (partialInstallPaths.isEmpty()) return emptyList()

return instance.epicManager.getNonInstalledGames()
.asSequence()
.filter { game -> !instance.activeDownloads.containsKey(game.id) }
.filter { game ->
val appName = game.appName.ifBlank { return@filter false }
partialInstallPaths.contains(EpicConstants.getGameInstallPath(context, appName))
}
.map { it.id }
.toList()
}

suspend fun deleteGame(context: Context, appId: Int): Result<Unit> {
val instance = getInstance()
if (instance == null) {
Expand Down Expand Up @@ -369,6 +410,12 @@ class EpicService : Service() {
gameId = appId,
downloadingAppIds = CopyOnWriteArrayList<Int>(),
)
downloadInfo.setPersistencePath(installPath)

val persistedBytes = downloadInfo.loadPersistedBytesDownloaded(installPath)
if (persistedBytes > 0L) {
downloadInfo.initializeBytesDownloaded(persistedBytes)
}

instance.activeDownloads[appId] = downloadInfo
downloadInfo.setActive(true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,7 @@ class GOGDownloadManager @Inject constructor(
if (game != null) {
val installSize = calculateDirectorySize(installPath)
gogManager.updateGame(game.copy(isInstalled = true, installPath = installPath.absolutePath, installSize = installSize))
downloadInfo.clearPersistedBytesDownloaded(installPath.absolutePath)
Timber.tag("GOG").i("Updated database: game marked as installed, size: ${installSize / 1_000_000} MB")
} else {
Timber.tag("GOG").w("Game $gameId not found in database, skipping DB update")
Expand Down
17 changes: 10 additions & 7 deletions app/src/main/java/app/gamenative/service/gog/GOGManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ class GOGManager @Inject constructor(
}
}

suspend fun getNonInstalledGames(): List<GOGGame> {
return withContext(Dispatchers.IO) { gogGameDao.getNonInstalledGames() }
}

suspend fun insertGame(game: GOGGame) {
withContext(Dispatchers.IO) {
gogGameDao.insert(game)
Expand Down Expand Up @@ -468,6 +472,7 @@ class GOGManager @Inject constructor(
val gameId = libraryItem.gameId.toString()
val installPath = getGameInstallPath(gameId, libraryItem.name)
val installDir = File(installPath)
val hadInstallArtifacts = installDir.exists() || MarkerUtils.hasPartialInstall(installPath)
val wasInstalled = MarkerUtils.hasMarker(installPath, Marker.DOWNLOAD_COMPLETE_MARKER)

// Delete the manifest file
Expand All @@ -493,13 +498,11 @@ class GOGManager @Inject constructor(
MarkerUtils.removeMarker(installPath, Marker.DOWNLOAD_COMPLETE_MARKER)
MarkerUtils.removeMarker(installPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER)

if (wasInstalled) {
val game = getGameFromDbById(gameId)
if (game != null) {
val updatedGame = game.copy(isInstalled = false, installPath = "")
gogGameDao.update(updatedGame)
Timber.d("Updated database: game marked as not installed")
}
val game = getGameFromDbById(gameId)
if (game != null && (wasInstalled || hadInstallArtifacts)) {
val updatedGame = game.copy(isInstalled = false, installPath = "")
gogGameDao.update(updatedGame)
Timber.d("Updated database: game marked as not installed")
}

withContext(Dispatchers.Main) {
Expand Down
52 changes: 52 additions & 0 deletions app/src/main/java/app/gamenative/service/gog/GOGService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,52 @@ class GOGService : Service() {
return getInstance()?.activeDownloads?.get(gameId)
}

fun getActiveDownloads(): Map<String, DownloadInfo> =
getInstance()?.activeDownloads?.let { HashMap(it) } ?: emptyMap()

private fun hasPartialDownload(game: GOGGame): Boolean {
if (game.isInstalled) return false
val title = game.title.ifBlank { return false }
val installPath = GOGConstants.getGameInstallPath(title)
return app.gamenative.utils.MarkerUtils.hasPartialInstall(installPath)
}

fun hasPartialDownload(gameId: String, fallbackTitle: String? = null): Boolean {
getGOGGameOf(gameId)?.let { return hasPartialDownload(it) }
Comment thread
xXJSONDeruloXx marked this conversation as resolved.
val title = fallbackTitle?.ifBlank { null } ?: return false
val installPath = GOGConstants.getGameInstallPath(title)
return app.gamenative.utils.MarkerUtils.hasPartialInstall(installPath)
}

private fun getPartialInstallPaths(): Set<String> {
val roots = buildList {
add(GOGConstants.internalGOGGamesPath)
if (app.gamenative.PrefManager.externalStoragePath.isNotBlank()) {
add(GOGConstants.externalGOGGamesPath)
}
}.distinct()

return roots.asSequence()
.flatMap { root -> app.gamenative.utils.MarkerUtils.findResumablePartialInstalls(root).asSequence() }
.toSet()
}

suspend fun getPartialDownloads(): List<String> {
val instance = getInstance() ?: return emptyList()
val partialInstallPaths = getPartialInstallPaths()
if (partialInstallPaths.isEmpty()) return emptyList()

return instance.gogManager.getNonInstalledGames()
.asSequence()
.filter { game -> !instance.activeDownloads.containsKey(game.id) }
.filter { game ->
val title = game.title.ifBlank { return@filter false }
partialInstallPaths.contains(GOGConstants.getGameInstallPath(title))
}
.map { it.id }
.toList()
}

fun cleanupDownload(gameId: String) {
getInstance()?.activeDownloads?.remove(gameId)
}
Expand Down Expand Up @@ -294,6 +340,12 @@ class GOGService : Service() {

// Create DownloadInfo for progress tracking
val downloadInfo = DownloadInfo(jobCount = 1, gameId = 0, downloadingAppIds = CopyOnWriteArrayList<Int>())
downloadInfo.setPersistencePath(installPath)

val persistedBytes = downloadInfo.loadPersistedBytesDownloaded(installPath)
if (persistedBytes > 0L) {
downloadInfo.initializeBytesDownloaded(persistedBytes)
}

// Track in activeDownloads first
instance.activeDownloads[gameId] = downloadInfo
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/app/gamenative/ui/PluviaMain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1258,7 +1258,7 @@ fun PluviaMain(
},
)
}
/** Library, Downloads, Friends **/
/** Library, Downloads **/
composable(
route = PluviaScreen.Home.route + "?offline={offline}",
deepLinks = listOf(navDeepLink { uriPattern = "pluvia://home" }),
Expand Down
Loading
Loading