Skip to content
Merged
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
4 changes: 2 additions & 2 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -40,4 +41,22 @@ class ManagedVersionDaoTest {
assertEquals(listOf(version), database.managedVersionDao().observeAll().first())
assertEquals(emptyList<DownloadRecordEntity>(), 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))
}
}
4 changes: 4 additions & 0 deletions app/src/main/java/com/zeuroux/launchly/AppContainer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down
41 changes: 30 additions & 11 deletions app/src/main/java/com/zeuroux/launchly/auth/AuthRepository.kt
Original file line number Diff line number Diff line change
@@ -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<AuthState>
suspend fun signIn(session: AuthSession): AuthResult
suspend fun signOut()
suspend fun refreshProfile(): AuthResult
suspend fun awaitSession(): AuthSession?
fun currentSession(): AuthSession?
}

Expand All @@ -22,27 +24,39 @@ class DefaultAuthRepository(
override val state: StateFlow<AuthState> = _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 {
Expand All @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/com/zeuroux/launchly/data/Entities.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
109 changes: 107 additions & 2 deletions app/src/main/java/com/zeuroux/launchly/download/DownloadPolicies.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,20 @@ 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.nio.file.AtomicMoveNotSupportedException
import java.nio.file.StandardCopyOption
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<String, Mutex>()
Expand Down Expand Up @@ -33,7 +46,99 @@ 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()
}

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 {
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<GPlayArtifact>) {
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<GPlayArtifact>,
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
}
Loading
Loading