diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f11d363..995c26e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,5 +1,7 @@ @file:Suppress("UnstableApiUsage") +import java.io.ByteArrayOutputStream + plugins { id("com.android.application") id("kotlin-android") @@ -21,6 +23,11 @@ android { buildConfigField("String", "TAG", "\"AliucordManager\"") buildConfigField("String", "SUPPORT_SERVER", "\"EsNDvBaHVU\"") + + buildConfigField("String", "GIT_BRANCH", "\"${getCurrentBranch()}\"") + buildConfigField("String", "GIT_COMMIT", "\"${getLatestCommit()}\"") + buildConfigField("boolean", "GIT_LOCAL_COMMITS", "${gitHasLocalCommits()}") + buildConfigField("boolean", "GIT_LOCAL_CHANGES", "${gitHasLocalChanges()}") } buildTypes { @@ -107,3 +114,39 @@ dependencies { implementation("com.android.tools.build:apksig:7.4.0-beta02") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0") } + +fun getCurrentBranch(): String? = + exec("git", "name-rev", "--name-only", "HEAD") + +fun getLatestCommit(): String? = + exec("git", "rev-parse", "--short", "HEAD") + +fun gitHasLocalCommits(): Boolean { + val branch = getCurrentBranch() ?: return false + return exec("git", "log", "origin/$branch..HEAD")?.isNotEmpty() ?: false +} + +fun gitHasLocalChanges(): Boolean = + exec("git", "status", "-s")?.isNotEmpty() ?: false + +fun exec(vararg command: String): String? { + return try { + val stdout = ByteArrayOutputStream() + val errout = ByteArrayOutputStream() + + exec { + commandLine = command.toList() + standardOutput = stdout + errorOutput = errout + isIgnoreExitValue = true + } + + if (errout.size() > 0) + throw Error(errout.toString(Charsets.UTF_8)) + + stdout.toString(Charsets.UTF_8).trim() + } catch (t: Throwable) { + t.printStackTrace() + null + } +} diff --git a/app/src/main/kotlin/com/aliucord/manager/installer/util/PackageInstaller.kt b/app/src/main/kotlin/com/aliucord/manager/installer/util/PackageInstaller.kt new file mode 100644 index 0000000..5827a18 --- /dev/null +++ b/app/src/main/kotlin/com/aliucord/manager/installer/util/PackageInstaller.kt @@ -0,0 +1,35 @@ +package com.aliucord.manager.installer.util + +import android.annotation.SuppressLint +import android.app.Application +import android.app.PendingIntent +import android.content.Intent +import android.content.pm.PackageInstaller +import android.os.Build +import com.aliucord.manager.installer.service.InstallService +import java.io.File + +fun PackageInstaller.installApks(application: Application, vararg apks: File) { + val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) + val sessionId = createSession(params) + val session = openSession(sessionId) + + apks.forEach { apk -> + session.openWrite(apk.name, 0, apk.length()).use { + it.write(apk.readBytes()) + session.fsync(it) + } + } + + val callbackIntent = Intent(application, InstallService::class.java) + + @SuppressLint("UnspecifiedImmutableFlag") + val contentIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.getService(application, 0, callbackIntent, PendingIntent.FLAG_MUTABLE) + } else { + PendingIntent.getService(application, 0, callbackIntent, 0) + } + + session.commit(contentIntent.intentSender) + session.close() +} diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/dialog/InstallerDialog.kt b/app/src/main/kotlin/com/aliucord/manager/ui/dialog/InstallerDialog.kt index fbc009f..3e4a71b 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/dialog/InstallerDialog.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/dialog/InstallerDialog.kt @@ -19,6 +19,7 @@ import com.aliucord.manager.R import com.aliucord.manager.ui.screen.InstallData enum class DownloadMethod { DOWNLOAD, SELECT } +enum class DiscordType { KOTLIN, REACT_NATIVE } @Composable fun InstallerDialog( @@ -70,6 +71,7 @@ fun InstallerDialog( onConfirm( InstallData( selectedMethod, + DiscordType.REACT_NATIVE ) ) onDismissRequest() diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screen/HomeScreen.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screen/HomeScreen.kt index a287862..b9137fd 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screen/HomeScreen.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screen/HomeScreen.kt @@ -14,8 +14,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.aliucord.manager.ui.component.home.CommitList import com.aliucord.manager.ui.component.home.InfoCard -import com.aliucord.manager.ui.dialog.DownloadMethod -import com.aliucord.manager.ui.dialog.InstallerDialog +import com.aliucord.manager.ui.dialog.* import com.aliucord.manager.ui.viewmodel.HomeViewModel import org.koin.androidx.compose.getViewModel @@ -33,6 +32,7 @@ fun HomeScreen( onClickInstall( InstallData( DownloadMethod.SELECT, + DiscordType.REACT_NATIVE ) ) } @@ -66,6 +66,7 @@ fun HomeScreen( onClickInstall( InstallData( DownloadMethod.DOWNLOAD, + DiscordType.REACT_NATIVE ) ) } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/screen/InstallScreen.kt b/app/src/main/kotlin/com/aliucord/manager/ui/screen/InstallScreen.kt index 28e7d49..57f6759 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/screen/InstallScreen.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/screen/InstallScreen.kt @@ -6,9 +6,8 @@ package com.aliucord.manager.ui.screen import android.os.Parcelable -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.NavigateBefore @@ -17,7 +16,9 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.aliucord.manager.R +import com.aliucord.manager.ui.dialog.DiscordType import com.aliucord.manager.ui.dialog.DownloadMethod import com.aliucord.manager.ui.viewmodel.InstallViewModel import kotlinx.parcelize.Parcelize @@ -27,6 +28,7 @@ import org.koin.core.parameter.parametersOf @Parcelize data class InstallData( val downloadMethod: DownloadMethod, + val discordType: DiscordType, var baseApk: String? = null, var splits: List? = null ) : Parcelable @@ -57,24 +59,27 @@ fun InstallerScreen( ) } ) { paddingValues -> - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .padding(paddingValues) - ) { - stickyHeader { - LinearProgressIndicator( - modifier = Modifier - .fillParentMaxWidth() - .padding(bottom = 4.dp) - ) - } + Column(Modifier.padding(paddingValues)) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .padding(bottom = 4.dp) + ) - item { - Text( - text = viewModel.log, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp) - ) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + ) { + item { + Text( + text = viewModel.log, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 6.dp), + fontSize = 16.sp, + lineHeight = 22.sp + ) + } } } } diff --git a/app/src/main/kotlin/com/aliucord/manager/ui/viewmodel/InstallViewModel.kt b/app/src/main/kotlin/com/aliucord/manager/ui/viewmodel/InstallViewModel.kt index 983e9ff..6a7e744 100644 --- a/app/src/main/kotlin/com/aliucord/manager/ui/viewmodel/InstallViewModel.kt +++ b/app/src/main/kotlin/com/aliucord/manager/ui/viewmodel/InstallViewModel.kt @@ -1,29 +1,27 @@ package com.aliucord.manager.ui.viewmodel -import android.annotation.SuppressLint import android.app.Application -import android.app.PendingIntent -import android.content.Intent -import android.content.pm.PackageInstaller import android.os.Build -import androidx.annotation.RequiresApi +import android.util.Log import androidx.compose.runtime.* import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.aliucord.manager.BuildConfig import com.aliucord.manager.domain.manager.DownloadManager import com.aliucord.manager.domain.manager.PreferencesManager import com.aliucord.manager.domain.repository.GithubRepository -import com.aliucord.manager.installer.service.InstallService -import com.aliucord.manager.installer.util.ManifestPatcher -import com.aliucord.manager.installer.util.Signer +import com.aliucord.manager.installer.util.* import com.aliucord.manager.network.utils.fold +import com.aliucord.manager.ui.dialog.DiscordType import com.aliucord.manager.ui.screen.InstallData import com.github.diamondminer88.zip.* import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow -import java.io.File import java.time.Instant +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.time.ExperimentalTime +import kotlin.time.measureTimedValue class InstallViewModel( private val application: Application, @@ -33,45 +31,64 @@ class InstallViewModel( private val installData: InstallData ) : ViewModel() { private val externalCacheDir = application.externalCacheDir!! - private val filesDir = application.filesDir!! - private val packageInstaller = application.packageManager.packageInstaller - private val dexRegex = Regex("classes(\\d)?\\.dex") + private val installationRunning = AtomicBoolean(false) + private var installStep = InstallStep.NONE + private var elapsedTime = 0f private val _returnToHome = MutableSharedFlow() val returnToHome = _returnToHome.asSharedFlow() - private var installationRunning: Boolean = false - var log by mutableStateOf("") private set init { viewModelScope.launch(Dispatchers.Main) { - startInstallation() + if (installationRunning.getAndSet(true)) { + return@launch + } + + log += "Aliucord Manager ${BuildConfig.VERSION_NAME}\n" + log += "Built from commit ${BuildConfig.GIT_COMMIT} on ${BuildConfig.GIT_BRANCH}\n" + if (BuildConfig.GIT_LOCAL_CHANGES || BuildConfig.GIT_LOCAL_COMMITS) { + log += "Local commits or changes are present!\n" + } + log += "Running Android ${Build.VERSION.RELEASE}, API level ${Build.VERSION.SDK_INT}\n" + log += "Supported ABIs: ${Build.SUPPORTED_ABIS.joinToString()}\n\n" + log += "Installing ${installData.discordType} with the ${installData.downloadMethod} apk method\n" + + withContext(Dispatchers.IO) { + try { + when (installData.discordType) { + DiscordType.REACT_NATIVE -> installReactNative() + DiscordType.KOTLIN -> {} + } + _returnToHome.emit(true) + } catch (t: Throwable) { + Log.e( + BuildConfig.TAG, + "Failed to patch ${installData.discordType.name} during ${installStep.name}: ${Log.getStackTraceString(t)}" + ) + } + + installationRunning.set(false) + } } } - @RequiresApi(Build.VERSION_CODES.O) - suspend fun startInstallation() { - if (installationRunning) return + private suspend fun installReactNative() { + externalCacheDir.resolve("patched").runCatching { deleteRecursively() } - withContext(Dispatchers.IO) { - installationRunning = true + val arch = Build.SUPPORTED_ABIS.first() + val supportedVersion = preferences.version - externalCacheDir.resolve("patched").runCatching { deleteRecursively() } - - val arch = Build.SUPPORTED_ABIS.first() - val supportedVersion = preferences.version - - // Download base.apk - val baseApkFile = externalCacheDir.resolve("base-${supportedVersion}.apk").let { file -> + // Download base.apk + val baseApkFile = step(InstallStep.DL_BASE_APK) { + externalCacheDir.resolve("base-${supportedVersion}.apk").let { file -> if (file.exists()) { - log += "Using cached base APK\n" + log += "cached... " } else { - log += "Downloading Discord APK... " downloadManager.downloadDiscordApk(supportedVersion) - log += "Done\n" } file.copyTo( @@ -81,19 +98,19 @@ class InstallViewModel( true ) } + } - // Download the native libraries split + // Download the native libraries split + val libsApkFile = step(InstallStep.DL_LIBS_APK) { val libArch = arch.replace("-v", "_v") - val libsApkFile = externalCacheDir.resolve("config.$libArch-${supportedVersion}.apk").let { file -> + externalCacheDir.resolve("config.$libArch-${supportedVersion}.apk").let { file -> if (file.exists()) { - log += "Using cached libs APK\n" + log += "cached... " } else { - log += "Downloading libs APK... " downloadManager.downloadSplit( version = supportedVersion, split = "config.$libArch" ) - log += "Done\n" } file.copyTo( @@ -103,18 +120,18 @@ class InstallViewModel( true ) } + } - // Download the locale split - val localeApkFile = externalCacheDir.resolve("config.en-${supportedVersion}.apk").also { file -> + // Download the locale split + val localeApkFile = step(InstallStep.DL_LOCALES_APKS) { + externalCacheDir.resolve("config.en-${supportedVersion}.apk").also { file -> if (file.exists()) { - log += "Using cached locale APK\n" + log += "cached... " } else { - log += "Downloading locale APK... " downloadManager.downloadSplit( version = supportedVersion, split = "config.en" ) - log += "Done\n" } file.copyTo( @@ -124,18 +141,19 @@ class InstallViewModel( true ) } + } - // Download the drawables split - val xxhdpiApkFile = externalCacheDir.resolve("config.xxhdpi-${supportedVersion}.apk").also { file -> + // Download the drawables split + val resApkFile = step(InstallStep.DL_RES_APKS) { + // TODO: download the appropriate dpi res apk + externalCacheDir.resolve("config.xxhdpi-${supportedVersion}.apk").also { file -> if (file.exists()) { - log += "Using cached drawables APK\n" + log += "cached... " } else { - log += "Downloading drawables APK... " downloadManager.downloadSplit( version = supportedVersion, split = "config.xxhdpi" ) - log += "Done\n" } file.copyTo( @@ -145,7 +163,10 @@ class InstallViewModel( true ) } + } + // Download hermes & cppruntime lib + val (hermesLibrary, cppRuntimeLibrary) = step(InstallStep.DL_HERMES) { // Fetch gh releases for Aliucord/Hermes val latestHermesRelease = githubRepository.getHermesReleases().fold( success = { releases -> @@ -155,29 +176,36 @@ class InstallViewModel( ) // Download the hermes-release.aar file to replace in the apk - val hermesLibrary = externalCacheDir.resolve("hermes-release-${latestHermesRelease.tagName}.aar").also { file -> - if (file.exists()) return@also run { log += "Using cached patched hermes library\n" } + val hermes = externalCacheDir.resolve("hermes-release-${latestHermesRelease.tagName}.aar").also { file -> + if (file.exists()) { + log += "cached... " + return@also + } - log += "Downloading patched hermes library... " downloadManager.download( url = latestHermesRelease.assets.find { it.name == "hermes-release.aar" }!!.browserDownloadUrl, fileName = "hermes-release-${latestHermesRelease.tagName}.aar" ) - log += "Done\n" } // Download the hermes-cppruntime-release.aar file to replace in the apk - val cppRuntimeLibrary = externalCacheDir.resolve("hermes-cppruntime-release-${latestHermesRelease.tagName}.aar").also { file -> - if (file.exists()) return@also run { log += "Using cached patched c++ runtime library\n" } + val cppruntime = externalCacheDir.resolve("hermes-cppruntime-release-${latestHermesRelease.tagName}.aar").also { file -> + if (file.exists()) { + log += "cached... " + return@also + } - log += "Downloading patched c++ runtime library... " downloadManager.download( url = latestHermesRelease.assets.find { it.name == "hermes-cppruntime-release.aar" }!!.browserDownloadUrl, fileName = "hermes-cppruntime-release-${latestHermesRelease.tagName}.aar" ) - log += "Done\n" } + Pair(hermes, cppruntime) + } + + // Download Aliucord Native lib + val aliucordDexFile = step(InstallStep.DL_ALIUNATIVE) { // Fetch the gh releases for Aliucord/AliucordNative val latestAliucordNativeRelease = githubRepository.getAliucordNativeReleases().fold( success = { releases -> @@ -187,10 +215,12 @@ class InstallViewModel( ) // Download the Aliucord classes.dex file to add to the apk - val aliucordDexFile = externalCacheDir.resolve("classes-${latestAliucordNativeRelease.tagName}.dex").also { file -> - if (file.exists()) return@also run { log += "Using cached Aliucord dex file\n" } + externalCacheDir.resolve("classes-${latestAliucordNativeRelease.tagName}.dex").also { file -> + if (file.exists()) { + log += "cached... " + return@also + } - log += "Downloading Aliucord dex file... " downloadManager.download( url = latestAliucordNativeRelease.assets.find { it.name == "classes.dex" }!!.browserDownloadUrl, fileName = "classes-${latestAliucordNativeRelease.tagName}.dex" @@ -201,14 +231,15 @@ class InstallViewModel( .resolve(this.name), true ) - }.also { log += "Done\n" } + } } + } - val apks = arrayOf(baseApkFile, libsApkFile, localeApkFile, xxhdpiApkFile) - - if (preferences.replaceIcon) { - log += "Replacing App Icons... " + val apks = arrayOf(baseApkFile, libsApkFile, localeApkFile, resApkFile) + // Replace app icons + if (preferences.replaceIcon) { + step(InstallStep.APP_ICONS) { ZipWriter(baseApkFile, true).use { baseApk -> val mipmaps = arrayOf("mipmap-xhdpi-v4", "mipmap-xxhdpi-v4", "mipmap-xxxhdpi-v4") val icons = arrayOf("ic_logo_foreground.png", "ic_logo_square.png", "ic_logo_foreground.png") @@ -224,43 +255,36 @@ class InstallViewModel( } } } - - log += "Done\n" } + } - log += "Patching manifests... " - try { - apks.forEach { apk -> - val manifest = ZipReader(apk) - .use { zip -> zip.openEntry("AndroidManifest.xml")?.read() } - ?: throw IllegalStateException("No manifest in ${apk.name}") + // Patch manifests + step(InstallStep.MANIFESTS) { + apks.forEach { apk -> + val manifest = ZipReader(apk) + .use { zip -> zip.openEntry("AndroidManifest.xml")?.read() } + ?: throw IllegalStateException("No manifest in ${apk.name}") - ZipWriter(apk, true).use { zip -> - val patchedManifestBytes = if (apk == baseApkFile) { - ManifestPatcher.patchManifest( - manifestBytes = manifest, - packageName = preferences.packageName, - appName = preferences.appName, - debuggable = preferences.debuggable, - ) - } else { - ManifestPatcher.renamePackage(manifest, preferences.packageName) - } - - zip.deleteEntry("AndroidManifest.xml", apk == libsApkFile) // Preserve alignment in libs apk - zip.writeEntry("AndroidManifest.xml", patchedManifestBytes) + ZipWriter(apk, true).use { zip -> + val patchedManifestBytes = if (apk == baseApkFile) { + ManifestPatcher.patchManifest( + manifestBytes = manifest, + packageName = preferences.packageName, + appName = preferences.appName, + debuggable = preferences.debuggable, + ) + } else { + ManifestPatcher.renamePackage(manifest, preferences.packageName) } + + zip.deleteEntry("AndroidManifest.xml", apk == libsApkFile) // Preserve alignment in libs apk + zip.writeEntry("AndroidManifest.xml", patchedManifestBytes) } - } catch (e: Exception) { - log += "An error has occurred: ${e.message}\n" - e.printStackTrace() - - return@withContext } - log += "Done\n" - - log += "Patching java classes... " + } + // Re-order dex files + step(InstallStep.DEX) { val (dexCount, firstDexBytes) = ZipReader(baseApkFile).use { zip -> Pair( // Find the amount of .dex files in apk @@ -280,10 +304,10 @@ class InstallViewModel( // Add Aliucord's .dex and make it load first by being the first .dex zip.writeEntry("classes.dex", aliucordDexFile.readBytes()) } - log += "Done\n" - - log += "Patching libraries... " + } + // Replace libs + step(InstallStep.REPLACE_LIBS) { ZipWriter(libsApkFile, true).use { libsApk -> // Process the hermes and cpp runtime library for (libFile in arrayOf(hermesLibrary, cppRuntimeLibrary)) { @@ -307,44 +331,63 @@ class InstallViewModel( libsApk.writeEntry("lib/$arch/$binaryName", libBytes, ZipCompression.NONE, 4096) } } + } - log += "Done\n" - log += "Signing APKs... " - + step(InstallStep.SIGNING) { apks.forEach(Signer::signApk) + } - log += "Done\n" + step(InstallStep.INSTALLING) { + application.packageManager.packageInstaller + .installApks(application, *apks) + } - installApks(*apks) + log += "\nCompleted in %.2f seconds".format(elapsedTime) + } - _returnToHome.emit(true) - installationRunning = false + @OptIn(ExperimentalTime::class) + private inline fun step(step: InstallStep, block: () -> T): T { + log += "${step.log}... " + installStep = step + + try { + val value = measureTimedValue(block) + val time = value.duration.inWholeMilliseconds.div(1000f) + + elapsedTime += time + log += "Done in %.2fs\n".format(time) + + return value.value + } catch (t: Throwable) { + val stacktrace = Log.getStackTraceString(t) + + log += "\n$stacktrace" + log += "\nFailed to install Aliucord ${installData.discordType.name} during the ${installStep.name} step!" + + throw t } } - private fun installApks(vararg apks: File) { - val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) - val sessionId = packageInstaller.createSession(params) - val session = packageInstaller.openSession(sessionId) + private enum class InstallStep(val log: String) { + // Shared install steps + NONE(""), + APP_ICONS("Patching app icons"), + MANIFESTS("Patching apk manifests"), + DEX("Adding aliu dex into apk"), + REPLACE_LIBS("Replacing libraries"), + SIGNING("Signing apks"), + INSTALLING("Installing apks"), - apks.forEach { apk -> - session.openWrite(apk.name, 0, apk.length()).use { - it.write(apk.readBytes()) + // Kotlin exclusive install steps + // DL_KT_APK("Downloading Discord apk"), + // DL_ALIUHOOK("Downloading Aliuhook library"), - session.fsync(it) - } - } - - val callbackIntent = Intent(application, InstallService::class.java) - - @SuppressLint("UnspecifiedImmutableFlag") - val contentIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.getService(application, 0, callbackIntent, PendingIntent.FLAG_MUTABLE) - } else { - PendingIntent.getService(application, 0, callbackIntent, 0) - } - - session.commit(contentIntent.intentSender) - session.close() + // AliuRN exclusive install steps + DL_BASE_APK("Downloading base apk"), + DL_LIBS_APK("Downloading libraries apk"), + DL_LOCALES_APKS("Downloading locale apks"), + DL_RES_APKS("Downloading resource apk"), + DL_HERMES("Downloading patched hermes & c++ runtime library"), + DL_ALIUNATIVE("Downloading AliucordNative library"), } } diff --git a/build.gradle.kts b/build.gradle.kts index 86c65bd..99443b3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,7 @@ buildscript { } dependencies { - classpath("com.android.tools.build:gradle:7.3.0") + classpath("com.android.tools.build:gradle:7.2.2") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.10") } }