diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b697a17..d250aa3 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -35,8 +35,8 @@ android { applicationId = "com.chouten.app" minSdk = 23 targetSdk = 34 - versionCode = 10 - versionName = "0.3.0" + versionCode = 11 + versionName = "0.3.1" buildConfigField("String", "WEBHOOK_URL", "\"${properties.getProperty("bug_webhook")}\"") testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/java/com/chouten/app/MainActivity.kt b/app/src/main/java/com/chouten/app/MainActivity.kt index b7e15ca..50fb90a 100644 --- a/app/src/main/java/com/chouten/app/MainActivity.kt +++ b/app/src/main/java/com/chouten/app/MainActivity.kt @@ -12,11 +12,11 @@ import androidx.compose.material.icons.filled.Warning import androidx.compose.runtime.SideEffect import androidx.core.net.toUri import androidx.lifecycle.ViewModelProvider -import com.chouten.app.common.compareSemVer import com.chouten.app.common.findActivity import com.chouten.app.domain.model.AlertDialogModel import com.chouten.app.domain.model.LogEntry import com.chouten.app.domain.model.SnackbarModel +import com.chouten.app.domain.model.Version import com.chouten.app.domain.proto.moduleDatastore import com.chouten.app.domain.use_case.log_use_cases.LogUseCases import com.chouten.app.domain.use_case.module_use_cases.ModuleInstallEvent @@ -83,7 +83,7 @@ class MainActivity : ComponentActivity() { moduleUseCases.addModule(updateUrl.toUri()) { event -> when (event) { is ModuleInstallEvent.PARSED -> { - (event.module.version.compareSemVer(module.version) != 1).also { res -> + (event.module.version < module.version).also { res -> if (!res) { appState.viewModel.runAsync { logUseCases.insertLog( diff --git a/app/src/main/java/com/chouten/app/common/Extensions.kt b/app/src/main/java/com/chouten/app/common/Extensions.kt index a1de656..b886271 100644 --- a/app/src/main/java/com/chouten/app/common/Extensions.kt +++ b/app/src/main/java/com/chouten/app/common/Extensions.kt @@ -12,6 +12,7 @@ import androidx.compose.material3.ColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color +import com.chouten.app.domain.model.Version import com.chouten.app.domain.proto.AppearancePreferences import com.chouten.app.domain.proto.appearanceDatastore import kotlinx.coroutines.flow.firstOrNull @@ -137,7 +138,7 @@ fun Long.formatMinSec(): String { return if (this <= 0L) { "00:00" } else { - // Format HH:MM::SS or MM:SS if hours is 0 + // Format HH:MM:SS or MM:SS if hours is 0 if (TimeUnit.MILLISECONDS.toHours(this) > 0) { String.format( "%02d:%02d:%02d", @@ -200,34 +201,11 @@ fun calculateFraction(start: Float, end: Float, pos: Float) = (if (end - start == 0f) 0f else (pos - start) / (end - start)).coerceIn(0f, 1f) /** - * Compare two semVer versions - * @param v2 The second version - * @return 1 if the first version is newer, -1 if the second version is newer, 0 if the versions are the same - * @throws IllegalArgumentException if the versions are invalid + * Parses a version string into a [Version] object. + * + * @param useRegex Flag to determine whether to use regex for parsing. + * Not using regex is stricter and will throw an exception for more invalid strings. + * @return The parsed [Version] object. + * @throws IllegalArgumentException If the version string is not valid. */ -fun String.compareSemVer(v2: String): Int { - // Return 1 if the first version is newer - // Return -1 if the second version is newer - // Return 0 if the versions are the same - val v1Split = split(".") - val v2Split = v2.split(".") - if (v1Split.size != 3 || v2Split.size != 3) { - throw IllegalArgumentException("Invalid version") - } - - return if (v1Split[0].toInt() > v2Split[0].toInt()) { - 1 - } else if (v1Split[0].toInt() < v2Split[0].toInt()) { - -1 - } else if (v1Split[1].toInt() > v2Split[1].toInt()) { - 1 - } else if (v1Split[1].toInt() < v2Split[1].toInt()) { - -1 - } else if (v1Split[2].toInt() > v2Split[2].toInt()) { - 1 - } else if (v1Split[2].toInt() < v2Split[2].toInt()) { - -1 - } else { - 0 - } -} \ No newline at end of file +fun String.toVersion(useRegex: Boolean = false) = Version(this, useRegex) \ No newline at end of file diff --git a/app/src/main/java/com/chouten/app/common/TimeUtils.kt b/app/src/main/java/com/chouten/app/common/TimeUtils.kt index 6aa8611..f0dc264 100644 --- a/app/src/main/java/com/chouten/app/common/TimeUtils.kt +++ b/app/src/main/java/com/chouten/app/common/TimeUtils.kt @@ -9,9 +9,9 @@ import java.util.Locale import java.util.TimeZone /** - * Converts epoch seconds to a formatted time string. + * Converts epoch milliseconds to a formatted time string. * - * @param epochMilli The epoch time in seconds. + * @param epochMilli The epoch time in milliseconds. * @param pattern The desired pattern for formatting the time (default is "h:mm:ss"). * @return The formatted time string of [epochMilli] in the pattern of [pattern]. */ @@ -28,4 +28,15 @@ fun epochMillisToTime(epochMilli: Long, pattern: String = "HH:mm:ss"): String { timeZone = userTimeZone }.format(Date(epochMilli)) } +} + +/** + * Converts epoch milliseconds to a formatted time string. + * + * @param epochMilli The epoch time in milliseconds. + * @param pattern The desired pattern for formatting the time (default is "h:mm:ss"). + * @return The formatted time string of [epochMilli] in the pattern of [pattern]. + */ +fun Long.toTimeString(pattern: String = "HH:mm:ss"): String { + return epochMillisToTime(this, pattern) } \ No newline at end of file diff --git a/app/src/main/java/com/chouten/app/data/repository/ModuleRepositoryImpl.kt b/app/src/main/java/com/chouten/app/data/repository/ModuleRepositoryImpl.kt index 974c86d..4281547 100644 --- a/app/src/main/java/com/chouten/app/data/repository/ModuleRepositoryImpl.kt +++ b/app/src/main/java/com/chouten/app/data/repository/ModuleRepositoryImpl.kt @@ -31,6 +31,7 @@ class ModuleRepositoryImpl @Inject constructor( override suspend fun getModuleDirs(): List { val preferences = context.filepathDatastore.data.first() + if (preferences.CHOUTEN_ROOT_DIR == Uri.EMPTY) return emptyList() val contentResolver = context.contentResolver return withContext(Dispatchers.IO) { diff --git a/app/src/main/java/com/chouten/app/di/AppModule.kt b/app/src/main/java/com/chouten/app/di/AppModule.kt index 63042c4..3779baa 100644 --- a/app/src/main/java/com/chouten/app/di/AppModule.kt +++ b/app/src/main/java/com/chouten/app/di/AppModule.kt @@ -124,9 +124,13 @@ object AppModule { @Provides fun provideModuleRepository(app: Application, httpClient: Requests): ModuleRepository { val moduleDirGetter: suspend (Uri) -> Uri = { uri: Uri -> - GetModuleDirUseCase( - app.applicationContext - )(uri) + try { + GetModuleDirUseCase( + app.applicationContext + )(uri) + } catch (e: Exception) { + Uri.EMPTY + } } return ModuleRepositoryImpl(app.applicationContext, moduleDirGetter) } diff --git a/app/src/main/java/com/chouten/app/domain/model/ModuleModel.kt b/app/src/main/java/com/chouten/app/domain/model/ModuleModel.kt index 0fa2259..fb5b0b3 100644 --- a/app/src/main/java/com/chouten/app/domain/model/ModuleModel.kt +++ b/app/src/main/java/com/chouten/app/domain/model/ModuleModel.kt @@ -47,7 +47,7 @@ data class ModuleModel( * The version of the module. * This is used to identify the module in the app. */ - val version: String, + val version: Version, /** * The format version for the code of the module. diff --git a/app/src/main/java/com/chouten/app/domain/model/SemanticVersion.kt b/app/src/main/java/com/chouten/app/domain/model/SemanticVersion.kt new file mode 100644 index 0000000..753a1f1 --- /dev/null +++ b/app/src/main/java/com/chouten/app/domain/model/SemanticVersion.kt @@ -0,0 +1,183 @@ +package com.chouten.app.domain.model + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * Serializer for [Version]. + */ +object VersionAsStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("Version", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: Version) { + return encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): Version { + return Version(decoder.decodeString()) + } +} + +/** + * Represents a semantic version number. + * + * @property major The major version number, used to indicate incompatible API changes. + * @property minor The minor version number, used to indicate new, but backwards compatible, functionality. + * @property patch The patch version number, used to indicate backwards compatible bug fixes and small changes. + * @property preRelease The pre-release identifier. + * @property buildMetadata The build metadata. + */ +@Serializable(with = VersionAsStringSerializer::class) +data class Version( + var major: Int, + var minor: Int, + var patch: Int, + var preRelease: String = "", + var buildMetadata: String = "" +) : Comparable { + + /** + * Creates a [Version] object from a version string. + * + * @param versionString The version string to parse. + * @param useRegex Flag to determine whether to use regex for parsing. + * Not using regex is stricter and will throw an exception for more invalid strings. + * @throws IllegalArgumentException If the version string is not valid. + */ + constructor(versionString: String, useRegex: Boolean = false) : this( + major = 0, minor = 0, patch = 0 + ) { + val parsedVersion = parse(versionString, useRegex) + major = parsedVersion.major + minor = parsedVersion.minor + patch = parsedVersion.patch + preRelease = parsedVersion.preRelease + buildMetadata = parsedVersion.buildMetadata + } + + companion object { + private val SEMVER_REGEX = Regex( + "(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?" + ) + + /** + * Parses a version string into a [Version] object. + * + * @param versionString The version string to parse. + * @param useRegex Flag to determine whether to use regex for parsing. + * Not using regex is stricter and will throw an exception for more invalid strings. + * @return The parsed [Version] object. + * @throws IllegalArgumentException If the version string is not valid. + */ + fun parse(versionString: String, useRegex: Boolean = false): Version { + if (useRegex) { + val matchResult = SEMVER_REGEX.matchEntire(versionString) + ?: throw IllegalArgumentException("Invalid semantic version format") + + return Version( + major = matchResult.groupValues[1].toInt(), + minor = matchResult.groupValues[2].toInt(), + patch = matchResult.groupValues[3].toInt(), + preRelease = matchResult.groupValues[4], + buildMetadata = matchResult.groupValues[5] + ) + } else { + val metadataSplit = versionString.split("+", limit = 2) + val mainPart = metadataSplit[0] + val buildMetadata = if (metadataSplit.size > 1) metadataSplit[1] else "" + + val preReleaseSplit = mainPart.split("-", limit = 2) + val numbers = preReleaseSplit[0].split(".") + val preRelease = if (preReleaseSplit.size > 1) preReleaseSplit[1] else "" + + if (numbers.size != 3) { + throw IllegalArgumentException("Invalid version format. Expected format: MAJOR.MINOR.PATCH") + } + + val major = numbers[0].toIntOrNull() + ?: throw IllegalArgumentException("Major version is not a valid integer") + val minor = numbers[1].toIntOrNull() + ?: throw IllegalArgumentException("Minor version is not a valid integer") + val patch = numbers[2].toIntOrNull() + ?: throw IllegalArgumentException("Patch version is not a valid integer") + + return Version(major, minor, patch, preRelease, buildMetadata) + } + } + } + + /** + * Compares this version with the specified version for order. + * + * @param other The [Version] to be compared. + * @return A negative integer if this version is less than the other version, + * zero if they are equal or a positive integer if this version is greater than the other version. + */ + override fun compareTo(other: Version): Int { + if (this.major != other.major) return this.major - other.major + if (this.minor != other.minor) return this.minor - other.minor + if (this.patch != other.patch) return this.patch - other.patch + if (this.preRelease != other.preRelease) { + if (this.preRelease.isEmpty()) return 1 + if (other.preRelease.isEmpty()) return -1 + // Split preRelease strings and compare them part by part. + val thisPreReleaseParts = this.preRelease.split(".") + val otherPreReleaseParts = other.preRelease.split(".") + val maxIndex = minOf(thisPreReleaseParts.size, otherPreReleaseParts.size) + for (i in 0 until maxIndex) { + val cmp = thisPreReleaseParts[i].compareTo(otherPreReleaseParts[i]) + if (cmp != 0) return cmp + } + return thisPreReleaseParts.size - otherPreReleaseParts.size + } + // Note: Build metadata does not affect version precedence + return 0 + } + + /** + * Checks if this version is equal to the specified version. + * + * @param other The [Version] to compare. + * @return `true` if the versions are equal, `false` otherwise. + */ + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Version + + if (major != other.major) return false + if (minor != other.minor) return false + if (patch != other.patch) return false + if (preRelease != other.preRelease) return false + + return true + } + + /** + * @return a hash code value for the version. + */ + override fun hashCode(): Int { + var result = major + result = 31 * result + minor + result = 31 * result + patch + result = 31 * result + preRelease.hashCode() + result = 31 * result + buildMetadata.hashCode() + return result + } + + /** + * Returns a string representation of the version. + */ + override fun toString(): String { + return "$major.$minor.$patch" + + (if (preRelease.isNotEmpty()) "-$preRelease" else "") + + (if (buildMetadata.isNotEmpty()) "+$buildMetadata" else "") + } +} diff --git a/app/src/main/java/com/chouten/app/domain/use_case/module_use_cases/AddModuleUseCase.kt b/app/src/main/java/com/chouten/app/domain/use_case/module_use_cases/AddModuleUseCase.kt index d5bb02e..f86b2d8 100644 --- a/app/src/main/java/com/chouten/app/domain/use_case/module_use_cases/AddModuleUseCase.kt +++ b/app/src/main/java/com/chouten/app/domain/use_case/module_use_cases/AddModuleUseCase.kt @@ -9,7 +9,6 @@ import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import com.chouten.app.common.OutOfDateAppException import com.chouten.app.common.OutOfDateModuleException -import com.chouten.app.common.compareSemVer import com.chouten.app.domain.model.ModuleModel import com.chouten.app.domain.proto.filepathDatastore import com.chouten.app.domain.repository.ModuleRepository @@ -216,17 +215,25 @@ class AddModuleUseCase @Inject constructor( /** * The parsed module */ - val module = metadataInputStream.use { - val stringBuffer = StringBuffer() - it.bufferedReader().use { reader -> - var line = reader.readLine() - while (line != null) { - stringBuffer.append(line) - line = reader.readLine() + val module = try { + metadataInputStream.use { + val stringBuffer = StringBuffer() + it.bufferedReader().use { reader -> + var line = reader.readLine() + while (line != null) { + stringBuffer.append(line) + line = reader.readLine() + } } - } - jsonParser(stringBuffer.toString()) + jsonParser(stringBuffer.toString()) + } + } catch (e: Exception) { + e.printStackTrace() + safeException( + IllegalArgumentException("Could not parse module", e), + newModuleUri + ) } if (callback(ModuleInstallEvent.PARSED(module))) { @@ -338,58 +345,28 @@ class AddModuleUseCase @Inject constructor( metadataUriPairs.forEach { log("Comparing module ${module.id} (${module.version}) with ${it.second.id} (${it.second.version})") // Check if the module already exists + // TODO: check how the error is handled if the module version is invalid, this was previously done here, now idk if (module.id == it.second.id) { - val ret: Int = try { - module.version.compareSemVer(it.second.version) - } catch (e: IllegalArgumentException) { - // Find which module has the invalid version - try { - "0.0.0".compareSemVer(module.version) - } catch (e: IllegalArgumentException) { - // The module being installed has an invalid version - e.printStackTrace() - safeException(e, newModuleUri) - } - - try { - "0.0.0".compareSemVer(it.second.version) - } catch (e: IllegalArgumentException) { - e.printStackTrace() - // The existing module has an invalid version - // We can make a note of it and let the module be installed - log("Module ${it.second.name} (${it.second.id}) has an invalid version (${it.second.version})") - 1 - } - } - - when (ret) { - // Module being installed is newer than the existing module - 1 -> { - // Delete the old module - if (DocumentFile.fromSingleUri(mContext, it.first)?.delete() == false) { - safeException( - IOException("Could not delete module ${it.second.name} (${it.second.id})"), - newModuleUri - ) - } - log("Updated module ${module.name} (${module.id})") - } - - // Module being installed is the same version as the existing module - 0 -> { + if (module.version > it.second.version) { // new module version > old module version + // Delete the old module + if (DocumentFile.fromSingleUri(mContext, it.first)?.delete() == false) { safeException( - IllegalArgumentException("Module ${module.name} (${module.id}) already exists"), - newModuleUri - ) - } - - // Module being installed is older than the existing module - else -> { - safeException( - IllegalArgumentException("Module ${module.name} (${module.id}) is older than the existing module"), - newModuleUri + IOException("Could not delete module ${it.second.name} (${it.second.id})"), + it.first ) } + log("Updated module ${module.name} (${module.id})") + } + if (module.version == it.second.version) { // new module version == old module version + safeException( + IllegalArgumentException("Module ${module.name} (${module.id}) already exists"), + newModuleUri + ) + } else if (module.version < it.second.version) { // new module version < old module version + safeException( + IllegalArgumentException("Module ${module.name} (${module.id}) is older than the existing module"), + newModuleUri + ) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 075e039..ef930b4 100755 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.3.0-alpha05" +agp = "8.3.0-alpha17" acraHttp = "5.11.3" autoService = "1.1.1" coilCompose = "2.4.0"