mirror of
https://github.com/geode-sdk/android-launcher.git
synced 2026-10-24 00:59:21 +00:00
Bundle Geode with Play Store build (#88)
* add gradle task to download geode binaries * disable automatic update on play build * get download url from release api * fix incorrect priorities between external library and prebundled * bundle geode resources this also fixes the release api fetch as i never tested it until now, oops * add message for loading from files dir * raise minimum gd version with bundled geode * clear old resources prior to copying
This commit is contained in:
1 parent
9e86bbe355
commit
3194dff7ff
14 files changed
+455
-83
No files matched your search
@@ -1,3 +1,9 @@
|
|||||||
|
import groovy.json.JsonSlurper
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.URI
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
extraWarnings.set(true)
|
extraWarnings.set(true)
|
||||||
@@ -10,6 +16,232 @@ plugins {
|
|||||||
id("org.jetbrains.kotlin.plugin.compose")
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val currentGeodeVersion = "5.10.1"
|
||||||
|
|
||||||
|
abstract class DownloadVersionMetadataTask : DefaultTask() {
|
||||||
|
@get:Input
|
||||||
|
abstract val geodeVersion: Property<String>
|
||||||
|
|
||||||
|
@get:OutputFile
|
||||||
|
abstract val targetFile: RegularFileProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun execute() {
|
||||||
|
val downloadGeodeVersion = geodeVersion.get()
|
||||||
|
val destinationFile = targetFile.get().asFile
|
||||||
|
|
||||||
|
val releaseUrl = "https://api.geode-sdk.org/v1/loader/versions/$downloadGeodeVersion"
|
||||||
|
URI.create(releaseUrl).toURL().openStream().use { response ->
|
||||||
|
destinationFile.outputStream().use { destination ->
|
||||||
|
response.copyTo(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class DownloadGeodeFileTask : DefaultTask() {
|
||||||
|
@get:Input
|
||||||
|
abstract val platform: Property<String>
|
||||||
|
|
||||||
|
@get:InputFile
|
||||||
|
abstract val metadata: RegularFileProperty
|
||||||
|
|
||||||
|
private fun downloadAndVerifyFile(filename: String, url: String, hash: String?): File {
|
||||||
|
logger.info("Downloading $filename from $url")
|
||||||
|
|
||||||
|
val tempFile = File(temporaryDir, filename)
|
||||||
|
|
||||||
|
URI.create(url).toURL().openStream().use { response ->
|
||||||
|
tempFile.outputStream().use { output ->
|
||||||
|
response.copyTo(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hash != null) {
|
||||||
|
val downloadedHash = MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(tempFile.readBytes())
|
||||||
|
.toHexString()
|
||||||
|
|
||||||
|
if (downloadedHash != hash) {
|
||||||
|
throw GradleException("Hash check failed for file $filename, expected $hash but found $downloadedHash.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tempFile
|
||||||
|
}
|
||||||
|
|
||||||
|
fun downloadZip(fallbackUrl: String?): File {
|
||||||
|
val downloadPlatform = platform.get()
|
||||||
|
|
||||||
|
val metadataFile = metadata.get().asFile
|
||||||
|
val json = JsonSlurper().parse(metadataFile) as Map<*, *>
|
||||||
|
|
||||||
|
val payload = json["payload"] as? Map<*, *>
|
||||||
|
|
||||||
|
val downloads = payload?.get("downloads") as? Map<*, *>
|
||||||
|
val platformInfo = downloads?.get(downloadPlatform) as? Map<*, *>
|
||||||
|
|
||||||
|
val downloadUrl = platformInfo?.get("url") as? String ?: fallbackUrl
|
||||||
|
if (downloadUrl == null) {
|
||||||
|
throw GradleException("Failed to fetch download url for platform $platform.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val expectedHash = platformInfo?.get("hash") as? String
|
||||||
|
|
||||||
|
return downloadAndVerifyFile("$downloadPlatform.zip", downloadUrl, expectedHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class DownloadAndUnzipGeodeTask : DownloadGeodeFileTask() {
|
||||||
|
@get:Input
|
||||||
|
abstract val geodeVersion: Property<String>
|
||||||
|
|
||||||
|
@get:OutputFile
|
||||||
|
abstract val targetFile: RegularFileProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun execute() {
|
||||||
|
val downloadGeodeVersion = geodeVersion.get()
|
||||||
|
val downloadPlatform = platform.get()
|
||||||
|
val destinationFile = targetFile.get().asFile
|
||||||
|
|
||||||
|
val tempOutput = this.downloadZip("https://github.com/geode-sdk/geode/releases/download/v$downloadGeodeVersion/geode-v$downloadGeodeVersion-$downloadPlatform.zip")
|
||||||
|
|
||||||
|
val expectedFile = "Geode.$downloadPlatform.so"
|
||||||
|
|
||||||
|
ZipInputStream(tempOutput.inputStream()).use { zip ->
|
||||||
|
var entry = zip.nextEntry
|
||||||
|
while (entry != null) {
|
||||||
|
if (entry.name == expectedFile) {
|
||||||
|
destinationFile.outputStream().use {
|
||||||
|
zip.copyTo(it)
|
||||||
|
}
|
||||||
|
return@execute
|
||||||
|
}
|
||||||
|
|
||||||
|
entry = zip.nextEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
throw GradleException("Could not find Geode release.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class DownloadAndUnzipGeodeResourcesTask : DownloadGeodeFileTask() {
|
||||||
|
init {
|
||||||
|
platform.set("resources")
|
||||||
|
}
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val geodeVersion: Property<String>
|
||||||
|
|
||||||
|
@get:OutputDirectory
|
||||||
|
abstract val outputDir: DirectoryProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun execute() {
|
||||||
|
val downloadGeodeVersion = geodeVersion.get()
|
||||||
|
|
||||||
|
val tempOutput = this.downloadZip("https://github.com/geode-sdk/geode/releases/download/v$downloadGeodeVersion/resources.zip")
|
||||||
|
|
||||||
|
val baseOutput = outputDir.get().asFile
|
||||||
|
if (baseOutput.exists()) baseOutput.deleteRecursively()
|
||||||
|
|
||||||
|
val output = File(baseOutput, "geode.loader")
|
||||||
|
output.mkdirs()
|
||||||
|
|
||||||
|
val canonicalOutput = output.canonicalPath
|
||||||
|
|
||||||
|
ZipInputStream(tempOutput.inputStream()).use { zipStream ->
|
||||||
|
var entry = zipStream.nextEntry
|
||||||
|
while (entry != null) {
|
||||||
|
val destination = File(output, entry.name)
|
||||||
|
if (!destination.canonicalPath.startsWith(canonicalOutput)) {
|
||||||
|
throw GradleException("attempted copy to outside of output directory: ${entry.name}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (destination.isDirectory) {
|
||||||
|
destination.mkdirs()
|
||||||
|
} else {
|
||||||
|
destination.parentFile?.mkdirs()
|
||||||
|
|
||||||
|
destination.outputStream().use { destinationStream ->
|
||||||
|
zipStream.copyTo(destinationStream)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry = zipStream.nextEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val fetchGeodeMetadataTask = tasks.register<DownloadVersionMetadataTask>("fetchGeodeMetadata") {
|
||||||
|
description = "Fetches and saves a Geode version's metadata"
|
||||||
|
inputs.property("geodeVersion", currentGeodeVersion)
|
||||||
|
|
||||||
|
geodeVersion = currentGeodeVersion
|
||||||
|
targetFile = temporaryDir.resolve("metadata.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
val downloadGeode32Task = tasks.register<DownloadAndUnzipGeodeTask>("downloadGeode32Binary") {
|
||||||
|
description = "Downloads the 32bit Geode release archive"
|
||||||
|
inputs.property("geodeVersion", currentGeodeVersion)
|
||||||
|
|
||||||
|
geodeVersion = currentGeodeVersion
|
||||||
|
platform = "android32"
|
||||||
|
metadata = fetchGeodeMetadataTask.flatMap { it.targetFile }
|
||||||
|
targetFile = temporaryDir.resolve("libgeode.so")
|
||||||
|
}
|
||||||
|
|
||||||
|
val downloadGeode64Task = tasks.register<DownloadAndUnzipGeodeTask>("downloadGeode64Binary") {
|
||||||
|
description = "Downloads the 64bit Geode release archive"
|
||||||
|
inputs.property("geodeVersion", currentGeodeVersion)
|
||||||
|
|
||||||
|
geodeVersion = currentGeodeVersion
|
||||||
|
platform = "android64"
|
||||||
|
metadata = fetchGeodeMetadataTask.flatMap { it.targetFile }
|
||||||
|
targetFile = temporaryDir.resolve("libgeode.so")
|
||||||
|
}
|
||||||
|
|
||||||
|
val downloadGeodeResourcesTask = tasks.register<DownloadAndUnzipGeodeResourcesTask>("downloadGeodeResources") {
|
||||||
|
description = "Downloads Geode resources"
|
||||||
|
inputs.property("geodeVersion", currentGeodeVersion)
|
||||||
|
|
||||||
|
geodeVersion = currentGeodeVersion
|
||||||
|
metadata = fetchGeodeMetadataTask.flatMap { it.targetFile }
|
||||||
|
outputDir = layout.buildDirectory.dir("geode-resources")
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class MergeGeodeFilesTask : DefaultTask() {
|
||||||
|
@get:InputFile
|
||||||
|
abstract val binary32: RegularFileProperty
|
||||||
|
|
||||||
|
@get:InputFile
|
||||||
|
abstract val binary64: RegularFileProperty
|
||||||
|
|
||||||
|
@get:OutputDirectory
|
||||||
|
abstract val outputDir: DirectoryProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun execute() {
|
||||||
|
val out = outputDir.get().asFile
|
||||||
|
out.deleteRecursively()
|
||||||
|
out.mkdirs()
|
||||||
|
|
||||||
|
binary32.get().asFile.copyTo(outputDir.file("armeabi-v7a/libgeode.so").get().asFile)
|
||||||
|
binary64.get().asFile.copyTo(outputDir.file("arm64-v8a/libgeode.so").get().asFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val fullDownloadTask = tasks.register<MergeGeodeFilesTask>("downloadGeodeFull") {
|
||||||
|
description = "Downloads both Geode releases"
|
||||||
|
|
||||||
|
binary32 = downloadGeode32Task.flatMap { it.targetFile }
|
||||||
|
binary64 = downloadGeode64Task.flatMap { it.targetFile }
|
||||||
|
outputDir = layout.buildDirectory.dir("geode-prebuilt")
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdk = 37
|
compileSdk = 37
|
||||||
|
|
||||||
@@ -54,12 +286,14 @@ android {
|
|||||||
dimension = "distribution"
|
dimension = "distribution"
|
||||||
isDefault = true
|
isDefault = true
|
||||||
buildConfigField("boolean", "GOOGLE_PLAY_BUILD", "false")
|
buildConfigField("boolean", "GOOGLE_PLAY_BUILD", "false")
|
||||||
|
buildConfigField("String", "PREBUNDLED_GEODE", "null")
|
||||||
}
|
}
|
||||||
|
|
||||||
create("googlePlay") {
|
create("googlePlay") {
|
||||||
dimension = "distribution"
|
dimension = "distribution"
|
||||||
applicationId = "com.geode.launcher.play"
|
applicationId = "com.geode.launcher.play"
|
||||||
buildConfigField("boolean", "GOOGLE_PLAY_BUILD", "true")
|
buildConfigField("boolean", "GOOGLE_PLAY_BUILD", "true")
|
||||||
|
buildConfigField("String", "PREBUNDLED_GEODE", "\"$currentGeodeVersion\"")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +334,19 @@ android {
|
|||||||
ndkVersion = "29.0.14206865"
|
ndkVersion = "29.0.14206865"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
androidComponents {
|
||||||
|
onVariants(selector().withFlavor("distribution" to "googlePlay")) { variant ->
|
||||||
|
variant.sources.jniLibs?.addGeneratedSourceDirectory(
|
||||||
|
fullDownloadTask,
|
||||||
|
MergeGeodeFilesTask::outputDir
|
||||||
|
)
|
||||||
|
variant.sources.assets?.addGeneratedSourceDirectory(
|
||||||
|
downloadGeodeResourcesTask,
|
||||||
|
DownloadAndUnzipGeodeResourcesTask::outputDir
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation (platform("androidx.compose:compose-bom:2026.08.00"))
|
implementation (platform("androidx.compose:compose-bom:2026.08.00"))
|
||||||
implementation ("androidx.core:core-ktx:1.19.0")
|
implementation ("androidx.core:core-ktx:1.19.0")
|
||||||
|
|||||||
@@ -379,56 +379,73 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
return "$sourceDir!/lib/$architecture/"
|
return "$sourceDir!/lib/$architecture/"
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
|
||||||
private fun loadGeodeLibrary() {
|
private fun loadGeodeLibrary() {
|
||||||
// Load Geode if exists
|
@Suppress("SENSELESS_COMPARISON")
|
||||||
// bundling the object with the application allows for nicer backtraces
|
if (BuildConfig.PREBUNDLED_GEODE != null) {
|
||||||
try {
|
// attempt to load an externally provided Geode first
|
||||||
// put libgeode.so in jniLibs/armeabi-v7a to get this
|
if (!loadExternalGeode()) {
|
||||||
System.loadLibrary("geode")
|
copyGeodeResources()
|
||||||
return
|
System.loadLibrary("geode")
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
}
|
||||||
// but users may prefer it stored with data
|
} else {
|
||||||
val geodeFilename = LaunchUtils.geodeFilename
|
// Load Geode if exists
|
||||||
val geodePath = File(filesDir.path, "launcher/$geodeFilename")
|
// bundling the object with the application allows for nicer backtraces
|
||||||
if (geodePath.exists()) {
|
try {
|
||||||
System.load(geodePath.path)
|
// put libgeode.so in jniLibs/armeabi-v7a to get this
|
||||||
|
System.loadLibrary("geode")
|
||||||
return
|
return
|
||||||
}
|
} catch (e: UnsatisfiedLinkError) {
|
||||||
|
if (!loadExternalGeode()) {
|
||||||
// you know zmx i have 0 clue what this does so im
|
throw e
|
||||||
// just gonna like copy the binary from external
|
|
||||||
// also i get 20 million permission denied errors
|
|
||||||
val externalGeodePath = LaunchUtils.getInstalledGeodePath(this)!!
|
|
||||||
|
|
||||||
val copiedPath = File(filesDir.path, "copied")
|
|
||||||
if (copiedPath.exists()) {
|
|
||||||
copiedPath.deleteRecursively()
|
|
||||||
}
|
|
||||||
copiedPath.mkdir()
|
|
||||||
|
|
||||||
val copiedGeodePath = File(copiedPath.path, "Geode.so")
|
|
||||||
|
|
||||||
if (externalGeodePath.exists()) {
|
|
||||||
DownloadUtils.copyFile(
|
|
||||||
FileInputStream(externalGeodePath),
|
|
||||||
FileOutputStream(copiedGeodePath)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (copiedGeodePath.exists()) {
|
|
||||||
// android 17 fix
|
|
||||||
copiedGeodePath.setWritable(false)
|
|
||||||
|
|
||||||
println("Loading Geode from ${externalGeodePath.name}")
|
|
||||||
System.load(copiedGeodePath.path)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw e
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||||
|
private fun loadExternalGeode(): Boolean {
|
||||||
|
// but users may prefer it stored with data
|
||||||
|
val geodeFilename = LaunchUtils.geodeFilename
|
||||||
|
val geodePath = File(filesDir.path, "launcher/$geodeFilename")
|
||||||
|
if (geodePath.exists()) {
|
||||||
|
println("Loading Geode from ${geodePath.path}")
|
||||||
|
System.load(geodePath.path)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// you know zmx i have 0 clue what this does so im
|
||||||
|
// just gonna like copy the binary from external
|
||||||
|
// also i get 20 million permission denied errors
|
||||||
|
val externalGeodePath = LaunchUtils.getInstalledGeodePath(this)
|
||||||
|
?: return false
|
||||||
|
|
||||||
|
val copiedPath = File(filesDir.path, "copied")
|
||||||
|
if (copiedPath.exists()) {
|
||||||
|
copiedPath.deleteRecursively()
|
||||||
|
}
|
||||||
|
copiedPath.mkdir()
|
||||||
|
|
||||||
|
val copiedGeodePath = File(copiedPath.path, "Geode.so")
|
||||||
|
|
||||||
|
if (externalGeodePath.exists()) {
|
||||||
|
DownloadUtils.copyFile(
|
||||||
|
FileInputStream(externalGeodePath),
|
||||||
|
FileOutputStream(copiedGeodePath)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (copiedGeodePath.exists()) {
|
||||||
|
// android 17 fix
|
||||||
|
copiedGeodePath.setWritable(false)
|
||||||
|
|
||||||
|
println("Loading Geode from ${externalGeodePath.name}")
|
||||||
|
System.load(copiedGeodePath.path)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
private fun createView(): FrameLayout {
|
private fun createView(): FrameLayout {
|
||||||
val frameLayoutParams = ViewGroup.LayoutParams(
|
val frameLayoutParams = ViewGroup.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
@@ -666,6 +683,56 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun copyGeodeResources() {
|
||||||
|
@Suppress("SENSELESS_COMPARISON")
|
||||||
|
if (BuildConfig.PREBUNDLED_GEODE == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val resourcesDirectory = LaunchUtils.getGeodeResourcesDirectory(this)
|
||||||
|
|
||||||
|
val pathBase = "geode.loader"
|
||||||
|
val versionFilename = "version"
|
||||||
|
|
||||||
|
val bundledVersion = try {
|
||||||
|
assets.open("$pathBase/$versionFilename").bufferedReader().use {
|
||||||
|
it.readText()
|
||||||
|
}.trimEnd()
|
||||||
|
} catch (_: IOException) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val versionFile = File(resourcesDirectory, versionFilename)
|
||||||
|
val resourcesVersion = if (versionFile.exists()) {
|
||||||
|
versionFile.readText().trimEnd()
|
||||||
|
} else null
|
||||||
|
|
||||||
|
if (bundledVersion == resourcesVersion) {
|
||||||
|
println("copyGeodeResources: found $resourcesVersion, expected $bundledVersion. No work needs to be done!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourcesDirectory.exists()) {
|
||||||
|
resourcesDirectory.deleteRecursively()
|
||||||
|
}
|
||||||
|
resourcesDirectory.mkdirs()
|
||||||
|
|
||||||
|
println("copyGeodeResources: found $resourcesVersion, expected $bundledVersion. Copying assets")
|
||||||
|
|
||||||
|
val fileListing = try {
|
||||||
|
assets.list(pathBase)
|
||||||
|
} catch (_: IOException) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileListing?.forEach { fileName ->
|
||||||
|
val resourceOutput = File(resourcesDirectory, fileName)
|
||||||
|
|
||||||
|
val resource = assets.open("$pathBase/$fileName")
|
||||||
|
DownloadUtils.copyFile(resource, resourceOutput.outputStream())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class EGLConfigChooser : GLSurfaceView.EGLConfigChooser {
|
class EGLConfigChooser : GLSurfaceView.EGLConfigChooser {
|
||||||
// this comes from EGL14, but is unavailable on EGL10
|
// this comes from EGL14, but is unavailable on EGL10
|
||||||
// also EGL14 is incompatible with EGL10. so whatever
|
// also EGL14 is incompatible with EGL10. so whatever
|
||||||
|
|||||||
@@ -111,6 +111,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
clearDownloadedApks(this)
|
clearDownloadedApks(this)
|
||||||
|
|
||||||
|
// remove old Geode for the like 4 people that downloaded the Play Store build
|
||||||
|
@Suppress("SENSELESS_COMPARISON")
|
||||||
|
if (BuildConfig.PREBUNDLED_GEODE != null) {
|
||||||
|
val preferences = PreferenceUtils.get(this)
|
||||||
|
if (!preferences.getBoolean(PreferenceUtils.Key.PREBUNDLED_GEODE_MIGRATION_PERFORMED)) {
|
||||||
|
LaunchUtils.getInstalledGeodePath(this)?.delete()
|
||||||
|
preferences.setBoolean(PreferenceUtils.Key.PREBUNDLED_GEODE_MIGRATION_PERFORMED, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val returnMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_MESSAGE)
|
val returnMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_MESSAGE)
|
||||||
val returnExtendedMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE)
|
val returnExtendedMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE)
|
||||||
|
|
||||||
@@ -207,7 +217,7 @@ fun mapCancelReasonToInfo(cancelReason: LaunchViewModel.LaunchCancelReason): Lau
|
|||||||
)
|
)
|
||||||
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED -> LaunchStatusInfo(
|
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED -> LaunchStatusInfo(
|
||||||
title = stringResource(R.string.launcher_cancelled_error),
|
title = stringResource(R.string.launcher_cancelled_error),
|
||||||
details = stringResource(R.string.launcher_cancelled_outdated)
|
details = stringResource(R.string.launcher_cancelled_outdated, GamePackageUtils.getSupportedUnifiedVersion())
|
||||||
)
|
)
|
||||||
LaunchViewModel.LaunchCancelReason.AUTOMATIC,
|
LaunchViewModel.LaunchCancelReason.AUTOMATIC,
|
||||||
LaunchViewModel.LaunchCancelReason.MANUAL-> LaunchStatusInfo(
|
LaunchViewModel.LaunchCancelReason.MANUAL-> LaunchStatusInfo(
|
||||||
@@ -828,10 +838,11 @@ fun AltMainScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// only show launcher update in a case where a user won't see it ingame
|
// only show launcher update in a case where a user won't see it ingame
|
||||||
if (launchUIState is LaunchViewModel.LaunchUIState.Cancelled) {
|
val currentUIState = launchUIState
|
||||||
|
if (currentUIState is LaunchViewModel.LaunchUIState.Cancelled) {
|
||||||
val gameVersion = remember { GamePackageUtils.getGameVersionCodeOrNull(context.packageManager) }
|
val gameVersion = remember { GamePackageUtils.getGameVersionCodeOrNull(context.packageManager) }
|
||||||
|
|
||||||
if (gameVersion != null && gameVersion < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
|
if (gameVersion != null && gameVersion < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING && currentUIState.reason != LaunchViewModel.LaunchCancelReason.GAME_OUTDATED) {
|
||||||
UnsupportedVersionWarning()
|
UnsupportedVersionWarning()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -202,7 +202,11 @@ fun onShowLogs(context: Context) {
|
|||||||
fun ErrorInfoDescription(description: String, modifier: Modifier = Modifier) {
|
fun ErrorInfoDescription(description: String, modifier: Modifier = Modifier) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val currentRelease = remember {
|
val currentRelease = remember {
|
||||||
PreferenceUtils.get(context).getString(PreferenceUtils.Key.CURRENT_VERSION_TAG)
|
@Suppress("SENSELESS_COMPARISON")
|
||||||
|
if (BuildConfig.PREBUNDLED_GEODE != null)
|
||||||
|
"${BuildConfig.PREBUNDLED_GEODE}*"
|
||||||
|
else
|
||||||
|
PreferenceUtils.get(context).getString(PreferenceUtils.Key.CURRENT_VERSION_TAG)
|
||||||
}
|
}
|
||||||
|
|
||||||
val gameVersion = remember {
|
val gameVersion = remember {
|
||||||
|
|||||||
@@ -187,6 +187,8 @@ fun LongPressButton(onClick: () -> Unit, onLongPress: () -> Unit, modifier: Modi
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UnsupportedVersionWarning(modifier: Modifier = Modifier) {
|
fun UnsupportedVersionWarning(modifier: Modifier = Modifier) {
|
||||||
|
val gameVersion = GamePackageUtils.getSupportedUnifiedVersion()
|
||||||
|
|
||||||
InlineDialog(
|
InlineDialog(
|
||||||
headline = {
|
headline = {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -202,7 +204,7 @@ fun UnsupportedVersionWarning(modifier: Modifier = Modifier) {
|
|||||||
},
|
},
|
||||||
body = {
|
body = {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.launcher_game_update_required),
|
stringResource(R.string.launcher_game_update_required, gameVersion),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
modifier = modifier.padding(8.dp)
|
modifier = modifier.padding(8.dp)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModelProvider
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.geode.launcher.BuildConfig
|
||||||
import com.geode.launcher.updater.ReleaseManager
|
import com.geode.launcher.updater.ReleaseManager
|
||||||
import com.geode.launcher.utils.Constants
|
import com.geode.launcher.utils.Constants
|
||||||
import com.geode.launcher.utils.GamePackageUtils
|
import com.geode.launcher.utils.GamePackageUtils
|
||||||
@@ -182,7 +183,9 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (GamePackageUtils.getGameVersionCode(packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN) {
|
// play store build only comes with the one build of geode
|
||||||
|
val minVersionCode = if (BuildConfig.GOOGLE_PLAY_BUILD) Constants.SUPPORTED_VERSION_CODE else Constants.SUPPORTED_VERSION_CODE_MIN
|
||||||
|
if (GamePackageUtils.getGameVersionCode(packageManager) < minVersionCode) {
|
||||||
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_OUTDATED))
|
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_OUTDATED))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -248,25 +251,31 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
|||||||
initReadyTimer()
|
initReadyTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
val releaseManager = ReleaseManager.get(application)
|
if (!BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
|
val releaseManager = ReleaseManager.get(application)
|
||||||
|
|
||||||
val hasGeode = LaunchUtils.isGeodeInstalled(application)
|
val hasGeode = LaunchUtils.isGeodeInstalled(application)
|
||||||
val shouldUpdate = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
|
val shouldUpdate = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
|
||||||
|
|
||||||
if ((shouldUpdate && !hasCheckedForUpdates) || !hasGeode) {
|
if ((shouldUpdate && !hasCheckedForUpdates) || !hasGeode) {
|
||||||
releaseManager.checkForUpdates(false)
|
releaseManager.checkForUpdates(false)
|
||||||
} else {
|
} else {
|
||||||
releaseManager.afterUseCachedUpdate()
|
releaseManager.afterUseCachedUpdate()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRestart) {
|
if (isRestart) {
|
||||||
releaseManager.dismissedLauncherUpdate = true
|
releaseManager.dismissedLauncherUpdate = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
preReadyCheck()
|
preReadyCheck()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun retryUpdate() {
|
fun retryUpdate() {
|
||||||
|
if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
ReleaseManager.get(application).checkForUpdates(true)
|
ReleaseManager.get(application).checkForUpdates(true)
|
||||||
if (ReleaseManager.get(application).isInUpdate) {
|
if (ReleaseManager.get(application).isInUpdate) {
|
||||||
restoreUpdateState = _uiState.value
|
restoreUpdateState = _uiState.value
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import androidx.compose.ui.res.pluralStringResource
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.geode.launcher.BuildConfig
|
||||||
import com.geode.launcher.R
|
import com.geode.launcher.R
|
||||||
import com.geode.launcher.preferences.components.OptionsButton
|
import com.geode.launcher.preferences.components.OptionsButton
|
||||||
import com.geode.launcher.preferences.components.OptionsGroup
|
import com.geode.launcher.preferences.components.OptionsGroup
|
||||||
@@ -204,28 +205,30 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
OptionsGroup(title = stringResource(R.string.preference_category_updater)) {
|
if (!BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
val context = LocalContext.current
|
OptionsGroup(title = stringResource(R.string.preference_category_updater)) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
SettingsSelectCard(
|
SettingsSelectCard(
|
||||||
title = stringResource(R.string.preference_release_channel_tag_name),
|
title = stringResource(R.string.preference_release_channel_tag_name),
|
||||||
dialogTitle = stringResource(R.string.preference_release_channel_select),
|
dialogTitle = stringResource(R.string.preference_release_channel_select),
|
||||||
preferenceKey = PreferenceUtils.Key.RELEASE_CHANNEL_TAG,
|
preferenceKey = PreferenceUtils.Key.RELEASE_CHANNEL_TAG,
|
||||||
options = linkedMapOf(
|
options = linkedMapOf(
|
||||||
0 to stringResource(R.string.preference_release_channel_stable),
|
0 to stringResource(R.string.preference_release_channel_stable),
|
||||||
1 to stringResource(R.string.preference_release_channel_beta),
|
1 to stringResource(R.string.preference_release_channel_beta),
|
||||||
2 to stringResource(R.string.preference_release_channel_nightly),
|
2 to stringResource(R.string.preference_release_channel_nightly),
|
||||||
),
|
),
|
||||||
extraSelectBehavior = {
|
extraSelectBehavior = {
|
||||||
PreferenceUtils.get(context)
|
PreferenceUtils.get(context)
|
||||||
.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, 0L)
|
.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, 0L)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
SettingsCard(
|
SettingsCard(
|
||||||
title = stringResource(R.string.preference_disable_update_cache),
|
title = stringResource(R.string.preference_disable_update_cache),
|
||||||
preferenceKey = PreferenceUtils.Key.DISABLE_UPDATE_CACHE
|
preferenceKey = PreferenceUtils.Key.DISABLE_UPDATE_CACHE
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
OptionsGroup(title = stringResource(R.string.preference_category_testing)) {
|
OptionsGroup(title = stringResource(R.string.preference_category_testing)) {
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.geode.launcher.BuildConfig
|
import com.geode.launcher.BuildConfig
|
||||||
import com.geode.launcher.MainActivity
|
|
||||||
import com.geode.launcher.R
|
import com.geode.launcher.R
|
||||||
import com.geode.launcher.UserDirectoryProvider
|
import com.geode.launcher.UserDirectoryProvider
|
||||||
import com.geode.launcher.preferences.components.BackupButton
|
import com.geode.launcher.preferences.components.BackupButton
|
||||||
@@ -510,6 +509,10 @@ fun GameplaySettingsGroup() {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UpdateSettingsGroup(releaseViewModel: ReleaseViewModel, snackbarHostState: SnackbarHostState, onUpdate: () -> Unit) {
|
fun UpdateSettingsGroup(releaseViewModel: ReleaseViewModel, snackbarHostState: SnackbarHostState, onUpdate: () -> Unit) {
|
||||||
|
if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val updateStatus by releaseViewModel.uiState.collectAsState()
|
val updateStatus by releaseViewModel.uiState.collectAsState()
|
||||||
|
|
||||||
OptionsGroup(stringResource(R.string.preference_category_updater)) {
|
OptionsGroup(stringResource(R.string.preference_category_updater)) {
|
||||||
@@ -661,11 +664,14 @@ fun AboutSettingsGroup(developerModeEnabled: Boolean) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("SENSELESS_COMPARISON")
|
||||||
OptionsLabel(
|
OptionsLabel(
|
||||||
stringResource(R.string.preference_loader_version_name),
|
stringResource(R.string.preference_loader_version_name),
|
||||||
stringResource(
|
stringResource(
|
||||||
R.string.preference_loader_version_description,
|
R.string.preference_loader_version_description,
|
||||||
currentRelease ?: "unknown"
|
if (BuildConfig.PREBUNDLED_GEODE != null)
|
||||||
|
"${BuildConfig.PREBUNDLED_GEODE}*"
|
||||||
|
else (currentRelease ?: "unknown")
|
||||||
),
|
),
|
||||||
displayInline = true
|
displayInline = true
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -409,6 +409,10 @@ class ReleaseManager private constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun shouldUseCache(): Boolean {
|
fun shouldUseCache(): Boolean {
|
||||||
|
if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
||||||
if (sharedPreferences.getBoolean(PreferenceUtils.Key.DISABLE_UPDATE_CACHE)) {
|
if (sharedPreferences.getBoolean(PreferenceUtils.Key.DISABLE_UPDATE_CACHE)) {
|
||||||
return false
|
return false
|
||||||
@@ -427,6 +431,11 @@ class ReleaseManager private constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun checkForNewRelease(isManual: Boolean = false) {
|
private suspend fun checkForNewRelease(isManual: Boolean = false) {
|
||||||
|
if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
|
_uiState.value = ReleaseManagerState.Finished()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
||||||
|
|
||||||
if (!isManual && shouldUseCache()) {
|
if (!isManual && shouldUseCache()) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.AP
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.geode.launcher.BuildConfig
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -89,6 +90,10 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun runReleaseCheck(isManual: Boolean = false) {
|
fun runReleaseCheck(isManual: Boolean = false) {
|
||||||
|
if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
hasPerformedCheck = true
|
hasPerformedCheck = true
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
|||||||
@@ -63,6 +63,10 @@ object GamePackageUtils {
|
|||||||
return gameVersionMap[versionCode] ?: getGameVersionString(packageManager)
|
return gameVersionMap[versionCode] ?: getGameVersionString(packageManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getSupportedUnifiedVersion(): String {
|
||||||
|
return gameVersionMap[Constants.SUPPORTED_VERSION_CODE] ?: Constants.SUPPORTED_VERSION_STRING
|
||||||
|
}
|
||||||
|
|
||||||
fun detectAbiMismatch(context: Context, packageInfo: PackageInfo, loadException: Error): Boolean {
|
fun detectAbiMismatch(context: Context, packageInfo: PackageInfo, loadException: Error): Boolean {
|
||||||
val abi = LaunchUtils.applicationArchitecture
|
val abi = LaunchUtils.applicationArchitecture
|
||||||
val isExtracted =
|
val isExtracted =
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ object LaunchUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun isGeodeInstalled(context: Context): Boolean {
|
fun isGeodeInstalled(context: Context): Boolean {
|
||||||
|
@Suppress("SENSELESS_COMPARISON")
|
||||||
|
if (BuildConfig.PREBUNDLED_GEODE != null) return true
|
||||||
|
|
||||||
return getInstalledGeodePath(context) != null
|
return getInstalledGeodePath(context) != null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
|||||||
LAST_UPDATE_CHECK_TIME,
|
LAST_UPDATE_CHECK_TIME,
|
||||||
LAST_LAUNCHER_UPDATE,
|
LAST_LAUNCHER_UPDATE,
|
||||||
DISABLE_UPDATE_CACHE,
|
DISABLE_UPDATE_CACHE,
|
||||||
|
PREBUNDLED_GEODE_MIGRATION_PERFORMED,
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun defaultValueForBooleanKey(key: Key): Boolean {
|
private fun defaultValueForBooleanKey(key: Key): Boolean {
|
||||||
@@ -199,6 +200,7 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
|||||||
Key.LAST_UPDATE_CHECK_TIME -> "PreferenceLastUpdateCheckTime"
|
Key.LAST_UPDATE_CHECK_TIME -> "PreferenceLastUpdateCheckTime"
|
||||||
Key.LAST_LAUNCHER_UPDATE -> "PreferenceLastLauncherUpdate"
|
Key.LAST_LAUNCHER_UPDATE -> "PreferenceLastLauncherUpdate"
|
||||||
Key.DISABLE_UPDATE_CACHE -> "PreferenceDisableUpdateCache"
|
Key.DISABLE_UPDATE_CACHE -> "PreferenceDisableUpdateCache"
|
||||||
|
Key.PREBUNDLED_GEODE_MIGRATION_PERFORMED -> "PreferencePrebundledGeodeMigrationPerformed"
|
||||||
// Key.RELEASE_CHANNEL -> "PreferenceReleaseChannel"
|
// Key.RELEASE_CHANNEL -> "PreferenceReleaseChannel"
|
||||||
// Key.LAST_DISMISSED_UPDATE -> "PreferenceLastDismissedUpdate"
|
// Key.LAST_DISMISSED_UPDATE -> "PreferenceLastDismissedUpdate"
|
||||||
// Key.FORCE_HRR -> "PreferenceForceHighRefreshRate"
|
// Key.FORCE_HRR -> "PreferenceForceHighRefreshRate"
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
</string>
|
</string>
|
||||||
|
|
||||||
<string name="launcher_game_update_required_short">A Geometry Dash update is required.</string>
|
<string name="launcher_game_update_required_short">A Geometry Dash update is required.</string>
|
||||||
<string name="launcher_game_update_required">The currently installed version of Geometry Dash is no longer supported. Please update Geometry Dash to continue receiving Geode updates.</string>
|
<string name="launcher_game_update_required">The currently installed version of Geometry Dash is no longer supported. Please update Geometry Dash to version %1$s to continue receiving Geode updates.</string>
|
||||||
|
|
||||||
<string name="launcher_update_available">A new launcher update is available!</string>
|
<string name="launcher_update_available">A new launcher update is available!</string>
|
||||||
<string name="launcher_update_dismiss">Dismiss</string>
|
<string name="launcher_update_dismiss">Dismiss</string>
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
<string name="launcher_cancelled_crash">Launch paused due to a crash.</string>
|
<string name="launcher_cancelled_crash">Launch paused due to a crash.</string>
|
||||||
<string name="launcher_cancelled_crash_instant">Geode crashed.</string>
|
<string name="launcher_cancelled_crash_instant">Geode crashed.</string>
|
||||||
<string name="launcher_cancelled_error">Geode cannot be used.</string>
|
<string name="launcher_cancelled_error">Geode cannot be used.</string>
|
||||||
<string name="launcher_cancelled_outdated">A Geometry Dash update is required.</string>
|
<string name="launcher_cancelled_outdated">Geometry Dash version %1$s is required.</string>
|
||||||
<string name="launcher_cancelled_restart">Restart</string>
|
<string name="launcher_cancelled_restart">Restart</string>
|
||||||
<string name="launcher_cancelled_resume">Resume</string>
|
<string name="launcher_cancelled_resume">Resume</string>
|
||||||
<string name="launcher_cancelled_retry">Retry</string>
|
<string name="launcher_cancelled_retry">Retry</string>
|
||||||
|
|||||||
Reference in new issue
Block a user