Compare commits

...
60 Commits
Author SHA1 Message Date
Chloe 7d2b5e0091 add multiline input util 2025-06-26 16:56:58 -07:00
Chloe 0b92f4a318 implement internal callbacks 2025-06-22 14:45:04 -07:00
Chloe 7d75825d01 this breaks things and might fix others 2025-06-07 19:38:54 -07:00
Chloe c77271f45c some text input changes 2025-06-07 19:38:01 -07:00
Chloe 09bb28d66f remove unused import 2025-05-28 17:14:33 -07:00
Chloe 157952b6f7 download to temp file first 2025-05-28 17:11:22 -07:00
Chloe 352ff63ab0 also send timestamp for back 2025-05-03 16:33:19 -07:00
Chloe 91fc804dee bump version 2025-05-03 16:24:55 -07:00
Chloe 87808b56b3 remove premature bug fix 2025-05-03 16:22:21 -07:00
Chloe 2c2884c3b8 use indeterminate state instead of ellipses 2025-05-03 16:14:12 -07:00
Chloe 74e91e7dc1 document some unlisted methods 2025-05-03 14:29:40 -07:00
Chloe e3ffbf311e try it 2025-05-03 14:13:39 -07:00
Chloe 5ad77bca57 i forgot i never actually committed that 2025-05-03 03:27:25 -07:00
Chloe 3be0127e04 unfortunate 2025-05-03 03:24:39 -07:00
Chloe 458c837aba add index release api opt in 2025-05-03 03:16:29 -07:00
Chloe 67ca7707d8 support predictive back gestures 2025-04-24 11:59:31 -07:00
Chloe 776a4fa468 obligatory update commit 2025-04-24 11:00:12 -07:00
Chloe 9528eef313 bump tulip 2025-04-23 23:56:55 -07:00
Chloe 9827fe36c1 add screen insets utils 2025-04-23 23:56:54 -07:00
Oleksandr Nemesh ce44270b7a add webview util (#37) 2025-04-22 13:01:53 -07:00
Chloe 34e324c54d revert all refresh rate changes 2025-01-23 20:14:52 -07:00
Chloe 986e33ce03 this is dumb 2025-01-23 19:15:08 -07:00
Chloe 91c06bde8c i forgot about this 2025-01-23 13:16:56 -07:00
Chloe a9d40a7594 one log 2025-01-23 12:08:32 -07:00
Chloe bb6146be37 tweak zoom calculation 2025-01-23 12:05:30 -07:00
Chloe 071cf40d42 initial profiles support 2025-01-22 23:14:08 -07:00
Chloe 19f47f138a avoid some allocations in launcherfix 2025-01-22 22:18:36 -07:00
Chloe c070b5cfe0 smoother manual launch 2025-01-22 22:16:02 -07:00
Chloe 4782431bae add getLauncherVersion method 2025-01-22 17:07:18 -07:00
Chloe b63073c135 log it too 2025-01-21 22:45:35 -07:00
Chloe c029fed37d small state cleanup 2025-01-21 22:28:23 -07:00
Chloe dacabda792 original select code cleanup 2025-01-20 16:44:52 -07:00
Chloe 824d4395c7 cleanup some warnings 2025-01-20 15:28:33 -07:00
Chloe bd502b56c2 smarter gl config selection 2025-01-20 15:19:05 -07:00
Chloe 6991598fcb minor settings improvement 2025-01-20 15:18:27 -07:00
Chloe 2cc8e7215a bump 2025-01-20 06:05:02 -07:00
Chloe 5e81d1c70e old device things 2025-01-20 06:04:01 -07:00
Chloe b03ff6b735 small ui change 2025-01-20 05:36:31 -07:00
Chloe 220c1b0dd6 fix default zoom 2025-01-20 05:34:01 -07:00
Chloe 0a4fb091f4 forgot this bit too 2025-01-20 03:55:52 -07:00
Chloe 110f74400c bring back the accelerometer 2025-01-20 03:55:34 -07:00
Chloe eed8020229 move launcher update button to top 2025-01-19 23:39:05 -07:00
Chloe ceb3d5b461 hjfod's suggestion 2025-01-19 17:00:46 -07:00
Chloe 53cd339213 add fps limiting option 2025-01-19 04:27:23 -07:00
Chloe 327e9b4d39 add zoom control 2025-01-19 01:25:49 -07:00
Chloe 10a0670a47 show changelog for launcher updates 2025-01-18 18:17:53 -07:00
Chloe 500d3ba015 trim null characters in logs 2025-01-18 14:53:32 -07:00
Chloe 1236180425 move force hrr option 2025-01-18 14:31:51 -07:00
Chloe 129ee40c0d add custom aspect ratio option 2025-01-18 14:30:49 -07:00
Chloe a687f8464e make updater abstract 2025-01-18 03:23:13 -07:00
Chloe b409a200bb add version info to settings 2025-01-17 17:11:54 -07:00
Chloe acb07db6f2 add methods for vibration 2025-01-17 16:15:48 -07:00
Chloe 0ae65971fc workaround new kotlin bug 2025-01-17 15:23:41 -07:00
Chloe c4bd4333ca opt out of android 15 forced edge to edge 2025-01-17 15:01:37 -07:00
Chloe f9e30e549a avoid string copy in redirect_path 2025-01-17 14:55:05 -07:00
Chloe 1583f259b9 more dynamic select options 2025-01-17 14:54:42 -07:00
Chloe c090754129 prevent crash on null clipdata text 2024-11-22 15:27:48 -07:00
Chloe 31227860f4 disable custom symbol list option 2024-11-19 13:46:39 -07:00
Chloe edc2428f48 tweak font size 2024-11-19 13:43:44 -07:00
Chloe 6f3473b4ca yay! 2024-11-19 08:05:59 -07:00
46 changed files with 2294 additions and 474 deletions

No files matched your search

+23 -13
View File
@@ -1,5 +1,11 @@
val composeBOM: String by rootProject.extra
kotlin {
compilerOptions {
extraWarnings.set(true)
}
}
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
@@ -8,14 +14,14 @@ plugins {
}
android {
compileSdk = 35
compileSdk = 36
defaultConfig {
applicationId = "com.geode.launcher"
minSdk = 23
targetSdk = 35
versionCode = 19
versionName = "1.4.2"
versionCode = 22
versionName = "1.6.1"
vectorDrawables {
useSupportLibrary = true
@@ -24,7 +30,7 @@ android {
@Suppress("UnstableApiUsage")
externalNativeBuild {
cmake {
arguments("-DUSE_TULIPHOOK:BOOL=OFF", "-DANDROID_STL=c++_shared")
arguments("-DUSE_TULIPHOOK:BOOL=OFF", "-DANDROID_STL=c++_shared", "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES:BOOL=OFF")
}
}
@@ -76,30 +82,34 @@ android {
}
externalNativeBuild {
cmake {
version = "3.25.0+"
path = file("src/main/cpp/CMakeLists.txt")
}
}
namespace = "com.geode.launcher"
ndkVersion = "27.2.12479018"
ndkVersion = "28.1.13356709"
}
dependencies {
implementation (platform("androidx.compose:compose-bom:$composeBOM"))
implementation ("androidx.core:core-ktx:1.15.0")
implementation ("androidx.core:core-ktx:1.16.0")
implementation ("androidx.compose.ui:ui")
implementation ("androidx.compose.material3:material3")
implementation ("androidx.compose.ui:ui-tooling-preview")
implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation ("androidx.activity:activity-compose:1.9.3")
implementation ("androidx.activity:activity-ktx:1.9.3")
implementation ("androidx.activity:activity-compose:1.10.1")
implementation ("androidx.activity:activity-ktx:1.10.1")
implementation ("androidx.appcompat:appcompat:1.7.0")
implementation ("androidx.documentfile:documentfile:1.0.1")
implementation ("com.squareup.okio:okio:3.9.1")
implementation ("com.squareup.okio:okio:3.11.0")
implementation ("com.squareup.okhttp3:okhttp:4.12.0")
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.7.3")
implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1")
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.8.1")
implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2")
implementation ("com.mikepenz:multiplatform-markdown-renderer-android:0.33.0")
implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.33.0")
implementation ("androidx.browser:browser:1.8.0")
debugImplementation ("androidx.compose.ui:ui-tooling")
coreLibraryDesugaring ("com.android.tools:desugar_jdk_libs:2.1.3")
coreLibraryDesugaring ("com.android.tools:desugar_jdk_libs:2.1.5")
}
+3 -1
View File
@@ -21,6 +21,7 @@
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.VIBRATE" />
<uses-feature
android:name="android.hardware.wifi"
@@ -38,8 +39,9 @@
android:label="@string/app_name"
android:hasFragileUserData="true"
android:requestLegacyExternalStorage="true"
android:enableOnBackInvokedCallback="true"
android:theme="@style/Theme.GeodeLauncher"
tools:targetApi="31">
tools:targetApi="35">
<activity
android:name=".AltMainActivity"
android:exported="false"
+3 -2
View File
@@ -13,7 +13,7 @@ option(USE_TULIPHOOK "enables choice of hooking library" OFF)
if (USE_TULIPHOOK)
FetchContent_Declare(TulipHook
GIT_REPOSITORY https://github.com/geode-sdk/TulipHook.git
GIT_TAG 0021301fcea265416fa8eb5372694931c1d410cb
GIT_TAG 9aa1f95091ebac18c657d57b755f59d00fe6f1f8
)
set(TULIP_LINK_SOURCE ON CACHE INTERNAL "")
@@ -24,8 +24,9 @@ if (USE_TULIPHOOK)
target_link_libraries(launcherfix PRIVATE TulipHook TulipHookInclude)
else()
FetchContent_Declare(dobby
# this is the last version that builds on android at all
GIT_REPOSITORY https://github.com/jmpews/Dobby
GIT_TAG 6a80a61ea062424d1595613058a174c64454fa57
GIT_TAG 0932d69c320e786672361ab53825ba8f4245e9d3
)
FetchContent_GetProperties(dobby)
+15 -10
View File
@@ -89,6 +89,8 @@ std::uint32_t elfhash(const char *_name) {
return h;
}
std::array<std::uint8_t, 3> SYMBOL_PATCH_VALUE{":3"};
bool patch_symbol(std::uint32_t* hash_table, char* str_table, ElfW(Sym)* sym_table, const char* orig_name) {
auto hash = elfhash(orig_name);
@@ -105,16 +107,15 @@ bool patch_symbol(std::uint32_t* hash_table, char* str_table, ElfW(Sym)* sym_tab
// there's probably no point to checking this, honestly
if (ELF_ST_BIND(sym->st_info) == STB_GLOBAL && ELF_ST_TYPE(sym->st_info) == STT_FUNC) {
// we found it! now go rename the symbol
std::array<std::uint8_t, 3> new_symbol{":3"};
#ifdef USE_TULIPHOOK
auto res = tulip::hook::writeMemory(sym_name, new_symbol.data(), new_symbol.size());
auto res = tulip::hook::writeMemory(sym_name, SYMBOL_PATCH_VALUE.data(), SYMBOL_PATCH_VALUE.size());
if (!res) {
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "failed to patch symbol: %s", res.unwrapErr().c_str());
return false;
}
#else
DobbyCodePatch(reinterpret_cast<void*>(sym_name), new_symbol.data(), new_symbol.size());
DobbyCodePatch(reinterpret_cast<void*>(sym_name), SYMBOL_PATCH_VALUE.data(), SYMBOL_PATCH_VALUE.size());
#endif
return true;
@@ -264,9 +265,9 @@ JNIEXPORT int JNICALL Java_com_geode_launcher_LauncherFix_performExceptionsRenam
return dl_iterate_phdr(on_dl_iterate, nullptr);
}
std::string redirect_path(const char* pathname) {
auto data_path = DataPaths::get_instance().data_path;
auto original_data_path = DataPaths::get_instance().original_data_path;
std::optional<std::string> redirect_path(const char* pathname) {
auto& data_path = DataPaths::get_instance().data_path;
auto& original_data_path = DataPaths::get_instance().original_data_path;
if (!data_path.empty() && !original_data_path.empty()) {
// call this a c string optimization
@@ -277,22 +278,26 @@ std::string redirect_path(const char* pathname) {
}
}
return pathname;
return std::nullopt;
}
FILE* (*fopen_original)(const char *pathname, const char *mode);
FILE* fopen_hook(const char* pathname, const char* mode) {
auto path_str = redirect_path(pathname);
auto redirect = path_str.has_value() ? path_str->c_str() : pathname;
return fopen_original(path_str.c_str(), mode);
return fopen_original(redirect, mode);
}
int (*rename_original)(const char* old_path, const char* new_path);
int rename_hook(const char* old_path, const char* new_path) {
auto old_path_str = redirect_path(old_path);
auto new_path_str = redirect_path(new_path);
auto old_redirect = old_path_str ? old_path_str->c_str() : old_path;
return rename_original(old_path_str.c_str(), new_path_str.c_str());
auto new_path_str = redirect_path(new_path);
auto new_redirect = new_path_str ? new_path_str->c_str() : new_path;
return rename_original(old_redirect, new_redirect);
}
bool hook_function(void* addr, auto* hook, auto** orig) {
@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.Row
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.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
@@ -24,13 +23,12 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
@@ -60,6 +58,7 @@ import com.geode.launcher.main.*
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.utils.Constants
import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
@@ -123,7 +122,7 @@ class AltMainActivity : ComponentActivity() {
}
data class LaunchStatusInfo(
val title: String,
val title: String?,
val details: String? = null,
val progress: (() -> Float)? = null
)
@@ -156,8 +155,9 @@ fun mapCancelReasonToInfo(cancelReason: LaunchViewModel.LaunchCancelReason): Lau
title = stringResource(R.string.launcher_cancelled_error),
details = stringResource(R.string.launcher_cancelled_outdated)
)
LaunchViewModel.LaunchCancelReason.AUTOMATIC -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_navigated_away)
LaunchViewModel.LaunchCancelReason.AUTOMATIC,
LaunchViewModel.LaunchCancelReason.MANUAL-> LaunchStatusInfo(
title = null
)
else -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_manual)
@@ -185,16 +185,18 @@ fun mapLaunchStatusToInfo(state: LaunchViewModel.LaunchUIState, inSafeMode: Bool
}
val outOf = remember(state.outOf) {
Formatter.formatShortFileSize(context, state.outOf)
state.outOf?.run {
Formatter.formatShortFileSize(context, this)
}
}
LaunchStatusInfo(
title = stringResource(R.string.launcher_downloading_update),
details = stringResource(R.string.launcher_downloading_update_details, downloaded, outOf),
details = stringResource(R.string.launcher_downloading_update_details, downloaded, outOf ?: "…"),
progress = {
val progress = state.downloaded / state.outOf.toDouble()
val progress = state.downloaded / state.outOf!!.toDouble()
progress.toFloat()
}
}.takeIf { state.outOf != null }
)
}
is LaunchViewModel.LaunchUIState.Cancelled -> {
@@ -205,16 +207,22 @@ fun mapLaunchStatusToInfo(state: LaunchViewModel.LaunchUIState, inSafeMode: Bool
@Composable
fun LaunchCancelledBody(statusInfo: LaunchStatusInfo, icon: @Composable () -> Unit, inProgress: Boolean, modifier: Modifier = Modifier) {
if (statusInfo.title == null && statusInfo.details == null && !inProgress) {
return
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.Start,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.width(300.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyLarge) {
icon()
Spacer(Modifier.size(8.dp))
Text(statusInfo.title)
if (statusInfo.title != null) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyLarge) {
icon()
Spacer(Modifier.size(8.dp))
Text(statusInfo.title)
}
}
}
@@ -225,7 +233,8 @@ fun LaunchCancelledBody(statusInfo: LaunchStatusInfo, icon: @Composable () -> Un
if (statusInfo.details != null) {
Text(
statusInfo.details,
style = MaterialTheme.typography.bodyMedium
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
}
}
@@ -239,7 +248,9 @@ fun LaunchProgressBody(statusInfo: LaunchStatusInfo, modifier: Modifier = Modifi
horizontalAlignment = Alignment.Start,
modifier = modifier.width(300.dp)
) {
Text(statusInfo.title, style = MaterialTheme.typography.bodyLarge)
if (statusInfo.title != null) {
Text(statusInfo.title, style = MaterialTheme.typography.bodyLarge)
}
if (statusInfo.progress != null) {
LinearProgressIndicator(
@@ -259,16 +270,7 @@ fun LaunchProgressBody(statusInfo: LaunchStatusInfo, modifier: Modifier = Modifi
@Composable
fun RetryButtonContents(reason: LaunchViewModel.LaunchCancelReason) {
when (reason) {
LaunchViewModel.LaunchCancelReason.MANUAL -> {
Icon(
Icons.Filled.Refresh,
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_cancelled_restart))
}
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED,
LaunchViewModel.LaunchCancelReason.AUTOMATIC -> {
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED -> {
Icon(
painterResource(R.drawable.icon_resume),
contentDescription = null
@@ -276,6 +278,15 @@ fun RetryButtonContents(reason: LaunchViewModel.LaunchCancelReason) {
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_cancelled_resume))
}
LaunchViewModel.LaunchCancelReason.MANUAL,
LaunchViewModel.LaunchCancelReason.AUTOMATIC -> {
Icon(
Icons.Filled.PlayArrow,
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_launch))
}
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> {
Icon(
Icons.Filled.Refresh,
@@ -305,8 +316,8 @@ fun LaunchProgressCard(
var showSafeModeDialog by remember { mutableStateOf(false) }
val status = mapLaunchStatusToInfo(uiState, safeModeEnabled)
Card(modifier = modifier.width(300.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(16.dp)) {
Box(modifier = modifier) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (uiState is LaunchViewModel.LaunchUIState.Cancelled) {
LaunchCancelledBody(
statusInfo = status,
@@ -314,13 +325,9 @@ fun LaunchProgressCard(
inProgress = uiState.inProgress
)
if (uiState.reason == LaunchViewModel.LaunchCancelReason.GAME_OUTDATED) {
if (uiState.reason.isGameInstallIssue()) {
val context = LocalContext.current
// game should be downloaded if this message is showing
val showDownload = remember {
!GamePackageUtils.identifyGameLegitimacy(context.packageManager)
}
val showDownload = remember { GamePackageUtils.showDownloadBadge(context.packageManager) }
if (showDownload) {
GooglePlayBadge(modifier = Modifier.align(Alignment.CenterHorizontally))
@@ -329,8 +336,8 @@ fun LaunchProgressCard(
if (uiState.reason.allowsRetry()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.width(300.dp)
horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally),
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
LongPressButton(onClick = { onResume(false) }, onLongPress = {
showSafeModeDialog = true
@@ -338,14 +345,14 @@ fun LaunchProgressCard(
RetryButtonContents(uiState.reason)
}
Spacer(Modifier.weight(1.0f))
if (crashInfo != null) {
IconButton(onClick = onMore) {
FilledTonalButton(onClick = onMore) {
Icon(
painterResource(R.drawable.icon_question_mark),
contentDescription = stringResource(R.string.launcher_cancelled_help_alt)
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_cancelled_help_alt))
}
}
@@ -353,7 +360,7 @@ fun LaunchProgressCard(
}
}
} else {
LaunchProgressBody(statusInfo = status)
LaunchProgressBody(statusInfo = status, modifier = Modifier.padding(top = 16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.width(300.dp)) {
TextButton(onClick = onCancel) {
@@ -436,10 +443,9 @@ fun AltMainScreen(
contentAlignment = Alignment.Center
) {
Column(
verticalArrangement = Arrangement.spacedBy(32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.height(350.dp)
.padding(8.dp)
.verticalScroll(rememberScrollState())
) {
@@ -483,27 +489,18 @@ fun AltMainScreen(
safeModeEnabled = launchInSafeMode
)
val nextLauncherUpdate by launchViewModel.nextLauncherUpdate.collectAsState()
// only show launcher update in a case where a user won't see it ingame
if (nextLauncherUpdate != null && launchUIState is LaunchViewModel.LaunchUIState.Cancelled) {
StatusIndicator(
icon = {
Icon(
Icons.Default.Info,
contentDescription = null
)
},
onClick = {
installLauncherUpdate(context)
},
text = {
Text(
stringResource(R.string.launcher_update_available),
textAlign = TextAlign.Center
)
}
)
if (launchUIState is LaunchViewModel.LaunchUIState.Cancelled) {
val gameVersion = remember { GamePackageUtils.getGameVersionCodeOrNull(context.packageManager) }
if (gameVersion != null && gameVersion < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
UnsupportedVersionWarning()
}
val nextLauncherUpdate by launchViewModel.nextLauncherUpdate.collectAsState()
if (nextLauncherUpdate != null) {
LauncherUpdateIndicator()
}
}
}
}
@@ -1,20 +1,23 @@
package com.geode.launcher
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo
import android.opengl.GLSurfaceView
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.text.InputType
import android.util.Log
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.AppCompatEditText
import androidx.compose.ui.platform.ComposeView
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
@@ -22,6 +25,7 @@ import androidx.core.view.WindowInsetsControllerCompat
import com.customRobTop.BaseRobTopActivity
import com.customRobTop.JniToCpp
import com.geode.launcher.main.LaunchNotification
import com.geode.launcher.main.determineDisplayedCards
import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.ConstrainedFrameLayout
import com.geode.launcher.utils.DownloadUtils
@@ -29,7 +33,6 @@ import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.GeodeUtils
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
import org.cocos2dx.lib.Cocos2dxEditText
import org.cocos2dx.lib.Cocos2dxGLSurfaceView
import org.cocos2dx.lib.Cocos2dxHelper
import org.cocos2dx.lib.Cocos2dxRenderer
@@ -38,6 +41,9 @@ import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import javax.microedition.khronos.egl.EGL10
import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.egl.EGLDisplay
enum class DisplayMode {
DEFAULT, LIMITED, FULLSCREEN;
@@ -51,15 +57,25 @@ enum class DisplayMode {
}
}
fun ratioForPreference(value: String) = when (value) {
"4_3" -> 4/3.0f
"1_1" -> 1.0f
"16_10" -> 16/10.0f
else -> 1.77f
}
class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperListener, GeodeUtils.CapabilityListener {
private var mGLSurfaceView: Cocos2dxGLSurfaceView? = null
private val sTag = GeometryDashActivity::class.simpleName
private var mIsRunning = false
private var mIsOnPause = false
private var mHasWindowFocus = false
private var displayMode = DisplayMode.DEFAULT
private var forceRefreshRate = false
private var mForceRefreshRate = false
private var mLimitedRefreshRate: Int? = null
private var mAspectRatio = 0.0f
private var mScreenZoom = 1.0f
private var mScreenZoomFit = false
override fun onCreate(savedInstanceState: Bundle?) {
setupUIState()
@@ -106,6 +122,13 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
return
}
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
mGLSurfaceView?.sendKeyBack()
}
})
mGLSurfaceView?.manualBackEvents = true
}
private fun createVersionFile() {
@@ -147,11 +170,13 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
tryLoadLibrary(gdPackageInfo, Constants.COCOS_LIB_NAME)
if (GamePackageUtils.getGameVersionCode(packageManager) >= 39L) {
/*
val customSymbols = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.CUSTOM_SYMBOL_LIST)
if (customSymbols) {
val symbolFile = File(LaunchUtils.getBaseDirectory(this), "exception_symbols.txt")
LauncherFix.enableCustomSymbolList(symbolFile.path)
}
*/
// this fix requires geode v3, which is 2.206+
// there is a short period in which 2.206 users will still have geode v2, but whatever. ig
@@ -366,14 +391,22 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
frameLayout.layoutParams = frameLayoutParams
if (displayMode == DisplayMode.LIMITED) {
frameLayout.aspectRatio = 1.77f
frameLayout.aspectRatio = mAspectRatio
}
if (mScreenZoomFit && mScreenZoom != 1.0f) {
frameLayout.fitZoom = true
}
if (mScreenZoom != 1.0f) {
frameLayout.zoom = mScreenZoom
}
val editTextLayoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
val editText = Cocos2dxEditText(this)
val editText = AppCompatEditText(this)
editText.layoutParams = editTextLayoutParams
frameLayout.addView(editText)
@@ -384,31 +417,33 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
val showNotification = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.ENABLE_REDESIGN)
if (showNotification) {
val notificationView = ComposeView(this)
frameLayout.addView(notificationView)
val hasCards = determineDisplayedCards(this)
if (hasCards.isNotEmpty()) {
val notificationView = ComposeView(this)
frameLayout.addView(notificationView)
notificationView.setContent {
LaunchNotification()
notificationView.setContent {
LaunchNotification()
}
}
}
glSurfaceView.setEGLContextClientVersion(2)
glSurfaceView.setEGLConfigChooser(5, 6, 5, 0, 16, 8)
if (isAndroidEmulator()) {
glSurfaceView.setEGLConfigChooser(8, 8, 8, 8, 16, 0)
}
glSurfaceView.setEGLConfigChooser(EGLConfigChooser())
glSurfaceView.initView()
val renderer = Cocos2dxRenderer(glSurfaceView)
glSurfaceView.setCocos2dxRenderer(renderer)
if (forceRefreshRate) {
renderer.setFrameRate = true
renderer.setFrameRate = mForceRefreshRate
val frameRate = mLimitedRefreshRate
if (frameRate != null) {
renderer.limitFrameRate(frameRate)
}
editText.inputType = 145
editText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
glSurfaceView.cocos2dxEditText = editText
return frameLayout
@@ -417,21 +452,37 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
private fun setupUIState() {
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
val preferenceUtils = PreferenceUtils.get(this)
displayMode = DisplayMode.fromInt(
PreferenceUtils.get(this).getInt(PreferenceUtils.Key.DISPLAY_MODE)
preferenceUtils.getInt(PreferenceUtils.Key.DISPLAY_MODE)
)
forceRefreshRate = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.FORCE_HRR)
mAspectRatio = ratioForPreference(preferenceUtils.getString(PreferenceUtils.Key.CUSTOM_ASPECT_RATIO) ?: "16_9")
mScreenZoom = preferenceUtils.getInt(PreferenceUtils.Key.SCREEN_ZOOM) / 100.0f
mScreenZoomFit = preferenceUtils.getBoolean(PreferenceUtils.Key.SCREEN_ZOOM_FIT)
if (forceRefreshRate && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val maxRefreshRate = display?.supportedModes?.maxBy { it.refreshRate }?.refreshRate
if (maxRefreshRate != null) {
window.attributes.preferredRefreshRate = maxRefreshRate
val limitedRefreshRate = preferenceUtils.getInt(PreferenceUtils.Key.LIMIT_FRAME_RATE).takeIf { it != 0 }
mLimitedRefreshRate = limitedRefreshRate
mForceRefreshRate = preferenceUtils.getBoolean(PreferenceUtils.Key.FORCE_HRR)
if (mForceRefreshRate && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val chosenDisplay = display.supportedModes?.maxByOrNull { it.refreshRate }
if (chosenDisplay != null) {
var chosenRefreshRate = chosenDisplay.refreshRate
println("setupUIState: Forcing a refresh rate of $chosenRefreshRate (display ${chosenDisplay.modeId})")
window.attributes.preferredRefreshRate = chosenRefreshRate
} else {
println("setupUIState could not find a display to select refresh rate of...")
}
}
if (displayMode == DisplayMode.FULLSCREEN && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
GeodeUtils.handleSafeArea = true
} else {
GeodeUtils.handleSafeArea = false
}
hideSystemUi()
@@ -464,13 +515,13 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
private fun resumeGame() {
mIsRunning = true
// Cocos2dxHelper.onResume()
Cocos2dxHelper.onResume()
mGLSurfaceView?.onResume()
}
private fun pauseGame() {
mIsRunning = false
// Cocos2dxHelper.onPause()
Cocos2dxHelper.onPause()
mGLSurfaceView?.onPause()
}
@@ -527,18 +578,6 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
TODO("Not yet implemented")
}
private fun isAndroidEmulator(): Boolean {
Log.d(sTag, "model=" + Build.MODEL)
val product = Build.PRODUCT
Log.d(sTag, "product=$product")
var isEmulator = false
if (product != null) {
isEmulator = product == "sdk" || product.contains("_sdk") || product.contains("sdk_")
}
Log.d(sTag, "isEmulator=$isEmulator")
return isEmulator
}
override fun onCapabilityAdded(capability: String): Boolean {
return when (capability) {
GeodeUtils.CAPABILITY_EXTENDED_INPUT -> {
@@ -549,6 +588,10 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
mGLSurfaceView?.sendTimestampEvents = true
true
}
GeodeUtils.CAPABILITY_INTERNAL_CALLBACKS -> {
mGLSurfaceView?.sendInternalTimestampEvents = true
true
}
else -> false
}
}
@@ -562,7 +605,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
val modListing = try {
assets.list(internalModBase)
} catch (ioe: IOException) {
} catch (_: IOException) {
emptyArray<String>()
}
@@ -584,4 +627,78 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
}
}
}
class EGLConfigChooser : GLSurfaceView.EGLConfigChooser {
// this comes from EGL14, but is unavailable on EGL10
// also EGL14 is incompatible with EGL10. so whatever
private var EGL_OPENGL_ES2_BIT: Int = 0x04
private data class ConfigValues(
val redSize: Int,
val greenSize: Int,
val blueSize: Int,
val alphaSize: Int,
val depthSize: Int,
val stencilSize: Int,
)
private fun getAttribValue(egl: EGL10, display: EGLDisplay, config: EGLConfig, attrib: Int): Int {
val value = IntArray(1)
egl.eglGetConfigAttrib(display, config, attrib, value)
return value[0]
}
private fun testConfig(egl: EGL10, display: EGLDisplay, attrib: ConfigValues): EGLConfig? {
val attribList = intArrayOf(
EGL10.EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,
EGL10.EGL_RED_SIZE, attrib.redSize,
EGL10.EGL_GREEN_SIZE, attrib.greenSize,
EGL10.EGL_BLUE_SIZE, attrib.blueSize,
EGL10.EGL_ALPHA_SIZE, attrib.alphaSize,
EGL10.EGL_DEPTH_SIZE, attrib.depthSize,
EGL10.EGL_STENCIL_SIZE, attrib.stencilSize,
EGL10.EGL_NONE
)
val configs = arrayOfNulls<EGLConfig>(1)
val numConfigs = IntArray(1)
val res = egl.eglChooseConfig(display, attribList, configs, configs.size, numConfigs)
if (res && numConfigs[0] > 0 && configs[0] != null) {
return configs[0]
}
return null
}
private fun printConfig(egl: EGL10, display: EGLDisplay, config: EGLConfig) {
val id = getAttribValue(egl, display, config, EGL10.EGL_CONFIG_ID)
val red = getAttribValue(egl, display, config, EGL10.EGL_RED_SIZE)
val green = getAttribValue(egl, display, config, EGL10.EGL_GREEN_SIZE)
val blue = getAttribValue(egl, display, config, EGL10.EGL_BLUE_SIZE)
val alpha = getAttribValue(egl, display, config, EGL10.EGL_ALPHA_SIZE)
val depth = getAttribValue(egl, display, config, EGL10.EGL_DEPTH_SIZE)
val stencil = getAttribValue(egl, display, config, EGL10.EGL_STENCIL_SIZE)
println("EGLConfig $id: (red = $red, green = $green, blue = $blue, alpha = $alpha, depth = $depth, stencil = $stencil)")
}
override fun chooseConfig(egl: EGL10, display: EGLDisplay): EGLConfig {
val configs = listOf(
ConfigValues(8, 8, 8, 8, 16, 8),
ConfigValues(8, 8, 8, 0, 16, 8),
ConfigValues(5, 6, 5, 0, 16, 8),
ConfigValues(0, 0, 0, 0, 0, 0),
)
return configs.firstNotNullOf {
val config = testConfig(egl, display, it)
if (config != null) {
printConfig(egl, display, config)
}
config
}
}
}
}
@@ -11,7 +11,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -63,7 +62,6 @@ class MainActivity : ComponentActivity() {
LoadFailureInfo(LaunchUtils.LauncherError.CRASHED)
} else { null }
/*
val redirectToAlt = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.ENABLE_REDESIGN)
if (redirectToAlt) {
val launchIntent = Intent(this, AltMainActivity::class.java)
@@ -72,7 +70,6 @@ class MainActivity : ComponentActivity() {
return
}
*/
setContent {
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
@@ -114,7 +111,7 @@ fun MainScreen(
val autoUpdateState by releaseViewModel.uiState.collectAsState()
val geodeJustInstalled = (autoUpdateState as? ReleaseViewModel.ReleaseUIState.Finished)
?.hasUpdated ?: false
?.hasUpdated == true
val geodeInstalled = geodePreinstalled || geodeJustInstalled
var beginLaunch by remember { mutableStateOf(false) }
@@ -194,26 +191,7 @@ fun MainScreen(
)
if (gdVersion < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
InlineDialog(
headline = {
Icon(
Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.size(28.dp)
)
Text(
stringResource(R.string.launcher_unsupported_version_title),
style = MaterialTheme.typography.titleMedium,
)
},
body = {
Text(
stringResource(R.string.launcher_game_update_required),
)
},
modifier = Modifier.padding(8.dp)
)
UnsupportedVersionWarning()
}
}
}
@@ -40,7 +40,7 @@ class UserDirectoryProvider : DocumentsProvider() {
override fun onCreate(): Boolean {
val context = context ?: return false
rootDir = LaunchUtils.getBaseDirectory(context)
rootDir = LaunchUtils.getBaseDirectory(context, true)
return true
}
@@ -212,7 +212,9 @@ data class LogLine(
val identifier = time.toJavaInstant()
val messageTrimmed by lazy { this.message.trim() }
val messageTrimmed by lazy {
this.message.trim { it <= ' ' }
}
val formattedTime by lazy { this.time.toLocalDateTime(TimeZone.currentSystemDefault()) }
val asSimpleString by lazy {
@@ -266,10 +266,7 @@ fun DownloadRecommendation(needsUniversal: Boolean, modifier: Modifier = Modifie
val context = LocalContext.current
val version = BuildConfig.VERSION_NAME
val showDownload = remember {
!GamePackageUtils.isGameInstalled(context.packageManager) ||
!GamePackageUtils.identifyGameLegitimacy(context.packageManager)
}
val showDownload = remember { GamePackageUtils.showDownloadBadge(context.packageManager) }
val downloadBase = "https://github.com/geode-sdk/android-launcher/releases/download/$version"
val legacyDownloadUrl = "$downloadBase/geode-launcher-v$version-android32.apk"
@@ -279,10 +279,7 @@ fun PlayButton(
fun LaunchBlockedLabel(text: String) {
val context = LocalContext.current
val showDownload = remember {
GamePackageUtils.isGameInstalled(context.packageManager) &&
!GamePackageUtils.identifyGameLegitimacy(context.packageManager)
}
val showDownload = remember { GamePackageUtils.showDownloadBadge(context.packageManager) }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text, textAlign = TextAlign.Center, modifier = Modifier.padding(12.dp))
@@ -292,3 +289,27 @@ fun LaunchBlockedLabel(text: String) {
}
}
}
@Composable
fun UnsupportedVersionWarning(modifier: Modifier = Modifier) {
InlineDialog(
headline = {
Icon(
Icons.Default.Warning,
contentDescription = null,
modifier = Modifier.size(28.dp)
)
Text(
stringResource(R.string.launcher_unsupported_version_title),
style = MaterialTheme.typography.titleMedium,
)
},
body = {
Text(
stringResource(R.string.launcher_game_update_required),
)
},
modifier = modifier.padding(8.dp)
)
}
@@ -26,7 +26,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
@@ -37,20 +39,27 @@ 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.ReleaseManager
import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.delay
enum class LaunchNotificationType {
GEODE_UPDATED, LAUNCHER_UPDATE_AVAILABLE, UPDATE_FAILED;
GEODE_UPDATED, LAUNCHER_UPDATE_AVAILABLE, UPDATE_FAILED, UNSUPPORTED_VERSION;
}
fun determineDisplayedCards(context: Context): List<LaunchNotificationType> {
val cards = mutableListOf<LaunchNotificationType>()
if (GamePackageUtils.getGameVersionCode(context.packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
cards.add(LaunchNotificationType.UNSUPPORTED_VERSION)
}
val releaseManager = ReleaseManager.get(context)
val loadAutomatically = PreferenceUtils.get(context).getBoolean(PreferenceUtils.Key.LOAD_AUTOMATICALLY)
val availableUpdate = releaseManager.availableLauncherUpdate.value
if (availableUpdate != null) {
if (loadAutomatically && availableUpdate != null) {
cards.add(LaunchNotificationType.LAUNCHER_UPDATE_AVAILABLE)
}
@@ -69,29 +78,36 @@ fun determineDisplayedCards(context: Context): List<LaunchNotificationType> {
@Composable
fun NotificationCardFromType(type: LaunchNotificationType) {
val context = LocalContext.current
when (type) {
LaunchNotificationType.LAUNCHER_UPDATE_AVAILABLE -> {
var showInfoDialog by remember { mutableStateOf(false) }
if (showInfoDialog) {
LauncherUpdateInformation {
showInfoDialog = false
}
}
AnimatedNotificationCard(
displayLength = 5000L,
onClick = {
installLauncherUpdate(context)
showInfoDialog = true
}
) {
LauncherUpdateContent()
}
}
LaunchNotificationType.GEODE_UPDATED -> {
AnimatedNotificationCard {
UpdateNotificationContent()
}
}
LaunchNotificationType.UPDATE_FAILED -> {
AnimatedNotificationCard {
UpdateFailedContent()
}
}
LaunchNotificationType.UNSUPPORTED_VERSION -> {
AnimatedNotificationCard {
OutdatedVersionContent()
}
}
else -> {}
}
}
@@ -208,6 +224,25 @@ fun UpdateNotificationContent(modifier: Modifier = Modifier) {
)
}
@Composable
fun OutdatedVersionContent(modifier: Modifier = Modifier) {
ListItem(
headlineContent = {
Text(text = stringResource(id = R.string.launcher_notification_compatibility))
},
leadingContent = {
Icon(
Icons.Filled.Warning,
contentDescription = null,
)
},
supportingContent = {
Text(text = stringResource(id = R.string.launcher_notification_compatibility_description))
},
modifier = modifier.width(IntrinsicSize.Max)
)
}
@Composable
fun UpdateFailedContent(modifier: Modifier = Modifier) {
ListItem(
@@ -18,7 +18,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch
private const val COOLDOWN_LENGTH_MS = 1000L
private const val COOLDOWN_LENGTH_MS = 3000L
class LaunchViewModel(private val application: Application): ViewModel() {
companion object {
@@ -65,12 +65,18 @@ class LaunchViewModel(private val application: Application): ViewModel() {
GAME_MISSING,
GAME_OUTDATED -> false
}
fun isGameInstallIssue() = when (this) {
GAME_OUTDATED,
GAME_MISSING -> true
else -> false
}
}
sealed class LaunchUIState {
data object Initial : LaunchUIState()
data object UpdateCheck : LaunchUIState()
data class Updating(val downloaded: Long, val outOf: Long) : LaunchUIState()
data class Updating(val downloaded: Long, val outOf: Long?) : LaunchUIState()
data class Cancelled(val reason: LaunchCancelReason, val inProgress: Boolean = false) : LaunchUIState()
data object Working : LaunchUIState()
data object Ready : LaunchUIState()
@@ -156,7 +162,7 @@ class LaunchViewModel(private val application: Application): ViewModel() {
return cancelLaunch(true)
}
if (!readyTimerPassed) {
if (!readyTimerPassed && !hasManuallyStarted) {
return _uiState.emit(LaunchUIState.Working)
}
@@ -8,39 +8,64 @@ import android.provider.DocumentsContract
import android.text.format.Formatter
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import com.geode.launcher.R
import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.updater.ReleaseManager
import com.geode.launcher.updater.ReleaseViewModel
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
import com.mikepenz.markdown.compose.LocalBulletListHandler
import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownTypography
import kotlinx.datetime.toJavaInstant
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
import java.io.File
import java.net.ConnectException
import java.net.UnknownHostException
import java.text.DateFormat
import java.util.Date
fun clearDownloadedApks(context: Context) {
// technically we should be using the activity results but it was too inconsistent for my liking
@@ -72,10 +97,10 @@ fun generateInstallIntent(uri: Uri): Intent {
fun installLauncherUpdate(context: Context) {
val nextUpdate = ReleaseManager.get(context).availableLauncherUpdate.value
val launcherDownload = nextUpdate?.getLauncherDownload()
val launcherDownload = nextUpdate?.getDownload()
if (launcherDownload != null) {
val outputFile = launcherDownload.name
val outputFile = launcherDownload.filename
val baseDirectory = LaunchUtils.getBaseDirectory(context)
val outputPathFile = File(baseDirectory, outputFile)
@@ -93,17 +118,113 @@ fun installLauncherUpdate(context: Context) {
val intent = generateInstallIntent(uri)
context.startActivity(intent)
} catch (_: ActivityNotFoundException) {
Toast.makeText(context, context.getString(R.string.no_activity_found), Toast.LENGTH_SHORT).show()
// if it fails to install, just open it in the browser
try {
val downloadUrl = launcherDownload.url
val downloadIntent = Intent(Intent.ACTION_VIEW).apply {
data = Uri.parse(downloadUrl)
}
context.startActivity(downloadIntent)
} catch (_: ActivityNotFoundException) {
Toast.makeText(context, context.getString(R.string.no_activity_found), Toast.LENGTH_SHORT).show()
}
}
} else {
Toast.makeText(context, context.getString(R.string.release_fetch_no_releases), Toast.LENGTH_SHORT).show()
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LauncherUpdateInformation(onDismiss: () -> Unit) {
val context = LocalContext.current
val nextRelease = remember { ReleaseManager.get(context).availableLauncherUpdate.value }
val sheetState = rememberModalBottomSheetState()
if (nextRelease != null) {
ModalBottomSheet(onDismissRequest = { onDismiss() }, sheetState = sheetState) {
Box(Modifier.verticalScroll(rememberScrollState())) {
Column(modifier = Modifier.padding(horizontal = 24.dp)) {
Text(
stringResource(R.string.launcher_update_name, nextRelease.release.tagName),
style = Typography.headlineSmall,
)
val releasedTime = remember {
DateFormat.getDateInstance().format(
Date.from(nextRelease.release.publishedAt.toJavaInstant())
)
}
Text(
stringResource(R.string.launcher_update_released, "$releasedTime"),
style = Typography.labelLarge
)
Row(modifier = Modifier.padding(vertical = 12.dp)) {
Button(
onClick = { installLauncherUpdate(context) },
) {
Icon(painterResource(R.drawable.icon_download), contentDescription = null)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_install))
}
val uriHandler = LocalUriHandler.current
IconButton(onClick = { uriHandler.openUri(nextRelease.release.htmlUrl) }) {
Icon(
painterResource(R.drawable.icon_link),
stringResource(R.string.launcher_update_external_link)
)
}
}
if (nextRelease.release.body != null) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
stringResource(R.string.launcher_update_changelog),
style = Typography.titleLarge
)
// every new release of the markdown library adds another arg to it
CompositionLocalProvider(LocalBulletListHandler provides { _, _, _, _, _ -> "•  " }) {
Markdown(
content = nextRelease.release.body.replace("\r", ""),
typography = markdownTypography(
link = MaterialTheme.typography.bodyLarge.copy(
textDecoration = TextDecoration.Underline,
color = MaterialTheme.colorScheme.primary,
),
),
flavour = GFMFlavourDescriptor(),
)
}
}
Spacer(modifier = Modifier.size(4.dp))
}
}
}
}
}
@Composable
fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
val context = LocalContext.current
var showInfoDialog by remember { mutableStateOf(false) }
if (showInfoDialog) {
LauncherUpdateInformation {
showInfoDialog = false
}
}
ElevatedCard(modifier) {
Column(
// buttons add enough padding already, lower to compensate
@@ -120,11 +241,18 @@ fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
modifier = Modifier.padding(horizontal = 10.dp)
)
TextButton(
onClick = { installLauncherUpdate(context) },
modifier = Modifier.align(Alignment.End)
) {
Text(stringResource(R.string.launcher_install))
Row(modifier = Modifier.align(Alignment.End)) {
TextButton(
onClick = { showInfoDialog = true },
) {
Text(stringResource(R.string.launcher_update_view))
}
TextButton(
onClick = { installLauncherUpdate(context) },
) {
Text(stringResource(R.string.launcher_install))
}
}
}
}
@@ -142,7 +270,7 @@ fun UpdateProgressIndicator(
horizontalAlignment = Alignment.Start,
modifier = modifier
) {
Text(message)
Text(message, style = MaterialTheme.typography.labelMedium)
Spacer(modifier = Modifier.padding(4.dp))
@@ -236,21 +364,26 @@ fun UpdateCard(releaseViewModel: ReleaseViewModel, modifier: Modifier = Modifier
}
val outOf = remember(state.outOf) {
Formatter.formatShortFileSize(context, state.outOf)
state.outOf?.run {
Formatter.formatShortFileSize(context, this)
}
}
UpdateProgressIndicator(
stringResource(
R.string.release_fetch_downloading,
if (outOf != null) stringResource(
R.string.release_fetch_downloading,
downloaded,
outOf
) else stringResource(
R.string.release_fetch_downloading_indeterminate,
downloaded,
outOf
),
modifier = modifier,
releaseViewModel = releaseViewModel,
progress = {
val progress = state.downloaded / state.outOf.toDouble()
val progress = state.downloaded / state.outOf!!.toDouble()
progress.toFloat()
}
}.takeIf { state.outOf != null }
)
}
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
@@ -135,12 +135,14 @@ fun SelectLogLevelDialog(
onDismissRequest = onDismissRequest,
onSelect = { onSelect(it) },
initialValue = initialValue,
toLabel = {
// i'm lazy
it.name.lowercase().replaceFirstChar { c -> c.uppercaseChar() }
},
optionsCount = LogPriority.FATAL downTo LogPriority.VERBOSE
)
) {
(LogPriority.VERBOSE..LogPriority.FATAL).reversed().forEach {
SelectOption(
name = it.name.lowercase().replaceFirstChar { c -> c.uppercaseChar() },
value = it
)
}
}
}
@Composable
@@ -1,5 +1,6 @@
package com.geode.launcher.preferences
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedDispatcher
@@ -66,13 +67,6 @@ class DeveloperSettingsActivity : ComponentActivity() {
}
}
@Composable
fun releaseChannelToKey(option: Int): String = when (option) {
1 -> stringResource(R.string.preference_release_channel_beta)
2 -> stringResource(R.string.preference_release_channel_nightly)
else -> stringResource(R.string.preference_release_channel_stable)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
@@ -135,20 +129,37 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*(){}<>[]?:;'\"~`-_+=\\| ".contains(c)
}}
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
SettingsCard(
title = stringResource(R.string.preference_force_hrr),
preferenceKey = PreferenceUtils.Key.FORCE_HRR,
)
}
/*
SettingsCard(
title = stringResource(R.string.preference_override_exceptions_name),
description = stringResource(R.string.preference_override_exceptions_description),
preferenceKey = PreferenceUtils.Key.CUSTOM_SYMBOL_LIST
)
*/
}
OptionsGroup(title = stringResource(R.string.preference_category_updater)) {
SettingsCard(
title = stringResource(R.string.preference_use_index_update_api),
preferenceKey = PreferenceUtils.Key.USE_INDEX_API
)
SettingsSelectCard(
title = stringResource(R.string.preference_release_channel_tag_name),
dialogTitle = stringResource(R.string.preference_release_channel_select),
maxVal = 2,
preferenceKey = PreferenceUtils.Key.RELEASE_CHANNEL_TAG,
toLabel = { releaseChannelToKey(it) }
options = linkedMapOf(
0 to stringResource(R.string.preference_release_channel_stable),
1 to stringResource(R.string.preference_release_channel_beta),
2 to stringResource(R.string.preference_release_channel_nightly),
)
)
ElevatedCard(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) {
@@ -161,14 +172,18 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
)
}
}
/*
OptionsGroup(title = stringResource(R.string.preference_category_testing)) {
SettingsCard(
title = stringResource(R.string.preference_enable_redesign),
preferenceKey = PreferenceUtils.Key.ENABLE_REDESIGN,
)
}
*/
OptionsGroup(title = stringResource(R.string.preference_profiles)) {
ProfileCreateCard()
ProfileSelectCard()
}
}
}
}
@@ -9,6 +9,7 @@ import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.provider.DocumentsContract
import android.view.WindowManager
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedDispatcher
@@ -35,10 +36,12 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -50,13 +53,13 @@ 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.ui.theme.Typography
import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
import java.net.ConnectException
import java.net.UnknownHostException
import kotlin.math.roundToInt
class SettingsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
@@ -149,11 +152,16 @@ fun UpdateIndicator(
is ReleaseViewModel.ReleaseUIState.InDownload -> {
// is this the ideal design? idk
enablePopup = true
val progress = updateStatus.downloaded / updateStatus.outOf.toDouble()
CircularProgressIndicator(
progress = { progress.toFloat() },
)
if (updateStatus.outOf != null) {
CircularProgressIndicator(
progress = {
val progress = updateStatus.downloaded / updateStatus.outOf.toDouble()
progress.toFloat()
},
)
} else {
CircularProgressIndicator()
}
}
else -> {}
}
@@ -180,8 +188,8 @@ fun UpdateIndicator(
context.getString(R.string.preference_check_for_updates_success)
)
} else {
val gdInstalled = GamePackageUtils.isGameInstalled(context.packageManager)
if (gdInstalled && GamePackageUtils.getGameVersionCode(context.packageManager) < Constants.SUPPORTED_VERSION_CODE) {
val gameVersion = GamePackageUtils.getGameVersionCodeOrNull(context.packageManager)
if (gameVersion != null && gameVersion < Constants.SUPPORTED_VERSION_CODE) {
snackbarHostState.showSnackbar(
context.getString(R.string.launcher_game_update_required_short)
)
@@ -197,24 +205,6 @@ fun UpdateIndicator(
}
}
@Composable
fun themeToKey(theme: Int): String {
return when (theme) {
1 -> stringResource(R.string.preference_theme_light)
2 -> stringResource(R.string.preference_theme_dark)
else -> stringResource(R.string.preference_theme_default)
}
}
@Composable
fun displayOptionToKey(option: Int): String {
return when (option) {
1 -> stringResource(R.string.preference_display_mode_legacy)
2 -> stringResource(R.string.preference_display_mode_fullscreen)
else -> stringResource(R.string.preference_display_mode_default)
}
}
fun updateTheme(context: Context, theme: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
@@ -315,9 +305,12 @@ fun SettingsScreen(
SettingsSelectCard(
title = stringResource(R.string.preference_theme_name),
dialogTitle = stringResource(R.string.preference_theme_select),
maxVal = 2,
preferenceKey = PreferenceUtils.Key.THEME,
toLabel = { themeToKey(it) },
options = linkedMapOf(
0 to stringResource(R.string.preference_theme_default),
1 to stringResource(R.string.preference_theme_light),
2 to stringResource(R.string.preference_theme_dark),
),
extraSelectBehavior = { updateTheme(context, it) }
)
SettingsCard(
@@ -339,17 +332,66 @@ fun SettingsScreen(
SettingsSelectCard(
title = stringResource(R.string.preference_display_mode_name),
dialogTitle = stringResource(R.string.preference_display_mode_select),
// necessary api doesn't exist on older versions of android
maxVal = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) 2 else 1,
preferenceKey = PreferenceUtils.Key.DISPLAY_MODE,
toLabel = { displayOptionToKey(it) }
options = buildMap {
put(0, stringResource(R.string.preference_display_mode_default))
put(1, stringResource(R.string.preference_display_mode_legacy))
// necessary api doesn't exist on older versions of android
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
put(2, stringResource(R.string.preference_display_mode_fullscreen))
}
}
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
SettingsCard(
title = stringResource(R.string.preference_force_hrr),
preferenceKey = PreferenceUtils.Key.FORCE_HRR,
val currentDisplayMode by PreferenceUtils.useIntPreference(PreferenceUtils.Key.DISPLAY_MODE)
if (currentDisplayMode == 1) {
SettingsStringSelectCard(
title = stringResource(R.string.preference_custom_aspect_ratio_name),
description = stringResource(R.string.preference_custom_aspect_ratio_description),
dialogTitle = stringResource(R.string.preference_custom_aspect_ratio_select),
preferenceKey = PreferenceUtils.Key.CUSTOM_ASPECT_RATIO,
options = linkedMapOf(
"16_9" to stringResource(R.string.preference_custom_aspect_ratio_16x9),
"16_10" to stringResource(R.string.preference_custom_aspect_ratio_16x10),
"4_3" to stringResource(R.string.preference_custom_aspect_ratio_4x3),
"1_1" to stringResource(R.string.preference_custom_aspect_ratio_1x1),
)
)
}
SettingsRangeCard(
title = stringResource(R.string.preference_screen_zoom_name),
description = stringResource(R.string.preference_screen_zoom_description),
dialogTitle = stringResource(R.string.preference_screen_zoom_select),
preferenceKey = PreferenceUtils.Key.SCREEN_ZOOM,
labelSuffix = "x",
range = 25..100,
scale = 100,
step = 5,
) {
SettingsCard(
title = context.getString(R.string.preference_screen_zoom_fit_name),
preferenceKey = PreferenceUtils.Key.SCREEN_ZOOM_FIT,
asCard = false
)
}
val maxFrameRate = remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
context.display.supportedModes.maxOf { it.refreshRate }
} else {
@Suppress("DEPRECATION")
(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay.refreshRate
}.roundToInt()
}
SettingsFPSCard(
title = stringResource(R.string.preference_limit_framerate_name),
dialogTitle = stringResource(R.string.preference_limit_framerate_select),
preferenceKey = PreferenceUtils.Key.LIMIT_FRAME_RATE,
maxFrameRate = maxFrameRate
)
}
OptionsGroup(context.getString(R.string.preference_category_updater)) {
@@ -405,11 +447,73 @@ fun SettingsScreen(
)
}
Text(
stringResource(R.string.preference_launcher_version, BuildConfig.VERSION_NAME),
style = Typography.labelSmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
)
OptionsGroup(stringResource(R.string.preference_category_about)) {
val clipboard = LocalClipboardManager.current
OptionsButton(
stringResource(R.string.preference_launcher_version_name),
stringResource(R.string.preference_launcher_version_description, BuildConfig.VERSION_NAME),
displayInline = true
) {
clipboard.setText(AnnotatedString(context.getString(
R.string.preference_launcher_version,
BuildConfig.VERSION_NAME
)))
}
OptionsButton(
stringResource(R.string.preference_loader_version_name),
stringResource(R.string.preference_loader_version_description, currentRelease ?: "unknown"),
displayInline = true
) {
clipboard.setText(AnnotatedString(context.getString(
R.string.preference_loader_version,
currentRelease ?: "unknown"
)))
}
val gameInstalled = remember {
GamePackageUtils.isGameInstalled(context.packageManager)
}
if (gameInstalled) {
val gameVersion = remember {
GamePackageUtils.getUnifiedVersionName(context.packageManager)
}
val gameSource = remember {
context.getString(when (GamePackageUtils.identifyGameSource(context.packageManager)) {
GamePackageUtils.GameSource.GOOGLE -> R.string.preference_game_source_google
GamePackageUtils.GameSource.AMAZON -> R.string.preference_game_source_amazon
else -> R.string.preference_game_source_unknown
})
}
OptionsButton(
stringResource(R.string.preference_game_version_name),
stringResource(R.string.preference_game_version_description, gameVersion, gameSource),
displayInline = true
) {
clipboard.setText(AnnotatedString(context.getString(
R.string.preference_game_version,
gameVersion,
gameSource
)))
}
OptionsButton(
stringResource(R.string.preference_architecture_name),
stringResource(R.string.preference_architecture_description, Build.MODEL, LaunchUtils.applicationArchitecture),
displayInline = true
) {
clipboard.setText(AnnotatedString(context.getString(
R.string.preference_architecture,
Build.MODEL,
LaunchUtils.applicationArchitecture
)))
}
}
}
}
}
)
@@ -1,18 +1,27 @@
package com.geode.launcher.preferences
import android.content.Context
import android.content.Intent
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
@@ -20,20 +29,29 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@@ -42,6 +60,13 @@ import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.utils.LabelledText
import com.geode.launcher.utils.PreferenceUtils
import com.geode.launcher.utils.Profile
import com.geode.launcher.utils.ProfileManager
import kotlin.math.log10
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.system.exitProcess
fun toggleSetting(context: Context, preferenceKey: PreferenceUtils.Key): Boolean {
@@ -72,9 +97,8 @@ fun OptionsGroup(title: String, content: @Composable () -> Unit) {
fun SettingsSelectCard(
title: String,
dialogTitle: String,
maxVal: Int,
preferenceKey: PreferenceUtils.Key,
toLabel: @Composable (Int) -> String,
options: Map<Int, String>,
extraSelectBehavior: ((Int) -> Unit)? = null
) {
val preferenceValue by PreferenceUtils.useIntPreference(preferenceKey)
@@ -91,7 +115,7 @@ fun SettingsSelectCard(
role = Role.Button
)
) {
Text(toLabel(preferenceValue))
Text(options[preferenceValue] ?: preferenceValue.toString())
}
if (showDialog) {
@@ -109,9 +133,60 @@ fun SettingsSelectCard(
extraSelectBehavior?.invoke(selected)
},
initialValue = preferenceValue,
toLabel = toLabel,
optionsCount = 0..maxVal
)
) {
options.forEach { (k, v) ->
SelectOption(name = v, value = k)
}
}
}
}
@Composable
fun SettingsStringSelectCard(
title: String,
dialogTitle: String,
preferenceKey: PreferenceUtils.Key,
options: Map<String, String>,
extraSelectBehavior: ((String?) -> Unit)? = null,
description: String? = null,
) {
val preferenceValue by PreferenceUtils.useStringPreference(preferenceKey)
var showDialog by remember { mutableStateOf(false) }
OptionsCard(
title = { OptionsTitle(title = title, description = description) },
modifier = Modifier
.clickable(
onClick = {
showDialog = true
},
role = Role.Button
)
) {
Text(options[preferenceValue] ?: preferenceValue ?: "")
}
if (showDialog) {
val context = LocalContext.current
SelectDialog(
title = dialogTitle,
onDismissRequest = {
showDialog = false
},
onSelect = { selected ->
showDialog = false
PreferenceUtils.get(context)
.setString(preferenceKey, selected)
extraSelectBehavior?.invoke(selected)
},
initialValue = preferenceValue,
) {
options.forEach { (k, v) ->
SelectOption(name = v, value = k)
}
}
}
}
@@ -204,16 +279,531 @@ fun StringDialog(
)
}
@Composable
fun RangeDialog(
title: String,
onDismissRequest: () -> Unit,
onSelect: (Int) -> Unit,
labelSuffix: String,
initialValue: Int,
range: IntRange,
scale: Int,
step: Int,
children: @Composable () -> Unit
) {
var enteredValue by remember {
mutableFloatStateOf(initialValue / scale.toFloat())
}
AlertDialog(
onDismissRequest = { onDismissRequest() },
title = {
Text(title)
},
text = {
Column {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1.0f, false)
) {
val precision = log10(scale.toFloat()).roundToInt()
Slider(
value = enteredValue,
onValueChange = { enteredValue = it },
valueRange = (range.first.toFloat()/scale)..(range.last.toFloat()/scale),
steps = ((range.last - range.first) - 1) / step,
modifier = Modifier.weight(1.0f)
)
Text("%.${precision}f$labelSuffix".format(enteredValue))
}
children()
}
},
confirmButton = {
TextButton(onClick = { onSelect((enteredValue * scale).roundToInt()) }) {
Text(stringResource(R.string.message_box_accept))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
)
}
@Composable
fun FrameRateDialog(
title: String,
onDismissRequest: () -> Unit,
onSelect: (Int) -> Unit,
initialValue: Int,
maxFrameRate: Int,
) {
var enteredValue by remember {
mutableStateOf(
if (initialValue == 0) maxFrameRate.toString()
else initialValue.toString()
)
}
val minFrameRate = 5
val currentValue = enteredValue.toIntOrNull()
val maximumReached = currentValue != null && currentValue > maxFrameRate
val minimumReached = currentValue != null && currentValue < minFrameRate
AlertDialog(
onDismissRequest = { onDismissRequest() },
title = {
Text(title)
},
text = {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1.0f, false)
) {
IconButton(onClick = {
val prevValue = max((currentValue ?: 0) - 5, minFrameRate)
enteredValue = prevValue.toString()
}, enabled = currentValue == null || currentValue > minFrameRate) {
Icon(
painterResource(R.drawable.icon_remove),
contentDescription = stringResource(R.string.preference_limit_framerate_subtract)
)
}
Spacer(Modifier.size(8.dp))
OutlinedTextField(
value = enteredValue,
onValueChange = {
enteredValue = it
},
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
),
suffix = {
Text(stringResource(R.string.preference_limit_framerate_suffix))
},
label = {
Text(stringResource(R.string.preference_limit_framerate_label))
},
isError = minimumReached || maximumReached,
modifier = Modifier.weight(1.0f),
)
Spacer(Modifier.size(8.dp))
IconButton(onClick = {
val nextValue = min((currentValue ?: 0) + 5, maxFrameRate)
enteredValue = nextValue.toString()
}, enabled = currentValue == null || currentValue < maxFrameRate) {
Icon(
Icons.Filled.Add,
contentDescription = stringResource(R.string.preference_limit_framerate_add)
)
}
IconButton(onClick = { onSelect(0) }) {
Icon(
painterResource(R.drawable.icon_delete),
contentDescription = stringResource(R.string.preference_limit_framerate_reset)
)
}
}
if (minimumReached) {
Spacer(Modifier.size(8.dp))
Text(
stringResource(R.string.preference_limit_framerate_error_min, minFrameRate),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelLarge,
)
} else if (maximumReached) {
Spacer(Modifier.size(8.dp))
Text(
stringResource(
R.string.preference_limit_framerate_error_max,
maxFrameRate
),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelLarge,
)
}
}
},
confirmButton = {
TextButton(
onClick = {
// automatically disable if already max
if (currentValue == maxFrameRate) {
onSelect(0)
} else {
onSelect(currentValue ?: 0)
}
},
enabled = !minimumReached && !maximumReached && currentValue != null
) {
Text(stringResource(R.string.message_box_accept))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
)
}
@Composable
fun SettingsFPSCard(
title: String,
dialogTitle: String,
preferenceKey: PreferenceUtils.Key,
maxFrameRate: Int,
description: String? = null,
) {
var preferenceValue by PreferenceUtils.useIntPreference(preferenceKey)
var showDialog by remember { mutableStateOf(false) }
OptionsCard(
title = { OptionsTitle(title = title, description = description) },
modifier = Modifier
.clickable(
onClick = {
showDialog = true
},
role = Role.Button
)
) {
if (preferenceValue == 0) {
Text(stringResource(R.string.preference_limit_framerate_default))
} else {
Text(stringResource(R.string.preference_limit_framerate_value, preferenceValue))
}
}
if (showDialog) {
FrameRateDialog(
title = dialogTitle,
onDismissRequest = { showDialog = false },
onSelect = {
preferenceValue = it
showDialog = false
},
initialValue = preferenceValue,
maxFrameRate = maxFrameRate
)
}
}
@Composable
fun SettingsRangeCard(
title: String,
dialogTitle: String,
preferenceKey: PreferenceUtils.Key,
labelSuffix: String,
range: IntRange,
scale: Int,
step: Int,
description: String? = null,
children: @Composable () -> Unit = {}
) {
var preferenceValue by PreferenceUtils.useIntPreference(preferenceKey)
var showDialog by remember { mutableStateOf(false) }
OptionsCard(
title = {
OptionsTitle(title = title, description = description)
},
modifier = Modifier
.clickable(
onClick = {
showDialog = true
},
role = Role.Button
)
) {
val precision = log10(scale.toFloat()).roundToInt()
val scaledValue = preferenceValue / scale.toFloat()
Text("%.${precision}f$labelSuffix".format(scaledValue))
}
if (showDialog) {
RangeDialog(
title = dialogTitle,
onDismissRequest = { showDialog = false },
onSelect = {
preferenceValue = it
showDialog = false
},
initialValue = preferenceValue,
range = range,
scale = scale,
labelSuffix = labelSuffix,
step = step,
children = children,
)
}
}
internal val LocalSelectValue = compositionLocalOf<Any> { 0 }
internal val LocalSelectSetValue = staticCompositionLocalOf<(Any) -> Unit> { {} }
@Composable
fun <T> SelectOption(name: String, value: T, enabled: Boolean = true, leadingContent: @Composable (Boolean) -> Unit = {}) {
val currentValue = LocalSelectValue.current
val setValue = LocalSelectSetValue.current
val isSelected = currentValue.equals(value)
// do not give the row or column padding!! it messes up the selection effect
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(
onClick = { setValue(value as Any) },
role = Role.RadioButton,
enabled = enabled
)
.padding(horizontal = 12.dp)
) {
Row(modifier = Modifier.weight(1.0f, true), verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = isSelected,
onClick = { setValue(value as Any) },
enabled = enabled
)
Text(name, style = Typography.bodyMedium)
}
leadingContent(isSelected)
}
}
@Composable
fun ProfileCreateDialog(onDismissRequest: () -> Unit) {
var enteredValue by remember { mutableStateOf("") }
val filename = enteredValue.take(16)
.lowercase()
.map {
if ("qwertyuiopasdfghjklzxcvbnm1234567890-_.".contains(it))
it else '_'
}
.joinToString("")
val context = LocalContext.current
val minimumReached = enteredValue.isEmpty() || filename.isEmpty()
val profileManager = ProfileManager.get(context)
val currentProfiles = remember {
profileManager.getProfiles().map { it.path }.toSet()
}
val isDuplicate = currentProfiles.contains(filename)
AlertDialog(
onDismissRequest = { onDismissRequest() },
title = {
Text(stringResource(R.string.preference_profiles_create))
},
text = {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1.0f, false)
) {
OutlinedTextField(
value = enteredValue,
onValueChange = {
enteredValue = it
},
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
),
label = {
Text(stringResource(R.string.preference_profiles_create_name))
},
isError = isDuplicate
)
}
if (isDuplicate) {
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.preference_profiles_create_duplicate))
} else if (!minimumReached) {
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.preference_profiles_create_info, filename))
}
}
},
confirmButton = {
TextButton(
onClick = {
ProfileManager.get(context).storeProfile(Profile(filename, enteredValue))
onDismissRequest()
},
enabled = !minimumReached && !isDuplicate
) {
Text(stringResource(R.string.preference_profiles_create_action))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
)
}
@Composable
fun ProfileCreateCard() {
var showDialog by remember { mutableStateOf(false) }
OptionsButton(
title = stringResource(R.string.preference_profiles_create),
icon = {
Icon(painterResource(R.drawable.icon_person_add), contentDescription = null)
}
) {
showDialog = true
}
if (showDialog) {
ProfileCreateDialog(onDismissRequest = {
showDialog = false
})
}
}
@Composable
fun ProfileSelectCard() {
val context = LocalContext.current
val profileManager = ProfileManager.get(context)
val currentProfileId = remember {
profileManager.getCurrentProfile()
}
var currentProfileName = remember {
val savedProfile = currentProfileId
if (savedProfile == null) {
"Default"
} else {
profileManager.getProfile(savedProfile)
?.name ?: savedProfile
}
}
val clearedProfiles = remember { mutableStateListOf<String>() }
val profileList by profileManager.storedProfiles.collectAsState()
var showDialog by remember { mutableStateOf(false) }
OptionsButton(
title = stringResource(R.string.preference_profiles_select),
description = stringResource(R.string.preference_profiles_current, currentProfileName)
) {
showDialog = true
}
val activity = LocalActivity.current
if (showDialog) {
SelectDialog(
title = stringResource(R.string.preference_profiles_select),
onDismissRequest = {
showDialog = false
},
onSelect = {
showDialog = false
val selectedProfileId = it.takeIf { it.isNotEmpty() }
if (clearedProfiles.isNotEmpty()) {
profileManager.deleteProfiles(clearedProfiles)
}
if (currentProfileId != selectedProfileId) {
profileManager
.setCurrentProfile(selectedProfileId)
activity?.run {
packageManager.getLaunchIntentForPackage(packageName)?.also {
val mainIntent = Intent.makeRestartActivityTask(it.component)
startActivity(mainIntent)
exitProcess(0)
}
}
}
},
initialValue = currentProfileId ?: "",
) {
SelectOption("Default", "")
profileList.forEach { profile ->
val path = profile.path
SelectOption(
stringResource(R.string.preference_profiles_select_value, profile.name, path),
path,
enabled = !clearedProfiles.contains(path),
leadingContent = { selected ->
IconButton(onClick = {
if (clearedProfiles.contains(path)) {
clearedProfiles.remove(path)
} else {
clearedProfiles.add(path)
}
}, enabled = !selected) {
if (clearedProfiles.contains(path)) {
Icon(
painterResource(R.drawable.icon_undo),
contentDescription = stringResource(R.string.preference_profiles_delete_undo_alt)
)
} else {
Icon(
Icons.Filled.Delete,
contentDescription = stringResource(R.string.preference_profiles_delete_alt)
)
}
}
}
)
}
}
}
}
@Composable
fun <T> SelectDialog(
title: String,
onDismissRequest: () -> Unit,
onSelect: (T) -> Unit,
initialValue: T,
toLabel: @Composable (T) -> String,
optionsCount: Iterable<T>,
options: @Composable () -> Unit,
) {
var selectedValue by remember { mutableStateOf(initialValue) }
val (selectedValue, setSelectedValue) = remember { mutableStateOf(initialValue) }
Dialog(onDismissRequest = onDismissRequest) {
Card(
@@ -221,7 +811,7 @@ fun <T> SelectDialog(
) {
// styling a dialog is actually a little hard if you're doing what i'm doing
// maybe there's a better way to make these padding values...
Column {
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(
title,
style = Typography.titleLarge,
@@ -232,27 +822,14 @@ fun <T> SelectDialog(
)
)
// do not give the row or column padding!! it messes up the selection effect
optionsCount.forEach { id ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(
onClick = { selectedValue = id },
role = Role.RadioButton
)
.padding(horizontal = 12.dp)
) {
RadioButton(
selected = selectedValue == id,
onClick = { selectedValue = id }
)
Text(
toLabel(id),
style = Typography.bodyMedium
)
}
CompositionLocalProvider(
LocalSelectValue provides (selectedValue as Any),
LocalSelectSetValue provides ({
@Suppress("UNCHECKED_CAST")
setSelectedValue(it as T)
})
) {
options()
}
Row(
@@ -279,22 +856,27 @@ fun <T> SelectDialog(
}
@Composable
fun OptionsButton(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, onClick: () -> Unit) {
fun OptionsButton(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, displayInline: Boolean = false, onClick: () -> Unit) {
OptionsCard(
title = {
OptionsTitle(
title = title,
description = description,
description = description.takeIf { !displayInline },
icon = icon
)
},
modifier = Modifier
.clickable(onClick = onClick, role = Role.Button)
) { }
.clickable(onClick = onClick, role = Role.Button),
wrapContent = true
) {
if (displayInline && description != null) {
Text(description, textAlign = TextAlign.End)
}
}
}
@Composable
fun SettingsCard(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, preferenceKey: PreferenceUtils.Key) {
fun SettingsCard(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, asCard: Boolean = true, preferenceKey: PreferenceUtils.Key) {
val context = LocalContext.current
val settingEnabled = remember {
mutableStateOf(getSetting(context, preferenceKey))
@@ -303,19 +885,23 @@ fun SettingsCard(title: String, description: String? = null, icon: (@Composable
OptionsCard(
title = {
OptionsTitle(
Modifier.fillMaxWidth(0.75f),
title = title,
description = description,
icon = icon
)
},
modifier = Modifier.toggleable(
modifier = if (asCard) Modifier.toggleable(
value = settingEnabled.value,
onValueChange = { settingEnabled.value = toggleSetting(context, preferenceKey) },
role = Role.Switch,
)
) else Modifier
) {
Switch(checked = settingEnabled.value, onCheckedChange = null)
Switch(
checked = settingEnabled.value,
onCheckedChange = if (!asCard)
{ _ -> settingEnabled.value = toggleSetting(context, preferenceKey) }
else null
)
}
}
@@ -342,16 +928,24 @@ fun OptionsTitle(modifier: Modifier = Modifier, title: String, description: Stri
}
@Composable
fun OptionsCard(modifier: Modifier = Modifier, title: @Composable () -> Unit, content: @Composable () -> Unit) {
fun OptionsCard(modifier: Modifier = Modifier, wrapContent: Boolean = false, title: @Composable () -> Unit, content: @Composable () -> Unit) {
Row(
modifier
.fillMaxWidth()
.height(64.dp)
.defaultMinSize(minHeight = 64.dp)
.padding(horizontal = 16.dp),
Arrangement.SpaceBetween,
Alignment.CenterVertically,
) {
title()
if (!wrapContent) {
Box(modifier = Modifier.weight(1.0f)) {
title()
}
Spacer(modifier = Modifier.size(12.dp))
} else {
title()
}
content()
}
}
@@ -16,7 +16,7 @@ data class Asset(
)
@Serializable
class Release(
data class Release(
val url: String,
val id: Int,
val targetCommitish: String,
@@ -24,40 +24,86 @@ class Release(
val createdAt: Instant,
val publishedAt: Instant,
val assets: List<Asset>,
val htmlUrl: String
) {
fun getDescription(): String {
if (tagName == "nightly") {
val htmlUrl: String,
val body: String?,
val name: String?,
)
@Serializable
data class LoaderVersion(
val tag: String,
val version: String,
val createdAt: Instant,
val commitHash: String,
val prerelease: Boolean
)
@Serializable
data class LoaderPayload<T>(
val payload: T?,
val error: String
)
data class DownloadableAsset(val url: String, val filename: String, val size: Long? = null)
abstract class Downloadable {
abstract fun getDescription(): String
abstract fun getDescriptor(): Long
abstract fun getDownload(): DownloadableAsset?
}
class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadable() {
override fun getDescription(): String {
if (release.tagName == "nightly") {
// get the commit from the assets
// otherwise, a separate request is needed to get the hash (ew)
val asset = assets.first()
val asset = release.assets.first()
val commit = asset.name.substring(6..12)
return "nightly-$commit"
}
return tagName
return release.tagName
}
fun getDescriptor(): Long {
return createdAt.epochSeconds
override fun getDescriptor(): Long {
return release.createdAt.epochSeconds
}
fun getGeodeDownload(): Asset? {
private fun getGitHubDownload(): Asset? {
// try to find an asset that matches the architecture first
val platform = LaunchUtils.platformName
val releaseSuffix = "$platform.zip"
return assets.find {
return release.assets.find {
it.name.endsWith(releaseSuffix)
}
}
fun getLauncherDownload(): Asset? {
override fun getDownload(): DownloadableAsset? {
val download = getGitHubDownload() ?: return null
return DownloadableAsset(
url = download.browserDownloadUrl,
filename = download.name,
size = download.size.toLong()
)
}
}
class DownloadableLauncherRelease(val release: Release) : Downloadable() {
override fun getDescription(): String {
return release.tagName
}
override fun getDescriptor(): Long {
return release.createdAt.epochSeconds
}
private fun getGitHubDownload(): Asset? {
val platform = LaunchUtils.platformName
val use32BitPlatform = platform == "android32"
return assets.find { asset ->
return release.assets.find { asset ->
/* you know it's good when you pull out the truth table
* u32bp | contains | found
* 1 | 1 | 1
@@ -70,4 +116,31 @@ class Release(
(asset.name.contains("android32")) == use32BitPlatform
}
}
override fun getDownload(): DownloadableAsset? {
val download = getGitHubDownload() ?: return null
return DownloadableAsset(
url = download.browserDownloadUrl,
filename = download.name,
size = download.size.toLong()
)
}
}
class DownloadableLoaderRelease(private val version: LoaderVersion) : Downloadable() {
override fun getDescription(): String {
return version.tag
}
override fun getDescriptor(): Long {
return version.createdAt.epochSeconds
}
override fun getDownload(): DownloadableAsset? {
val filename = "geode-${version.tag}-${LaunchUtils.platformName}.zip"
return DownloadableAsset(
url = "https://github.com/geode-sdk/geode/releases/download/${version.tag}/$filename",
filename = filename
)
}
}
@@ -61,7 +61,7 @@ class ReleaseManager private constructor(
sealed class ReleaseManagerState {
data object InUpdateCheck : ReleaseManagerState()
data class Failure(val exception: Exception) : ReleaseManagerState()
data class InDownload(val downloaded: Long, val outOf: Long) : ReleaseManagerState()
data class InDownload(val downloaded: Long, val outOf: Long?) : ReleaseManagerState()
data class Finished(val hasUpdated: Boolean = false) : ReleaseManagerState()
}
@@ -82,7 +82,7 @@ class ReleaseManager private constructor(
val isInUpdate: Boolean
get() = _uiState.value !is ReleaseManagerState.Failure && _uiState.value !is ReleaseManagerState.Finished
private val _availableLauncherUpdate = MutableStateFlow<Release?>(null)
private val _availableLauncherUpdate = MutableStateFlow<DownloadableLauncherRelease?>(null)
val availableLauncherUpdate = _availableLauncherUpdate.asStateFlow()
private fun sendError(e: Exception) {
@@ -106,50 +106,58 @@ class ReleaseManager private constructor(
}
}
private fun getBestReleaseForGameVersion(): String? {
private fun getBestReleaseForGameVersion(gameVersion: Long): String? = when {
gameVersion >= 40L -> mapSelectedReleaseToTag()
gameVersion == 39L -> "v3.9.3"
gameVersion == 38L -> "v2.0.0-beta.27"
gameVersion == 37L -> "v2.0.0-beta.4"
else -> null
}
private suspend fun getLatestRelease(): Downloadable? {
if (!GamePackageUtils.isGameInstalled(applicationContext.packageManager)) {
return null
}
val gameVersion = GamePackageUtils.getGameVersionCode(applicationContext.packageManager)
val preferences = PreferenceUtils.get(applicationContext)
return when {
// default 2.2074 to prereleases while we're in the prerelease era
gameVersion >= 40L -> mapSelectedReleaseToTag().takeIf { it != TAG_LATEST } ?: TAG_BETA
gameVersion == 39L -> "v3.9.2"
gameVersion == 38L -> "v2.0.0-beta.27"
gameVersion == 37L -> "v2.0.0-beta.4"
else -> null
if (preferences.getBoolean(PreferenceUtils.Key.USE_INDEX_API)) {
val targetTag = preferences.getInt(PreferenceUtils.Key.RELEASE_CHANNEL_TAG)
return when (targetTag) {
2 -> releaseRepository.getReleaseByTag("nightly")
1 -> releaseRepository.getLatestIndexRelease(gameVersion, true)
else -> releaseRepository.getLatestIndexRelease(gameVersion, false)
}
} else {
val targetTag = getBestReleaseForGameVersion(gameVersion) ?: return null
return when (targetTag) {
TAG_LATEST -> releaseRepository.getLatestGeodeRelease()
TAG_BETA -> releaseRepository.getLatestGeodePreRelease()
else -> releaseRepository.getReleaseByTag(targetTag)
}
}
}
private suspend fun getLatestRelease(): Release? {
val targetTag = getBestReleaseForGameVersion() ?: return null
return when (targetTag) {
TAG_LATEST -> releaseRepository.getLatestGeodeRelease()
TAG_BETA -> releaseRepository.getLatestGeodePreRelease()
else -> releaseRepository.getReleaseByTag(targetTag)
}
}
private suspend fun downloadLauncherUpdate(release: Release) {
val download = release.getLauncherDownload() ?: return
private suspend fun downloadLauncherUpdate(release: Downloadable) {
val download = release.getDownload() ?: return
val outputDirectory = LaunchUtils.getBaseDirectory(applicationContext)
val outputFile = File(outputDirectory, download.name)
val outputFile = File(outputDirectory, download.filename)
if (outputFile.exists()) {
// only download the apk once
return
}
_uiState.value = ReleaseManagerState.InDownload(0, download.size.toLong())
_uiState.value = ReleaseManagerState.InDownload(0, download.size)
try {
val fileStream = DownloadUtils.downloadStream(
httpClient,
download.browserDownloadUrl
download.url
) { progress, outOf ->
_uiState.value = ReleaseManagerState.InDownload(progress, outOf)
}
@@ -161,8 +169,8 @@ class ReleaseManager private constructor(
}
}
private suspend fun performUpdate(release: Release) {
val releaseAsset = release.getGeodeDownload()
private suspend fun performUpdate(release: Downloadable) {
val releaseAsset = release.getDownload()
if (releaseAsset == null) {
val noAssetException = Exception("missing Android download")
_uiState.value = ReleaseManagerState.Failure(noAssetException)
@@ -171,33 +179,44 @@ class ReleaseManager private constructor(
}
// set an initial download size
_uiState.value = ReleaseManagerState.InDownload(0, releaseAsset.size.toLong())
_uiState.value = ReleaseManagerState.InDownload(0, releaseAsset.size)
val outputFile = getTempFile()
// clone the file instance as renameTo may move the original file
val tempFilePath = outputFile.path
try {
val fileStream = DownloadUtils.downloadStream(
httpClient,
releaseAsset.browserDownloadUrl
releaseAsset.url
) { progress, outOf ->
_uiState.value = ReleaseManagerState.InDownload(progress, outOf)
}
outputFile.parentFile?.mkdirs()
val geodeFile = getGeodeOutputPath()
DownloadUtils.extractFileFromZipStream(
fileStream,
outputFile.outputStream(),
geodeFile.name
)
// work around a permission issue from adb push
if (geodeFile.exists()) {
geodeFile.delete()
}
geodeFile.parentFile?.mkdirs()
DownloadUtils.extractFileFromZipStream(
fileStream,
geodeFile.outputStream(),
geodeFile.name
)
outputFile.renameTo(geodeFile)
} catch (e: Exception) {
sendError(e)
return
} finally {
val tempFileClone = File(tempFilePath)
try {
if (tempFileClone.exists()) tempFileClone.delete()
} catch (_: Exception) { }
}
downloadLauncherUpdateIfNecessary()
@@ -224,8 +243,8 @@ class ReleaseManager private constructor(
return originalFileHash != currentFileHash
}
private fun checkLauncherUpdate(launcherUpdate: Release) {
if (launcherUpdate.tagName != BuildConfig.VERSION_NAME) {
private fun checkLauncherUpdate(launcherUpdate: DownloadableLauncherRelease) {
if (launcherUpdate.release.tagName != BuildConfig.VERSION_NAME) {
_availableLauncherUpdate.value = launcherUpdate
}
}
@@ -287,7 +306,7 @@ class ReleaseManager private constructor(
performUpdate(release)
}
private fun updatePreferences(release: Release) {
private fun updatePreferences(release: Downloadable) {
val sharedPreferences = PreferenceUtils.get(applicationContext)
sharedPreferences.setString(
@@ -317,6 +336,15 @@ class ReleaseManager private constructor(
return File(geodeDirectory, geodeName)
}
private fun getTempFile(): File {
val geodeName = LaunchUtils.geodeFilename
val geodeDirectory = LaunchUtils.getBaseDirectory(applicationContext)
val tempFile = File.createTempFile(geodeName, null, geodeDirectory)
return tempFile
}
/**
* Cancels the current update job.
*/
@@ -9,6 +9,7 @@ import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
import kotlinx.serialization.json.okio.decodeFromBufferedSource
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.IOException
@@ -22,32 +23,34 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
private const val GITHUB_RATELIMIT_REMAINING = "x-ratelimit-remaining"
private const val GITHUB_RATELIMIT_RESET = "x-ratelimit-reset"
private const val GEODE_API_BASE = "https://api.geode-sdk.org/v1"
}
suspend fun getLatestLauncherRelease(): Release? {
suspend fun getLatestLauncherRelease(): DownloadableLauncherRelease? {
val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/android-launcher/releases/latest"
val url = URL(releasePath)
return getReleaseByUrl(url)
return getReleaseByUrl(url)?.let(::DownloadableLauncherRelease)
}
suspend fun getLatestGeodeRelease(): Release? {
suspend fun getLatestGeodeRelease(): DownloadableGitHubLoaderRelease? {
val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases/latest"
val url = URL(releasePath)
return getReleaseByUrl(url)
return getReleaseByUrl(url)?.let(::DownloadableGitHubLoaderRelease)
}
suspend fun getReleaseByTag(tag: String): Release? {
suspend fun getReleaseByTag(tag: String): DownloadableGitHubLoaderRelease? {
val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases/tags/$tag"
val url = URL(releasePath)
return getReleaseByUrl(url)
return getReleaseByUrl(url)?.let(::DownloadableGitHubLoaderRelease)
}
@OptIn(ExperimentalSerializationApi::class)
suspend fun getLatestGeodePreRelease(): Release? {
suspend fun getLatestGeodePreRelease(): DownloadableGitHubLoaderRelease? {
val releasesUrl = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases?per_page=2"
val url = URL(releasesUrl)
@@ -71,7 +74,8 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
response.body!!.source()
)
release.find { it.tagName != "nightly" }
(release.find { it.tagName != "nightly" })
?.let(::DownloadableGitHubLoaderRelease)
}
403 -> {
// determine if the error code is a ratelimit
@@ -137,8 +141,8 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
// determine if the error code is a ratelimit
// (github docs say it sends 429 too, but haven't seen that)
val limitRemaining = response.headers.get(GITHUB_RATELIMIT_REMAINING)?.toInt()
val limitReset = response.headers.get(GITHUB_RATELIMIT_RESET)?.toLong()
val limitRemaining = response.headers[GITHUB_RATELIMIT_REMAINING]?.toInt()
val limitReset = response.headers[GITHUB_RATELIMIT_RESET]?.toLong()
if (limitRemaining == 0 && limitReset != null) {
// handle ratelimit with a custom error
@@ -159,4 +163,45 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
}
}
}
@OptIn(ExperimentalSerializationApi::class)
suspend fun getLatestIndexRelease(gd: Long, prerelease: Boolean = false): DownloadableLoaderRelease? {
val url = "$GEODE_API_BASE/loader/versions/latest".toHttpUrlOrNull()!!
.newBuilder()
.addQueryParameter("gd", gd.toString())
.addQueryParameter("platform", "android")
.addQueryParameter("prerelease", prerelease.toString())
.build()
val request = Request.Builder()
.url(url)
.addHeader("Accept", "application/json")
.build()
val call = httpClient.newCall(request)
val response = call.executeCoroutine()
return when (response.code) {
200 -> {
val format = Json {
namingStrategy = JsonNamingStrategy.SnakeCase
ignoreUnknownKeys = true
}
val body = format.decodeFromBufferedSource<LoaderPayload<LoaderVersion>>(
response.body!!.source()
)
val release = body.payload ?: throw IOException("index error: ${body.error}")
DownloadableLoaderRelease(release)
}
404 -> {
null
}
else -> {
throw IOException("unknown response ${response.code}")
}
}
}
}
@@ -30,7 +30,7 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
sealed class ReleaseUIState {
data object InUpdateCheck : ReleaseUIState()
data class Failure(val exception: Exception) : ReleaseUIState()
data class InDownload(val downloaded: Long, val outOf: Long) : ReleaseUIState()
data class InDownload(val downloaded: Long, val outOf: Long?) : ReleaseUIState()
data class Finished(val hasUpdated: Boolean = false) : ReleaseUIState()
data class Cancelled(val isCancelling: Boolean = false) : ReleaseUIState()
@@ -17,7 +17,7 @@ object Constants {
const val SUPPORTED_VERSION_CODE_MIN = 37L
// anything below this version code shows a warning on the main menu
const val SUPPORTED_VERSION_CODE_MIN_WARNING = 39L
const val SUPPORTED_VERSION_CODE_MIN_WARNING = 40L
// anything above this version code will show a warning when starting the game
const val SUPPORTED_VERSION_CODE = 40L
@@ -2,26 +2,71 @@ package com.geode.launcher.utils
import android.content.Context
import android.widget.FrameLayout
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.min
class ConstrainedFrameLayout(context: Context) : FrameLayout(context) {
// avoid running the aspect ratio fix when we don't need it
var aspectRatio: Float? = null;
var aspectRatio: Float? = null
var zoom: Float? = null
var fitZoom: Boolean = false
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
if (aspectRatio != null) {
var paddingX = 0.0f
var paddingY = 0.0f
val currentZoom = zoom
if (currentZoom != null) {
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)
if (width == 0 || height == 0) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
return
}
val calculatedWidth = height * (aspectRatio ?: 1.0f);
val padding = ((width - calculatedWidth) / 2).toInt()
val calculatedWidth = width * currentZoom
val calculatedHeight = height * currentZoom
// ignore paddings where the screen ends up looking odd
if (padding > 0) {
setPadding(padding, 0, padding, 0)
paddingX = (width - calculatedWidth)/2
paddingY = (height - calculatedHeight)/2
}
val currentAspectRatio = aspectRatio
if (currentAspectRatio != null) {
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)
if (width == 0 || height == 0) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
return
}
val calculatedWidth = (height - paddingY * 2) * currentAspectRatio
val padding = (width - calculatedWidth) / 2
paddingX = padding
}
if (paddingX > 0.0 || paddingY > 0.0) {
if (fitZoom) {
val width = MeasureSpec.getSize(widthMeasureSpec)
val height = MeasureSpec.getSize(heightMeasureSpec)
val calculatedHeight = (height - paddingY * 2)
val calculatedWidth = (width - paddingX * 2)
val zoomFactor = min(height / calculatedHeight, width / calculatedWidth)
scaleX = zoomFactor
scaleY = zoomFactor
}
setPadding(
ceil(paddingX).toInt(),
ceil(paddingY).toInt(),
floor(paddingX).toInt(),
floor(paddingY).toInt()
)
}
super.onMeasure(
@@ -20,6 +20,9 @@ object GamePackageUtils {
}
}
fun showDownloadBadge(packageManager: PackageManager) =
!isGameInstalled(packageManager) || !identifyGameLegitimacy(packageManager)
fun getGameVersionCode(packageManager: PackageManager): Long {
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
@@ -31,6 +34,14 @@ object GamePackageUtils {
}
}
fun getGameVersionCodeOrNull(packageManager: PackageManager): Long? {
return try {
getGameVersionCode(packageManager)
} catch (e: PackageManager.NameNotFoundException) {
null
}
}
fun getGameVersionString(packageManager: PackageManager): String {
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
return game.versionName ?: "0.000"
@@ -113,24 +124,40 @@ object GamePackageUtils {
}
private const val GAME_CERTIFICATE_HASH = "f5e7d8284d72c461a5f022d0cf755df101c3bb1e69cbe241bc1aef2cc5610a43"
private const val AMAZON_CERTIFICATE_HASH = "1f228081e4d66e006887d4ba0f3d40e96a80c758b84231f11cd5c1c9aef6048f"
private fun validateCertificate(certificate: ByteArray): Boolean =
certificate.toByteString().sha256().hex() == GAME_CERTIFICATE_HASH
private fun validateCertificate(certificate: ByteArray): GameSource = when (val hash = certificate.toByteString().sha256().hex()) {
GAME_CERTIFICATE_HASH -> GameSource.GOOGLE
AMAZON_CERTIFICATE_HASH -> GameSource.AMAZON
else -> {
println("Encountered certificate hash: $hash")
GameSource.UNKNOWN
}
}
fun identifyGameLegitimacy(packageManager: PackageManager): Boolean {
enum class GameSource {
UNKNOWN, GOOGLE, AMAZON
}
fun identifyGameSource(packageManager: PackageManager): GameSource {
@Suppress("DEPRECATION")
val certificates = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, PackageManager.GET_SIGNING_CERTIFICATES)
val signingInfo = game.signingInfo
signingInfo?.signingCertificateHistory ?: return false
signingInfo?.signingCertificateHistory ?: return GameSource.UNKNOWN
} else {
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, PackageManager.GET_SIGNATURES)
game.signatures
}
return certificates?.any {
return certificates?.map {
validateCertificate(it.toByteArray())
} ?: false
}?.firstOrNull {
it != GameSource.UNKNOWN
} ?: GameSource.UNKNOWN
}
fun identifyGameLegitimacy(packageManager: PackageManager) =
identifyGameSource(packageManager) != GameSource.UNKNOWN
}
@@ -10,6 +10,9 @@ import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.provider.DocumentsContract
import android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION
import android.util.Log
@@ -18,7 +21,9 @@ import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.Keep
import androidx.appcompat.app.AppCompatActivity
import androidx.browser.customtabs.CustomTabsIntent
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import com.geode.launcher.BuildConfig
import com.geode.launcher.R
@@ -26,6 +31,7 @@ import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.activityresult.GeodeOpenFileActivityResult
import com.geode.launcher.activityresult.GeodeOpenFilesActivityResult
import com.geode.launcher.activityresult.GeodeSaveFileActivityResult
import org.cocos2dx.lib.Cocos2dxGLSurfaceView
import java.io.File
import java.lang.ref.WeakReference
import kotlin.system.exitProcess
@@ -34,6 +40,8 @@ import kotlin.system.exitProcess
@Suppress("unused", "KotlinJniMissingFunction")
object GeodeUtils {
private lateinit var activity: WeakReference<AppCompatActivity>
var handleSafeArea: Boolean = false
private lateinit var openFileResultLauncher: ActivityResultLauncher<GeodeOpenFileActivityResult.OpenFileParams>
private lateinit var openDirectoryResultLauncher: ActivityResultLauncher<Uri?>
private lateinit var openFilesResultLauncher: ActivityResultLauncher<GeodeOpenFilesActivityResult.OpenFileParams>
@@ -172,7 +180,7 @@ object GeodeUtils {
val manager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = manager.primaryClip
if (clip != null && clip.itemCount > 0) {
return clip.getItemAt(0).text.toString()
return clip.getItemAt(0).coerceToText(this).toString()
}
}
return ""
@@ -373,11 +381,6 @@ object GeodeUtils {
return activity.filesDir.canonicalPath
}
private val gameVersionMap = mapOf(
37L to "2.200",
38L to "2.205"
)
@JvmStatic
fun getGameVersion(): String {
// these versions should be aligned to windows releases, not what android says
@@ -388,6 +391,14 @@ object GeodeUtils {
return ""
}
/**
* Returns the current version code of the launcher.
*/
@JvmStatic
fun getLauncherVersion(): Int {
return BuildConfig.VERSION_CODE
}
fun isGeodeUri(uri: Uri): Boolean {
return "com.geode.launcher.user" == uri.authority
}
@@ -443,6 +454,64 @@ object GeodeUtils {
private external fun permissionCallback(granted: Boolean)
internal fun getVibrator(): Vibrator? = activity.get()?.run {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val manager = getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
manager.defaultVibrator
} else {
@Suppress("DEPRECATION")
getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
}
}
// vibration
/**
* If vibration is supported on the device.
*/
@JvmStatic
fun vibrateSupported(): Boolean = getVibrator()?.hasVibrator() == true
/**
* Vibrates for the given amount of time.
*/
@JvmStatic
fun vibrate(ms: Long) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
getVibrator()?.vibrate(VibrationEffect.createOneShot(ms, VibrationEffect.DEFAULT_AMPLITUDE))
} else {
@Suppress("DEPRECATION")
getVibrator()?.vibrate(ms)
}
}
/**
* Vibrates with the given pattern.
* @see android.os.VibrationEffect.createWaveform
*/
@JvmStatic
fun vibratePattern(pattern: LongArray, repeat: Int) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
getVibrator()?.vibrate(VibrationEffect.createWaveform(pattern, repeat))
} else {
@Suppress("DEPRECATION")
getVibrator()?.vibrate(pattern, repeat)
}
}
@JvmStatic
fun getScreenInsets(): IntArray? {
if (!handleSafeArea || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
return null
}
return activity.get()?.run {
val displayCutout = window.decorView.rootWindowInsets?.displayCutout ?: return null
intArrayOf(displayCutout.safeInsetLeft, displayCutout.safeInsetBottom, displayCutout.safeInsetRight, displayCutout.safeInsetTop)
}
}
const val ARGUMENT_SAFE_MODE = "--geode:safe-mode"
private var additionalLaunchArguments = arrayListOf<String>()
@@ -474,6 +543,7 @@ object GeodeUtils {
const val CAPABILITY_EXTENDED_INPUT = "extended_input"
const val CAPABILITY_TIMESTAMP_INPUT = "timestamp_inputs"
const val CAPABILITY_INTERNAL_CALLBACKS = "internal_callbacks_v1"
private var capabilityListener: WeakReference<CapabilityListener?> = WeakReference(null)
@@ -481,13 +551,31 @@ object GeodeUtils {
capabilityListener = WeakReference(listener)
}
/**
* Reports that the game client supports a feature.
* These features typically require callbacks into the game, so ensure that the required methods are registered first.
*/
@JvmStatic
fun reportPlatformCapability(capability: String?): Boolean {
if (capability.isNullOrEmpty()) {
return false
}
return capabilityListener.get()?.onCapabilityAdded(capability) ?: false
return capabilityListener.get()?.onCapabilityAdded(capability) == true
}
@JvmStatic
fun openWebview(url: String) {
val activity = activity.get() ?: return
CustomTabsIntent.Builder()
.setShowTitle(true)
.build()
.launchUrl(activity, url.toUri())
}
@JvmStatic
fun openMultilineIMEKeyboard() {
Cocos2dxGLSurfaceView.openMultilineIMEKeyboard()
}
external fun nativeKeyUp(keyCode: Int, modifiers: Int)
@@ -495,6 +583,11 @@ object GeodeUtils {
external fun nativeActionScroll(scrollX: Float, scrollY: Float)
external fun resizeSurface(width: Int, height: Int)
// represents the timestamp of the next input callback, in nanoseconds (most events don't send it, but it's there)
/**
* Gives the timestamp of the next input callback, in nanoseconds.
* Enable this by sending the "timestamp_inputs" platform capability.
* @see reportPlatformCapability
*/
external fun setNextInputTimestamp(timestamp: Long)
external fun setNextInputTimestampInternal(timestamp: Long)
}
@@ -3,6 +3,7 @@ package com.geode.launcher.utils
import android.content.Context
import android.os.Build
import java.io.File
import java.io.Serializable
object LaunchUtils {
// supposedly CPU_ABI returns the current arch for the running application
@@ -46,7 +47,7 @@ object LaunchUtils {
/**
* Returns the directory that Geode/the game should base itself off of.
*/
fun getBaseDirectory(context: Context): File {
fun getBaseDirectory(context: Context, ignoreProfile: Boolean = false): File {
// deprecated, but seems to be the best choice of directory (i forced mat to test it)
// also, is getting the first item the correct choice here?? what do they mean
@Suppress("DEPRECATION")
@@ -57,6 +58,14 @@ object LaunchUtils {
val noMediaPath = File(dir, ".nomedia")
noMediaPath.createNewFile()
val currentProfile = ProfileManager.get(context).getCurrentProfile()
if (currentProfile != null && !ignoreProfile) {
val profile = File(dir, "profiles/$currentProfile/")
profile.mkdirs()
return profile
}
return dir
}
@@ -92,7 +101,7 @@ object LaunchUtils {
return crashIndicatorFile.exists()
}
enum class LauncherError {
enum class LauncherError : Serializable {
LINKER_NEEDS_64BIT,
LINKER_NEEDS_32BIT,
LINKER_FAILED_STL,
@@ -20,8 +20,6 @@ import com.geode.launcher.BuildConfig
*/
class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
companion object {
private const val FILE_KEY = "GeodeLauncherPreferencesFileKey"
@Composable
fun useBooleanPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<Boolean> {
return usePreference(
@@ -70,11 +68,14 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
preferenceSet: (PreferenceUtils, Key, T) -> Unit
): MutableState<T> {
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences(FILE_KEY, Context.MODE_PRIVATE)
val currentProfile = remember { ProfileManager.get(context).getCurrentFileKey() }
val sharedPreferences = context.getSharedPreferences(currentProfile, Context.MODE_PRIVATE)
val preferences = get(sharedPreferences)
val preferenceValue = remember {
// inferring the type causes an internal compiler error. lol
val preferenceValue: MutableState<T> = remember {
val state = mutableStateOf(preferenceGet(preferences, preferenceKey))
object : MutableState<T> by state {
override var value: T
@@ -113,7 +114,8 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
}
fun get(context: Context): PreferenceUtils {
val sharedPreferences = context.getSharedPreferences(FILE_KEY, Context.MODE_PRIVATE)
val currentFileKey = ProfileManager.get(context).getCurrentFileKey()
val sharedPreferences = context.getSharedPreferences(currentFileKey, Context.MODE_PRIVATE)
return get(sharedPreferences)
}
@@ -141,23 +143,35 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
RELEASE_CHANNEL_TAG,
DEVELOPER_MODE,
CLEANUP_APKS,
CUSTOM_SYMBOL_LIST
CUSTOM_SYMBOL_LIST,
CUSTOM_ASPECT_RATIO,
SCREEN_ZOOM,
SCREEN_ZOOM_FIT,
LIMIT_FRAME_RATE,
USE_INDEX_API,
}
private fun defaultValueForBooleanKey(key: Key): Boolean {
return when (key) {
Key.UPDATE_AUTOMATICALLY, Key.FORCE_HRR -> true
Key.DEVELOPER_MODE -> BuildConfig.DEBUG
Key.USE_INDEX_API -> true
else -> false
}
}
private fun defaultValueForIntKey(key: Key) = when (key) {
Key.DISPLAY_MODE -> if (this.getBoolean(Key.LIMIT_ASPECT_RATIO)) 1 else 0
Key.SCREEN_ZOOM -> 100
// people wanted a reset on nightly anyways, so this is a good excuse to do so
else -> 0
}
private fun defaultValueForStringKey(key: Key) = when (key) {
Key.CUSTOM_ASPECT_RATIO -> "16_9"
else -> null
}
private fun keyToName(key: Key): String {
return when (key) {
Key.LOAD_AUTOMATICALLY -> "PreferenceLoadAutomatically"
@@ -179,6 +193,11 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
Key.DEVELOPER_MODE -> "PreferenceDeveloperMode"
Key.CLEANUP_APKS -> "PreferenceCleanupPackages"
Key.CUSTOM_SYMBOL_LIST -> "PreferenceCustomSymbolList"
Key.CUSTOM_ASPECT_RATIO -> "PreferenceCustomAspectRatio"
Key.SCREEN_ZOOM -> "PreferenceScreenZoom"
Key.SCREEN_ZOOM_FIT -> "PreferenceScreenZoomFit"
Key.LIMIT_FRAME_RATE -> "PreferenceLimitFrameRate"
Key.USE_INDEX_API -> "PreferenceUseIndexApi"
}
}
@@ -209,7 +228,7 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
fun getString(key: Key): String? {
val keyName = keyToName(key)
return sharedPreferences.getString(keyName, null)
return sharedPreferences.getString(keyName, defaultValueForStringKey(key))
}
fun setString(key: Key, value: String?) {
@@ -0,0 +1,133 @@
package com.geode.launcher.utils
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
private const val FILE_KEY = "GeodeLauncherPreferencesFileKey"
private const val PROFILES_FILE_KEY = "GeodeLauncherProfiles"
private const val PROFILE_LIST_SAVE_KEY = "profiles"
private const val PROFILE_CURRENT_SAVE_KEY = "currentProfile"
@Serializable
data class Profile(val path: String, val name: String)
class ProfileManager {
private val applicationContext: Context
private val preferences: SharedPreferences
companion object {
private lateinit var managerInstance: ProfileManager
fun get(context: Context): ProfileManager {
if (!Companion::managerInstance.isInitialized) {
val applicationContext = context.applicationContext
managerInstance = ProfileManager(applicationContext)
}
return managerInstance
}
}
constructor(applicationContext: Context) {
this.applicationContext = applicationContext
this.preferences = applicationContext.getSharedPreferences(PROFILES_FILE_KEY, Context.MODE_PRIVATE)
_storedProfiles.value = getProfiles()
}
private val _storedProfiles = MutableStateFlow<List<Profile>>(emptyList())
val storedProfiles = _storedProfiles.asStateFlow()
fun getCurrentProfile(): String? {
return preferences.getString(PROFILE_CURRENT_SAVE_KEY, null)
.takeUnless { it.isNullOrEmpty() }
}
fun getCurrentFileKey(): String {
val currentProfile = getCurrentProfile()
?: return FILE_KEY
return "${FILE_KEY}_${currentProfile}"
}
fun getProfile(path: String): Profile? {
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null)
return profilesSet?.firstNotNullOfOrNull {
Json.decodeFromString<Profile>(it).takeIf { it.path == path }
}
}
/**
* Store a profile into the save, removing any duplicate profiles beforehand
*/
fun storeProfile(profile: Profile) {
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) ?: emptySet<String>()
val removedList = profilesSet.filterTo(LinkedHashSet()) {
val saveProfile = Json.decodeFromString<Profile>(it)
saveProfile.path != profile.path
}
removedList.add(Json.encodeToString(profile))
preferences.edit {
putStringSet(PROFILE_LIST_SAVE_KEY, removedList)
}
_storedProfiles.value = getProfiles()
}
fun deleteProfile(path: String) {
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) ?: emptySet<String>()
val removedList = profilesSet.filterTo(LinkedHashSet()) {
val saveProfile = Json.decodeFromString<Profile>(it)
saveProfile.path != path
}
preferences.edit {
putStringSet(PROFILE_LIST_SAVE_KEY, removedList)
}
_storedProfiles.value = getProfiles()
}
fun deleteProfiles(paths: List<String>) {
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) ?: emptySet<String>()
val removedList = profilesSet.filterTo(LinkedHashSet()) {
val saveProfile = Json.decodeFromString<Profile>(it)
!paths.contains(saveProfile.path)
}
preferences.edit(commit = true) {
putStringSet(PROFILE_LIST_SAVE_KEY, removedList)
}
_storedProfiles.value = getProfiles()
}
fun setCurrentProfile(profile: String?) {
preferences.edit(commit = true) {
if (profile.isNullOrEmpty()) {
remove(PROFILE_CURRENT_SAVE_KEY)
} else {
putString(PROFILE_CURRENT_SAVE_KEY, profile)
}
}
}
fun getProfiles(): List<Profile> {
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) ?: emptySet<String>()
return profilesSet.map {
Json.decodeFromString<Profile>(it)
}
}
}
@@ -0,0 +1,102 @@
/****************************************************************************
Copyright (c) 2010-2011 cocos2d-x.org
http://www.cocos2d-x.org
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
****************************************************************************/
package org.cocos2dx.lib
import android.content.Context
import android.content.res.Configuration
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.Build
import android.view.Surface
import android.view.WindowManager
import java.lang.ref.WeakReference
import kotlin.math.roundToInt
class Cocos2dxAccelerometer : SensorEventListener {
companion object {
@JvmStatic
external fun onSensorChanged(pX: Float, pY: Float, pZ: Float, pTimestamp: Long)
}
constructor(context: Context) {
mContext = WeakReference(context)
mSensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
mAccelerometer = mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
val display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
context.display
} else {
@Suppress("DEPRECATION")
(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay
}
this.mNaturalOrientation = display.rotation
}
private val mContext: WeakReference<Context>
private val mSensorManager: SensorManager
private val mAccelerometer: Sensor?
private val mNaturalOrientation: Int
fun enable() {
this.mSensorManager.registerListener(this, this.mAccelerometer, SensorManager.SENSOR_DELAY_GAME)
}
fun setInterval(interval: Float) {
this.mSensorManager.registerListener(this, this.mAccelerometer, (interval * 100000).roundToInt())
}
fun disable() {
this.mSensorManager.unregisterListener(this)
}
override fun onSensorChanged(pSensorEvent: SensorEvent) {
if (pSensorEvent.sensor.type != Sensor.TYPE_ACCELEROMETER) {
return
}
var (x, y, z) = pSensorEvent.values
/*
* Because the axes are not swapped when the device's screen orientation
* changes. So we should swap it here. In tablets such as Motorola Xoom,
* the default orientation is landscape, so should consider this.
*/
val orientation = this.mContext.get()!!.resources.configuration.orientation
if ((orientation == Configuration.ORIENTATION_LANDSCAPE) && (this.mNaturalOrientation != Surface.ROTATION_0)) {
val tmp = x
x = -y
y = tmp
} else if ((orientation == Configuration.ORIENTATION_PORTRAIT) && (this.mNaturalOrientation != Surface.ROTATION_0)) {
val tmp = x
x = y
y = -tmp
}
Cocos2dxGLSurfaceView.queueAccelerometer(x, y, z, pSensorEvent.timestamp)
}
override fun onAccuracyChanged(pSensor: Sensor, pAccuracy: Int) {}
}
@@ -1,18 +0,0 @@
package org.cocos2dx.lib
import android.content.Context
import android.view.KeyEvent
import androidx.appcompat.widget.AppCompatEditText
class Cocos2dxEditText(context: Context) : AppCompatEditText(context) {
var cocos2dxGLSurfaceView: Cocos2dxGLSurfaceView? = null
override fun onKeyDown(keyCode: Int, keyEvent: KeyEvent): Boolean {
super.onKeyDown(keyCode, keyEvent)
if (keyCode != KeyEvent.KEYCODE_BACK) {
return true
}
cocos2dxGLSurfaceView?.requestFocus()
return true
}
}
@@ -1,22 +1,27 @@
package org.cocos2dx.lib
import android.app.Activity
import android.content.Context
import android.opengl.GLSurfaceView
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.Message
import android.text.InputType
import android.util.Log
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.Surface
import android.view.inputmethod.InputMethodManager
import androidx.annotation.RequiresApi
import androidx.appcompat.widget.AppCompatEditText
import com.customRobTop.BaseRobTopActivity
import com.geode.launcher.utils.GeodeUtils
private const val HANDLER_OPEN_IME_KEYBOARD = 2
private const val HANDLER_CLOSE_IME_KEYBOARD = 3
private const val HANDLER_ARG_MULTILINE = 1
private const val HANDLER_ARG_SINGLELINE = 0
private const val MS_TO_NS = 1_000_000
class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
@@ -25,11 +30,22 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
private lateinit var handler: Handler
private lateinit var cocos2dxTextInputWrapper: Cocos2dxTextInputWrapper
fun openMultilineIMEKeyboard() {
val msg = Message().apply {
what = HANDLER_OPEN_IME_KEYBOARD
obj = cocos2dxGLSurfaceView.getContentText()
arg1 = HANDLER_ARG_MULTILINE
}
handler.sendMessage(msg)
}
@JvmStatic
fun openIMEKeyboard() {
val msg = Message()
msg.what = HANDLER_OPEN_IME_KEYBOARD
msg.obj = cocos2dxGLSurfaceView.getContentText()
val msg = Message().apply {
what = HANDLER_OPEN_IME_KEYBOARD
obj = cocos2dxGLSurfaceView.getContentText()
arg1 = HANDLER_ARG_SINGLELINE
}
handler.sendMessage(msg)
}
@@ -39,6 +55,11 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
msg.what = HANDLER_CLOSE_IME_KEYBOARD
handler.sendMessage(msg)
}
fun queueAccelerometer(x: Float, y: Float, z: Float, timestamp: Long) =
cocos2dxGLSurfaceView.queueEvent {
Cocos2dxAccelerometer.onSensorChanged(x, y, z, timestamp)
}
}
private lateinit var cocos2dxRenderer: Cocos2dxRenderer
@@ -49,16 +70,29 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
}
var sendTimestampEvents = false
var sendInternalTimestampEvents = false
var manualBackEvents = false
var cocos2dxEditText: Cocos2dxEditText? = null
var cocos2dxEditText: AppCompatEditText? = null
set(value) {
field = value
field?.setOnEditorActionListener(cocos2dxTextInputWrapper)
field?.cocos2dxGLSurfaceView = this
requestFocus()
}
fun setKeyboardMultiline() {
val editText = cocos2dxEditText ?: return
editText.inputType = editText.inputType or InputType.TYPE_TEXT_FLAG_MULTI_LINE
editText.maxLines = Int.MAX_VALUE
}
fun setKeyboardSingleLine() {
val editText = cocos2dxEditText ?: return
editText.inputType = editText.inputType and InputType.TYPE_TEXT_FLAG_MULTI_LINE.inv()
editText.maxLines = 1
}
fun initView() {
setEGLContextClientVersion(2)
isFocusableInTouchMode = true
@@ -70,6 +104,12 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
when (msg.what) {
HANDLER_OPEN_IME_KEYBOARD -> {
if (cocos2dxEditText?.requestFocus() == true) {
if (msg.arg1 == HANDLER_ARG_MULTILINE) {
setKeyboardMultiline()
} else {
setKeyboardSingleLine()
}
cocos2dxEditText?.apply {
removeTextChangedListener(
cocos2dxTextInputWrapper
@@ -115,11 +155,20 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
@RequiresApi(Build.VERSION_CODES.R)
fun updateRefreshRate() {
val maxRefreshRate = display.supportedModes.maxBy { it.refreshRate }
holder.surface.setFrameRate(
maxRefreshRate.refreshRate,
Surface.FRAME_RATE_COMPATIBILITY_DEFAULT
)
val chosenDisplay = display.supportedModes?.maxByOrNull { it.refreshRate }
if (chosenDisplay == null) {
println("updateRefreshRate failed to find a display to maximize refresh rate...")
return
}
val chosenRefreshRate = chosenDisplay.refreshRate
println("updateRefreshRate: selecting refresh rate of ${chosenDisplay.refreshRate} (display ${chosenDisplay.modeId})")
holder.surface.setFrameRate(chosenRefreshRate, Surface.FRAME_RATE_COMPATIBILITY_DEFAULT)
if (isAttachedToWindow) {
(context as Activity).window.attributes.preferredRefreshRate = chosenRefreshRate
}
}
override fun onPause() {
@@ -134,6 +183,14 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
queueEvent { cocos2dxRenderer.handleOnResume() }
}
private fun sendNextEventTimestamp(timestamp: Long) {
if (sendTimestampEvents)
GeodeUtils.setNextInputTimestamp(timestamp)
if (sendInternalTimestampEvents)
GeodeUtils.setNextInputTimestampInternal(timestamp)
}
override fun onTouchEvent(motionEvent: MotionEvent): Boolean {
val pointerNumber = motionEvent.pointerCount
val ids = IntArray(pointerNumber)
@@ -153,9 +210,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
val xDown = xs[0]
val f = ys[0]
queueEvent {
if (sendTimestampEvents)
GeodeUtils.setNextInputTimestamp(timestamp)
sendNextEventTimestamp(timestamp)
cocos2dxRenderer.handleActionDown(idDown, xDown, f)
}
true
@@ -165,27 +220,21 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
val f2 = xs[0]
val f3 = ys[0]
queueEvent {
if (sendTimestampEvents)
GeodeUtils.setNextInputTimestamp(timestamp)
sendNextEventTimestamp(timestamp)
cocos2dxRenderer.handleActionUp(idUp, f2, f3)
}
true
}
MotionEvent.ACTION_MOVE -> {
queueEvent {
if (sendTimestampEvents)
GeodeUtils.setNextInputTimestamp(timestamp)
sendNextEventTimestamp(timestamp)
cocos2dxRenderer.handleActionMove(ids, xs, ys)
}
true
}
MotionEvent.ACTION_CANCEL -> {
queueEvent {
if (sendTimestampEvents)
GeodeUtils.setNextInputTimestamp(timestamp)
sendNextEventTimestamp(timestamp)
cocos2dxRenderer.handleActionCancel(ids, xs, ys)
}
true
@@ -196,9 +245,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
val xPointerDown = motionEvent.getX(indexPointerDown)
val y = motionEvent.getY(indexPointerDown)
queueEvent {
if (sendTimestampEvents)
GeodeUtils.setNextInputTimestamp(timestamp)
sendNextEventTimestamp(timestamp)
cocos2dxRenderer.handleActionDown(idPointerDown, xPointerDown, y)
}
true
@@ -209,9 +256,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
val xPointerUp = motionEvent.getX(indexPointUp)
val y2 = motionEvent.getY(indexPointUp)
queueEvent {
if (sendTimestampEvents)
GeodeUtils.setNextInputTimestamp(timestamp)
sendNextEventTimestamp(timestamp)
cocos2dxRenderer.handleActionUp(idPointerUp, xPointerUp, y2)
}
true
@@ -244,6 +289,28 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
}
}
fun sendKeyBack() {
if (cocos2dxEditText?.isFocused == true) {
requestFocus()
}
if (BaseRobTopActivity.blockBackButton) {
return
}
if (useKeyboardEvents) {
queueEvent {
cocos2dxRenderer.handleKeyDown(KeyEvent.KEYCODE_BACK)
}
} else {
val currentTime = System.nanoTime()
queueEvent {
sendNextEventTimestamp(currentTime)
GeodeUtils.nativeKeyDown(KeyEvent.KEYCODE_BACK, 0, false)
}
}
}
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
if (!useKeyboardEvents) {
return legacyKeyDown(keyCode, event)
@@ -260,9 +327,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
}
queueEvent {
if (sendTimestampEvents)
GeodeUtils.setNextInputTimestamp(event.eventTime * MS_TO_NS)
sendNextEventTimestamp(event.eventTime * MS_TO_NS)
GeodeUtils.nativeKeyDown(keyCode, event.modifiers, event.repeatCount != 0)
}
true
@@ -285,9 +350,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
}
queueEvent {
if (sendTimestampEvents)
GeodeUtils.setNextInputTimestamp(event.eventTime * MS_TO_NS)
sendNextEventTimestamp(event.eventTime * MS_TO_NS)
GeodeUtils.nativeKeyUp(keyCode, event.modifiers)
}
@@ -308,9 +371,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
event.eventTimeNanos else event.eventTime * MS_TO_NS
queueEvent {
if (sendTimestampEvents)
GeodeUtils.setNextInputTimestamp(timestamp)
sendNextEventTimestamp(timestamp)
GeodeUtils.nativeActionScroll(scrollX, scrollY)
}
@@ -2,6 +2,7 @@ package org.cocos2dx.lib
import android.content.Context
import android.content.pm.ApplicationInfo
import android.content.res.AssetManager
import android.os.Process
import com.customRobTop.BaseRobTopActivity
import com.geode.launcher.utils.LaunchUtils
@@ -11,9 +12,9 @@ import com.geode.launcher.utils.LaunchUtils
object Cocos2dxHelper {
// private val sCocos2dMusic: Cocos2dxMusic? = null
// private val sCocos2dSound: Cocos2dxSound? = null
// private val sAssetManager: AssetManager? = null
// private val sCocos2dxAccelerometer: Cocos2dxAccelerometer? = null
// private val sAccelerometerEnabled = false
private var sAssetManager: AssetManager? = null
private var sCocos2dxAccelerometer: Cocos2dxAccelerometer? = null
private var sAccelerometerEnabled = false
private var packageName: String? = null
private var fileDirectory: String? = null
@@ -36,6 +37,40 @@ object Cocos2dxHelper {
Process.killProcess(Process.myPid())
}
@JvmStatic
fun getAssetManager(): AssetManager? {
return sAssetManager
}
@JvmStatic
fun enableAccelerometer() {
sAccelerometerEnabled = true
sCocos2dxAccelerometer?.enable()
}
@JvmStatic
fun setAccelerometerInterval(interval: Float) {
sCocos2dxAccelerometer?.setInterval(interval)
}
@JvmStatic
fun disableAccelerometer() {
sAccelerometerEnabled = false
sCocos2dxAccelerometer?.disable()
}
fun onResume() {
if (sAccelerometerEnabled) {
sCocos2dxAccelerometer?.enable()
}
}
fun onPause() {
if (sAccelerometerEnabled) {
sCocos2dxAccelerometer?.disable()
}
}
@JvmStatic
fun getDPI(): Int {
return BaseRobTopActivity.me.get()?.resources?.configuration?.densityDpi ?: -1
@@ -52,14 +87,14 @@ object Cocos2dxHelper {
this.cocos2dxHelperListener = cocos2dxHelperListener
packageName = applicationInfo.packageName
fileDirectory = LaunchUtils.getSaveDirectory(context).absolutePath
// Cocos2dxHelper.sCocos2dxAccelerometer = Cocos2dxAccelerometer(pContext)
sCocos2dxAccelerometer = Cocos2dxAccelerometer(context)
// Cocos2dxHelper.sCocos2dMusic = Cocos2dxMusic(pContext)
// var simultaneousStreams: Int = Cocos2dxSound.MAX_SIMULTANEOUS_STREAMS_DEFAULT
// if (Cocos2dxHelper.getDeviceModel().indexOf("GT-I9100") !== -1) {
// simultaneousStreams = Cocos2dxSound.MAX_SIMULTANEOUS_STREAMS_I9100
// }
// Cocos2dxHelper.sCocos2dSound = Cocos2dxSound(pContext, simultaneousStreams)
// Cocos2dxHelper.sAssetManager = pContext.getAssets()
sAssetManager = context.assets
Cocos2dxBitmap.setContext(context)
// Cocos2dxETCLoader.setContext(pContext)
}
@@ -5,6 +5,9 @@ import android.os.Build
import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.opengles.GL10
private const val NANOSECONDS_PER_SECOND = 1000000000L
private const val NANOSECONDS_PER_MICROSECOND = 1000000L
@Suppress("unused", "KotlinJniMissingFunction")
class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceView.Renderer {
companion object {
@@ -62,6 +65,11 @@ class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceVi
private var screenHeight = 0
var sendResizeEvents = false
var setFrameRate = false
private var mAnimationInterval: Long? = null
fun limitFrameRate(rate: Int) {
mAnimationInterval = NANOSECONDS_PER_SECOND/rate
}
fun setScreenWidthAndHeight(surfaceWidth: Int, surfaceHeight: Int) {
screenWidth = surfaceWidth
@@ -77,10 +85,28 @@ class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceVi
if (setFrameRate && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
handler.updateRefreshRate()
}
println("renderer surfaceChanged: ${width}x${height}@${handler.display.refreshRate}fps")
}
override fun onDrawFrame(gl: GL10?) {
nativeRender()
val animationInterval = mAnimationInterval
if (animationInterval == null) {
nativeRender()
} else {
nativeRender()
val now = System.nanoTime()
val interval = now - this.lastTickInNanoSeconds
if (interval < animationInterval) {
try {
Thread.sleep((animationInterval - interval) / NANOSECONDS_PER_MICROSECOND)
} catch (e: Exception) {}
}
lastTickInNanoSeconds = System.nanoTime()
}
}
fun handleActionDown(id: Int, x: Float, y: Float) {
@@ -5,6 +5,7 @@ import android.text.TextWatcher
import android.widget.TextView.OnEditorActionListener
import android.text.Editable
import android.view.KeyEvent
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
@@ -47,22 +48,22 @@ class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurf
override fun onTextChanged(pCharSequence: CharSequence, start: Int, before: Int, count: Int) {}
override fun onEditorAction(textView: TextView, actionID: Int, keyEvent: KeyEvent?): Boolean {
if (actionID != EditorInfo.IME_ACTION_DONE) {
return false
}
if (cocos2dxGLSurfaceView.cocos2dxEditText == textView && isFullScreenEdit) {
for (i in originText!!.length downTo 1) {
cocos2dxGLSurfaceView.deleteBackward()
}
var text = textView.text.toString()
if (text.compareTo("") == 0) {
text = "\n"
}
if (10 != text[text.length - 1].code) {
text += 10.toChar()
if (!text.endsWith('\n')) {
text += '\n'
}
cocos2dxGLSurfaceView.insertText(text)
}
if (actionID != 6) {
return false
}
cocos2dxGLSurfaceView.requestFocus()
Cocos2dxGLSurfaceView.closeIMEKeyboard()
return false
@@ -10,8 +10,9 @@ object Cocos2dxTypefaces {
@Synchronized
operator fun get(pContext: Context, pAssetName: String): Typeface? {
var typeface: Typeface?
var typeface2: Typeface
val typeface: Typeface?
val typeface2: Typeface
synchronized(Cocos2dxTypefaces::class.java) {
if (!typefaceCache.containsKey(pAssetName)) {
typeface2 = if (pAssetName.startsWith("/")) {
+10
View File
@@ -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="M440,680L280,680Q197,680 138.5,621.5Q80,563 80,480Q80,397 138.5,338.5Q197,280 280,280L440,280L440,360L280,360Q230,360 195,395Q160,430 160,480Q160,530 195,565Q230,600 280,600L440,600L440,680ZM320,520L320,440L640,440L640,520L320,520ZM520,680L520,600L680,600Q730,600 765,565Q800,530 800,480Q800,430 765,395Q730,360 680,360L520,360L520,280L680,280Q763,280 821.5,338.5Q880,397 880,480Q880,563 821.5,621.5Q763,680 680,680L520,680Z"/>
</vector>
+10
View File
@@ -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="M480,480Q414,480 367,433Q320,386 320,320Q320,254 367,207Q414,160 480,160Q546,160 593,207Q640,254 640,320Q640,386 593,433Q546,480 480,480ZM160,800L160,688Q160,654 177.5,625.5Q195,597 224,582Q286,551 350,535.5Q414,520 480,520Q546,520 610,535.5Q674,551 736,582Q765,597 782.5,625.5Q800,654 800,688L800,800L160,800ZM240,720L720,720L720,688Q720,677 714.5,668Q709,659 700,654Q646,627 591,613.5Q536,600 480,600Q424,600 369,613.5Q314,627 260,654Q251,659 245.5,668Q240,677 240,688L240,720ZM480,400Q513,400 536.5,376.5Q560,353 560,320Q560,287 536.5,263.5Q513,240 480,240Q447,240 423.5,263.5Q400,287 400,320Q400,353 423.5,376.5Q447,400 480,400ZM480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320ZM480,720L480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720L480,720Z"/>
</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="M720,560L720,440L600,440L600,360L720,360L720,240L800,240L800,360L920,360L920,440L800,440L800,560L720,560ZM360,480Q294,480 247,433Q200,386 200,320Q200,254 247,207Q294,160 360,160Q426,160 473,207Q520,254 520,320Q520,386 473,433Q426,480 360,480ZM40,800L40,688Q40,654 57.5,625.5Q75,597 104,582Q166,551 230,535.5Q294,520 360,520Q426,520 490,535.5Q554,551 616,582Q645,597 662.5,625.5Q680,654 680,688L680,800L40,800ZM120,720L600,720L600,688Q600,677 594.5,668Q589,659 580,654Q526,627 471,613.5Q416,600 360,600Q304,600 249,613.5Q194,627 140,654Q131,659 125.5,668Q120,677 120,688L120,720ZM360,400Q393,400 416.5,376.5Q440,353 440,320Q440,287 416.5,263.5Q393,240 360,240Q327,240 303.5,263.5Q280,287 280,320Q280,353 303.5,376.5Q327,400 360,400ZM360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320ZM360,720L360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720L360,720Z"/>
</vector>
+10
View File
@@ -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="M200,520L200,440L760,440L760,520L200,520Z"/>
</vector>
+11
View File
@@ -0,0 +1,11 @@
<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"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M280,760L280,680L564,680Q627,680 673.5,640Q720,600 720,540Q720,480 673.5,440Q627,400 564,400L312,400L416,504L360,560L160,360L360,160L416,216L312,320L564,320Q661,320 730.5,383Q800,446 800,540Q800,634 730.5,697Q661,760 564,760L280,760Z"/>
</vector>
+76 -8
View File
@@ -3,7 +3,7 @@
<string name="no_activity_found">No activity found for this action.</string>
<!-- MainActivity strings -->
<string name="game_not_found">Geometry Dash was not found.</string>
<string name="game_not_found">Geometry Dash was not found.\nThe full version of the game is required.</string>
<string name="launcher_launch">Launch</string>
<string name="launcher_settings">Settings</string>
<string name="launcher_download">Download</string>
@@ -39,6 +39,11 @@
<string name="launcher_update_available">A new launcher update is available!</string>
<string name="launcher_update_dismiss">Dismiss</string>
<string name="launcher_update_view">View</string>
<string name="launcher_update_external_link">View in browser</string>
<string name="launcher_update_name">Geode Launcher v%1$s</string>
<string name="launcher_update_changelog">Changelog</string>
<string name="launcher_update_released">Released %1$s</string>
<string name="safe_mode_enable_title">Enable safe mode?</string>
<string name="safe_mode_enable_description">Safe mode disables loading of all mods, allowing one to remove problematic mods or diagnose crashes. If you\'re not experiencing issues with Geode, you should probably not be using safe mode.</string>
<string name="launcher_failed_to_load">Geode failed to load.</string>
@@ -53,14 +58,16 @@
<string name="launcher_error_device_info">Android %2$s, %1$s (%5$s)\nGD %4$s, Geode %3$s + v%6$s</string>
<string name="launcher_notification_settings">Launcher Settings</string>
<string name="launcher_notification_update_failed">Failed to check for updates.</string>
<string name="launcher_notification_update_failed">Failed to update Geode.</string>
<string name="launcher_notification_update_success">Geode updated!</string>
<string name="launcher_notification_update_cta">Tap to install.</string>
<string name="launcher_notification_compatibility">Unsupported Geometry Dash version.</string>
<string name="launcher_notification_compatibility_description">An update is required to receive Geode updates.</string>
<string name="launcher_notification_update_cta">Tap for more information.</string>
<string name="launcher_starting_game">Starting Geometry Dash…</string>
<string name="launcher_downloading_update">Downloading update…</string>
<string name="launcher_downloading_update_details">(%1$s/%2$s)</string>
<string name="launcher_cancelled_manual">Launch cancelled.</string>
<string name="launcher_cancelled_manual">Launch paused.</string>
<string name="launcher_cancelled_navigated_away">Launch paused.</string>
<string name="launcher_cancelled_crash">Launch paused due to a crash.</string>
<string name="launcher_cancelled_error">Could not start Geode.</string>
@@ -77,6 +84,7 @@
<string name="release_fetch_in_progress">Checking for new releases…</string>
<string name="release_fetch_success">Geode updated!</string>
<string name="release_fetch_downloading">Downloading update… (%1$s/%2$s)</string>
<string name="release_fetch_downloading_indeterminate">Downloading update… (%1$s)</string>
<string name="release_fetch_cancelling">Cancelling update…</string>
<string name="release_fetch_no_internet">No internet found.</string>
<string name="release_fetch_cancelled">Update cancelled.</string>
@@ -131,12 +139,38 @@
<string name="preference_force_hrr">Force maximum refresh rate</string>
<string name="preference_enable_redesign">✨ Enable UI refresh</string>
<string name="preference_display_mode_name">Screen scale</string>
<string name="preference_display_mode_select">Select screen scale</string>
<string name="preference_display_mode_legacy">Limited (16:9)</string>
<string name="preference_display_mode_name">Display mode</string>
<string name="preference_display_mode_select">Select display mode</string>
<string name="preference_display_mode_legacy">Limited (Custom)</string>
<string name="preference_display_mode_fullscreen">Edge-to-edge</string>
<string name="preference_display_mode_default">Default</string>
<string name="preference_custom_aspect_ratio_name">Aspect ratio</string>
<string name="preference_custom_aspect_ratio_description">Limits aspect ratio of the game.</string>
<string name="preference_custom_aspect_ratio_select">Select aspect ratio</string>
<string name="preference_custom_aspect_ratio_16x9">16:9</string>
<string name="preference_custom_aspect_ratio_16x10">16:10</string>
<string name="preference_custom_aspect_ratio_4x3">4:3</string>
<string name="preference_custom_aspect_ratio_1x1">1:1</string>
<string name="preference_screen_zoom_name">Screen zoom</string>
<string name="preference_screen_zoom_description">Reduces the size of the game window.</string>
<string name="preference_screen_zoom_select">Edit screen zoom</string>
<string name="preference_screen_zoom_fit_name">Fit to screen</string>
<string name="preference_limit_framerate_name">Limit max framerate</string>
<string name="preference_limit_framerate_description">Limits framerate during rendering.</string>
<string name="preference_limit_framerate_select">Edit maximum framerate</string>
<string name="preference_limit_framerate_default">Disabled</string>
<string name="preference_limit_framerate_label">Framerate</string>
<string name="preference_limit_framerate_suffix">FPS</string>
<string name="preference_limit_framerate_value">%1$d FPS</string>
<string name="preference_limit_framerate_error_max">This device supports at most %1$s FPS.</string>
<string name="preference_limit_framerate_error_min">Framerate must be at least %1$s FPS.</string>
<string name="preference_limit_framerate_subtract">Decrement</string>
<string name="preference_limit_framerate_add">Increment</string>
<string name="preference_limit_framerate_reset">Disable</string>
<string name="preference_category_updater">Updates</string>
<string name="preference_update_automatically_name">Enable automatic updates</string>
<string name="preference_release_channel_name">Use nightly release channel</string>
@@ -149,9 +183,29 @@
<string name="preference_release_channel_beta">Pre-release</string>
<string name="preference_release_channel_nightly">Nightly</string>
<string name="preference_category_about">About</string>
<string name="preference_launcher_version_name">Android Launcher</string>
<string name="preference_launcher_version_description">v%1$s</string>
<string name="preference_launcher_version">Android Launcher v%1$s</string>
<string name="preference_loader_version_name">Geode</string>
<string name="preference_loader_version_description">%1$s</string>
<string name="preference_loader_version">Geode %1$s</string>
<string name="preference_game_version_name">Geometry Dash</string>
<string name="preference_game_version_description">%1$s (%2$s)</string>
<string name="preference_game_version">Geometry Dash %1$s (%2$s)</string>
<string name="preference_game_source_google">Google</string>
<string name="preference_game_source_amazon">Amazon</string>
<string name="preference_game_source_unknown">Unknown</string>
<string name="preference_architecture_name">Device</string>
<string name="preference_architecture_description">%1$s (%2$s)</string>
<string name="preference_architecture">%1$s (%2$s)</string>
<string name="preference_check_for_updates_button">Check for updates</string>
<string name="preference_check_for_updates_description">Current loader version: %1$s</string>
<string name="preference_launcher_version">Geode Launcher v%1$s</string>
<string name="preference_check_for_updates_failed">Update failed. See logs for more details.</string>
<string name="preference_check_for_updates_none_found">No updates found.</string>
<string name="preference_check_for_updates_success">Geode has been updated!</string>
@@ -166,6 +220,19 @@
<string name="preference_open_developer_options">Open developer options</string>
<string name="preference_profiles">Profiles</string>
<string name="preference_profiles_create">Create profile</string>
<string name="preference_profiles_name">Change profile</string>
<string name="preference_profiles_select">Select profile</string>
<string name="preference_profiles_create_name">Profile name</string>
<string name="preference_profiles_create_action">Create</string>
<string name="preference_profiles_select_value">%1$s</string>
<string name="preference_profiles_current">Current profile: %1$s</string>
<string name="preference_profiles_create_info">This profile will be created at %1$s.</string>
<string name="preference_profiles_create_duplicate">A profile with this name already exists.</string>
<string name="preference_profiles_delete_alt">Delete</string>
<string name="preference_profiles_delete_undo_alt">Undo</string>
<!-- ApplicationLogsActivity strings -->
<string name="title_activity_application_logs">Application Logs</string>
<string name="application_logs_no_logs">No logs available.</string>
@@ -188,4 +255,5 @@
<string name="preference_override_exceptions_name">Custom exceptions fix symbols</string>
<string name="preference_override_exceptions_description">Uses exception_symbols.txt to patch</string>
<string name="preference_use_index_update_api">Use index release API</string>
</resources>
+2 -1
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.NoActionBar.Fullscreen" parent="@style/Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowNoTitle">true</item>
@@ -7,6 +7,7 @@
<item name="android:windowFullscreen">true</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowBackground">@android:drawable/screen_background_dark</item>
<item name="android:windowOptOutEdgeToEdgeEnforcement" tools:targetApi="35">true</item>
</style>
<style name="Theme.GeodeLauncher" parent="android:Theme.Material.Light.NoActionBar" />
+6 -6
View File
@@ -1,14 +1,14 @@
buildscript {
val composeBOM by extra("2024.10.01")
val composeBOM by extra("2025.04.01")
}
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id("com.android.application") version "8.7.2" apply false
id("com.android.library") version "8.7.2" apply false
id("org.jetbrains.kotlin.android") version "2.0.10" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.10" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.10" apply false
id("com.android.application") version "8.9.2" apply false
id("com.android.library") version "8.9.2" apply false
id("org.jetbrains.kotlin.android") version "2.1.20" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.1.20" apply false
}
tasks.register<Delete>("clean") {
Binary file not shown.
+2 -1
View File
@@ -1,6 +1,7 @@
#Thu Apr 24 10:54:01 MST 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME