add save backup functionality

This commit is contained in:
Chloe committed 2026-02-01 04:52:24 -07:00
1 parent 1f1e0de523
commit 24dd71a96d
6 files changed
+492 -5

No files matched your search

+4
View File
@@ -108,6 +108,10 @@ dependencies {
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0")
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.10.0")
implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1")
implementation("com.fleeksoft.ksoup:ksoup:0.2.5")
implementation("com.fleeksoft.ksoup:ksoup-okio:0.2.5")
implementation ("com.mikepenz:multiplatform-markdown-renderer-android:0.39.1")
implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.39.1")
implementation ("androidx.browser:browser:1.9.0")
@@ -0,0 +1,419 @@
package com.geode.launcher.preferences
import android.content.Context
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import com.fleeksoft.ksoup.Ksoup
import com.fleeksoft.ksoup.nodes.Document
import com.fleeksoft.ksoup.parseSource
import com.fleeksoft.ksoup.parser.Parser
import com.geode.launcher.R
import com.geode.launcher.utils.LaunchUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.FormBody
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
import okio.Buffer
import okio.ByteString.Companion.decodeBase64
import okio.GzipSource
import okio.buffer
import okio.source
import java.io.File
import kotlin.collections.chunked
import kotlin.collections.component1
import kotlin.collections.component2
import kotlin.experimental.xor
import kotlin.time.Duration.Companion.seconds
data class UserDetails(
val userName: String,
val gjp2: String,
val accountId: Int,
val uuid: Int,
val udid: String,
val binaryVersion: Int,
)
enum class BackupParseFailure {
SAVE_MISSING,
DECODE_FAILED,
DETAILS_MISSING,
SAVE_TOO_LARGE
}
class BackupParseException(val failureCode: BackupParseFailure) : Exception("parse error: $failureCode")
enum class BackupUploadFailure {
GENERIC_ERROR,
NETWORK_ERROR,
INVALID_LOGIN
}
class BackupUploadException(val failureCode: BackupUploadFailure, val gjCode: Int = -1) : Exception("upload error: $failureCode")
suspend fun getAccountBackupUrl(backupClient: OkHttpClient, details: UserDetails): Result<HttpUrl> {
return try {
// accountID=%i&type=1&secret=Wmfd2893gb7
val body = FormBody.Builder()
.add("secret", "Wmfd2893gb7")
.add("type", "1")
.add("accountID", details.accountId.toString())
.build()
val request = Request.Builder()
.url("https://www.boomlings.com/database/getAccountURL.php")
.addHeader("User-Agent", "")
.post(body)
.build()
val call = backupClient.newCall(request)
call.executeAsync()
} catch (e: Exception) {
e.printStackTrace()
return Result.failure(BackupUploadException(BackupUploadFailure.NETWORK_ERROR))
}.use { response ->
if (response.code != 200) {
return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
}
val url = response.body.string().toHttpUrlOrNull()
?: return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
Result.success(url)
}
}
fun decryptFileToBuffer(file: File, buffer: Buffer) = file.source().buffer()
.use { baseBuffer ->
while (!baseBuffer.exhausted()) {
val b = baseBuffer.readByte()
val d = b.xor(0xb)
// gd sometimes encodes garbage after null byte, treat it like a c string
if (d == 0.toByte())
break
buffer.writeByte(d.toInt())
}
}
fun generateSaveString(gameManager: File, localLevels: File): String = Buffer()
.use { saveBuffer ->
decryptFileToBuffer(gameManager, saveBuffer)
saveBuffer.writeByte(';'.code)
decryptFileToBuffer(localLevels, saveBuffer)
saveBuffer.readUtf8()
}
fun generateBackupRequest(details: UserDetails, url: HttpUrl, gameManager: File, localLevels: File): Request {
val saveData = generateSaveString(gameManager, localLevels)
// %s/database/accounts/backupGJAccountNew.php
val backupUrl = url.newBuilder()
.addPathSegments("database/accounts/backupGJAccountNew.php")
.build()
// gameVersion=22&binaryVersion=%i&dvs=2&udid=%s&uuid=%i&accountID=%i&gjp2=%s&secret=Wmfv3899gc9&saveData=%s
val body = FormBody.Builder()
.add("gameVersion", "22")
.add("binaryVersion", details.binaryVersion.toString())
.add("dvs", "2")
.add("udid", details.udid)
.add("uuid", details.uuid.toString())
.add("accountID", details.accountId.toString())
.add("secret", "Wmfv3899gc9")
.add("gjp2", details.gjp2)
.add("saveData", saveData)
.build()
return Request.Builder()
.url(backupUrl)
.addHeader("User-Agent", "")
.post(body)
.build()
}
suspend fun performBackupRequest(backupClient: OkHttpClient, details: UserDetails, url: HttpUrl, gameManager: File, localLevels: File): Result<Unit> {
return try {
// we split this into multiple functions to free up as many references as we can
// go little gc go
val request = generateBackupRequest(details, url, gameManager, localLevels)
val call = backupClient.newCall(request)
call.executeAsync()
} catch (e: Exception) {
e.printStackTrace()
return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
}.use { response ->
if (response.code != 200) {
return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
}
val responseBody = response.body.string().toIntOrNull()
?: return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
when (responseBody) {
1 -> Result.success(Unit)
-5 -> Result.failure(BackupUploadException(BackupUploadFailure.INVALID_LOGIN, responseBody))
else -> Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR, responseBody))
}
}
}
fun parseSaveFile(file: Document): Result<UserDetails> = try {
var userName: String? = null
var gjp2: String? = null
var accountId: Int? = null
var uuid: Int? = null
var udid: String? = null
var binaryVersion: Int? = null
val doc = file.child(0).child(0)
doc.childNodes.chunked(2).forEach { (k, v) ->
if (k.nodeName() == "k") {
val keyName = k.nodeValue()
when (keyName) {
"GJA_001" -> userName = v.nodeValue()
"GJA_003" -> accountId = v.nodeValue().toIntOrNull()
"GJA_005" -> gjp2 = v.nodeValue()
"playerUserID" -> uuid = v.nodeValue().toIntOrNull()
"playerUDID" -> udid = v.nodeValue()
"binaryVersion" -> binaryVersion = v.nodeValue().toIntOrNull()
}
}
}
if (userName == null || gjp2 == null || accountId == null || uuid == null || udid == null || binaryVersion == null) {
Result.failure(BackupParseException(BackupParseFailure.DETAILS_MISSING))
} else {
Result.success(
UserDetails(
userName = userName,
gjp2 = gjp2,
accountId = accountId,
uuid = uuid,
udid = udid,
binaryVersion = binaryVersion
)
)
}
} catch (e: Exception) {
e.printStackTrace()
Result.failure(BackupParseException(BackupParseFailure.DECODE_FAILED))
}
fun decryptFileToString(file: File) = Buffer()
.use { decryptedBuffer ->
decryptFileToBuffer(file, decryptedBuffer)
decryptedBuffer.readUtf8()
}
fun getUserDetails(gameManager: File): Result<UserDetails> {
return try {
val base64Decoded = run {
// free decryptString after decoding
val decryptString = decryptFileToString(gameManager)
decryptString.decodeBase64()
} ?: return Result.failure(BackupParseException(BackupParseFailure.DECODE_FAILED))
// gzipSource will close the underlying buffer when closed
val saveDoc = GzipSource(
Buffer().write(base64Decoded)
).buffer().use { gzipSource ->
Ksoup.parseSource(gzipSource, parser = Parser.xmlParser())
}
parseSaveFile(saveDoc)
} catch (e: Exception) {
e.printStackTrace()
return Result.failure(BackupParseException(BackupParseFailure.DECODE_FAILED))
}
}
const val MB_TO_BYTES = 1048576 // 2^20
suspend fun backupSaveToCloud(context: Context): Result<Unit> {
val baseDirectory = LaunchUtils.getBaseDirectory(context)
val gameManager = File(baseDirectory,"/save/CCGameManager.dat")
val localLevels = File(baseDirectory, "/save/CCLocalLevels.dat")
if (!gameManager.isFile || !localLevels.isFile) {
return Result.failure(BackupParseException(BackupParseFailure.SAVE_MISSING))
}
// hardcode 32mb max size
// this is how the game checks it, which probably isn't the best
val totalSize = (gameManager.length() + localLevels.length()) / MB_TO_BYTES.toDouble()
if (totalSize > 32.0) {
println("Rejecting save file - calculated size of ${totalSize}mb")
return Result.failure(BackupParseException(BackupParseFailure.SAVE_TOO_LARGE))
}
return withContext(Dispatchers.IO) {
val userDetails = getUserDetails(gameManager).getOrElse {
return@withContext Result.failure(it)
}
val backupClient = OkHttpClient.Builder()
.writeTimeout(500.seconds)
.build()
val backupUrl = getAccountBackupUrl(backupClient, userDetails).getOrElse {
return@withContext Result.failure(it)
}
performBackupRequest(backupClient, userDetails, backupUrl, gameManager, localLevels).getOrElse {
return@withContext Result.failure(it)
}
Result.success(Unit)
}
}
@Composable
fun BackupConfirmDialog(onDismiss: () -> Unit, onEnable: () -> Unit) {
AlertDialog(
icon = {
Icon(
painterResource(R.drawable.icon_cloud_alert),
contentDescription = null,
)
},
title = { Text(stringResource(R.string.preference_backup_confirm_title)) },
text = { Text(stringResource(R.string.preference_backup_confirm_description)) },
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.message_box_cancel))
}
},
confirmButton = {
TextButton(onClick = onEnable) {
Text(stringResource(R.string.message_box_continue))
}
},
onDismissRequest = onDismiss
)
}
fun messageFromResult(context: Context, result: Result<Unit>): String {
val exception = result.exceptionOrNull()
?: return context.getString(R.string.preference_backup_success)
return when (exception) {
is BackupParseException -> when (exception.failureCode) {
BackupParseFailure.SAVE_MISSING -> context.getString(R.string.preference_backup_save_missing)
BackupParseFailure.DECODE_FAILED -> context.getString(R.string.preference_backup_save_decode_failed)
BackupParseFailure.DETAILS_MISSING -> context.getString(R.string.preference_backup_save_details_missing)
BackupParseFailure.SAVE_TOO_LARGE -> context.getString(R.string.preference_backup_save_too_large)
}
is BackupUploadException -> when (exception.failureCode) {
BackupUploadFailure.GENERIC_ERROR -> context.getString(R.string.preference_backup_failed, exception.gjCode)
BackupUploadFailure.NETWORK_ERROR -> context.getString(R.string.preference_backup_failed_network)
BackupUploadFailure.INVALID_LOGIN -> context.getString(R.string.preference_backup_failed_invalid_login)
}
else -> context.getString(R.string.preference_backup_failed_generic)
}
}
@Composable
fun BackupDialog(onDismiss: () -> Unit, isCancelling: Boolean) {
AlertDialog(
icon = {
Icon(
painterResource(R.drawable.icon_cloud_upload),
contentDescription = null
)
},
title = {
if (isCancelling) {
Text(stringResource(R.string.preference_backup_cancelling))
} else {
Text(stringResource(R.string.preference_backup_in_progress))
}
},
text = {
LinearProgressIndicator()
},
confirmButton = {
TextButton(onClick = onDismiss, enabled = !isCancelling) {
Text(stringResource(R.string.message_box_cancel))
}
},
onDismissRequest = {}
)
}
@Composable
fun BackupButton(snackbarHostState: SnackbarHostState) {
val context = LocalContext.current
var showBackupDialog by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
var backupInProgress by remember { mutableStateOf(false) }
var backupJob by remember { mutableStateOf<Job?>(null) }
OptionsButton(
title = stringResource(R.string.preference_backup_cloud),
icon = {
Icon(
painterResource(R.drawable.icon_cloud_upload),
contentDescription = null
)
},
) {
showBackupDialog = true
}
if (showBackupDialog) {
BackupConfirmDialog(onDismiss = {
showBackupDialog = false
}) {
showBackupDialog = false
backupJob = coroutineScope.launch {
backupInProgress = true
val responseStatus = backupSaveToCloud(context)
backupInProgress = false
snackbarHostState.showSnackbar(messageFromResult(context, responseStatus))
}
}
}
var isCancelling by remember { mutableStateOf(false) }
if (backupInProgress) {
BackupDialog(onDismiss = {
coroutineScope.launch {
isCancelling = true
backupJob?.cancelAndJoin()
isCancelling = false
backupInProgress = false
}
}, isCancelling)
}
}
@@ -19,10 +19,29 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
@@ -48,10 +67,10 @@ import com.geode.launcher.BuildConfig
import com.geode.launcher.MainActivity
import com.geode.launcher.R
import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.updater.ReleaseViewModel
import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.updater.ReleaseViewModel
import com.geode.launcher.utils.ApplicationIcon
import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.GamePackageUtils
@@ -63,6 +82,7 @@ import java.net.UnknownHostException
import kotlin.math.roundToInt
import kotlin.time.Clock
class SettingsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
@@ -430,7 +450,7 @@ fun UpdateSettingsGroup(releaseViewModel: ReleaseViewModel, snackbarHostState: S
}
@Composable
fun AdvancedSettingsGroup(developerModeEnabled: Boolean) {
fun AdvancedSettingsGroup(developerModeEnabled: Boolean, snackbarHostState: SnackbarHostState) {
val context = LocalContext.current
OptionsGroup(stringResource(R.string.preference_category_developer)) {
@@ -487,6 +507,8 @@ fun AdvancedSettingsGroup(developerModeEnabled: Boolean) {
context.startActivity(launchIntent)
}
}
BackupButton(snackbarHostState)
}
}
@@ -644,7 +666,7 @@ fun SettingsScreen(
val developerModeEnabled by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
AdvancedSettingsGroup(developerModeEnabled)
AdvancedSettingsGroup(developerModeEnabled, snackbarHostState)
AboutSettingsGroup(developerModeEnabled)
Spacer(Modifier.height(4.dp))
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M260,800Q169,800 104.5,737Q40,674 40,583Q40,505 87,444Q134,383 210,366Q235,274 310,217Q385,160 480,160Q597,160 678.5,241.5Q760,323 760,440L760,440L760,440Q829,448 874.5,499.5Q920,551 920,620Q920,695 867.5,747.5Q815,800 740,800L260,800ZM260,720L740,720Q782,720 811,691Q840,662 840,620Q840,578 811,549Q782,520 740,520L680,520L680,440Q680,357 621.5,298.5Q563,240 480,240Q397,240 338.5,298.5Q280,357 280,440L280,440L260,440Q202,440 161,481Q120,522 120,580Q120,638 161,679Q202,720 260,720ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480ZM480,640Q497,640 508.5,628.5Q520,617 520,600Q520,583 508.5,571.5Q497,560 480,560Q463,560 451.5,571.5Q440,583 440,600Q440,617 451.5,628.5Q463,640 480,640ZM440,500L520,500L520,320L440,320L440,500Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M260,800Q169,800 104.5,737Q40,674 40,583Q40,505 87,444Q134,383 210,366Q235,274 310,217Q385,160 480,160Q597,160 678.5,241.5Q760,323 760,440L760,440L760,440Q829,448 874.5,499.5Q920,551 920,620Q920,695 867.5,747.5Q815,800 740,800L520,800Q487,800 463.5,776.5Q440,753 440,720L440,514L376,576L320,520L480,360L640,520L584,576L520,514L520,720Q520,720 520,720Q520,720 520,720L740,720Q782,720 811,691Q840,662 840,620Q840,578 811,549Q782,520 740,520L680,520L680,440Q680,357 621.5,298.5Q563,240 480,240Q397,240 338.5,298.5Q280,357 280,440L260,440Q202,440 161,481Q120,522 120,580Q120,638 161,679Q202,720 260,720L360,720L360,800L260,800ZM480,520L480,520L480,520Q480,520 480,520Q480,520 480,520L480,520L480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,520Z"/>
</vector>
+22
View File
@@ -241,6 +241,28 @@
<string name="preference_launch_safe_mode">Launch in safe mode</string>
<string name="preference_launch_safe_mode_confirm_title">Start in safe mode?</string>
<string name="preference_backup_cloud">Backup save to cloud</string>
<string name="preference_backup_confirm_title">Backup save data?</string>
<string name="preference_backup_in_progress">Backing up data…</string>
<string name="preference_backup_cancelling">Cancelling backup…</string>
<string name="preference_backup_confirm_description">This will backup your save data using your in-game account. You can then access it on an unmodified version of the game through the account backup menu.\nContinuing will overwrite any save data you currently have on the Geometry Dash servers!</string>
<string name="preference_backup_save_missing">Failed to find save data.</string>
<string name="preference_backup_save_decode_failed">Failed to decode save file.</string>
<string name="preference_backup_save_too_large">Save file is too large.</string>
<string name="preference_backup_save_details_missing">Failed to find account information.</string>
<string name="preference_backup_success">Backup success!</string>
<string name="preference_backup_cancelled">Backup cancelled.</string>
<string name="preference_backup_failed_generic">An unknown error has occurred.</string>
<string name="preference_backup_failed_invalid_login">Invalid login information.</string>
<string name="preference_backup_failed_network">A network error has occurred. Please try again later.</string>
<string name="preference_backup_failed_title">Backup failed</string>
<string name="preference_backup_failed">Backup failed with error code %1$d.</string>
<string name="preference_launch_arguments_name">Launch arguments</string>
<string name="preference_launch_arguments_set_title">Set launch arguments</string>
<string name="preference_text_clear">Clear</string>