mirror of
https://github.com/geode-sdk/android-launcher.git
synced 2026-10-22 16:42:38 +00:00
add save backup functionality
This commit is contained in:
6 files changed
+492
-5
No files matched your search
@@ -108,6 +108,10 @@ dependencies {
|
|||||||
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0")
|
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-serialization-json-okio:1.10.0")
|
||||||
implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1")
|
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-android:0.39.1")
|
||||||
implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.39.1")
|
implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.39.1")
|
||||||
implementation ("androidx.browser:browser:1.9.0")
|
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.animation.core.spring
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.foundation.clickable
|
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.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
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.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -48,10 +67,10 @@ import com.geode.launcher.BuildConfig
|
|||||||
import com.geode.launcher.MainActivity
|
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.updater.ReleaseViewModel
|
|
||||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
import com.geode.launcher.ui.theme.LocalTheme
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
import com.geode.launcher.ui.theme.Theme
|
import com.geode.launcher.ui.theme.Theme
|
||||||
|
import com.geode.launcher.updater.ReleaseViewModel
|
||||||
import com.geode.launcher.utils.ApplicationIcon
|
import com.geode.launcher.utils.ApplicationIcon
|
||||||
import com.geode.launcher.utils.Constants
|
import com.geode.launcher.utils.Constants
|
||||||
import com.geode.launcher.utils.GamePackageUtils
|
import com.geode.launcher.utils.GamePackageUtils
|
||||||
@@ -63,6 +82,7 @@ import java.net.UnknownHostException
|
|||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
|
|
||||||
|
|
||||||
class SettingsActivity : ComponentActivity() {
|
class SettingsActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
@@ -430,7 +450,7 @@ fun UpdateSettingsGroup(releaseViewModel: ReleaseViewModel, snackbarHostState: S
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AdvancedSettingsGroup(developerModeEnabled: Boolean) {
|
fun AdvancedSettingsGroup(developerModeEnabled: Boolean, snackbarHostState: SnackbarHostState) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
OptionsGroup(stringResource(R.string.preference_category_developer)) {
|
OptionsGroup(stringResource(R.string.preference_category_developer)) {
|
||||||
@@ -487,6 +507,8 @@ fun AdvancedSettingsGroup(developerModeEnabled: Boolean) {
|
|||||||
context.startActivity(launchIntent)
|
context.startActivity(launchIntent)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
BackupButton(snackbarHostState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,7 +666,7 @@ fun SettingsScreen(
|
|||||||
|
|
||||||
val developerModeEnabled by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
|
val developerModeEnabled by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
|
||||||
|
|
||||||
AdvancedSettingsGroup(developerModeEnabled)
|
AdvancedSettingsGroup(developerModeEnabled, snackbarHostState)
|
||||||
AboutSettingsGroup(developerModeEnabled)
|
AboutSettingsGroup(developerModeEnabled)
|
||||||
|
|
||||||
Spacer(Modifier.height(4.dp))
|
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>
|
||||||
@@ -241,6 +241,28 @@
|
|||||||
<string name="preference_launch_safe_mode">Launch in safe mode</string>
|
<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_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_name">Launch arguments</string>
|
||||||
<string name="preference_launch_arguments_set_title">Set launch arguments</string>
|
<string name="preference_launch_arguments_set_title">Set launch arguments</string>
|
||||||
<string name="preference_text_clear">Clear</string>
|
<string name="preference_text_clear">Clear</string>
|
||||||
|
|||||||
Reference in new issue
Block a user