From 96786d11ce830552e73ca1c4af02ddde329df641 Mon Sep 17 00:00:00 2001 From: Ashank Sundaram <81349208+aShanki@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:49:59 +0000 Subject: [PATCH 1/3] Harden persistent download recovery --- .../launchly/data/ManagedVersionDaoTest.kt | 19 ++++ .../java/com/zeuroux/launchly/AppContainer.kt | 4 + .../zeuroux/launchly/auth/AuthRepository.kt | 41 +++++-- .../com/zeuroux/launchly/data/Entities.kt | 3 +- .../launchly/download/DownloadPolicies.kt | 88 ++++++++++++++- .../download/VersionDownloadWorker.kt | 99 +++++++++++++++-- .../zeuroux/launchly/gplay/GPlayService.kt | 4 +- .../java/com/zeuroux/launchly/ui/Screens.kt | 4 +- app/src/main/res/values/strings.xml | 2 +- .../launchly/auth/AuthRepositoryTest.kt | 81 ++++++++++++++ .../launchly/download/DownloadPoliciesTest.kt | 100 ++++++++++++++++++ 11 files changed, 418 insertions(+), 27 deletions(-) create mode 100644 app/src/test/java/com/zeuroux/launchly/auth/AuthRepositoryTest.kt diff --git a/app/src/androidTest/java/com/zeuroux/launchly/data/ManagedVersionDaoTest.kt b/app/src/androidTest/java/com/zeuroux/launchly/data/ManagedVersionDaoTest.kt index 5e575ec..34cc697 100644 --- a/app/src/androidTest/java/com/zeuroux/launchly/data/ManagedVersionDaoTest.kt +++ b/app/src/androidTest/java/com/zeuroux/launchly/data/ManagedVersionDaoTest.kt @@ -5,6 +5,7 @@ import androidx.room.Room import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.zeuroux.launchly.model.Architecture +import com.zeuroux.launchly.model.DownloadStatus import com.zeuroux.launchly.model.ReleaseTrack import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -40,4 +41,22 @@ class ManagedVersionDaoTest { assertEquals(listOf(version), database.managedVersionDao().observeAll().first()) assertEquals(emptyList(), database.downloadRecordDao().observeAll().first()) } + + @Test + fun updatingManagedVersionPreservesDownloadRecord() = runBlocking { + val version = ManagedVersionEntity( + "22222222-2222-2222-2222-222222222222", "Original", 972604031, "1.26.40.31", + ReleaseTrack.BETA, Architecture.ARM64, null, 1, 1 + ) + val download = DownloadRecordEntity( + version.id, null, DownloadStatus.READY, 100, 100, null, null, null, 1 + ) + database.managedVersionDao().upsert(version) + database.downloadRecordDao().upsert(download) + + database.managedVersionDao().upsert(version.copy(displayName = "Renamed", updatedAt = 2)) + + assertEquals("Renamed", database.managedVersionDao().get(version.id)?.displayName) + assertEquals(download, database.downloadRecordDao().get(version.id)) + } } diff --git a/app/src/main/java/com/zeuroux/launchly/AppContainer.kt b/app/src/main/java/com/zeuroux/launchly/AppContainer.kt index e81503b..81ca681 100644 --- a/app/src/main/java/com/zeuroux/launchly/AppContainer.kt +++ b/app/src/main/java/com/zeuroux/launchly/AppContainer.kt @@ -14,6 +14,7 @@ import com.zeuroux.launchly.data.ManagedVersionRepository import com.zeuroux.launchly.data.RoomManagedVersionRepository import com.zeuroux.launchly.download.DownloadCoordinator import com.zeuroux.launchly.download.WorkManagerDownloadCoordinator +import com.zeuroux.launchly.download.forPersistentDownloads import com.zeuroux.launchly.gplay.GPlayService import com.zeuroux.launchly.media.UserImageStore import com.zeuroux.launchly.packageops.AckpinePackageCoordinator @@ -28,6 +29,7 @@ import java.util.concurrent.TimeUnit interface AppContainer { val okHttpClient: OkHttpClient + val downloadHttpClient: OkHttpClient val database: AppDatabase val preferences: AppPreferences val authRepository: AuthRepository @@ -53,6 +55,8 @@ class DefaultAppContainer(context: Context) : AppContainer { .followSslRedirects(false) .build() + override val downloadHttpClient: OkHttpClient = okHttpClient.forPersistentDownloads() + override val database: AppDatabase = Room.databaseBuilder( applicationContext, AppDatabase::class.java, diff --git a/app/src/main/java/com/zeuroux/launchly/auth/AuthRepository.kt b/app/src/main/java/com/zeuroux/launchly/auth/AuthRepository.kt index 88d0499..e017ba4 100644 --- a/app/src/main/java/com/zeuroux/launchly/auth/AuthRepository.kt +++ b/app/src/main/java/com/zeuroux/launchly/auth/AuthRepository.kt @@ -1,16 +1,18 @@ package com.zeuroux.launchly.auth +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch interface AuthRepository { val state: StateFlow suspend fun signIn(session: AuthSession): AuthResult suspend fun signOut() suspend fun refreshProfile(): AuthResult + suspend fun awaitSession(): AuthSession? fun currentSession(): AuthSession? } @@ -22,27 +24,39 @@ class DefaultAuthRepository( override val state: StateFlow = _state.asStateFlow() private var profileLoader: (suspend () -> AuthSession)? = null - init { - applicationScope.launch { - _state.value = store.read()?.let { AuthState.Authenticated(it) } ?: AuthState.SignedOut + private val restoration = applicationScope.async { + val restored = try { + store.read() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + null } + _state.value = restored + ?.let { AuthState.Authenticated(it) } + ?: AuthState.SignedOut } - override suspend fun signIn(session: AuthSession): AuthResult = runCatching { - require(session.email.isNotBlank() && session.aasToken.isNotBlank()) - store.write(session) - _state.value = AuthState.Authenticated(session) - AuthResult.Success - }.getOrElse { - AuthResult.Failure("The sign-in session could not be saved securely.") + override suspend fun signIn(session: AuthSession): AuthResult { + restoration.await() + return runCatching { + require(session.email.isNotBlank() && session.aasToken.isNotBlank()) + store.write(session) + _state.value = AuthState.Authenticated(session) + AuthResult.Success + }.getOrElse { + AuthResult.Failure("The sign-in session could not be saved securely.") + } } override suspend fun signOut() { + restoration.await() store.clear() _state.value = AuthState.SignedOut } override suspend fun refreshProfile(): AuthResult { + restoration.await() val current = currentSession() ?: return AuthResult.Expired("Sign in again to refresh your profile.") val loader = profileLoader ?: return AuthResult.Failure("Profile refresh is not ready yet.") return runCatching { @@ -57,6 +71,11 @@ class DefaultAuthRepository( } } + override suspend fun awaitSession(): AuthSession? { + restoration.await() + return currentSession() + } + override fun currentSession(): AuthSession? = (_state.value as? AuthState.Authenticated)?.session fun setProfileLoader(loader: suspend () -> AuthSession) { diff --git a/app/src/main/java/com/zeuroux/launchly/data/Entities.kt b/app/src/main/java/com/zeuroux/launchly/data/Entities.kt index b0140df..e0d4f39 100644 --- a/app/src/main/java/com/zeuroux/launchly/data/Entities.kt +++ b/app/src/main/java/com/zeuroux/launchly/data/Entities.kt @@ -7,6 +7,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.PrimaryKey import androidx.room.Query +import androidx.room.Upsert import com.zeuroux.launchly.model.Architecture import com.zeuroux.launchly.model.DownloadRecord import com.zeuroux.launchly.model.DownloadStatus @@ -66,7 +67,7 @@ interface ManagedVersionDao { @Query("SELECT * FROM managed_versions WHERE id = :id") suspend fun get(id: String): ManagedVersionEntity? - @Insert(onConflict = OnConflictStrategy.REPLACE) + @Upsert suspend fun upsert(value: ManagedVersionEntity) @Query("DELETE FROM managed_versions WHERE id = :id") diff --git a/app/src/main/java/com/zeuroux/launchly/download/DownloadPolicies.kt b/app/src/main/java/com/zeuroux/launchly/download/DownloadPolicies.kt index 3e5e257..e28e72d 100644 --- a/app/src/main/java/com/zeuroux/launchly/download/DownloadPolicies.kt +++ b/app/src/main/java/com/zeuroux/launchly/download/DownloadPolicies.kt @@ -2,7 +2,18 @@ package com.zeuroux.launchly.download import com.zeuroux.launchly.gplay.GPlayArtifact import kotlinx.coroutines.sync.Mutex +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import java.io.File import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit + +internal fun OkHttpClient.forPersistentDownloads(): OkHttpClient = newBuilder() + // Large Minecraft artifacts may transfer for many minutes. Keep the base + // client's connect and idle-read timeouts, but never cap the whole call. + .callTimeout(0, TimeUnit.MILLISECONDS) + .build() internal object VersionFileLocks { private val locks = ConcurrentHashMap() @@ -33,7 +44,80 @@ internal object ArtifactNamePolicy { } } +internal object ArtifactUrlPolicy { + fun requireHttps(value: String): HttpUrl { + val url = value.toHttpUrlOrNull() + require(url?.isHttps == true) { "Google returned an unsafe APK URL." } + return url + } +} + internal object ResumePolicy { - fun canAppend(existingBytes: Long, responseCode: Int, contentRange: String?): Boolean = - existingBytes > 0L && responseCode == 206 && contentRange?.startsWith("bytes $existingBytes-") == true + private val contentRangePattern = Regex("""bytes (\d+)-(\d+)/(\d+|\*)""", RegexOption.IGNORE_CASE) + + fun canAppend(existingBytes: Long, responseCode: Int, contentRange: String?): Boolean { + if (existingBytes <= 0L || responseCode != 206) return false + val match = contentRangePattern.matchEntire(contentRange?.trim().orEmpty()) ?: return false + val start = match.groupValues[1].toLongOrNull() ?: return false + val end = match.groupValues[2].toLongOrNull() ?: return false + val total = match.groupValues[3].takeUnless { it == "*" }?.toLongOrNull() + return start == existingBytes && end >= start && (total == null || total > end) + } +} + +internal object ArtifactCachePolicy { + fun isReusableFinal(file: File, expectedSize: Long): Boolean = + expectedSize > 0L && file.isFile && file.length() == expectedSize + + fun invalidateFinalApks(directory: File): Int { + val files = directory.listFiles { file -> file.isFile && file.extension.equals("apk", true) }.orEmpty() + return files.count(File::delete) + } + + fun invalidatePartial(partFile: File, validatorFile: File) { + partFile.delete() + validatorFile.delete() + } +} + +internal object StoragePolicy { + const val RESERVE_BYTES = 64L * 1024L * 1024L + const val MAX_ARTIFACT_BYTES = 4L * 1024L * 1024L * 1024L + const val MAX_TOTAL_BYTES = 8L * 1024L * 1024L * 1024L + const val MAX_ARTIFACT_COUNT = 128 + + fun validateDelivery(artifacts: List) { + require(artifacts.size <= MAX_ARTIFACT_COUNT) { "Google returned too many APK files." } + require(artifacts.none { it.expectedSize > MAX_ARTIFACT_BYTES }) { + "Google returned an APK larger than Launchly's safety limit." + } + val knownTotal = artifacts.asSequence() + .map(GPlayArtifact::expectedSize) + .filter { it > 0L } + .fold(0L, ::saturatedAdd) + require(knownTotal <= MAX_TOTAL_BYTES) { "Google returned an APK set larger than Launchly's safety limit." } + } + + fun streamLimit(artifact: GPlayArtifact, alreadyCompleted: Long): Long { + val artifactLimit = artifact.expectedSize.takeIf { it > 0L } ?: MAX_ARTIFACT_BYTES + val consumed = alreadyCompleted.coerceIn(0L, MAX_TOTAL_BYTES) + return minOf(artifactLimit, MAX_TOTAL_BYTES - consumed) + } + + fun remainingBytes( + artifacts: List, + cachedBytes: (GPlayArtifact) -> Long + ): Long? { + if (artifacts.any { it.expectedSize <= 0L }) return null + return artifacts.fold(0L) { total, artifact -> + val cached = cachedBytes(artifact).coerceIn(0L, artifact.expectedSize) + saturatedAdd(total, artifact.expectedSize - cached) + } + } + + fun hasEnoughSpace(allocatableBytes: Long, remainingBytes: Long): Boolean = + remainingBytes <= 0L || allocatableBytes >= saturatedAdd(remainingBytes, RESERVE_BYTES) + + private fun saturatedAdd(left: Long, right: Long): Long = + if (right > Long.MAX_VALUE - left) Long.MAX_VALUE else left + right } diff --git a/app/src/main/java/com/zeuroux/launchly/download/VersionDownloadWorker.kt b/app/src/main/java/com/zeuroux/launchly/download/VersionDownloadWorker.kt index 6091235..159149d 100644 --- a/app/src/main/java/com/zeuroux/launchly/download/VersionDownloadWorker.kt +++ b/app/src/main/java/com/zeuroux/launchly/download/VersionDownloadWorker.kt @@ -57,10 +57,19 @@ class VersionDownloadWorker( if (artifacts.isEmpty()) throw DownloadFailure("EMPTY_DELIVERY", "Google returned no APK files.") val safeArtifacts = try { ArtifactNamePolicy.sanitize(artifacts) + .onEach { ArtifactUrlPolicy.requireHttps(it.url) } + .also(StoragePolicy::validateDelivery) } catch (failure: IllegalArgumentException) { - throw DownloadFailure("UNSAFE_OR_DUPLICATE_NAME", failure.message ?: "Google returned an invalid APK filename.") + throw DownloadFailure( + "UNSAFE_DELIVERY_METADATA", + failure.message ?: "Google returned unsafe APK metadata." + ) } - val total = safeArtifacts.map { it.expectedSize }.takeIf { values -> values.all { it > 0L } }?.sum() + val total = StoragePolicy.remainingBytes(safeArtifacts) { 0L } + ensureStorageAvailable(safeArtifacts) + val hadCachedFinalApks = versionDirectory().listFiles { file -> + file.isFile && file.extension.equals("apk", true) + }?.isNotEmpty() == true var completedBytes = 0L for (artifact in safeArtifacts) { completedBytes += downloadArtifact(artifact, completedBytes, total) @@ -68,7 +77,18 @@ class VersionDownloadWorker( val apkFiles = versionDirectory().listFiles { file -> file.extension.equals("apk", true) } ?.sortedBy { it.name } .orEmpty() - container.apkSetValidator.validate(apkFiles, version.versionCode, version.architecture) + try { + container.apkSetValidator.validate(apkFiles, version.versionCode, version.architecture) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + val invalidated = ArtifactCachePolicy.invalidateFinalApks(versionDirectory()) + throw DownloadFailure( + "APK_VALIDATION_FAILED", + failure.message ?: "The downloaded APK files failed validation.", + retryable = invalidated > 0 && hadCachedFinalApks + ) + } records.upsert( currentRecord().copy( status = DownloadStatus.READY, @@ -113,14 +133,14 @@ class VersionDownloadWorker( val finalFile = safeFile(directory, artifact.name) val partFile = safeFile(directory, "${artifact.name}.part") val validatorFile = safeFile(directory, "${artifact.name}.part.meta") - if (finalFile.isFile && (artifact.expectedSize <= 0L || finalFile.length() == artifact.expectedSize)) { + if (ArtifactCachePolicy.isReusableFinal(finalFile, artifact.expectedSize)) { return@withContext finalFile.length() } if (finalFile.exists()) finalFile.delete() var existing = partFile.length().coerceAtLeast(0L) - if (artifact.expectedSize > 0 && existing > artifact.expectedSize) { - partFile.delete() + if (artifact.expectedSize > 0 && existing >= artifact.expectedSize) { + ArtifactCachePolicy.invalidatePartial(partFile, validatorFile) existing = 0L } val validator = validatorFile.takeIf { existing > 0L && it.isFile } @@ -129,16 +149,25 @@ class VersionDownloadWorker( partFile.delete() existing = 0L } - val request = Request.Builder().url(artifact.url).apply { + val request = Request.Builder().url(ArtifactUrlPolicy.requireHttps(artifact.url)).apply { if (existing > 0L) { header("Range", "bytes=$existing-") header("If-Range", validator.orEmpty()) } }.build() + val streamLimit = StoragePolicy.streamLimit(artifact, alreadyCompleted) - container.okHttpClient.newCall(request).execute().use { response -> + container.downloadHttpClient.newCall(request).execute().use { response -> val append = ResumePolicy.canAppend(existing, response.code, response.header("Content-Range")) if (!response.isSuccessful) { + if (response.code == 416 && existing > 0L) { + ArtifactCachePolicy.invalidatePartial(partFile, validatorFile) + throw DownloadFailure( + "RANGE_REJECTED", + "${artifact.name} could not be resumed and will restart.", + true + ) + } throw DownloadFailure( "HTTP_${response.code}", "Downloading ${artifact.name} failed with HTTP ${response.code}.", @@ -147,6 +176,14 @@ class VersionDownloadWorker( } if (!append) existing = 0L val body = response.body ?: throw DownloadFailure("EMPTY_FILE", "${artifact.name} had no response body.") + val contentLength = body.contentLength() + if (contentLength > 0L && contentLength > streamLimit - existing) { + ArtifactCachePolicy.invalidatePartial(partFile, validatorFile) + throw DownloadFailure( + "ARTIFACT_TOO_LARGE", + "${artifact.name} exceeded Launchly's download safety limit." + ) + } val responseValidator = response.header("ETag") ?: response.header("Last-Modified") if (!append && responseValidator.isNullOrBlank()) validatorFile.delete() else if (!responseValidator.isNullOrBlank()) validatorFile.writeText(responseValidator) @@ -160,6 +197,14 @@ class VersionDownloadWorker( val count = input.read(buffer) if (count < 0) break if (count == 0) continue + if (count.toLong() > streamLimit - downloaded) { + output.setLength(0L) + validatorFile.delete() + throw DownloadFailure( + "ARTIFACT_TOO_LARGE", + "${artifact.name} exceeded Launchly's download safety limit." + ) + } output.write(buffer, 0, count) downloaded += count publishProgress(alreadyCompleted + downloaded, totalBytes) @@ -171,6 +216,7 @@ class VersionDownloadWorker( val actualSize = partFile.length() if (actualSize <= 0L) throw DownloadFailure("EMPTY_FILE", "${artifact.name} was empty.") if (artifact.expectedSize > 0L && actualSize != artifact.expectedSize) { + ArtifactCachePolicy.invalidatePartial(partFile, validatorFile) throw DownloadFailure( "SIZE_MISMATCH", "${artifact.name} was $actualSize bytes; ${artifact.expectedSize} bytes were expected.", @@ -233,6 +279,43 @@ class VersionDownloadWorker( private fun versionDirectory() = File(applicationContext.filesDir, "versions/$versionId") + private fun ensureStorageAvailable(artifacts: List) { + val directory = versionDirectory().apply { mkdirs() } + artifacts.forEach { artifact -> + val finalFile = safeFile(directory, artifact.name) + if (finalFile.exists() && !ArtifactCachePolicy.isReusableFinal(finalFile, artifact.expectedSize)) { + finalFile.delete() + } + val partFile = safeFile(directory, "${artifact.name}.part") + val validatorFile = safeFile(directory, "${artifact.name}.part.meta") + val partSize = partFile.length() + val invalidPartial = partFile.exists() && ( + partSize <= 0L || !validatorFile.isFile || + (artifact.expectedSize > 0L && partSize >= artifact.expectedSize) + ) + if (invalidPartial) ArtifactCachePolicy.invalidatePartial(partFile, validatorFile) + } + val remaining = StoragePolicy.remainingBytes(artifacts) { artifact -> + val finalFile = safeFile(directory, artifact.name) + if (ArtifactCachePolicy.isReusableFinal(finalFile, artifact.expectedSize)) { + finalFile.length() + } else { + val partFile = safeFile(directory, "${artifact.name}.part") + val validatorFile = safeFile(directory, "${artifact.name}.part.meta") + partFile.length().takeIf { + validatorFile.isFile && it in 1L..artifact.expectedSize + } ?: 0L + } + } ?: return + val available = directory.usableSpace + if (!StoragePolicy.hasEnoughSpace(available, remaining)) { + throw DownloadFailure( + "STORAGE_FULL", + "There is not enough storage to download this Minecraft version." + ) + } + } + private fun isStorageExhausted(): Boolean = runCatching { val storageManager = applicationContext.getSystemService(StorageManager::class.java) val storageUuid = storageManager.getUuidForPath(versionDirectory()) diff --git a/app/src/main/java/com/zeuroux/launchly/gplay/GPlayService.kt b/app/src/main/java/com/zeuroux/launchly/gplay/GPlayService.kt index fca333d..089e814 100644 --- a/app/src/main/java/com/zeuroux/launchly/gplay/GPlayService.kt +++ b/app/src/main/java/com/zeuroux/launchly/gplay/GPlayService.kt @@ -81,8 +81,8 @@ class GPlayService( GPlayProfile(profile?.name, profile?.email, profile?.artwork?.url) } - private fun authData(): AuthData { - val session = authRepository.currentSession() + private suspend fun authData(): AuthData { + val session = authRepository.awaitSession() ?: throw GPlayDeliveryException("AUTH_EXPIRED", "Your Google session expired. Sign in again.", false) return AuthHelper.build( email = session.email, diff --git a/app/src/main/java/com/zeuroux/launchly/ui/Screens.kt b/app/src/main/java/com/zeuroux/launchly/ui/Screens.kt index 484299d..6d67e2e 100644 --- a/app/src/main/java/com/zeuroux/launchly/ui/Screens.kt +++ b/app/src/main/java/com/zeuroux/launchly/ui/Screens.kt @@ -221,8 +221,8 @@ internal fun LibraryScreen( var pendingDownload by rememberSaveable { mutableStateOf(null) } val notificationLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> pendingDownload?.let { id -> - if (granted) viewModel.download(id) - else scope.launch { snackbar.showSnackbar(notificationPermissionDeniedMessage) } + viewModel.download(id) + if (!granted) scope.launch { snackbar.showSnackbar(notificationPermissionDeniedMessage) } } pendingDownload = null } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 75fb8e7..ff2420f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,7 +47,7 @@ %1$s/s Minecraft downloads Downloading Minecraft - Notification permission is required before starting a persistent download. + Download started. Android may hide its normal notification because notification permission was denied. Allow app installation Android must allow Launchly to request package installs before this verified APK set can be installed. Open settings diff --git a/app/src/test/java/com/zeuroux/launchly/auth/AuthRepositoryTest.kt b/app/src/test/java/com/zeuroux/launchly/auth/AuthRepositoryTest.kt new file mode 100644 index 0000000..4c99fd9 --- /dev/null +++ b/app/src/test/java/com/zeuroux/launchly/auth/AuthRepositoryTest.kt @@ -0,0 +1,81 @@ +package com.zeuroux.launchly.auth + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class AuthRepositoryTest { + @Test + fun awaitSessionWaitsForEncryptedStoreRestoration() = runBlocking { + val restored = CompletableDeferred() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val repository = DefaultAuthRepository( + object : AuthStore { + override suspend fun read(): AuthSession? = restored.await() + override suspend fun write(session: AuthSession) = Unit + override suspend fun clear() = Unit + }, + scope + ) + val expected = AuthSession("owner@example.test", "token", null, null, 1) + + val waiting = async { repository.awaitSession() } + assertFalse(waiting.isCompleted) + restored.complete(expected) + + assertEquals(expected, waiting.await()) + scope.cancel() + } + + @Test + fun awaitSessionDoesNotHangWhenEncryptedStoreReadFails() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val repository = DefaultAuthRepository( + object : AuthStore { + override suspend fun read(): AuthSession? = error("corrupt store") + override suspend fun write(session: AuthSession) = Unit + override suspend fun clear() = Unit + }, + scope + ) + + assertEquals(null, repository.awaitSession()) + scope.cancel() + } + + @Test + fun signInWaitsForRestorationAndCannotBeOverwrittenByIt() = runBlocking { + val restored = CompletableDeferred() + var persisted: AuthSession? = null + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val repository = DefaultAuthRepository( + object : AuthStore { + override suspend fun read(): AuthSession? = restored.await() + override suspend fun write(session: AuthSession) { + persisted = session + } + override suspend fun clear() = Unit + }, + scope + ) + val old = AuthSession("old@example.test", "old-token", null, null, 1) + val replacement = AuthSession("new@example.test", "new-token", null, null, 2) + + val signingIn = async(start = CoroutineStart.UNDISPATCHED) { repository.signIn(replacement) } + assertFalse(signingIn.isCompleted) + restored.complete(old) + + assertEquals(AuthResult.Success, signingIn.await()) + assertEquals(replacement, persisted) + assertEquals(replacement, repository.currentSession()) + scope.cancel() + } +} \ No newline at end of file diff --git a/app/src/test/java/com/zeuroux/launchly/download/DownloadPoliciesTest.kt b/app/src/test/java/com/zeuroux/launchly/download/DownloadPoliciesTest.kt index ec7aab3..21d2b38 100644 --- a/app/src/test/java/com/zeuroux/launchly/download/DownloadPoliciesTest.kt +++ b/app/src/test/java/com/zeuroux/launchly/download/DownloadPoliciesTest.kt @@ -9,6 +9,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test +import java.nio.file.Files class DownloadPoliciesTest { @Test @@ -27,6 +28,13 @@ class DownloadPoliciesTest { assertTrue(result.isFailure) } + @Test + fun artifactUrlsMustUseHttps() { + assertEquals("https", ArtifactUrlPolicy.requireHttps("https://example.test/base.apk").scheme) + assertTrue(runCatching { ArtifactUrlPolicy.requireHttps("http://example.test/base.apk") }.isFailure) + assertTrue(runCatching { ArtifactUrlPolicy.requireHttps("not a url") }.isFailure) + } + @Test fun rangeAppendRequiresMatchingPartialContentResponse() { val server = MockWebServer() @@ -41,8 +49,100 @@ class DownloadPoliciesTest { } assertEquals("bytes=5-", server.takeRequest().getHeader("Range")) assertFalse(ResumePolicy.canAppend(5, 200, null)) + assertFalse(ResumePolicy.canAppend(5, 206, "bytes 5-garbage")) + assertFalse(ResumePolicy.canAppend(5, 206, "bytes 5-4/10")) } finally { server.shutdown() } } + + @Test + fun persistentDownloadClientHasNoWholeCallDeadline() { + val client = OkHttpClient.Builder().callTimeout(2, java.util.concurrent.TimeUnit.MINUTES).build() + + val downloadClient = client.forPersistentDownloads() + + assertEquals(0, downloadClient.callTimeoutMillis) + assertEquals(client.connectTimeoutMillis, downloadClient.connectTimeoutMillis) + assertEquals(client.readTimeoutMillis, downloadClient.readTimeoutMillis) + } + + @Test + fun invalidFinalApksAreRemovedBeforeRetry() { + val directory = Files.createTempDirectory("launchly-invalid-apks").toFile() + try { + val corrupt = directory.resolve("base.apk").apply { writeBytes(byteArrayOf(1, 2, 3)) } + val part = directory.resolve("split.apk.part").apply { writeBytes(byteArrayOf(4)) } + assertTrue(ArtifactCachePolicy.isReusableFinal(corrupt, 3)) + + assertEquals(1, ArtifactCachePolicy.invalidateFinalApks(directory)) + assertFalse(corrupt.exists()) + assertTrue(part.exists()) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun rejectedOrInvalidPartialDownloadIsFullyReset() { + val directory = Files.createTempDirectory("launchly-invalid-part").toFile() + try { + val part = directory.resolve("base.apk.part").apply { writeBytes(byteArrayOf(1, 2, 3)) } + val validator = directory.resolve("base.apk.part.meta").apply { writeText("etag") } + + ArtifactCachePolicy.invalidatePartial(part, validator) + + assertFalse(part.exists()) + assertFalse(validator.exists()) + } finally { + directory.deleteRecursively() + } + } + + @Test + fun storagePreflightAccountsForResumableBytesAndReserve() { + val artifacts = listOf( + GPlayArtifact("https://example.test/base", "base.apk", 100), + GPlayArtifact("https://example.test/split", "split.apk", 50) + ) + val cached = mapOf("base.apk" to 40L, "split.apk" to 50L) + + val remaining = StoragePolicy.remainingBytes(artifacts) { cached[it.name] ?: 0L } + + assertEquals(60L, remaining) + assertFalse(StoragePolicy.hasEnoughSpace(StoragePolicy.RESERVE_BYTES + 59L, remaining!!)) + assertTrue(StoragePolicy.hasEnoughSpace(StoragePolicy.RESERVE_BYTES + 60L, remaining)) + assertTrue(StoragePolicy.hasEnoughSpace(0L, 0L)) + } + + @Test + fun deliveryAndStreamingSizesAreBounded() { + StoragePolicy.validateDelivery( + listOf(GPlayArtifact("https://example.test/base", "base.apk", StoragePolicy.MAX_ARTIFACT_BYTES)) + ) + assertTrue( + runCatching { + StoragePolicy.validateDelivery( + listOf( + GPlayArtifact( + "https://example.test/base", + "base.apk", + StoragePolicy.MAX_ARTIFACT_BYTES + 1 + ) + ) + ) + }.isFailure + ) + assertEquals( + StoragePolicy.MAX_ARTIFACT_BYTES, + StoragePolicy.streamLimit(GPlayArtifact("https://example.test/base", "base.apk", 0), 0) + ) + assertEquals( + 1L, + StoragePolicy.streamLimit( + GPlayArtifact("https://example.test/base", "base.apk", 100), + StoragePolicy.MAX_TOTAL_BYTES - 1 + ) + ) + } } From 4568322bdf524eb840527ce9108b6eae5a55a1cb Mon Sep 17 00:00:00 2001 From: Ashank Sundaram <81349208+aShanki@users.noreply.github.com> Date: Tue, 4 Aug 2026 05:04:30 +0000 Subject: [PATCH 2/3] Recover completed partial downloads --- .../launchly/download/DownloadPolicies.kt | 21 ++++++++++++ .../download/VersionDownloadWorker.kt | 33 +++++++++++-------- .../launchly/auth/AuthRepositoryTest.kt | 29 +++++++++++++++- .../launchly/download/DownloadPoliciesTest.kt | 18 ++++++++++ 4 files changed, 86 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/zeuroux/launchly/download/DownloadPolicies.kt b/app/src/main/java/com/zeuroux/launchly/download/DownloadPolicies.kt index e28e72d..1e982a0 100644 --- a/app/src/main/java/com/zeuroux/launchly/download/DownloadPolicies.kt +++ b/app/src/main/java/com/zeuroux/launchly/download/DownloadPolicies.kt @@ -6,6 +6,8 @@ import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient import java.io.File +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.StandardCopyOption import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit @@ -78,6 +80,25 @@ internal object ArtifactCachePolicy { partFile.delete() validatorFile.delete() } + + fun promoteCompletePartial(partFile: File, finalFile: File, validatorFile: File) { + require(partFile.isFile) { "The completed partial APK is missing." } + try { + java.nio.file.Files.move( + partFile.toPath(), + finalFile.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE + ) + } catch (_: AtomicMoveNotSupportedException) { + java.nio.file.Files.move( + partFile.toPath(), + finalFile.toPath(), + StandardCopyOption.REPLACE_EXISTING + ) + } + validatorFile.delete() + } } internal object StoragePolicy { diff --git a/app/src/main/java/com/zeuroux/launchly/download/VersionDownloadWorker.kt b/app/src/main/java/com/zeuroux/launchly/download/VersionDownloadWorker.kt index 159149d..2d2cefb 100644 --- a/app/src/main/java/com/zeuroux/launchly/download/VersionDownloadWorker.kt +++ b/app/src/main/java/com/zeuroux/launchly/download/VersionDownloadWorker.kt @@ -28,8 +28,6 @@ import okhttp3.Request import java.io.File import java.io.IOException import java.io.RandomAccessFile -import java.nio.file.Files -import java.nio.file.StandardCopyOption import kotlin.coroutines.coroutineContext class VersionDownloadWorker( @@ -67,8 +65,11 @@ class VersionDownloadWorker( } val total = StoragePolicy.remainingBytes(safeArtifacts) { 0L } ensureStorageAvailable(safeArtifacts) - val hadCachedFinalApks = versionDirectory().listFiles { file -> - file.isFile && file.extension.equals("apk", true) + val hadCachedArtifacts = versionDirectory().listFiles { file -> + file.isFile && ( + file.extension.equals("apk", true) || + (file.name.endsWith(".apk.part", true) && file.length() > 0L) + ) }?.isNotEmpty() == true var completedBytes = 0L for (artifact in safeArtifacts) { @@ -86,7 +87,7 @@ class VersionDownloadWorker( throw DownloadFailure( "APK_VALIDATION_FAILED", failure.message ?: "The downloaded APK files failed validation.", - retryable = invalidated > 0 && hadCachedFinalApks + retryable = invalidated > 0 && hadCachedArtifacts ) } records.upsert( @@ -134,12 +135,17 @@ class VersionDownloadWorker( val partFile = safeFile(directory, "${artifact.name}.part") val validatorFile = safeFile(directory, "${artifact.name}.part.meta") if (ArtifactCachePolicy.isReusableFinal(finalFile, artifact.expectedSize)) { + ArtifactCachePolicy.invalidatePartial(partFile, validatorFile) return@withContext finalFile.length() } if (finalFile.exists()) finalFile.delete() var existing = partFile.length().coerceAtLeast(0L) - if (artifact.expectedSize > 0 && existing >= artifact.expectedSize) { + if (artifact.expectedSize > 0L && existing == artifact.expectedSize) { + ArtifactCachePolicy.promoteCompletePartial(partFile, finalFile, validatorFile) + return@withContext existing + } + if (artifact.expectedSize > 0L && existing > artifact.expectedSize) { ArtifactCachePolicy.invalidatePartial(partFile, validatorFile) existing = 0L } @@ -223,11 +229,7 @@ class VersionDownloadWorker( true ) } - Files.move( - partFile.toPath(), finalFile.toPath(), - StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE - ) - validatorFile.delete() + ArtifactCachePolicy.promoteCompletePartial(partFile, finalFile, validatorFile) actualSize } @@ -289,9 +291,11 @@ class VersionDownloadWorker( val partFile = safeFile(directory, "${artifact.name}.part") val validatorFile = safeFile(directory, "${artifact.name}.part.meta") val partSize = partFile.length() + val completePartial = artifact.expectedSize > 0L && partSize == artifact.expectedSize val invalidPartial = partFile.exists() && ( - partSize <= 0L || !validatorFile.isFile || - (artifact.expectedSize > 0L && partSize >= artifact.expectedSize) + partSize <= 0L || + (artifact.expectedSize > 0L && partSize > artifact.expectedSize) || + (!completePartial && !validatorFile.isFile) ) if (invalidPartial) ArtifactCachePolicy.invalidatePartial(partFile, validatorFile) } @@ -303,7 +307,8 @@ class VersionDownloadWorker( val partFile = safeFile(directory, "${artifact.name}.part") val validatorFile = safeFile(directory, "${artifact.name}.part.meta") partFile.length().takeIf { - validatorFile.isFile && it in 1L..artifact.expectedSize + it == artifact.expectedSize || + (validatorFile.isFile && it > 0L && it < artifact.expectedSize) } ?: 0L } } ?: return diff --git a/app/src/test/java/com/zeuroux/launchly/auth/AuthRepositoryTest.kt b/app/src/test/java/com/zeuroux/launchly/auth/AuthRepositoryTest.kt index 4c99fd9..8a74066 100644 --- a/app/src/test/java/com/zeuroux/launchly/auth/AuthRepositoryTest.kt +++ b/app/src/test/java/com/zeuroux/launchly/auth/AuthRepositoryTest.kt @@ -78,4 +78,31 @@ class AuthRepositoryTest { assertEquals(replacement, repository.currentSession()) scope.cancel() } -} \ No newline at end of file + + @Test + fun signOutWaitsForRestorationAndCannotResurrectItsSession() = runBlocking { + val restored = CompletableDeferred() + var cleared = false + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val repository = DefaultAuthRepository( + object : AuthStore { + override suspend fun read(): AuthSession? = restored.await() + override suspend fun write(session: AuthSession) = Unit + override suspend fun clear() { + cleared = true + } + }, + scope + ) + val old = AuthSession("old@example.test", "old-token", null, null, 1) + + val signingOut = async(start = CoroutineStart.UNDISPATCHED) { repository.signOut() } + assertFalse(signingOut.isCompleted) + restored.complete(old) + + signingOut.await() + assertEquals(true, cleared) + assertEquals(null, repository.currentSession()) + scope.cancel() + } +} diff --git a/app/src/test/java/com/zeuroux/launchly/download/DownloadPoliciesTest.kt b/app/src/test/java/com/zeuroux/launchly/download/DownloadPoliciesTest.kt index 21d2b38..a8a249a 100644 --- a/app/src/test/java/com/zeuroux/launchly/download/DownloadPoliciesTest.kt +++ b/app/src/test/java/com/zeuroux/launchly/download/DownloadPoliciesTest.kt @@ -99,6 +99,24 @@ class DownloadPoliciesTest { } } + @Test + fun completePartialCanBePromotedWithoutValidatorMetadata() { + val directory = Files.createTempDirectory("launchly-complete-part").toFile() + try { + val part = directory.resolve("base.apk.part").apply { writeBytes(byteArrayOf(1, 2, 3)) } + val final = directory.resolve("base.apk") + val validator = directory.resolve("base.apk.part.meta") + + ArtifactCachePolicy.promoteCompletePartial(part, final, validator) + + assertFalse(part.exists()) + assertFalse(validator.exists()) + assertEquals(listOf(1, 2, 3), final.readBytes().toList()) + } finally { + directory.deleteRecursively() + } + } + @Test fun storagePreflightAccountsForResumableBytesAndReserve() { val artifacts = listOf( From 7914f2a50aa3a6981edcedb43349815678b76343 Mon Sep 17 00:00:00 2001 From: Ashank Sundaram <81349208+aShanki@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:25:58 +0000 Subject: [PATCH 3/3] Bump version to 0.3.0 --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 1d06c3f..1aee01a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -29,8 +29,8 @@ android { applicationId = "com.zeuroux.launchly" minSdk = 28 targetSdk = 35 - versionCode = 3 - versionName = "0.2.0" + versionCode = 4 + versionName = "0.3.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true