Compare commits

..
3 Commits
Author SHA1 Message Date
Chloe 208819436a actually keep it missing 2025-12-17 22:59:45 -07:00
Chloe d7c9e82fc2 fix missing bean 2025-12-17 22:53:00 -07:00
Chloe 8448b3f2a9 i am curious 2025-12-17 22:44:29 -07:00
52 changed files with 2211 additions and 3148 deletions

No files matched your search

-15
View File
@@ -1,18 +1,3 @@
# Geode Android Launcher # Geode Android Launcher
Launches a vanilla copy of Geometry Dash with the Geode loader added. Launches a vanilla copy of Geometry Dash with the Geode loader added.
## Shortcut Links
The launcher supports a handful of links as an escape hatch when the main functionality is impaired in some way (such as needing to prevent an automatic launch due to a crash).
You can open these links by copying them into the URL bar in some browsers (like Chrome), or through ADB.
| Link | Action |
|---------------------------------------------|--------------------------------------------------------------------------|
| geode-launcher://main | Opens to the main activity, forces the user to explicitly confirm launch |
| geode-launcher://main/launch | Opens to the game after the launcher is finished checking for updates |
| geode-launcher://main/launch?safe-mode=true | Opens to the game like before, but with safe mode enabled |
| geode-launcher://settings | Opens the main settings |
| geode-launcher://developer-settings | Opens the developer settings |
| geode-launcher://logs | Opens the application logs |
| geode-launcher://crashes | Opens the crash dumps viewer |
+25 -14
View File
@@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
val composeBOM: String by rootProject.extra val composeBOM: String by rootProject.extra
kotlin { kotlin {
@@ -8,6 +10,7 @@ kotlin {
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization") id("org.jetbrains.kotlin.plugin.serialization")
id("org.jetbrains.kotlin.plugin.compose") id("org.jetbrains.kotlin.plugin.compose")
} }
@@ -18,9 +21,9 @@ android {
defaultConfig { defaultConfig {
applicationId = "com.geode.launcher" applicationId = "com.geode.launcher"
minSdk = 23 minSdk = 23
targetSdk = 36 targetSdk = 35
versionCode = 24 versionCode = 22
versionName = "1.7.1" versionName = "1.6.1"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@@ -29,7 +32,7 @@ android {
@Suppress("UnstableApiUsage") @Suppress("UnstableApiUsage")
externalNativeBuild { externalNativeBuild {
cmake { 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")
} }
} }
@@ -67,6 +70,11 @@ android {
sourceCompatibility = JavaVersion.VERSION_1_8 sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8
} }
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_1_8
}
}
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true buildConfig = true
@@ -94,22 +102,25 @@ dependencies {
implementation ("androidx.compose.ui:ui-tooling-preview") implementation ("androidx.compose.ui:ui-tooling-preview")
implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0") implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0") implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
implementation ("androidx.activity:activity-compose:1.12.4") implementation ("androidx.activity:activity-compose:1.12.1")
implementation ("androidx.activity:activity-ktx:1.12.4") implementation ("androidx.activity:activity-ktx:1.12.1")
implementation ("androidx.appcompat:appcompat:1.7.1") implementation ("androidx.appcompat:appcompat:1.7.1")
implementation ("androidx.documentfile:documentfile:1.1.0") implementation ("androidx.documentfile:documentfile:1.1.0")
implementation ("com.squareup.okio:okio:3.16.4") implementation ("com.squareup.okio:okio:3.16.4")
implementation ("com.squareup.okhttp3:okhttp:5.3.2") implementation ("com.squareup.okhttp3:okhttp:5.3.2")
implementation ("com.squareup.okhttp3:okhttp-coroutines:5.3.2") implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.9.0")
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0")
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.10.0")
implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1") implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1")
implementation ("com.mikepenz:multiplatform-markdown-renderer-android:0.38.1")
implementation ("com.mikepenz:multiplatform-markdown-renderer-android:0.39.2") implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.38.1")
implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.39.2")
implementation ("androidx.browser:browser:1.9.0") implementation ("androidx.browser:browser:1.9.0")
implementation ("io.ktor:ktor-server-core-jvm:3.3.3")
implementation ("io.ktor:ktor-server-cio:3.3.3")
implementation ("io.ktor:ktor-utils-jvm:3.3.3")
implementation ("io.ktor:ktor-server-status-pages:3.3.3")
implementation ("io.ktor:ktor-server-sse:3.3.3")
debugImplementation ("androidx.compose.ui:ui-tooling") debugImplementation ("androidx.compose.ui:ui-tooling")
coreLibraryDesugaring ("com.android.tools:desugar_jdk_libs:2.1.5") coreLibraryDesugaring ("com.android.tools:desugar_jdk_libs:2.1.5")
} }
+1
View File
@@ -5,3 +5,4 @@
# note: if you're going to add more rules, consider the @Keep annotation # note: if you're going to add more rules, consider the @Keep annotation
# this should really only be kept to non-custom code # this should really only be kept to non-custom code
-dontwarn java.lang.management.**
+49 -107
View File
@@ -21,9 +21,9 @@
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.LIGHTS" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-feature <uses-feature
android:name="android.hardware.wifi" android:name="android.hardware.wifi"
@@ -31,65 +31,38 @@
<uses-feature <uses-feature
android:glEsVersion="0x00020000" android:glEsVersion="0x00020000"
android:required="true" /> android:required="true" />
<uses-feature
android:name="android.hardware.gamepad"
android:required="false" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:appCategory="game" android:appCategory="game"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:hasFragileUserData="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:hasFragileUserData="true"
android:requestLegacyExternalStorage="true" android:requestLegacyExternalStorage="true"
android:enableOnBackInvokedCallback="true"
android:theme="@style/Theme.GeodeLauncher" android:theme="@style/Theme.GeodeLauncher"
tools:targetApi="35"> tools:targetApi="35">
<receiver <activity
android:name=".InstallReceiver" android:name=".AltMainActivity"
android:enabled="true" android:exported="false"
android:exported="false" /> android:theme="@style/Theme.GeodeLauncher" />
<activity <activity
android:name=".preferences.DeveloperSettingsActivity" android:name=".preferences.DeveloperSettingsActivity"
android:exported="true" android:exported="false"
android:label="@string/title_activity_developer_settings" android:label="@string/title_activity_developer_settings"
android:theme="@style/Theme.GeodeLauncher"> android:theme="@style/Theme.GeodeLauncher" />
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="geode-launcher" android:host="developer-settings" />
</intent-filter>
</activity>
<activity <activity
android:name=".preferences.ApplicationLogsActivity" android:name=".preferences.ApplicationLogsActivity"
android:exported="true" android:exported="false"
android:label="@string/title_activity_application_logs" android:label="@string/title_activity_application_logs"
android:theme="@style/Theme.GeodeLauncher"> android:theme="@style/Theme.GeodeLauncher" />
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="geode-launcher" android:host="logs" />
</intent-filter>
</activity>
<activity <activity
android:name=".preferences.CrashLogsActivity" android:name=".preferences.CrashLogsActivity"
android:exported="true" android:exported="false"
android:label="@string/title_activity_crash_logs" android:label="@string/title_activity_crash_logs"
android:theme="@style/Theme.GeodeLauncher"> android:theme="@style/Theme.GeodeLauncher" />
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="geode-launcher" android:host="crashes" />
</intent-filter>
</activity>
<activity <activity
android:name=".preferences.ApplicationIconActivity" android:name=".preferences.ApplicationIconActivity"
android:exported="false" android:exported="false"
@@ -97,24 +70,17 @@
android:theme="@style/Theme.GeodeLauncher" /> android:theme="@style/Theme.GeodeLauncher" />
<activity <activity
android:name=".preferences.SettingsActivity" android:name=".preferences.SettingsActivity"
android:exported="true" android:exported="false"
android:label="@string/title_activity_settings" android:label="@string/title_activity_settings"
android:theme="@style/Theme.GeodeLauncher"> android:theme="@style/Theme.GeodeLauncher">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="geode-launcher" android:host="settings" />
</intent-filter>
</activity> </activity>
<activity <activity
android:name=".GeometryDashActivity" android:name=".GeometryDashActivity"
android:configChanges="orientation|screenSize|smallestScreenSize"
android:exported="true" android:exported="true"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:screenOrientation="landscape" android:screenOrientation="landscape"
android:theme="@style/Theme.NoActionBar.Fullscreen" android:theme="@style/Theme.NoActionBar.Fullscreen"
android:configChanges="orientation|screenSize|smallestScreenSize"
android:windowSoftInputMode="stateAlwaysHidden"> android:windowSoftInputMode="stateAlwaysHidden">
<meta-data <meta-data
android:name="android.app.lib_name" android:name="android.app.lib_name"
@@ -126,77 +92,47 @@
android:theme="@style/Theme.GeodeLauncher"> android:theme="@style/Theme.GeodeLauncher">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="geode-launcher" android:host="main" />
</intent-filter>
</activity> </activity>
<activity-alias
android:name=".MainActivityGeode"
android:enabled="false"
android:exported="true"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher_geode"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".MainActivityPride"
android:enabled="false"
android:exported="true"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher_pride"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>
<activity-alias <activity-alias
android:name=".MainActivityTrans" android:name=".MainActivityTrans"
android:enabled="false" android:enabled="false"
android:exported="true" android:exported="true"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher_trans" android:icon="@mipmap/ic_launcher_trans"
android:label="@string/app_name"
android:targetActivity=".MainActivity"> android:targetActivity=".MainActivity">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="geode-launcher" android:host="main" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".MainActivityPride"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_pride"
android:label="@string/app_name"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="geode-launcher" android:host="main" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".MainActivityGeode"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_geode"
android:label="@string/app_name"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="geode-launcher" android:host="main" />
</intent-filter>
</activity-alias> </activity-alias>
<provider <provider
@@ -222,6 +158,12 @@
<meta-data <meta-data
android:name="android.game_mode_config" android:name="android.game_mode_config"
android:resource="@xml/game_mode_config" /> android:resource="@xml/game_mode_config" />
<service
android:name=".DeveloperServerService"
android:foregroundServiceType="dataSync"
android:exported="false">
</service>
</application> </application>
</manifest> </manifest>
@@ -0,0 +1,376 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Geode Android Web Interface</title>
<meta name="color-scheme" content="light dark">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Quicksand:wght@300..700&display=swap" rel="stylesheet">
<style>
.logo {
width: 1em;
height: 1em;
}
.title {
font-family: "Quicksand", sans-serif;
font-weight: 600;
font-size: 2rem;
display: flex;
align-items: center;
gap: 0.3em;
}
.listing-main {
display: flex;
}
body {
font-family: "Poppins", sans-serif;
}
#file-name {
font-family: "Quicksand", sans-serif;
font-weight: 600;
}
.hidden {
display: none;
}
#logs-area {
font-family: monospace;
font-size: 0.9em;
height: 15rem;
overflow-y: auto;
}
</style>
</head>
<body>
<h1 class="title">
<svg
width="698"
height="698"
viewBox="0 0 696 698"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="logo"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M393.076 24.4223L372.884 9.00248C358.192 -2.21718 337.808 -2.2172 323.116 9.00246L16.9433 242.812C3.28395 253.243 -2.41995 271.086 2.656 287.506L120.413 668.435C125.723 685.612 141.605 697.326 159.584 697.326H536.416C554.395 697.326 570.277 685.612 575.587 668.435L693.344 287.506C698.42 271.086 692.716 253.243 679.057 242.812L650.835 221.26L618.374 323.385L529.198 613.394C525.713 624.725 515.245 632.458 503.39 632.458H190.61C178.755 632.458 168.287 624.725 164.802 613.394L67.1963 295.971C63.8815 285.191 67.6162 273.487 76.562 266.619L330.558 71.6226C331.579 70.8386 332.643 70.1371 333.739 69.5183L393.076 24.4223Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M336.614 143.264C344.511 137.209 355.489 137.209 363.386 143.264L552.797 288.49C560.096 294.086 563.143 303.632 560.437 312.422L487.62 548.924C484.778 558.153 476.251 564.451 466.594 564.451H233.406C223.749 564.451 215.222 558.153 212.38 548.924L199.934 508.5H261.676C262.393 508.647 263.134 508.724 263.888 508.724H432.112C436.963 508.724 441.241 505.546 442.643 500.903L495.141 326.982C496.452 322.638 494.961 317.934 491.386 315.139L354.777 208.3C350.795 205.186 345.205 205.186 341.223 208.3L215.925 306.292L152.151 353.307L139.563 312.422C136.857 303.632 139.904 294.086 147.203 288.49L336.614 143.264Z"
fill="currentColor"
/>
</svg>
Geode Android Manager
</h1>
<div class="listing-main">
<section>
<label for="path-listing">
Directory Listing:
</label> <br />
<select id="path-listing" size=25>
</select>
<br />
<label for="path-name">Path:</label>
<input type="text" id="path-name" />
<button id="traverse-btn">Go</button>
<!--br />
<input type="file" id="upload-file">
<button id="upload-btn">Upload File</button-->
</section>
<section>
<h2><span id="file-name"></span></h2>
<div class="hidden" id="file-metadata">
<p>
Size: <span id="file-size"></span> <br />
Modified At: <span id="file-modified"></span> <br />
</p>
<p>
<!-- button id="file-replace">Replace</button>
<button id="file-delete">Delete</button>
<br /-->
<a href="" download id="file-download">Download</a>
</p>
</div>
</section>
</div>
<div>
<h2>Logs</h2>
<pre id="logs-area"></pre>
</div>
<script>
const basePath = "/";
/** @type {HTMLSelectElement | null} */
const pathListing = document.querySelector("#path-listing");
/** @type {HTMLInputElement | null} */
const pathName = document.querySelector("#path-name");
/** @type {HTMLButtonElement | null} */
const goBtn = document.querySelector("#traverse-btn");
/** @type {HTMLSpanElement | null} */
const fileName = document.querySelector("#file-name");
/** @type {HTMLSpanElement | null} */
const fileSize = document.querySelector("#file-size");
/** @type {HTMLSpanElement | null} */
const fileModified = document.querySelector("#file-modified");
/** @type {HTMLAnchorElement | null} */
const fileDownload = document.querySelector("#file-download");
/** @type {HTMLDivElement | null} */
const fileMetadata = document.querySelector("#file-metadata");
/** @type {HTMLPreElement | null} */
const logsArea = document.querySelector("#logs-area");
const currentListing = {};
function formatBytes(bytes, decimals = 2) {
if (bytes == 0) return "0 B";
const base = 1024;
const units = ["B", "KB", "MB", "GB", "TB"];
const unitIndex = Math.floor(Math.log(bytes) / Math.log(base));
const formattedValue = (bytes / Math.pow(base, unitIndex)).toFixed(decimals);
return `${formattedValue} ${units[unitIndex]}`;
};
async function fetchDirectoryListing(path) {
goBtn.disabled = true;
try {
const data = await fetch(`${basePath}files/${path}`);
const json = await data.json();
const selectElements = json.sort((a, b) => {
const aIsDir = a.type == "directory";
const bIsDir = b.type == "directory";
if (aIsDir && !bIsDir) {
return -1;
}
if (!aIsDir && bIsDir) {
return 1;
}
return a.filename.localeCompare(b.filename);
}).map((f) => {
currentListing[f.filename] = f;
const optionElement = document.createElement("option");
optionElement.value = f.filename;
if (f.type == "directory") {
optionElement.innerText = "📁 " + f.filename;
} else {
optionElement.innerText = f.filename;
}
return optionElement;
});
const parentElement = document.createElement("option");
parentElement.value = "..";
parentElement.innerText = "⤴️ ..";
selectElements.unshift(parentElement);
pathListing.replaceChildren(...selectElements);
pathName.value = path;
} catch (e) {
console.error(e);
} finally {
goBtn.disabled = false;
}
}
function updateFileData() {
if (pathListing.value == "..") {
return;
}
const currentFile = currentListing[pathListing.value];
fileName.innerText = currentFile.filename;
if (currentFile.type == "directory") {
fileMetadata.classList.add("hidden");
return;
}
fileMetadata.classList.remove("hidden");
fileSize.innerText = formatBytes(currentFile.size);
const modifiedDate = new Date(currentFile.lastModified);
fileModified.innerText = new Intl.DateTimeFormat("default", {
timeStyle: "long",
dateStyle: "long",
}).format(modifiedDate);
fileDownload.href = `${basePath}files${updatePath(currentFile.filename)}`;
}
function updatePath(append) {
const currentPath = pathName.value;
if (append == "..") {
if (currentPath == "/") {
return "/"
} else {
const path = currentPath.split("/");
path.pop();
return path.join("/");
}
}
if (currentPath.endsWith("/")) {
return currentPath + append;
} else {
return `${currentPath}/${append}`;
}
}
pathListing.addEventListener("dblclick", async (event) => {
const clickedOption = event.target.value;
if (clickedOption == "..") {
const newPath = updatePath("..");
await fetchDirectoryListing(newPath);
return;
}
const selected = currentListing[clickedOption];
if (selected.type == "directory") {
const newPath = updatePath(selected.filename);
await fetchDirectoryListing(newPath);
} else {
fileDownload.click();
}
});
pathListing.addEventListener("change", (event) => {
const clickedOption = event.target.value;
updateFileData();
});
goBtn.addEventListener("click", async () => {
const goto = pathName.value;
await fetchDirectoryListing(goto);
});
function formatLog(line) {
/*
{
"process": {
"processId": 31123,
"threadId": 31123,
"processUid": 10486
},
"time": "2025-12-17T10:38:19.488696104Z",
"logId": 4,
"priority": "error",
"tag": "AndroidRuntime",
"message": "..."
},
*/
return `[${line.priority.charAt(0)}] ${line.time} - [${line.tag}] ${line.message}`
}
async function updateLogs() {
const data = await fetch(`${basePath}logs`);
const json = await data.json();
const lines = json.map((l) => formatLog(l));
const message = lines.join("\n");
logsArea.innerText = message;
logsArea.scrollTop = logsArea.scrollHeight - logsArea.clientHeight
}
const unprocessedLogs = [];
function onTick() {
const batch = unprocessedLogs.splice(0, 100);
if (batch.length == 0) {
return;
}
const formatted = batch.map((l) => formatLog(JSON.parse(l))).join("\n");
const isScrolledToBottom = (logsArea.scrollHeight - logsArea.clientHeight) <= logsArea.scrollTop + 1;
logsArea.innerText += `\n${formatted}`;
if (isScrolledToBottom) {
logsArea.scrollTop = logsArea.scrollHeight - logsArea.clientHeight
}
}
function animationFrame() {
onTick();
requestAnimationFrame(animationFrame);
}
function openLogsStream() {
const evtSource = new EventSource(`${basePath}logs_live`);
// we store new logs to process on the next frame, to avoid slowdowns when lots of logs come in at once
evtSource.addEventListener("log", (event) => {
unprocessedLogs.push(event.data);
});
}
document.addEventListener("DOMContentLoaded", async (event) => {
await fetchDirectoryListing("/");
await updateLogs();
openLogsStream();
requestAnimationFrame(animationFrame);
});
</script>
</body>
</html>
+2 -2
View File
@@ -261,8 +261,8 @@ int on_dl_iterate(dl_phdr_info* info, size_t size, void* data) {
// this should be called after gd is loaded but before geode // this should be called after gd is loaded but before geode
extern "C" extern "C"
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_performExceptionsRenaming(JNIEnv*, jobject) { JNIEXPORT int JNICALL Java_com_geode_launcher_LauncherFix_performExceptionsRenaming(JNIEnv*, jobject) {
dl_iterate_phdr(on_dl_iterate, nullptr); return dl_iterate_phdr(on_dl_iterate, nullptr);
} }
std::optional<std::string> redirect_path(const char* pathname) { std::optional<std::string> redirect_path(const char* pathname) {
@@ -5,11 +5,11 @@ import android.app.Activity
import android.content.* import android.content.*
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.Settings import android.provider.Settings
import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.core.net.toUri
import com.geode.launcher.R
import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.closeIMEKeyboard import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.closeIMEKeyboard
import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.openIMEKeyboard import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.openIMEKeyboard
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
@@ -24,7 +24,7 @@ object BaseRobTopActivity {
var isPaused = false var isPaused = false
lateinit var me: WeakReference<Activity> var me: WeakReference<Activity> = WeakReference(null)
private var shouldResumeSound = true private var shouldResumeSound = true
fun setCurrentActivity(currentActivity: Activity) { fun setCurrentActivity(currentActivity: Activity) {
@@ -61,40 +61,33 @@ object BaseRobTopActivity {
@JvmStatic @JvmStatic
fun openURL(url: String) { fun openURL(url: String) {
Log.d("MAIN", "Open URL")
me.get()?.runOnUiThread { me.get()?.runOnUiThread {
try { try {
val intent = Intent(Intent.ACTION_VIEW, url.toUri()) me.get()?.startActivity(
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) Intent(
me.get()?.startActivity(intent) "android.intent.action.VIEW",
Uri.parse(url)
)
)
} catch (e: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
Toast.makeText( Toast.makeText(
me.get(), me.get(),
R.string.no_activity_found, "No activity found to open this URL.",
Toast.LENGTH_SHORT, Toast.LENGTH_SHORT,
).show() ).show()
} }
} }
} }
@JvmStatic
fun copyToClipboard(str: String) {
val context = me.get() ?: return
context.runOnUiThread {
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboardManager.setPrimaryClip(
ClipData.newPlainText("text", str)
)
}
}
@JvmStatic @JvmStatic
fun sendMail(subject: String, body: String, to: String) { fun sendMail(subject: String, body: String, to: String) {
me.get()?.runOnUiThread { me.get()?.runOnUiThread {
val i = Intent(Intent.ACTION_SEND) val i = Intent("android.intent.action.SEND")
i.type = "message/rfc822" i.type = "message/rfc822"
i.putExtra(Intent.EXTRA_EMAIL, arrayOf(to)) i.putExtra("android.intent.extra.EMAIL", arrayOf(to))
i.putExtra(Intent.EXTRA_SUBJECT, subject) i.putExtra("android.intent.extra.SUBJECT", subject)
i.putExtra(Intent.EXTRA_TEXT, body) i.putExtra("android.intent.extra.TEXT", body)
try { try {
me.get()?.startActivity(Intent.createChooser(i, "Send mail...")) me.get()?.startActivity(Intent.createChooser(i, "Send mail..."))
} catch (e: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
@@ -0,0 +1,523 @@
package com.geode.launcher
import android.os.Bundle
import android.text.format.Formatter
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.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.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
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.ui.theme.launcherTitleStyle
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 kotlinx.coroutines.launch
class AltMainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
clearDownloadedApks(this)
val gdInstalled = GamePackageUtils.isGameInstalled(packageManager)
val geodeInstalled = LaunchUtils.isGeodeInstalled(this)
val returnMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_MESSAGE)
val returnExtendedMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE)
@Suppress("DEPRECATION") // the new api is android 13 only (why)
val returnError = intent.extras?.getSerializable(LaunchUtils.LAUNCHER_KEY_RETURN_ERROR)
as? LaunchUtils.LauncherError
val loadFailureInfo = if (returnError != null && returnMessage != null) {
LoadFailureInfo(returnError, returnMessage, returnExtendedMessage)
} else if (LaunchUtils.lastSessionCrashed(this)) {
LoadFailureInfo(LaunchUtils.LauncherError.CRASHED)
} else { null }
setContent {
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
val launchViewModel = viewModel<LaunchViewModel>(factory = LaunchViewModel.Factory)
if (loadFailureInfo != null) {
launchViewModel.loadFailure = loadFailureInfo
}
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
Surface(
modifier = Modifier.fillMaxSize()
) {
AltMainScreen(launchViewModel)
}
}
}
}
if (gdInstalled && geodeInstalled) {
intent.getBooleanExtra("restarted", false).let {
if (it) {
onLaunch(this)
}
}
}
}
}
data class LaunchStatusInfo(
val title: String?,
val details: String? = null,
val progress: (() -> Float)? = null
)
@Composable
fun LaunchCancelledIcon(cancelReason: LaunchViewModel.LaunchCancelReason, modifier: Modifier = Modifier) {
when (cancelReason) {
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED,
LaunchViewModel.LaunchCancelReason.GAME_MISSING -> Icon(painterResource(R.drawable.icon_error), contentDescription = null, modifier)
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED,
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> Icon(painterResource(R.drawable.icon_warning), contentDescription = null, modifier)
else -> Icon(painterResource(R.drawable.icon_info), contentDescription = null, modifier)
}
}
@Composable
fun mapCancelReasonToInfo(cancelReason: LaunchViewModel.LaunchCancelReason): LaunchStatusInfo {
return when (cancelReason) {
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_crash)
)
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> LaunchStatusInfo(
title = stringResource(R.string.geode_download_title)
)
LaunchViewModel.LaunchCancelReason.GAME_MISSING -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_error),
details = stringResource(R.string.game_not_found)
)
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_error),
details = stringResource(R.string.launcher_cancelled_outdated)
)
LaunchViewModel.LaunchCancelReason.AUTOMATIC,
LaunchViewModel.LaunchCancelReason.MANUAL-> LaunchStatusInfo(
title = null
)
else -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_manual)
)
}
}
@Composable
fun mapLaunchStatusToInfo(state: LaunchViewModel.LaunchUIState, inSafeMode: Boolean = false): LaunchStatusInfo {
return when (state) {
is LaunchViewModel.LaunchUIState.Initial,
is LaunchViewModel.LaunchUIState.Working,
is LaunchViewModel.LaunchUIState.Ready -> LaunchStatusInfo(
title = stringResource(R.string.launcher_starting_game),
details = if (inSafeMode) stringResource(R.string.launcher_in_safe_mode) else null
)
is LaunchViewModel.LaunchUIState.UpdateCheck -> LaunchStatusInfo(
title = stringResource(R.string.release_fetch_in_progress)
)
is LaunchViewModel.LaunchUIState.Updating -> {
val context = LocalContext.current
val downloaded = remember(state.downloaded) {
Formatter.formatShortFileSize(context, state.downloaded)
}
val outOf = remember(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 ?: "…"),
progress = {
val progress = state.downloaded / state.outOf!!.toDouble()
progress.toFloat()
}.takeIf { state.outOf != null }
)
}
is LaunchViewModel.LaunchUIState.Cancelled -> {
return mapCancelReasonToInfo(state.reason)
}
}
}
@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.CenterHorizontally,
modifier = modifier.width(300.dp)
) {
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)
}
}
}
if (inProgress) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
if (statusInfo.details != null) {
Text(
statusInfo.details,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
}
}
}
@Composable
fun LaunchProgressBody(statusInfo: LaunchStatusInfo, modifier: Modifier = Modifier) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.Start,
modifier = modifier.width(300.dp)
) {
if (statusInfo.title != null) {
Text(statusInfo.title, style = MaterialTheme.typography.bodyLarge)
}
if (statusInfo.progress != null) {
LinearProgressIndicator(
statusInfo.progress,
modifier = Modifier.fillMaxWidth()
)
} else {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
if (statusInfo.details != null) {
Text(statusInfo.details, style = MaterialTheme.typography.bodyMedium)
}
}
}
@Composable
fun RetryButtonContents(reason: LaunchViewModel.LaunchCancelReason) {
when (reason) {
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED -> {
Icon(
painterResource(R.drawable.icon_resume),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_cancelled_resume))
}
LaunchViewModel.LaunchCancelReason.MANUAL,
LaunchViewModel.LaunchCancelReason.AUTOMATIC -> {
Icon(
painterResource(R.drawable.icon_play_arrow),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_launch))
}
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> {
Icon(
painterResource(R.drawable.icon_refresh),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_cancelled_retry))
}
// these shouldn't be allowing retries anyways
LaunchViewModel.LaunchCancelReason.GAME_MISSING,
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED -> {}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun LaunchProgressCard(
uiState: LaunchViewModel.LaunchUIState,
crashInfo: LoadFailureInfo?,
onCancel: () -> Unit,
onResume: (safeMode: Boolean) -> Unit,
onMore: () -> Unit,
extraOptions: @Composable () -> Unit,
safeModeEnabled: Boolean,
modifier: Modifier = Modifier
) {
var showSafeModeDialog by remember { mutableStateOf(false) }
val status = mapLaunchStatusToInfo(uiState, safeModeEnabled)
Box(modifier = modifier) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (uiState is LaunchViewModel.LaunchUIState.Cancelled) {
LaunchCancelledBody(
statusInfo = status,
icon = { LaunchCancelledIcon(uiState.reason, Modifier.size(32.dp)) },
inProgress = uiState.inProgress
)
if (uiState.reason.isGameInstallIssue()) {
val context = LocalContext.current
val showDownload = remember { GamePackageUtils.showDownloadBadge(context.packageManager) }
if (showDownload) {
GooglePlayBadge(modifier = Modifier.align(Alignment.CenterHorizontally))
}
}
if (uiState.reason.allowsRetry()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally),
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
LongPressButton(onClick = { onResume(false) }, onLongPress = {
showSafeModeDialog = true
}) {
RetryButtonContents(uiState.reason)
}
if (crashInfo != null) {
FilledTonalButton(onClick = onMore) {
Icon(
painterResource(R.drawable.icon_question_mark),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_cancelled_help_alt))
}
}
extraOptions()
}
}
} else {
LaunchProgressBody(statusInfo = status, modifier = Modifier.padding(top = 16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.width(300.dp)) {
TextButton(onClick = onCancel) {
Text(stringResource(R.string.release_fetch_button_cancel))
}
Spacer(Modifier.weight(1.0f))
extraOptions()
}
}
}
}
if (showSafeModeDialog) {
SafeModeDialog(
onDismiss = {
showSafeModeDialog = false
},
onLaunch = {
onResume(true)
showSafeModeDialog = false
}
)
}
}
@Composable
fun ExtraOptions(onSettings: () -> Unit) {
IconButton(
onClick = onSettings,
) {
Icon(
painterResource(R.drawable.icon_settings),
contentDescription = stringResource(R.string.launcher_settings_icon_alt)
)
}
}
@Composable
fun GeodeLogo(modifier: Modifier = Modifier) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
) {
Icon(
painter = painterResource(id = R.drawable.geode_monochrome),
contentDescription = null,
modifier = Modifier
.size(64.dp, 64.dp),
)
Text(
stringResource(R.string.launcher_title),
style = launcherTitleStyle,
fontSize = 64.sp,
modifier = Modifier
.padding(12.dp)
)
}
}
@Composable
fun AltMainScreen(
launchViewModel: LaunchViewModel = viewModel(factory = LaunchViewModel.Factory)
) {
val context = LocalContext.current
LaunchedEffect(Unit) {
launchViewModel.beginLaunchFlow()
}
val launchUIState by launchViewModel.uiState.collectAsState()
val coroutineScope = rememberCoroutineScope()
var launchInSafeMode by remember { mutableStateOf(false) }
var showErrorInfo by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding(),
contentAlignment = Alignment.Center
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(8.dp)
.verticalScroll(rememberScrollState())
) {
GeodeLogo()
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
LaunchProgressCard(
launchUIState,
crashInfo = launchViewModel.currentCrashInfo(),
onCancel = {
launchInSafeMode = false
coroutineScope.launch {
launchViewModel.cancelLaunch()
}
},
onResume = { safeMode ->
launchInSafeMode = safeMode
launchViewModel.clearCrashInfo()
coroutineScope.launch {
launchViewModel.beginLaunchFlow(true)
}
},
onMore = {
showErrorInfo = true
},
extraOptions = {
ExtraOptions(
onSettings = {
coroutineScope.launch {
launchViewModel.cancelLaunch(true)
}
onSettings(context)
}
)
},
safeModeEnabled = launchInSafeMode
)
// only show launcher update in a case where a user won't see it ingame
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()
}
}
}
}
}
if (launchUIState is LaunchViewModel.LaunchUIState.Ready) {
UpdateWarning(launchInSafeMode) {
launchInSafeMode = false
coroutineScope.launch {
launchViewModel.cancelLaunch()
}
}
}
val loadFailure = launchViewModel.currentCrashInfo()
if (showErrorInfo && loadFailure != null) {
ErrorInfoSheet(loadFailure, onDismiss = { showErrorInfo = false })
}
}
@@ -0,0 +1,351 @@
package com.geode.launcher
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Binder
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import com.geode.launcher.log.LogLine
import com.geode.launcher.utils.DownloadUtils
import com.geode.launcher.utils.LaunchUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.withContext
import kotlinx.coroutines.yield
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerialName
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okio.Buffer
import okio.buffer
import okio.source
import java.io.EOFException
import java.io.File
import java.io.IOException
import java.nio.file.DirectoryNotEmptyException
import kotlin.collections.plusAssign
import kotlin.io.path.deleteExisting
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
import io.ktor.http.*
import io.ktor.http.content.PartData
import io.ktor.http.content.forEachPart
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.engine.*
import io.ktor.server.cio.*
import io.ktor.server.http.content.staticFiles
import io.ktor.server.plugins.statuspages.StatusPages
import io.ktor.server.request.receiveChannel
import io.ktor.server.request.receiveMultipart
import io.ktor.server.sse.*
import io.ktor.sse.ServerSentEvent
import io.ktor.util.cio.writeChannel
import io.ktor.utils.io.copyAndClose
enum class FileType {
@SerialName("file")
FILE,
@SerialName("directory")
DIRECTORY
}
@Serializable
data class FileMeta(val filename: String, val type: FileType, val size: Long, val lastModified: Instant)
class DeveloperServerService : Service() {
private val binder = LocalBinder()
private var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
inner class LocalBinder : Binder() {
fun getService(): DeveloperServerService = this@DeveloperServerService
}
private fun startForeground() {
try {
val notification = NotificationCompat.Builder(this, "geode_developer_server")
.setSmallIcon(R.drawable.geode_monochrome)
.build()
ServiceCompat.startForeground(
this,
100,
notification,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
} else {
0
},
)
} catch (e: Exception) {
/*
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
&& e is ForegroundServiceStartNotAllowedException
) {
println("idk")
}
*/
}
}
override fun onBind(intent: Intent): IBinder {
return binder
}
override fun onTimeout(startId: Int, fgsType: Int) {
stopSelf()
}
override fun onDestroy() {
super.onDestroy()
server?.stop()
server = null
}
// not recursive copy
private fun copyIndexToCache() {
val folderName = "web_interface"
val base = File(application.cacheDir, folderName)
base.deleteRecursively()
base.mkdirs()
assets.list(folderName)?.forEach {
val assetName = "$folderName/$it"
val outputPath = File(base, it)
assets.open(assetName).use { inputStream ->
outputPath.outputStream().use { outputStream ->
DownloadUtils.copyFile(inputStream, outputStream)
}
}
}
}
private fun createLogsFlow(): Flow<LogLine> = flow {
val timestamp = System.currentTimeMillis()
val seconds = timestamp / 1000
val ms = timestamp % 1000
// Time format is 'MM-DD hh:mm:ss.mmm...', 'YYYY-MM-DD hh:mm:ss.mmm...', or 'sssss.mmm...'.
val timeString = "$seconds.$ms"
val logCommand = "logcat -B -T $timeString"
val logProcess = try {
Runtime.getRuntime().exec(logCommand)
} catch (ioe: IOException) {
ioe.printStackTrace()
emit(LogLine.showException(ioe))
return@flow
}
val logBuffer = logProcess.inputStream.source().buffer()
try {
// this runs until the stream is exhausted
while (currentCoroutineContext().isActive) {
val line = LogLine.fromBufferedSource(logBuffer)
emit(line)
yield()
}
} catch (_: EOFException) {
// ignore, end of file reached
} catch (e: Exception) {
e.printStackTrace()
emit(LogLine.showException(e))
}
}
private suspend fun readLogs(): List<LogLine> {
val logLines = mutableListOf<LogLine>()
val logCommand = "logcat -B -d"
val logProcess = try {
withContext(Dispatchers.IO) {
Runtime.getRuntime().exec(logCommand)
}
} catch (ioe: IOException) {
ioe.printStackTrace()
logLines += LogLine.showException(ioe)
return logLines
}
// read entire log into a buffer so no logs are added to the buffer during processing
val logBuffer = Buffer()
logProcess.inputStream.source().buffer().readAll(logBuffer)
try {
coroutineScope {
// this runs until the stream is exhausted
while (true) {
val line = LogLine.fromBufferedSource(logBuffer)
logLines += line
yield()
}
}
} catch (_: EOFException) {
// ignore, end of file reached
} catch (e: Exception) {
e.printStackTrace()
logLines += LogLine.showException(e)
}
return logLines
}
override fun onCreate() {
super.onCreate()
val staticDir = File(application.cacheDir, "web_interface")
val baseDir = LaunchUtils.getBaseDirectory(application, true)
val logsFlow = createLogsFlow()
server = embeddedServer(CIO, port = 9043) {
routing {
install(SSE)
install(StatusPages) {
exception<Throwable> { call, cause ->
call.respondText(text = "500: $cause" , status = HttpStatusCode.InternalServerError)
}
}
get("/logs") {
val logs = Json.encodeToString(readLogs())
call.respondText(logs, contentType = ContentType.Application.Json)
}
post("/stop") {
stopSelf()
}
sse("/logs_live") {
heartbeat {
period = 5000.milliseconds
event = ServerSentEvent(comments = "heartbeat")
}
logsFlow.collect {
val log = Json.encodeToString(it)
send(log, event = "log", id = it.logId.toString())
}
}
staticFiles("/", staticDir)
staticFiles("/files", baseDir, index = null) {
fallback { requestedPath, call ->
val basePath = baseDir.toPath()
val builtPath = basePath.resolve(requestedPath).normalize()
if (!builtPath.startsWith(basePath) || !builtPath.exists()) {
// block path traversal issues
call.response.status(HttpStatusCode.NotFound)
} else {
val filenames = builtPath.toFile().listFiles()?.map {
FileMeta(
filename = it.name,
type = if (it.isDirectory) FileType.DIRECTORY else FileType.FILE,
size = it.length(),
lastModified = Instant.fromEpochMilliseconds(it.lastModified())
)
} ?: emptyList()
val data = Json.encodeToString(filenames)
call.respondText(data, contentType = ContentType.Application.Json)
}
}
}
delete("/files/{path...}") {
val pathSegments = call.parameters.getAll("path")
val requestedPath = pathSegments?.joinToString("/") ?: ""
val basePath = baseDir.toPath()
val builtPath = basePath.resolve(requestedPath).normalize()
if (!builtPath.startsWith(basePath) || !builtPath.exists()) {
call.response.status(HttpStatusCode.NotFound)
} else {
try {
builtPath.deleteExisting()
call.response.status(HttpStatusCode.NoContent)
} catch (e: DirectoryNotEmptyException) {
val message = Json.encodeToString(buildJsonObject {
put("message", "Provided directory $requestedPath was not empty")
})
call.respondText(message, status = HttpStatusCode.Conflict, contentType = ContentType.Application.Json)
}
}
}
put("/files/{path...}") {
val pathSegments = call.parameters.getAll("path")
val requestedPath = pathSegments?.joinToString("/") ?: ""
val basePath = baseDir.toPath()
val builtPath = basePath.resolve(requestedPath).normalize()
if (!builtPath.startsWith(basePath)) {
call.response.status(HttpStatusCode.NotFound)
} else {
if (builtPath.isDirectory()) {
val multipart = call.receiveMultipart(formFieldLimit = 1024 * 1024 * 100)
multipart.forEachPart { part ->
if (part is PartData.FileItem) {
val fileName = part.originalFileName ?: "uploaded_file"
val outputPath = builtPath.resolve(fileName).normalize()
if (!outputPath.startsWith(basePath)) {
val outputFile = outputPath.toFile()
part.provider().copyAndClose(outputFile.writeChannel())
}
}
part.dispose()
}
} else {
val outputFile = builtPath.toFile()
outputFile.parentFile?.mkdirs()
call.receiveChannel().copyAndClose(outputFile.writeChannel())
}
call.response.status(HttpStatusCode.NoContent)
}
}
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
this.startForeground()
copyIndexToCache()
server?.start(wait = false)
return START_STICKY
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
stopSelf()
}
}
@@ -12,7 +12,6 @@ import android.os.Environment
import android.text.InputType import android.text.InputType
import android.util.Log import android.util.Log
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowInsets
import android.view.WindowManager import android.view.WindowManager
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
@@ -23,7 +22,6 @@ import androidx.compose.ui.platform.ComposeView
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.updatePadding
import com.customRobTop.BaseRobTopActivity import com.customRobTop.BaseRobTopActivity
import com.customRobTop.JniToCpp import com.customRobTop.JniToCpp
import com.geode.launcher.main.LaunchNotification import com.geode.launcher.main.LaunchNotification
@@ -187,60 +185,16 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
loadInternalMods() loadInternalMods()
val baseView = createView() setContentView(createView())
if (!GeodeUtils.handleSafeArea && Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
val frameLayoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
val frameLayout = FrameLayout(this)
frameLayout.layoutParams = frameLayoutParams
frameLayout.setOnApplyWindowInsetsListener { v, insets ->
val displayInsets = insets.displayCutout
if (displayInsets != null) {
v.updatePadding(
left = displayInsets.safeInsetLeft,
top = displayInsets.safeInsetTop,
right = displayInsets.safeInsetRight,
bottom = displayInsets.safeInsetBottom
)
}
WindowInsets.CONSUMED
}
frameLayout.addView(baseView)
setContentView(frameLayout)
} else {
setContentView(baseView)
}
setupPostLibraryLoad(gdPackageInfo) setupPostLibraryLoad(gdPackageInfo)
val geodeFailed = try { try {
loadGeodeLibrary() loadGeodeLibrary()
false
} catch (e: UnsatisfiedLinkError) { } catch (e: UnsatisfiedLinkError) {
handleGeodeException(e) handleGeodeException(e)
true
} catch (e: Exception) { } catch (e: Exception) {
handleGeodeException(e) handleGeodeException(e)
true
}
setupNotificationView(baseView, geodeFailed)
}
private fun setupNotificationView(baseView: FrameLayout, geodeFailed: Boolean) {
val cards = determineDisplayedCards(this, geodeFailed)
if (cards.isNotEmpty()) {
val notificationView = ComposeView(this)
baseView.addView(notificationView)
notificationView.setContent {
LaunchNotification(cards)
}
} }
} }
@@ -461,6 +415,19 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
this.mGLSurfaceView = glSurfaceView this.mGLSurfaceView = glSurfaceView
frameLayout.addView(this.mGLSurfaceView) frameLayout.addView(this.mGLSurfaceView)
val showNotification = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.ENABLE_REDESIGN)
if (showNotification) {
val hasCards = determineDisplayedCards(this)
if (hasCards.isNotEmpty()) {
val notificationView = ComposeView(this)
frameLayout.addView(notificationView)
notificationView.setContent {
LaunchNotification()
}
}
}
glSurfaceView.setEGLContextClientVersion(2) glSurfaceView.setEGLContextClientVersion(2)
glSurfaceView.setEGLConfigChooser(EGLConfigChooser()) glSurfaceView.setEGLConfigChooser(EGLConfigChooser())
@@ -502,7 +469,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val chosenDisplay = display.supportedModes?.maxByOrNull { it.refreshRate } val chosenDisplay = display.supportedModes?.maxByOrNull { it.refreshRate }
if (chosenDisplay != null) { if (chosenDisplay != null) {
val chosenRefreshRate = chosenDisplay.refreshRate var chosenRefreshRate = chosenDisplay.refreshRate
println("setupUIState: Forcing a refresh rate of $chosenRefreshRate (display ${chosenDisplay.modeId})") println("setupUIState: Forcing a refresh rate of $chosenRefreshRate (display ${chosenDisplay.modeId})")
window.attributes.preferredRefreshRate = chosenRefreshRate window.attributes.preferredRefreshRate = chosenRefreshRate
@@ -621,8 +588,8 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
mGLSurfaceView?.sendTimestampEvents = true mGLSurfaceView?.sendTimestampEvents = true
true true
} }
GeodeUtils.CAPABILITY_RICH_INPUT -> { GeodeUtils.CAPABILITY_INTERNAL_CALLBACKS -> {
mGLSurfaceView?.enableControllerEvents() mGLSurfaceView?.sendInternalTimestampEvents = true
true true
} }
else -> false else -> false
@@ -1,33 +0,0 @@
package com.geode.launcher
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.widget.Toast
import com.geode.launcher.main.clearDownloadedApks
class InstallReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val activityIntent = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
if (activityIntent != null) {
context.startActivity(activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
PackageInstaller.STATUS_SUCCESS -> {
clearDownloadedApks(context)
}
else -> {
val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
println("failed to install update: $status, $msg")
val message = context.getString(R.string.launcher_self_update_failed, msg)
Toast.makeText(context, message, Toast.LENGTH_SHORT)
.show()
}
}
}
}
@@ -1,52 +1,18 @@
package com.geode.launcher package com.geode.launcher
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Intent import android.content.Intent
import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.text.format.Formatter
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.*
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -54,58 +20,37 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.geode.launcher.main.* import com.geode.launcher.main.*
import com.geode.launcher.ui.theme.GeodeLauncherTheme import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.ui.theme.Theme.DARK
import com.geode.launcher.ui.theme.Theme.LIGHT
import com.geode.launcher.ui.theme.launcherTitleStyle import com.geode.launcher.ui.theme.launcherTitleStyle
import com.geode.launcher.updater.ReleaseManager import com.geode.launcher.updater.ReleaseViewModel
import com.geode.launcher.utils.Constants import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.GamePackageUtils import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.LaunchUtils import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import java.net.ConnectException
import java.net.UnknownHostException
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
clearDownloadedApks(this)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val gdInstalled = GamePackageUtils.isGameInstalled(packageManager) val gdInstalled = GamePackageUtils.isGameInstalled(packageManager)
val geodeInstalled = LaunchUtils.isGeodeInstalled(this) val geodeInstalled = LaunchUtils.isGeodeInstalled(this)
if (gdInstalled && geodeInstalled && intent.getBooleanExtra("restarted", false)) {
onLaunch(this)
}
enableEdgeToEdge()
clearDownloadedApks(this)
val returnMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_MESSAGE) val returnMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_MESSAGE)
val returnExtendedMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE) val returnExtendedMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE)
@@ -119,19 +64,30 @@ class MainActivity : ComponentActivity() {
LoadFailureInfo(LaunchUtils.LauncherError.CRASHED) LoadFailureInfo(LaunchUtils.LauncherError.CRASHED)
} else { null } } else { null }
var launchArguments: LaunchArguments? = null if (PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.DEVELOPER_SERVER)) {
val intentData = intent.data val intent = Intent(this, DeveloperServerService::class.java)
if (intent.action == Intent.ACTION_VIEW && intentData != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val isLaunch = intentData.path == "/launch"
val safeMode = if (isLaunch) {
intentData.getBooleanQueryParameter("safe-mode", false)
} else false
launchArguments = LaunchArguments( val name = getString(R.string.notification_channel_development_server)
autoSafeMode = safeMode, val descriptionText = getString(R.string.notification_channel_development_server_description)
forcePause = !isLaunch, val importance = NotificationManager.IMPORTANCE_DEFAULT
forceLaunch = isLaunch, val mChannel = NotificationChannel("geode_developer_server", name, importance)
) mChannel.description = descriptionText
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(mChannel)
this.startForegroundService(intent)
}
}
val redirectToAlt = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.ENABLE_REDESIGN)
if (redirectToAlt) {
val launchIntent = Intent(this, AltMainActivity::class.java)
launchIntent.putExtras(intent)
startActivity(launchIntent)
return
} }
setContent { setContent {
@@ -141,631 +97,195 @@ class MainActivity : ComponentActivity() {
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND) val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME) val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
val launchViewModel = viewModel<LaunchViewModel>(factory = LaunchViewModel.Factory)
if (loadFailureInfo != null) {
launchViewModel.loadFailure = loadFailureInfo
}
if (launchArguments != null) {
launchViewModel.launchArguments = launchArguments
}
launchViewModel.beginLaunchFlow()
CompositionLocalProvider(LocalTheme provides theme) { CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) { GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
Surface( Surface(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) { ) {
AltMainScreen(launchViewModel) MainScreen(gdInstalled, geodeInstalled, loadFailureInfo)
} }
} }
} }
} }
if (gdInstalled && geodeInstalled) {
intent.getBooleanExtra("restarted", false).let {
if (it) {
onLaunch(this)
}
} }
} }
data class LaunchStatusInfo(
val title: String?,
val details: String? = null,
val progress: (() -> Float)? = null
)
@Composable
fun LaunchCancelledIcon(cancelReason: LaunchViewModel.LaunchCancelReason, modifier: Modifier = Modifier) {
when (cancelReason) {
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED,
LaunchViewModel.LaunchCancelReason.GAME_MISSING -> Icon(painterResource(R.drawable.icon_error), contentDescription = null, modifier)
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED,
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED_AUTOMATIC,
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> Icon(painterResource(R.drawable.icon_warning), contentDescription = null, modifier)
else -> Icon(painterResource(R.drawable.icon_info), contentDescription = null, modifier)
} }
} }
@Composable @Composable
fun mapCancelReasonToInfo(cancelReason: LaunchViewModel.LaunchCancelReason): LaunchStatusInfo { fun MainScreen(
return when (cancelReason) { gdInstalled: Boolean = true,
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED -> LaunchStatusInfo( geodePreinstalled: Boolean = true,
title = stringResource(R.string.launcher_cancelled_crash) loadFailureInfo: LoadFailureInfo? = null,
) releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED_AUTOMATIC -> LaunchStatusInfo( ) {
title = stringResource(R.string.launcher_cancelled_crash_instant)
)
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> LaunchStatusInfo(
title = stringResource(R.string.geode_download_title)
)
LaunchViewModel.LaunchCancelReason.GAME_MISSING -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_error),
details = stringResource(R.string.game_not_found)
)
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_error),
details = stringResource(R.string.launcher_cancelled_outdated)
)
LaunchViewModel.LaunchCancelReason.AUTOMATIC,
LaunchViewModel.LaunchCancelReason.MANUAL-> LaunchStatusInfo(
title = null
)
/*
else -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_manual)
)
*/
}
}
@Composable
fun mapLaunchStatusToInfo(state: LaunchViewModel.LaunchUIState, inSafeMode: Boolean = false): LaunchStatusInfo {
return when (state) {
is LaunchViewModel.LaunchUIState.Initial,
is LaunchViewModel.LaunchUIState.Working,
is LaunchViewModel.LaunchUIState.Ready -> LaunchStatusInfo(
title = stringResource(R.string.launcher_starting_game),
details = if (inSafeMode) stringResource(R.string.launcher_in_safe_mode) else null
)
is LaunchViewModel.LaunchUIState.UpdateCheck -> LaunchStatusInfo(
title = stringResource(R.string.release_fetch_in_progress)
)
is LaunchViewModel.LaunchUIState.Updating -> {
val context = LocalContext.current val context = LocalContext.current
val downloaded = remember(state.downloaded) { val shouldUpdate by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
Formatter.formatShortFileSize(context, state.downloaded) val autoUpdateState by releaseViewModel.uiState.collectAsState()
}
val outOf = remember(state.outOf) { val geodeJustInstalled = (autoUpdateState as? ReleaseViewModel.ReleaseUIState.Finished)
state.outOf?.run { ?.hasUpdated == true
Formatter.formatShortFileSize(context, this) val geodeInstalled = geodePreinstalled || geodeJustInstalled
var beginLaunch by remember { mutableStateOf(false) }
var launchInSafeMode by remember { mutableStateOf(false) }
LaunchedEffect(shouldUpdate) {
if (shouldUpdate && !releaseViewModel.hasPerformedCheck && gdInstalled) {
releaseViewModel.runReleaseCheck()
} else {
releaseViewModel.useGlobalCheckState()
} }
} }
LaunchStatusInfo( val snackbarHostState = remember { SnackbarHostState() }
title = stringResource(R.string.launcher_downloading_update),
details = stringResource(R.string.launcher_downloading_update_details, downloaded, outOf ?: "…"), // automatically show load failed dialog (but not crash)
progress = { var showErrorInfo by remember { mutableStateOf(
val progress = state.downloaded / state.outOf!!.toDouble() loadFailureInfo != null && loadFailureInfo.title != LaunchUtils.LauncherError.CRASHED
progress.toFloat() )}
}.takeIf { state.outOf != null }
) val hasError = loadFailureInfo != null
} LaunchedEffect(hasError, showErrorInfo) {
is LaunchViewModel.LaunchUIState.Cancelled -> { if (!showErrorInfo && loadFailureInfo != null) {
return mapCancelReasonToInfo(state.reason) showFailureSnackbar(context, loadFailureInfo, snackbarHostState) {
showErrorInfo = true
} }
} }
} }
@Composable if (showErrorInfo && loadFailureInfo != null) {
fun LaunchCancelledBody(statusInfo: LaunchStatusInfo, icon: @Composable () -> Unit, inProgress: Boolean, modifier: Modifier = Modifier) { ErrorInfoSheet(loadFailureInfo, onDismiss = { showErrorInfo = false })
if (statusInfo.title == null && statusInfo.details == null && !inProgress) {
return
} }
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
) { innerPadding ->
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier
horizontalAlignment = Alignment.CenterHorizontally, .padding(innerPadding)
modifier = modifier.width(300.dp) .fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
if (statusInfo.title != null) { Image(
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) { painter = painterResource(id = R.drawable.geode_logo),
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyLarge) { contentDescription = stringResource(R.string.launcher_logo_alt),
icon() modifier = Modifier.size(100.dp, 100.dp)
Spacer(Modifier.size(8.dp)) )
Text(statusInfo.title)
}
}
}
if (inProgress) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
if (statusInfo.details != null) {
Text( Text(
statusInfo.details, stringResource(R.string.launcher_title),
style = MaterialTheme.typography.bodyMedium, style = launcherTitleStyle,
textAlign = TextAlign.Center modifier = Modifier.padding(12.dp)
) )
}
when {
gdInstalled && geodeInstalled -> {
val gdVersion = remember {
GamePackageUtils.getGameVersionCode(context.packageManager)
} }
if (gdVersion < Constants.SUPPORTED_VERSION_CODE_MIN) {
val versionName = remember {
GamePackageUtils.getUnifiedVersionName(context.packageManager)
} }
@Composable LaunchBlockedLabel(stringResource(R.string.game_outdated, versionName))
fun LaunchProgressBody(statusInfo: LaunchStatusInfo, modifier: Modifier = Modifier) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.Start,
modifier = modifier.width(300.dp)
) {
if (statusInfo.title != null) {
Text(statusInfo.title, style = MaterialTheme.typography.bodyLarge)
}
if (statusInfo.progress != null) {
LinearProgressIndicator(
statusInfo.progress,
modifier = Modifier.fillMaxWidth()
)
} else { } else {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) val stopLaunch = releaseViewModel.isInUpdate || hasError
} PlayButton(
stopAutomaticLaunch = stopLaunch,
if (statusInfo.details != null) { blockLaunch = releaseViewModel.isInUpdate,
Text(statusInfo.details, style = MaterialTheme.typography.bodyMedium) onPlayGame = { safeMode ->
} launchInSafeMode = safeMode
} beginLaunch = true
}
@Composable
fun RetryButtonContents(reason: LaunchViewModel.LaunchCancelReason) {
when (reason) {
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED -> {
Icon(
painterResource(R.drawable.icon_resume),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_cancelled_resume))
}
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED_AUTOMATIC,
LaunchViewModel.LaunchCancelReason.MANUAL,
LaunchViewModel.LaunchCancelReason.AUTOMATIC -> {
Icon(
painterResource(R.drawable.icon_play_arrow),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_launch))
}
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> {
Icon(
painterResource(R.drawable.icon_refresh),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_cancelled_retry))
}
// these shouldn't be allowing retries anyways
LaunchViewModel.LaunchCancelReason.GAME_MISSING,
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED -> {}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun LaunchProgressCard(
uiState: LaunchViewModel.LaunchUIState,
crashInfo: LoadFailureInfo?,
onCancel: () -> Unit,
onResume: (safeMode: Boolean) -> Unit,
onMore: () -> Unit,
extraOptions: @Composable () -> Unit,
safeModeEnabled: Boolean,
modifier: Modifier = Modifier
) {
var showSafeModeDialog by remember { mutableStateOf(false) }
val status = mapLaunchStatusToInfo(uiState, safeModeEnabled)
Box(modifier = modifier) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (uiState is LaunchViewModel.LaunchUIState.Cancelled) {
LaunchCancelledBody(
statusInfo = status,
icon = { LaunchCancelledIcon(uiState.reason, Modifier.size(32.dp)) },
inProgress = uiState.inProgress
)
if (uiState.reason.isGameInstallIssue()) {
val context = LocalContext.current
val showDownload = remember { GamePackageUtils.showDownloadBadge(context.packageManager) }
if (showDownload) {
GooglePlayBadge(modifier = Modifier.align(Alignment.CenterHorizontally))
}
}
if (uiState.reason.allowsRetry()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally),
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
LongPressButton(onClick = { onResume(false) }, onLongPress = {
showSafeModeDialog = true
}) {
RetryButtonContents(uiState.reason)
}
if (crashInfo != null) {
FilledTonalButton(onClick = onMore) {
Icon(
painterResource(R.drawable.icon_question_mark),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_cancelled_help_alt))
}
}
extraOptions()
}
}
} else {
LaunchProgressBody(statusInfo = status, modifier = Modifier.padding(top = 16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.width(300.dp)) {
TextButton(onClick = onCancel) {
Text(stringResource(R.string.release_fetch_button_cancel))
}
Spacer(Modifier.weight(1.0f))
extraOptions()
}
}
}
}
if (showSafeModeDialog) {
SafeModeDialog(
onDismiss = {
showSafeModeDialog = false
}, },
onLaunch = {
onResume(true)
showSafeModeDialog = false
}
) )
}
}
@Composable if (gdVersion < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
fun ExtraOptions(onSettings: () -> Unit) { UnsupportedVersionWarning()
IconButton( }
onClick = onSettings, }
}
gdInstalled -> {
Text(
stringResource(R.string.geode_download_title),
modifier = Modifier.padding(12.dp)
)
Row {
Button(
onClick = { releaseViewModel.runReleaseCheck(true) },
enabled = !releaseViewModel.isInUpdate
) { ) {
Icon(
painterResource(R.drawable.icon_download),
contentDescription = stringResource(R.string.launcher_download_icon_alt)
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_download))
}
Spacer(Modifier.size(2.dp))
IconButton(onClick = { onSettings(context) }) {
Icon( Icon(
painterResource(R.drawable.icon_settings), painterResource(R.drawable.icon_settings),
contentDescription = stringResource(R.string.launcher_settings_icon_alt) contentDescription = stringResource(R.string.launcher_settings_icon_alt)
) )
} }
} }
val lightOuterColorGradient = arrayOf(
0.0f to Color(0xfff5b11d),
0.10f to Color(0xfff0834d),
0.24f to Color(0xffe85f6b),
0.49f to Color(0xffc9659e),
1.0f to Color(0xffb9588f)
)
val lightInnerColorGradient = arrayOf(
0.48f to Color(0xfff2ce00),
0.63f to Color(0xfff4b97e),
0.73f to Color(0xfff7b66a),
1.0f to Color(0xffeb8fac)
)
val darkOuterColorGradient = arrayOf(
0.0f to Color(0xfffffdff),
0.08f to Color(0xffF2EAF5),
0.12f to Color(0xffEDE5EF),
0.31f to Color(0xffCDB5CD),
0.49f to Color(0xffBA9BBC),
1.0f to Color(0xff8D7ACF),
)
val darkInnerColorGradient = arrayOf(
0.48f to Color(0xffffffff),
0.63f to Color(0xfff5e4c2),
0.73f to Color(0xffe5c7ad),
1.0f to Color(0xffb790a9),
)
@Preview(name = "non-animated, light")
@Preview(name = "non-animated, dark", uiMode = UI_MODE_NIGHT_YES)
@Composable
fun GeodeLogoPreview() {
val theme = if (isSystemInDarkTheme()) DARK else LIGHT
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme) {
Surface {
GeodeLogo()
}
}
} }
else -> LaunchBlockedLabel(stringResource(R.string.game_not_found))
} }
@Preview(name = "animated, light") UpdateCard(
@Preview(name = "animated, dark", uiMode = UI_MODE_NIGHT_YES) releaseViewModel,
@Composable
fun GeodeLogoAnimatedPreview() {
val theme = if (isSystemInDarkTheme()) DARK else LIGHT
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme) {
Surface {
GeodeLogo(true)
}
}
}
}
@Composable
fun AnimatedLogo(modifier: Modifier = Modifier) {
val theme = LocalTheme.current
val innerColorPalette = remember(theme) {
if (theme == LIGHT) lightInnerColorGradient else darkInnerColorGradient
}
val outerColorPalette = remember(theme) {
if (theme == LIGHT) lightOuterColorGradient else darkOuterColorGradient
}
val infiniteTransition = rememberInfiniteTransition(label = "infinite transition")
val offset by infiniteTransition.animateFloat(
initialValue = -1.0f,
targetValue = 1.0f,
animationSpec = infiniteRepeatable(
animation = tween(2000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "gradient offset"
)
val innerBrush = Brush.linearGradient(
colorStops = innerColorPalette,
start = Offset(0.0f, 0.0f + (165.06f * offset)),
end = Offset(0.0f, 165.06f + (165.06f * offset)),
tileMode = TileMode.Mirror
)
val outerBrush = Brush.linearGradient(
colorStops = outerColorPalette,
start = Offset(0.0f + (84.69f * offset), 0.0f + (165.06f * offset)),
end = Offset(84.69f + (84.69f * offset), 169.61f + (165.06f * offset)),
tileMode = TileMode.Mirror
)
Box(modifier = modifier) {
Icon(
painter = painterResource(id = R.drawable.geode_monochrome_inner),
contentDescription = null,
modifier = Modifier
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
.drawWithContent {
drawContent()
drawRect(
brush = innerBrush,
blendMode = BlendMode.SrcIn
)
}
)
Icon(
painter = painterResource(id = R.drawable.geode_monochrome_outer),
contentDescription = null,
modifier = Modifier
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
.drawWithContent {
drawContent()
drawRect(
brush = outerBrush,
blendMode = BlendMode.SrcIn
)
}
)
}
}
@Composable
fun GeodeLogo(shouldAnimate: Boolean = false, modifier: Modifier = Modifier) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
) {
val theme = LocalTheme.current
Crossfade(
targetState = shouldAnimate,
animationSpec = tween(durationMillis = 1000, easing = FastOutSlowInEasing),
label="logo fade"
) { screen ->
when (screen) {
true -> AnimatedLogo(modifier = Modifier.size(64.dp, 64.dp))
false -> Image(
painterResource(if (theme == LIGHT)
R.drawable.geode_base_light else R.drawable.geode_base
),
contentDescription = null,
modifier = Modifier.size(64.dp, 64.dp)
)
}
}
Text(
stringResource(R.string.launcher_title),
style = launcherTitleStyle,
fontSize = 64.sp,
modifier = Modifier modifier = Modifier
.padding(12.dp) .padding(12.dp)
) )
} }
} }
@Composable if (beginLaunch) {
fun GeodeUpdateIndicator(snackbarHostState: SnackbarHostState, onRetry: () -> Unit) {
val context = LocalContext.current
val resources = LocalResources.current
val releaseState = ReleaseManager.get(context).uiState.collectAsState().value
if (releaseState !is ReleaseManager.ReleaseManagerState.Failure) {
return
}
val message = when (releaseState.exception) {
is UnknownHostException, is ConnectException ->
stringResource(R.string.release_fetch_no_internet_short)
is ReleaseManager.UpdateException -> {
when (releaseState.exception.reason) {
ReleaseManager.UpdateException.Reason.EXTERNAL_FILE_IN_USE -> stringResource(
R.string.release_fetch_external_in_use_short
)
else -> stringResource(R.string.release_fetch_generic_short)
}
}
is CancellationException -> null
else -> stringResource(R.string.release_fetch_generic_short)
}
if (message == null) {
return
}
LaunchedEffect(message) {
val result = snackbarHostState.showSnackbar(
message = message,
actionLabel = resources.getString(R.string.release_fetch_button_retry),
duration = SnackbarDuration.Indefinite,
withDismissAction = true
)
when (result) {
SnackbarResult.ActionPerformed -> {
onRetry()
}
else -> {}
}
}
}
@Composable
fun AltMainScreen(
launchViewModel: LaunchViewModel = viewModel(factory = LaunchViewModel.Factory)
) {
val context = LocalContext.current
val launchUIState by launchViewModel.uiState.collectAsState()
val coroutineScope = rememberCoroutineScope()
var launchInSafeMode by remember {
mutableStateOf(launchViewModel.launchArguments?.autoSafeMode == true)
}
var showErrorInfo by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
Scaffold(
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
modifier = Modifier.safeDrawingPadding()
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(8.dp)
.verticalScroll(rememberScrollState())
) {
GeodeLogo(shouldAnimate = launchUIState.isInProgress())
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
LaunchProgressCard(
launchUIState,
crashInfo = launchViewModel.currentCrashInfo(),
onCancel = {
launchInSafeMode = false
coroutineScope.launch {
launchViewModel.cancelLaunch()
}
},
onResume = { safeMode ->
launchInSafeMode = safeMode
launchViewModel.clearCrashInfo()
coroutineScope.launch {
launchViewModel.beginLaunchFlow(true)
}
},
onMore = {
showErrorInfo = true
},
extraOptions = {
ExtraOptions(
onSettings = {
coroutineScope.launch {
launchViewModel.cancelLaunch(true)
}
onSettings(context)
}
)
},
safeModeEnabled = launchInSafeMode
)
// only show launcher update in a case where a user won't see it ingame
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()
}
GeodeUpdateIndicator(snackbarHostState) {
launchViewModel.retryUpdate()
}
}
}
}
}
}
if (launchUIState is LaunchViewModel.LaunchUIState.Ready) {
UpdateWarning(launchInSafeMode) { UpdateWarning(launchInSafeMode) {
beginLaunch = false
launchInSafeMode = false launchInSafeMode = false
coroutineScope.launch {
launchViewModel.cancelLaunch()
} }
} }
} }
val loadFailure = launchViewModel.currentCrashInfo() @Preview(showSystemUi = true)
if (showErrorInfo && loadFailure != null) { @Composable
ErrorInfoSheet(loadFailure, onDismiss = { showErrorInfo = false }) fun MainScreenLightPreview() {
GeodeLauncherTheme {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
MainScreen()
}
}
}
@Preview(showSystemUi = true)
@Composable
fun MainScreenDarkPreview() {
GeodeLauncherTheme(theme = Theme.DARK) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
MainScreen()
}
}
}
@Preview
@Composable
fun MainScreenNoGeometryDashPreview() {
GeodeLauncherTheme {
MainScreen(gdInstalled = false)
} }
} }
@@ -45,8 +45,6 @@ class CrashViewModel(private val application: Application) : ViewModel() {
private var logJob: Job? = null private var logJob: Job? = null
var hasIndicator = false
private fun getCrashList(): List<CrashDump> { private fun getCrashList(): List<CrashDump> {
val crashDirectory = LaunchUtils.getCrashDirectory(application) val crashDirectory = LaunchUtils.getCrashDirectory(application)
if (!crashDirectory.exists()) { if (!crashDirectory.exists()) {
@@ -58,8 +56,6 @@ class CrashViewModel(private val application: Application) : ViewModel() {
_, name -> name != LaunchUtils.CRASH_INDICATOR_NAME && name != "last-pid" _, name -> name != LaunchUtils.CRASH_INDICATOR_NAME && name != "last-pid"
} ?: return emptyList() } ?: return emptyList()
hasIndicator = File(crashDirectory, LaunchUtils.CRASH_INDICATOR_NAME).exists()
return children return children
.map { .map {
CrashDump( CrashDump(
@@ -70,24 +66,6 @@ class CrashViewModel(private val application: Application) : ViewModel() {
.sortedByDescending { it.lastModified } .sortedByDescending { it.lastModified }
} }
fun clearIndicator() {
viewModelScope.launch(Dispatchers.IO) {
val crashDirectory = LaunchUtils.getCrashDirectory(application)
File(crashDirectory, LaunchUtils.CRASH_INDICATOR_NAME).delete()
hasIndicator = false
}
}
fun createIndicator() {
viewModelScope.launch(Dispatchers.IO) {
val crashDirectory = LaunchUtils.getCrashDirectory(application)
File(crashDirectory, LaunchUtils.CRASH_INDICATOR_NAME).createNewFile()
hasIndicator = true
}
}
fun clearCrashes() { fun clearCrashes() {
logJob?.cancel() logJob?.cancel()
@@ -4,6 +4,9 @@ import kotlin.time.Clock
import kotlin.time.Instant import kotlin.time.Instant
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import okio.Buffer import okio.Buffer
import okio.BufferedSource import okio.BufferedSource
import java.io.IOException import java.io.IOException
@@ -26,10 +29,29 @@ fun BufferedSource.readCString(): String {
return buffer.toString() return buffer.toString()
} }
@Serializable
data class ProcessInformation(val processId: Int, val threadId: Int, val processUid: Int) data class ProcessInformation(val processId: Int, val threadId: Int, val processUid: Int)
@Serializable
enum class LogPriority { enum class LogPriority {
UNKNOWN, DEFAULT, VERBOSE, DEBUG, INFO, WARN, ERROR, FATAL, SILENT; @SerialName("unknown")
UNKNOWN,
@SerialName("default")
DEFAULT,
@SerialName("verbose")
VERBOSE,
@SerialName("debug")
DEBUG,
@SerialName("info")
INFO,
@SerialName("warn")
WARN,
@SerialName("error")
ERROR,
@SerialName("fatal")
FATAL,
@SerialName("silent")
SILENT;
class LogPriorityIterator( class LogPriorityIterator(
start: LogPriority, start: LogPriority,
@@ -116,7 +138,8 @@ enum class LogPriority {
/** /**
* Represents a log entry from logcat. * Represents a log entry from logcat.
*/ */
data class LogLine @OptIn(ExperimentalTime::class) constructor( @Serializable
data class LogLine(
val process: ProcessInformation, val process: ProcessInformation,
val time: Instant, val time: Instant,
val logId: Int, val logId: Int,
@@ -198,11 +221,10 @@ data class LogLine @OptIn(ExperimentalTime::class) constructor(
time = time, time = time,
logId = lid, logId = lid,
tag = tag, tag = tag,
message = message message = message.trimEnd('\u0000')
) )
} }
@OptIn(ExperimentalTime::class)
fun showException(exception: Exception) = LogLine( fun showException(exception: Exception) = LogLine(
process = ProcessInformation(0, 0, 0), process = ProcessInformation(0, 0, 0),
time = Clock.System.now(), time = Clock.System.now(),
@@ -213,15 +235,15 @@ data class LogLine @OptIn(ExperimentalTime::class) constructor(
) )
} }
@OptIn(ExperimentalTime::class) @Transient
val identifier = time.toJavaInstant() val identifier = time.toJavaInstant()
val messageTrimmed by lazy { val messageTrimmed by lazy {
this.message.trim { it <= ' ' } this.message.trim { it <= ' ' }
} }
@OptIn(ExperimentalTime::class)
val formattedTime by lazy { this.time.toLocalDateTime(TimeZone.currentSystemDefault()) } val formattedTime by lazy { this.time.toLocalDateTime(TimeZone.currentSystemDefault()) }
val asSimpleString by lazy { val asSimpleString by lazy {
"$formattedTime [${this.priority.toChar()}/${this.tag}]: ${this.messageTrimmed}" "$formattedTime [${this.priority.toChar()}/${this.tag}]: ${this.messageTrimmed}"
} }
@@ -3,13 +3,16 @@ package com.geode.launcher.main
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.AbsoluteRoundedCornerShape import androidx.compose.foundation.shape.AbsoluteRoundedCornerShape
@@ -24,13 +27,12 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.geode.launcher.R import com.geode.launcher.R
import com.geode.launcher.utils.Constants import com.geode.launcher.utils.Constants
import androidx.core.net.toUri
fun onDownloadGame(context: Context) { fun onDownloadGame(context: Context) {
val appUrl = "https://play.google.com/store/apps/details?id=${Constants.PACKAGE_NAME}" val appUrl = "https://play.google.com/store/apps/details?id=${Constants.PACKAGE_NAME}"
try { try {
val intent = Intent(Intent.ACTION_VIEW).apply { val intent = Intent(Intent.ACTION_VIEW).apply {
data = appUrl.toUri() data = Uri.parse(appUrl)
setPackage("com.android.vending") setPackage("com.android.vending")
} }
context.startActivity(intent) context.startActivity(intent)
@@ -53,6 +55,22 @@ fun GooglePlayBadge(modifier: Modifier = Modifier) {
) )
} }
@Composable
fun StatusIndicator(
icon: @Composable () -> Unit,
text: @Composable () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
ElevatedCard(modifier = modifier, onClick = onClick) {
Row(modifier = Modifier.padding(8.dp)) {
icon()
Spacer(Modifier.size(8.dp))
text()
}
}
}
@Composable @Composable
fun InlineDialog(headline: @Composable () -> Unit, body: @Composable () -> Unit, modifier: Modifier = Modifier) { fun InlineDialog(headline: @Composable () -> Unit, body: @Composable () -> Unit, modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier) { ElevatedCard(modifier = modifier) {
@@ -1,7 +1,6 @@
package com.geode.launcher.main package com.geode.launcher.main
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
@@ -33,14 +32,10 @@ import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
@@ -60,7 +55,6 @@ import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.utils.GamePackageUtils import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.LaunchUtils import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.launch
data class LoadFailureInfo( data class LoadFailureInfo(
val title: LaunchUtils.LauncherError, val title: LaunchUtils.LauncherError,
@@ -213,12 +207,9 @@ fun ErrorInfoDescription(description: String, modifier: Modifier = Modifier) {
@Composable @Composable
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
fun ErrorInfoActions(extraDetails: String?, modifier: Modifier = Modifier) { fun ErrorInfoActions(extraDetails: String?, modifier: Modifier = Modifier) {
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current val context = LocalContext.current
val clipboard = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
val resources = LocalResources.current
FlowRow( FlowRow(
modifier = modifier, modifier = modifier,
horizontalArrangement = Arrangement.spacedBy( horizontalArrangement = Arrangement.spacedBy(
@@ -236,15 +227,7 @@ fun ErrorInfoActions(extraDetails: String?, modifier: Modifier = Modifier) {
} }
FilledTonalButton(onClick = { FilledTonalButton(onClick = {
val clipboardLabel = resources.getString(R.string.application_log_copy_label) clipboardManager.setText(AnnotatedString(extraDetails))
coroutineScope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText(
clipboardLabel, extraDetails
))
)
}
}) { }) {
Icon( Icon(
painterResource(R.drawable.icon_content_copy), painterResource(R.drawable.icon_content_copy),
@@ -4,12 +4,17 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -19,21 +24,26 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.geode.launcher.GeometryDashActivity import com.geode.launcher.GeometryDashActivity
import com.geode.launcher.R import com.geode.launcher.R
import com.geode.launcher.preferences.SettingsActivity import com.geode.launcher.preferences.SettingsActivity
import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.utils.Constants import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.GamePackageUtils import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.GeodeUtils import com.geode.launcher.utils.GeodeUtils
import com.geode.launcher.utils.PreferenceUtils import com.geode.launcher.utils.PreferenceUtils
import com.geode.launcher.utils.useCountdownTimer
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
@@ -103,11 +113,11 @@ fun SafeModeDialog(onDismiss: () -> Unit, onLaunch: () -> Unit) {
AlertDialog( AlertDialog(
icon = { icon = {
Icon( Icon(
painterResource(R.drawable.icon_shield_alert), painterResource(R.drawable.icon_warning),
contentDescription = null contentDescription = stringResource(R.string.launcher_warning_icon_alt)
) )
}, },
title = { Text(stringResource(R.string.preference_launch_safe_mode_confirm_title)) }, title = { Text(stringResource(R.string.safe_mode_enable_title)) },
text = { Text(stringResource(R.string.safe_mode_enable_description)) }, text = { Text(stringResource(R.string.safe_mode_enable_description)) },
dismissButton = { dismissButton = {
TextButton(onClick = onDismiss) { TextButton(onClick = onDismiss) {
@@ -184,6 +194,99 @@ fun LongPressButton(onClick: () -> Unit, onLongPress: () -> Unit, enabled: Boole
) )
} }
@Composable
fun PlayButton(
stopAutomaticLaunch: Boolean,
blockLaunch: Boolean,
onPlayGame: (Boolean) -> Unit
) {
val context = LocalContext.current
var showSafeModeDialog by remember { mutableStateOf(false) }
var launchInSafeMode by remember { mutableStateOf(false) }
val shouldAutomaticallyLaunch by PreferenceUtils.useBooleanPreference(
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY
)
if (shouldAutomaticallyLaunch && !stopAutomaticLaunch && !showSafeModeDialog) {
val countdownTimer = useCountdownTimer(
time = 3000,
onCountdownFinish = { onPlayGame(launchInSafeMode) }
)
if (countdownTimer != 0L) {
Text(
pluralStringResource(
R.plurals.automatically_load_countdown,
countdownTimer.toInt(),
countdownTimer
),
style = Typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
Spacer(Modifier.size(12.dp))
}
}
Row {
LongPressButton(
enabled = !blockLaunch,
onLongPress = {
showSafeModeDialog = true
},
onClick = {
onPlayGame(launchInSafeMode)
}
) {
Icon(
painterResource(R.drawable.icon_play_arrow),
contentDescription = stringResource(R.string.launcher_launch_icon_alt)
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_launch))
}
Spacer(Modifier.size(2.dp))
IconButton(onClick = { onSettings(context) }) {
Icon(
painterResource(R.drawable.icon_settings),
contentDescription = stringResource(R.string.launcher_settings_icon_alt)
)
}
}
if (showSafeModeDialog) {
SafeModeDialog(
onDismiss = {
launchInSafeMode = false
showSafeModeDialog = false
},
onLaunch = {
launchInSafeMode = true
onPlayGame(true)
showSafeModeDialog = false
}
)
}
}
@Composable
fun LaunchBlockedLabel(text: String) {
val context = LocalContext.current
val showDownload = remember { GamePackageUtils.showDownloadBadge(context.packageManager) }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text, textAlign = TextAlign.Center, modifier = Modifier.padding(12.dp))
if (showDownload) {
GooglePlayBadge()
}
}
}
@Composable @Composable
fun UnsupportedVersionWarning(modifier: Modifier = Modifier) { fun UnsupportedVersionWarning(modifier: Modifier = Modifier) {
InlineDialog( InlineDialog(
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.displayCutoutPadding import androidx.compose.foundation.layout.displayCutoutPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
@@ -26,6 +27,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -36,15 +38,14 @@ import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.updater.ReleaseManager import com.geode.launcher.updater.ReleaseManager
import com.geode.launcher.utils.Constants import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.GamePackageUtils import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.GeodeUtils
import com.geode.launcher.utils.PreferenceUtils import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
enum class LaunchNotificationType { enum class LaunchNotificationType {
GEODE_UPDATED, LAUNCHER_UPDATE_AVAILABLE, UPDATE_FAILED, UNSUPPORTED_VERSION, FAILED_LOAD; GEODE_UPDATED, LAUNCHER_UPDATE_AVAILABLE, UPDATE_FAILED, UNSUPPORTED_VERSION;
} }
fun determineDisplayedCards(context: Context, geodeFailed: Boolean = false): List<LaunchNotificationType> { fun determineDisplayedCards(context: Context): List<LaunchNotificationType> {
val cards = mutableListOf<LaunchNotificationType>() val cards = mutableListOf<LaunchNotificationType>()
if (GamePackageUtils.getGameVersionCode(context.packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) { if (GamePackageUtils.getGameVersionCode(context.packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
@@ -52,8 +53,10 @@ fun determineDisplayedCards(context: Context, geodeFailed: Boolean = false): Lis
} }
val releaseManager = ReleaseManager.get(context) val releaseManager = ReleaseManager.get(context)
val availableUpdate = releaseManager.availableLauncherUpdateTag.value val loadAutomatically = PreferenceUtils.get(context).getBoolean(PreferenceUtils.Key.LOAD_AUTOMATICALLY)
if (availableUpdate != null && !releaseManager.dismissedLauncherUpdate) {
val availableUpdate = releaseManager.availableLauncherUpdate.value
if (loadAutomatically && availableUpdate != null) {
cards.add(LaunchNotificationType.LAUNCHER_UPDATE_AVAILABLE) cards.add(LaunchNotificationType.LAUNCHER_UPDATE_AVAILABLE)
} }
@@ -67,10 +70,6 @@ fun determineDisplayedCards(context: Context, geodeFailed: Boolean = false): Lis
cards.add(LaunchNotificationType.UPDATE_FAILED) cards.add(LaunchNotificationType.UPDATE_FAILED)
} }
if (geodeFailed) {
cards.add(LaunchNotificationType.FAILED_LOAD)
}
return cards return cards
} }
@@ -105,30 +104,27 @@ fun NotificationCardFromType(type: LaunchNotificationType) {
OutdatedVersionContent() OutdatedVersionContent()
} }
} }
LaunchNotificationType.FAILED_LOAD -> {
AnimatedNotificationCard {
ForwardCompatibilityContent()
}
}
else -> {} else -> {}
} }
} }
@Composable @Composable
fun LaunchNotification(cards: List<LaunchNotificationType>) { fun LaunchNotification() {
val context = LocalContext.current
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME) val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
val theme = Theme.fromInt(themeOption) val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND) val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val cards = determineDisplayedCards(context)
CompositionLocalProvider(LocalTheme provides theme) { CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) { GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
// surface is not in use, so this is unfortunately not provided // surface is not in use, so this is unfortunately not provided
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
Column( Column(
modifier = if (GeodeUtils.handleSafeArea) modifier = Modifier.displayCutoutPadding()
Modifier.displayCutoutPadding()
else Modifier
) { ) {
cards.forEach { cards.forEach {
NotificationCardFromType(it) NotificationCardFromType(it)
@@ -208,7 +204,6 @@ fun LauncherUpdateContent(modifier: Modifier = Modifier) {
) )
} }
/*
@Composable @Composable
fun UpdateNotificationContent(modifier: Modifier = Modifier) { fun UpdateNotificationContent(modifier: Modifier = Modifier) {
ListItem( ListItem(
@@ -225,7 +220,6 @@ fun UpdateNotificationContent(modifier: Modifier = Modifier) {
modifier = modifier.width(IntrinsicSize.Max) modifier = modifier.width(IntrinsicSize.Max)
) )
} }
*/
@Composable @Composable
fun OutdatedVersionContent(modifier: Modifier = Modifier) { fun OutdatedVersionContent(modifier: Modifier = Modifier) {
@@ -261,22 +255,3 @@ fun UpdateFailedContent(modifier: Modifier = Modifier) {
modifier = modifier.width(IntrinsicSize.Max) modifier = modifier.width(IntrinsicSize.Max)
) )
} }
@Composable
fun ForwardCompatibilityContent(modifier: Modifier = Modifier) {
ListItem(
headlineContent = {
Text(text = stringResource(id = R.string.launcher_notification_forwards_compatibility))
},
leadingContent = {
Icon(
painterResource(R.drawable.icon_warning),
contentDescription = null,
)
},
supportingContent = {
Text(text = stringResource(id = R.string.launcher_notification_forwards_compatibility_description))
},
modifier = modifier.width(IntrinsicSize.Max)
)
}
@@ -18,11 +18,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class LaunchArguments( private const val COOLDOWN_LENGTH_MS = 3000L
val autoSafeMode: Boolean,
val forcePause: Boolean,
val forceLaunch: Boolean
)
class LaunchViewModel(private val application: Application): ViewModel() { class LaunchViewModel(private val application: Application): ViewModel() {
companion object { companion object {
@@ -41,13 +37,7 @@ class LaunchViewModel(private val application: Application): ViewModel() {
private var readyTimer: CountDownTimer? = null private var readyTimer: CountDownTimer? = null
private fun initReadyTimer() { private fun initReadyTimer() {
val cooldownLength = PreferenceUtils.get(application).getInt(PreferenceUtils.Key.WAIT_PERIOD) * 1000L readyTimer = object : CountDownTimer(COOLDOWN_LENGTH_MS, 1000) {
if (cooldownLength == 0L) {
readyTimerPassed = true
return
}
readyTimer = object : CountDownTimer(cooldownLength, 1000) {
override fun onTick(millisUntilFinished: Long) { override fun onTick(millisUntilFinished: Long) {
// no tick necessary // no tick necessary
} }
@@ -65,25 +55,17 @@ class LaunchViewModel(private val application: Application): ViewModel() {
} }
enum class LaunchCancelReason { enum class LaunchCancelReason {
MANUAL, AUTOMATIC, LAST_LAUNCH_CRASHED, LAST_LAUNCH_CRASHED_AUTOMATIC, MANUAL, AUTOMATIC, LAST_LAUNCH_CRASHED, GEODE_NOT_FOUND, GAME_MISSING, GAME_OUTDATED;
GEODE_NOT_FOUND, GAME_MISSING, GAME_OUTDATED;
fun allowsRetry() = when (this) { fun allowsRetry() = when (this) {
MANUAL, MANUAL,
AUTOMATIC, AUTOMATIC,
LAST_LAUNCH_CRASHED, LAST_LAUNCH_CRASHED,
LAST_LAUNCH_CRASHED_AUTOMATIC,
GEODE_NOT_FOUND -> true GEODE_NOT_FOUND -> true
GAME_MISSING, GAME_MISSING,
GAME_OUTDATED -> false GAME_OUTDATED -> false
} }
fun isCrash() = when (this) {
LAST_LAUNCH_CRASHED,
LAST_LAUNCH_CRASHED_AUTOMATIC -> true
else -> false
}
fun isGameInstallIssue() = when (this) { fun isGameInstallIssue() = when (this) {
GAME_OUTDATED, GAME_OUTDATED,
GAME_MISSING -> true GAME_MISSING -> true
@@ -98,30 +80,18 @@ class LaunchViewModel(private val application: Application): ViewModel() {
data class Cancelled(val reason: LaunchCancelReason, val inProgress: Boolean = false) : LaunchUIState() data class Cancelled(val reason: LaunchCancelReason, val inProgress: Boolean = false) : LaunchUIState()
data object Working : LaunchUIState() data object Working : LaunchUIState()
data object Ready : LaunchUIState() data object Ready : LaunchUIState()
fun isInProgress() = when (this) {
is Ready,
is Working,
is Updating,
is UpdateCheck -> true
else -> false
}
} }
private val _uiState = MutableStateFlow<LaunchUIState>(LaunchUIState.Initial) private val _uiState = MutableStateFlow<LaunchUIState>(LaunchUIState.Initial)
val uiState = _uiState.asStateFlow() val uiState = _uiState.asStateFlow()
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdateTag val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdate
var loadFailure: LoadFailureInfo? = null var loadFailure: LoadFailureInfo? = null
var launchArguments: LaunchArguments? = null
private var hasCheckedForUpdates = false private var hasCheckedForUpdates = false
private var isCancelling = false private var isCancelling = false
private var hasManuallyStarted = false private var hasManuallyStarted = false
private var restoreUpdateState: LaunchUIState? = null
private suspend fun determineUpdateStatus() { private suspend fun determineUpdateStatus() {
ReleaseManager.get(application).uiState.takeWhile { ReleaseManager.get(application).uiState.takeWhile {
it is ReleaseManager.ReleaseManagerState.InUpdateCheck || it is ReleaseManager.ReleaseManagerState.InDownload it is ReleaseManager.ReleaseManagerState.InUpdateCheck || it is ReleaseManager.ReleaseManagerState.InDownload
@@ -136,19 +106,12 @@ class LaunchViewModel(private val application: Application): ViewModel() {
// flow terminates once update check is finished // flow terminates once update check is finished
hasCheckedForUpdates = true hasCheckedForUpdates = true
val currentState = restoreUpdateState
if (currentState != null) {
_uiState.emit(currentState)
return
}
preReadyCheck() preReadyCheck()
} }
fun currentCrashInfo(): LoadFailureInfo? { fun currentCrashInfo(): LoadFailureInfo? {
val currentState = _uiState.value val currentState = _uiState.value
if (currentState is LaunchUIState.Cancelled && currentState.reason.isCrash()) { if (currentState is LaunchUIState.Cancelled && currentState.reason == LaunchCancelReason.LAST_LAUNCH_CRASHED) {
return loadFailure return loadFailure
} }
@@ -157,12 +120,12 @@ class LaunchViewModel(private val application: Application): ViewModel() {
fun clearCrashInfo() { fun clearCrashInfo() {
val currentState = _uiState.value val currentState = _uiState.value
if (currentState is LaunchUIState.Cancelled && currentState.reason.isCrash()) { if (currentState is LaunchUIState.Cancelled && currentState.reason == LaunchCancelReason.LAST_LAUNCH_CRASHED) {
loadFailure = null loadFailure = null
} }
} }
private fun preReadyCheck() { private suspend fun preReadyCheck() {
if (isCancelling) { if (isCancelling) {
// don't let the game start if cancelled // don't let the game start if cancelled
return return
@@ -171,8 +134,7 @@ class LaunchViewModel(private val application: Application): ViewModel() {
val packageManager = application.packageManager val packageManager = application.packageManager
if (!GamePackageUtils.isGameInstalled(packageManager)) { if (!GamePackageUtils.isGameInstalled(packageManager)) {
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING)) return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
return
} }
if (ReleaseManager.get(application).isInUpdate) { if (ReleaseManager.get(application).isInUpdate) {
@@ -182,48 +144,32 @@ class LaunchViewModel(private val application: Application): ViewModel() {
return return
} }
// last launch crashed, so cancel
if (loadFailure != null) { if (loadFailure != null) {
// show a different message if this is the first thing users see // last launch crashed, so cancel
val cancelType = if (_uiState.value == LaunchUIState.Initial) { return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.LAST_LAUNCH_CRASHED))
LaunchCancelReason.LAST_LAUNCH_CRASHED_AUTOMATIC
} else LaunchCancelReason.LAST_LAUNCH_CRASHED
_uiState.tryEmit(LaunchUIState.Cancelled(cancelType))
return
} }
if (GamePackageUtils.getGameVersionCode(packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN) { if (GamePackageUtils.getGameVersionCode(packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN) {
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_OUTDATED)) return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_OUTDATED))
return
} }
if (!LaunchUtils.isGeodeInstalled(application)) { if (!LaunchUtils.isGeodeInstalled(application)) {
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GEODE_NOT_FOUND)) return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GEODE_NOT_FOUND))
return
}
// if forcing immediate launch, then act as if it's manually started (skips timers)
val forceImmediate = launchArguments?.forceLaunch == true
if (forceImmediate) {
hasManuallyStarted = true
} }
val loadAutomatically = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.LOAD_AUTOMATICALLY) val loadAutomatically = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.LOAD_AUTOMATICALLY)
val forcePause = launchArguments?.forcePause == true if (!loadAutomatically && !hasManuallyStarted) {
if (!hasManuallyStarted && (forcePause || !loadAutomatically)) {
return cancelLaunch(true) return cancelLaunch(true)
} }
if (!readyTimerPassed && !hasManuallyStarted) { if (!readyTimerPassed && !hasManuallyStarted) {
_uiState.tryEmit(LaunchUIState.Working) return _uiState.emit(LaunchUIState.Working)
return
} }
_uiState.tryEmit(LaunchUIState.Ready) _uiState.emit(LaunchUIState.Ready)
} }
fun beginLaunchFlow(isRestart: Boolean = false) { suspend fun beginLaunchFlow(isRestart: Boolean = false) {
if (isRestart) { if (isRestart) {
hasManuallyStarted = true hasManuallyStarted = true
} }
@@ -239,44 +185,23 @@ class LaunchViewModel(private val application: Application): ViewModel() {
isCancelling = false isCancelling = false
if (!GamePackageUtils.isGameInstalled(application.packageManager)) { if (!GamePackageUtils.isGameInstalled(application.packageManager)) {
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING)) return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
return
} }
if (readyTimer == null) { if (readyTimer == null) {
initReadyTimer() initReadyTimer()
} }
val releaseManager = ReleaseManager.get(application)
val hasGeode = LaunchUtils.isGeodeInstalled(application) val hasGeode = LaunchUtils.isGeodeInstalled(application)
val shouldUpdate = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY) val shouldUpdate = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
if ((shouldUpdate && !hasCheckedForUpdates) || !hasGeode) { if ((shouldUpdate && !hasCheckedForUpdates) || !hasGeode) {
releaseManager.checkForUpdates(false) ReleaseManager.get(application).checkForUpdates(false)
} else {
releaseManager.afterUseCachedUpdate()
}
if (isRestart) {
releaseManager.dismissedLauncherUpdate = true
} }
preReadyCheck() preReadyCheck()
} }
fun retryUpdate() { suspend fun cancelLaunch(isAutomatic: Boolean = false) {
ReleaseManager.get(application).checkForUpdates(true)
if (ReleaseManager.get(application).isInUpdate) {
restoreUpdateState = _uiState.value
viewModelScope.launch {
determineUpdateStatus()
}
return
}
}
fun cancelLaunch(isAutomatic: Boolean = false) {
// no need to double cancel // no need to double cancel
if (_uiState.value is LaunchUIState.Cancelled) { if (_uiState.value is LaunchUIState.Cancelled) {
return return
@@ -285,17 +210,14 @@ class LaunchViewModel(private val application: Application): ViewModel() {
val reason = if (isAutomatic) LaunchCancelReason.AUTOMATIC else LaunchCancelReason.MANUAL val reason = if (isAutomatic) LaunchCancelReason.AUTOMATIC else LaunchCancelReason.MANUAL
isCancelling = true isCancelling = true
_uiState.emit(LaunchUIState.Cancelled(reason, true))
val releaseManager = ReleaseManager.get(application) val releaseManager = ReleaseManager.get(application)
if (releaseManager.isInUpdate) { if (releaseManager.isInUpdate) {
_uiState.tryEmit(LaunchUIState.Cancelled(reason, true))
viewModelScope.launch {
releaseManager.cancelUpdate() releaseManager.cancelUpdate()
} }
}
_uiState.tryEmit(LaunchUIState.Cancelled(reason, false)) _uiState.emit(LaunchUIState.Cancelled(reason, false))
} }
override fun onCleared() { override fun onCleared() {
@@ -1,25 +1,24 @@
package com.geode.launcher.main package com.geode.launcher.main
import android.app.PendingIntent import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageInstaller import android.net.Uri
import android.os.Build import android.provider.DocumentsContract
import android.text.format.Formatter
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -28,17 +27,16 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -47,30 +45,26 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.geode.launcher.InstallReceiver
import com.geode.launcher.R import com.geode.launcher.R
import com.geode.launcher.main.LauncherUpdater.downloadUpdate import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.ui.theme.Typography import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.updater.ReleaseManager import com.geode.launcher.updater.ReleaseManager
import com.geode.launcher.updater.ReleaseViewModel
import com.geode.launcher.utils.LaunchUtils import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils import com.geode.launcher.utils.PreferenceUtils
import com.mikepenz.markdown.compose.LocalBulletListHandler import com.mikepenz.markdown.compose.LocalBulletListHandler
import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownTypography import com.mikepenz.markdown.m3.markdownTypography
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
import java.io.File import java.io.File
import java.net.ConnectException
import java.net.UnknownHostException
import java.text.DateFormat import java.text.DateFormat
import java.util.Date import java.util.Date
import kotlin.time.ExperimentalTime
import kotlin.time.toJavaInstant import kotlin.time.toJavaInstant
fun clearDownloadedApks(context: Context) { fun clearDownloadedApks(context: Context) {
@@ -90,220 +84,74 @@ fun clearDownloadedApks(context: Context) {
it.delete() it.delete()
} }
context.filesDir.listFiles { _, name -> name.lowercase().endsWith(".apk") }?.forEach {
it.delete()
}
preferenceUtils.setBoolean(PreferenceUtils.Key.CLEANUP_APKS, false) preferenceUtils.setBoolean(PreferenceUtils.Key.CLEANUP_APKS, false)
} }
// lazy singleton idc fun generateInstallIntent(uri: Uri): Intent {
object LauncherUpdater { // maybe one day i'll rewrite this to use packageinstaller. not today
enum class LauncherUpdateState { return Intent(Intent.ACTION_INSTALL_PACKAGE).apply {
Began, setDataAndType(uri, "application/vnd.android.package-archive")
Downloading, addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
Active,
Inactive,
Finished;
}
private val _uiState = MutableStateFlow<LauncherUpdateState>(LauncherUpdateState.Inactive)
val uiState = _uiState.asStateFlow()
suspend fun resetState() {
_uiState.emit(LauncherUpdateState.Inactive)
}
suspend fun downloadUpdate(context: Context): Result<File> {
_uiState.emit(LauncherUpdateState.Downloading)
return withContext(Dispatchers.IO) {
ReleaseManager.get(context)
.downloadLatestLauncherUpdate()
} }
} }
suspend fun installPackage(context: Context, file: File) { fun installLauncherUpdate(context: Context) {
_uiState.emit(LauncherUpdateState.Began) val nextUpdate = ReleaseManager.get(context).availableLauncherUpdate.value
val launcherDownload = nextUpdate?.getDownload()
val installer = context.packageManager.packageInstaller if (launcherDownload != null) {
val outputFile = launcherDownload.filename
val baseDirectory = LaunchUtils.getBaseDirectory(context)
installer.registerSessionCallback(object : PackageInstaller.SessionCallback() { val outputPathFile = File(baseDirectory, outputFile)
override fun onActiveChanged(sessionId: Int, active: Boolean) { if (!outputPathFile.exists()) {
if (active) {
_uiState.tryEmit(LauncherUpdateState.Active)
} else {
_uiState.tryEmit(LauncherUpdateState.Began)
}
}
override fun onBadgingChanged(sessionId: Int) {}
override fun onCreated(sessionId: Int) {
_uiState.tryEmit(LauncherUpdateState.Began)
}
override fun onFinished(sessionId: Int, success: Boolean) {
_uiState.tryEmit(LauncherUpdateState.Finished)
}
override fun onProgressChanged(sessionId: Int, progress: Float) {
_uiState.tryEmit(LauncherUpdateState.Active)
}
})
withContext(Dispatchers.IO) { runInterruptible {
file.inputStream().use { apkStream ->
val length = file.length()
val params = PackageInstaller.SessionParams(
PackageInstaller.SessionParams.MODE_FULL_INSTALL,
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
}
val sessionId = installer.createSession(params)
installer.openSession(sessionId).use { session ->
session.openWrite("package", 0, length).use { sessionStream ->
apkStream.copyTo(sessionStream)
session.fsync(sessionStream)
}
val intent = Intent(context, InstallReceiver::class.java)
val pi = PendingIntent.getBroadcast(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
session.commit(pi.intentSender)
}
}
}}
}
}
suspend fun installLauncherUpdate(context: Context) {
val launcherDownload = downloadUpdate(context)
.getOrElse { e ->
e.printStackTrace()
Toast.makeText(
context, context.getString(
R.string.launcher_update_download_failed,
e.cause
), Toast.LENGTH_SHORT
).show()
return return
} }
if (!launcherDownload.exists()) { val outputPath = "${UserDirectoryProvider.ROOT}${outputFile}"
return
} val uri = DocumentsContract.buildDocumentUri("${context.packageName}.user", outputPath)
PreferenceUtils.get(context).setBoolean(PreferenceUtils.Key.CLEANUP_APKS, true) PreferenceUtils.get(context).setBoolean(PreferenceUtils.Key.CLEANUP_APKS, true)
try { try {
LauncherUpdater.installPackage(context, launcherDownload) val intent = generateInstallIntent(uri)
} catch (e: Exception) { context.startActivity(intent)
e.printStackTrace() } catch (_: ActivityNotFoundException) {
val msg = context.getString(R.string.launcher_self_update_failed, e.message) // if it fails to install, just open it in the browser
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() try {
val downloadUrl = launcherDownload.url
LauncherUpdater.resetState() val downloadIntent = Intent(Intent.ACTION_VIEW).apply {
} data = Uri.parse(downloadUrl)
} }
@Composable context.startActivity(downloadIntent)
fun LauncherUpdateDialog(isCancelling: Boolean, onDismiss: () -> Unit) { } catch (_: ActivityNotFoundException) {
val updateState by LauncherUpdater.uiState.collectAsState() Toast.makeText(context, context.getString(R.string.no_activity_found), Toast.LENGTH_SHORT).show()
LaunchedEffect(updateState) {
if (updateState == LauncherUpdater.LauncherUpdateState.Finished) {
onDismiss()
} }
} }
AlertDialog(
icon = {
Icon(
painterResource(R.drawable.icon_update),
contentDescription = null
)
},
title = {
if (isCancelling) {
Text(stringResource(R.string.release_fetch_cancelling))
} else if (updateState == LauncherUpdater.LauncherUpdateState.Downloading) {
Text(stringResource(R.string.launcher_downloading_update))
} else { } else {
Text(stringResource(R.string.launcher_self_update_progress)) Toast.makeText(context, context.getString(R.string.release_fetch_no_releases), Toast.LENGTH_SHORT).show()
} }
},
text = {
LinearProgressIndicator()
},
confirmButton = {
TextButton(onClick = onDismiss, enabled = !isCancelling) {
Text(stringResource(R.string.message_box_cancel))
}
},
onDismissRequest = {}
)
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class)
@Composable @Composable
fun LauncherUpdateInformation(onDismiss: () -> Unit) { fun LauncherUpdateInformation(onDismiss: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val releaseManager = remember { ReleaseManager.get(context) } val nextRelease = remember { ReleaseManager.get(context).availableLauncherUpdate.value }
val nextReleaseName by releaseManager.availableLauncherUpdateTag.collectAsState()
val nextRelease by releaseManager.availableLauncherUpdateDetails.collectAsState()
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
val coroutineScope = rememberCoroutineScope()
var updateJob by remember { mutableStateOf<Job?>(null) } if (nextRelease != null) {
var cancellingUpdate by remember { mutableStateOf(false) }
var beginUpdate by remember { mutableStateOf(false) }
if (beginUpdate) {
LauncherUpdateDialog(cancellingUpdate, onDismiss = {
cancellingUpdate = true
coroutineScope.launch {
updateJob?.cancelAndJoin()
beginUpdate = false
}
})
}
LaunchedEffect(Unit) {
if (nextReleaseName != null && nextRelease == null) {
coroutineScope.launch { withContext(Dispatchers.IO) {
ReleaseManager.get(context)
.checkLauncherUpdate()
}}
}
}
val nextReleaseNameSaved = nextReleaseName
if (nextReleaseNameSaved != null) {
ModalBottomSheet(onDismissRequest = { onDismiss() }, sheetState = sheetState) { ModalBottomSheet(onDismissRequest = { onDismiss() }, sheetState = sheetState) {
Box(Modifier.verticalScroll(rememberScrollState())) { Box(Modifier.verticalScroll(rememberScrollState())) {
Column(modifier = Modifier.padding(horizontal = 24.dp)) { Column(modifier = Modifier.padding(horizontal = 24.dp)) {
Text( Text(
stringResource(R.string.launcher_update_name, nextReleaseNameSaved), stringResource(R.string.launcher_update_name, nextRelease.release.tagName),
style = Typography.headlineSmall, style = Typography.headlineSmall,
) )
val nextRelease = nextRelease
if (nextRelease != null) {
val releasedTime = remember { val releasedTime = remember {
DateFormat.getDateInstance().format( DateFormat.getDateInstance().format(
Date.from(nextRelease.release.publishedAt.toJavaInstant()) Date.from(nextRelease.release.publishedAt.toJavaInstant())
@@ -317,14 +165,7 @@ fun LauncherUpdateInformation(onDismiss: () -> Unit) {
Row(modifier = Modifier.padding(vertical = 12.dp)) { Row(modifier = Modifier.padding(vertical = 12.dp)) {
Button( Button(
onClick = { onClick = { installLauncherUpdate(context) },
updateJob = coroutineScope.launch {
cancellingUpdate = false
beginUpdate = true
installLauncherUpdate(context)
}
},
) { ) {
Icon(painterResource(R.drawable.icon_download), contentDescription = null) Icon(painterResource(R.drawable.icon_download), contentDescription = null)
Spacer(Modifier.size(ButtonDefaults.IconSpacing)) Spacer(Modifier.size(ButtonDefaults.IconSpacing))
@@ -366,16 +207,6 @@ fun LauncherUpdateInformation(onDismiss: () -> Unit) {
) )
} }
} }
} else {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp)
) {
CircularProgressIndicator()
}
}
Spacer(modifier = Modifier.size(4.dp)) Spacer(modifier = Modifier.size(4.dp))
} }
@@ -387,7 +218,6 @@ fun LauncherUpdateInformation(onDismiss: () -> Unit) {
@Composable @Composable
fun LauncherUpdateIndicator(modifier: Modifier = Modifier) { fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
val context = LocalContext.current val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
var showInfoDialog by remember { mutableStateOf(false) } var showInfoDialog by remember { mutableStateOf(false) }
@@ -397,22 +227,6 @@ fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
} }
} }
var updateJob by remember { mutableStateOf<Job?>(null) }
var cancellingUpdate by remember { mutableStateOf(false) }
var beginUpdate by remember { mutableStateOf(false) }
if (beginUpdate) {
LauncherUpdateDialog(cancellingUpdate) {
cancellingUpdate = true
coroutineScope.launch {
updateJob?.cancelAndJoin()
updateJob = null
beginUpdate = false
}
}
}
ElevatedCard(modifier) { ElevatedCard(modifier) {
Column( Column(
// buttons add enough padding already, lower to compensate // buttons add enough padding already, lower to compensate
@@ -437,14 +251,7 @@ fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
} }
TextButton( TextButton(
onClick = { onClick = { installLauncherUpdate(context) },
updateJob = coroutineScope.launch {
cancellingUpdate = false
beginUpdate = true
installLauncherUpdate(context)
}
},
) { ) {
Text(stringResource(R.string.launcher_install)) Text(stringResource(R.string.launcher_install))
} }
@@ -452,3 +259,174 @@ fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
} }
} }
} }
@Composable
fun UpdateProgressIndicator(
message: String,
releaseViewModel: ReleaseViewModel,
modifier: Modifier = Modifier,
progress: (() -> Float)? = null
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start,
modifier = modifier
) {
Text(message, style = MaterialTheme.typography.labelMedium)
Spacer(modifier = Modifier.padding(4.dp))
if (progress == null) {
LinearProgressIndicator()
} else {
LinearProgressIndicator(progress = progress)
}
TextButton(
onClick = {
releaseViewModel.cancelUpdate()
},
modifier = Modifier.offset((-12).dp)
) {
Text(stringResource(R.string.release_fetch_button_cancel))
}
}
}
@Composable
fun UpdateMessageIndicator(
message: String,
releaseViewModel: ReleaseViewModel,
modifier: Modifier = Modifier,
allowRetry: Boolean = false
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
Text(
message,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.padding(4.dp))
if (allowRetry) {
OutlinedButton(
onClick = {
releaseViewModel.runReleaseCheck(true)
},
) {
Icon(
painterResource(R.drawable.icon_refresh),
contentDescription = stringResource(R.string.launcher_retry_icon_alt)
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.release_fetch_button_retry))
}
}
}
}
@Composable
fun UpdateCard(releaseViewModel: ReleaseViewModel, modifier: Modifier = Modifier) {
val context = LocalContext.current
val releaseState by releaseViewModel.uiState.collectAsState()
when (val state = releaseState) {
is ReleaseViewModel.ReleaseUIState.Failure -> {
val message = when (state.exception) {
is UnknownHostException, is ConnectException ->
stringResource(R.string.release_fetch_no_internet)
is ReleaseManager.UpdateException -> {
when (state.exception.reason) {
ReleaseManager.UpdateException.Reason.EXTERNAL_FILE_IN_USE -> stringResource(
R.string.release_fetch_manual_check_required
)
else -> null
}
}
else -> null
}
UpdateMessageIndicator(
stringResource(
R.string.release_fetch_failed,
message ?: stringResource(R.string.release_fetch_try_later)
),
modifier = modifier,
allowRetry = true,
releaseViewModel = releaseViewModel
)
}
is ReleaseViewModel.ReleaseUIState.InDownload -> {
val downloaded = remember(state.downloaded) {
Formatter.formatShortFileSize(context, state.downloaded)
}
val outOf = remember(state.outOf) {
state.outOf?.run {
Formatter.formatShortFileSize(context, this)
}
}
UpdateProgressIndicator(
if (outOf != null) stringResource(
R.string.release_fetch_downloading,
downloaded,
outOf
) else stringResource(
R.string.release_fetch_downloading_indeterminate,
downloaded,
),
modifier = modifier,
releaseViewModel = releaseViewModel,
progress = {
val progress = state.downloaded / state.outOf!!.toDouble()
progress.toFloat()
}.takeIf { state.outOf != null }
)
}
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
UpdateProgressIndicator(
stringResource(R.string.release_fetch_in_progress),
modifier = modifier,
releaseViewModel = releaseViewModel
)
}
is ReleaseViewModel.ReleaseUIState.Finished -> {
if (state.hasUpdated) {
UpdateMessageIndicator(
stringResource(R.string.release_fetch_success),
modifier = modifier,
releaseViewModel = releaseViewModel
)
}
}
is ReleaseViewModel.ReleaseUIState.Cancelled -> {
if (state.isCancelling) {
UpdateProgressIndicator(
stringResource(R.string.release_fetch_cancelling),
modifier = modifier,
releaseViewModel = releaseViewModel
)
} else {
UpdateMessageIndicator(
stringResource(R.string.release_fetch_cancelled),
modifier = modifier,
allowRetry = true,
releaseViewModel = releaseViewModel
)
}
}
}
val nextUpdate by releaseViewModel.nextLauncherUpdate.collectAsState()
if (!releaseViewModel.isInUpdate && nextUpdate != null) {
LauncherUpdateIndicator(
modifier = modifier
)
}
}
@@ -19,11 +19,8 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -32,7 +29,6 @@ import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -87,40 +83,6 @@ class ApplicationIconActivity : ComponentActivity() {
} }
} }
@Composable
fun IconSelectConfirmDialog(selectedIcon: ApplicationIconDetails, onDismissRequest: () -> Unit) {
AlertDialog(
icon = {
Image(
adaptiveIconPainterResource(selectedIcon.iconId),
contentDescription = null
)
},
title = {
Text(stringResource(
R.string.application_icon_confirm,
stringResource(selectedIcon.nameId)
))
},
text = {
Text(stringResource(R.string.application_icon_confirm_description))
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
confirmButton = {
val context = LocalContext.current
TextButton(onClick = { onSelectIcon(context, selectedIcon) }) {
Text(stringResource(R.string.message_box_accept))
}
},
onDismissRequest = onDismissRequest
)
}
fun onSelectIcon(context: Context, selectedIcon: ApplicationIconDetails) { fun onSelectIcon(context: Context, selectedIcon: ApplicationIconDetails) {
val packageManager = context.packageManager val packageManager = context.packageManager
@@ -145,14 +107,7 @@ fun onSelectIcon(context: Context, selectedIcon: ApplicationIconDetails) {
@Composable @Composable
fun IconCard(iconData: ApplicationIconDetails, selected: Boolean, modifier: Modifier = Modifier) { fun IconCard(iconData: ApplicationIconDetails, selected: Boolean, modifier: Modifier = Modifier) {
var showSelection by remember { mutableStateOf(false) } val context = LocalContext.current
if (showSelection) {
IconSelectConfirmDialog(iconData) {
showSelection = false
}
}
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -162,8 +117,7 @@ fun IconCard(iconData: ApplicationIconDetails, selected: Boolean, modifier: Modi
.selectable( .selectable(
selected = selected, selected = selected,
onClick = { onClick = {
if (!selected) onSelectIcon(context, iconData)
showSelection = true
}, },
role = Role.RadioButton role = Role.RadioButton
) )
@@ -197,6 +151,8 @@ fun ApplicationIconScreen(
) { ) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
var showMoreOptions by remember { mutableStateOf(false) }
Scaffold( Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = { topBar = {
@@ -225,8 +181,7 @@ fun ApplicationIconScreen(
Column( Column(
Modifier Modifier
.padding(innerPadding) .padding(innerPadding)
.fillMaxWidth() .fillMaxWidth(),
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.SpaceEvenly, verticalArrangement = Arrangement.SpaceEvenly,
) { ) {
val selectedIcon by PreferenceUtils.useStringPreference(PreferenceUtils.Key.SELECTED_ICON) val selectedIcon by PreferenceUtils.useStringPreference(PreferenceUtils.Key.SELECTED_ICON)
@@ -1,6 +1,6 @@
package com.geode.launcher.preferences package com.geode.launcher.preferences
import android.content.ClipData import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@@ -45,13 +45,14 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@@ -145,10 +146,7 @@ fun SelectLogLevelDialog(
@Composable @Composable
fun LogCard(logLine: LogLine, modifier: Modifier = Modifier) { fun LogCard(logLine: LogLine, modifier: Modifier = Modifier) {
val haptics = LocalHapticFeedback.current val haptics = LocalHapticFeedback.current
val clipboard = LocalClipboard.current val clipboardManager = LocalClipboardManager.current
val resources = LocalResources.current
val coroutineScope = rememberCoroutineScope()
Column( Column(
modifier = modifier modifier = modifier
@@ -157,17 +155,7 @@ fun LogCard(logLine: LogLine, modifier: Modifier = Modifier) {
detectTapGestures( detectTapGestures(
onLongPress = { onLongPress = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) haptics.performHapticFeedback(HapticFeedbackType.LongPress)
clipboardManager.setText(AnnotatedString(logLine.asSimpleString))
val clipboardLabel = resources.getString(R.string.application_log_copy_label)
val clipboardData = logLine.asSimpleString
coroutineScope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText(
clipboardLabel, clipboardData
))
)
}
} }
) )
}, },
@@ -51,6 +51,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@@ -232,42 +233,6 @@ fun CrashLogsScreen(
showMoreOptions = false showMoreOptions = false
} }
) )
val developerMode by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
if (developerMode) {
if (crashViewModel.hasIndicator) {
DropdownMenuItem(
leadingIcon = {
Icon(
painterResource(R.drawable.icon_remove_selection),
contentDescription = null
)
},
text = {
Text(stringResource(R.string.application_crashes_clear_indicator))
}, onClick = {
crashViewModel.clearIndicator()
showMoreOptions = false
}
)
} else {
DropdownMenuItem(
leadingIcon = {
Icon(
painterResource(R.drawable.icon_skull),
contentDescription = null
)
},
text = {
Text(stringResource(R.string.application_crashes_create_indicator))
}, onClick = {
crashViewModel.createIndicator()
showMoreOptions = false
}
)
}
}
} }
}, },
title = { title = {
@@ -20,7 +20,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -36,10 +38,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.geode.launcher.R import com.geode.launcher.R
import com.geode.launcher.ui.theme.GeodeLauncherTheme import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme import com.geode.launcher.ui.theme.LocalTheme
@@ -191,11 +193,21 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
description = LaunchUtils.getBaseDirectory(context).path, description = LaunchUtils.getBaseDirectory(context).path,
onClick = { onOpenFolder(context) } onClick = { onOpenFolder(context) }
) )
SettingsCard(
title = stringResource(R.string.preference_development_server_name),
description = stringResource(R.string.preference_development_server_description),
preferenceKey = PreferenceUtils.Key.DEVELOPER_SERVER,
) { enabled ->
if (enabled) {
} else {
}
}
} }
OptionsGroup(title = stringResource(R.string.preference_category_updater)) { OptionsGroup(title = stringResource(R.string.preference_category_updater)) {
val context = LocalContext.current
SettingsSelectCard( SettingsSelectCard(
title = stringResource(R.string.preference_release_channel_tag_name), title = stringResource(R.string.preference_release_channel_tag_name),
dialogTitle = stringResource(R.string.preference_release_channel_select), dialogTitle = stringResource(R.string.preference_release_channel_select),
@@ -204,38 +216,15 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
0 to stringResource(R.string.preference_release_channel_stable), 0 to stringResource(R.string.preference_release_channel_stable),
1 to stringResource(R.string.preference_release_channel_beta), 1 to stringResource(R.string.preference_release_channel_beta),
2 to stringResource(R.string.preference_release_channel_nightly), 2 to stringResource(R.string.preference_release_channel_nightly),
),
extraSelectBehavior = {
PreferenceUtils.get(context)
.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, 0L)
}
) )
SettingsCard(
title = stringResource(R.string.preference_disable_update_cache),
preferenceKey = PreferenceUtils.Key.DISABLE_UPDATE_CACHE
) )
} }
OptionsGroup(title = stringResource(R.string.preference_category_testing)) { OptionsGroup(title = stringResource(R.string.preference_category_testing)) {
SettingsSelectCard(
title = stringResource(R.string.preference_launch_delay),
dialogTitle = stringResource(R.string.preference_launch_delay_select),
preferenceKey = PreferenceUtils.Key.WAIT_PERIOD,
options = linkedMapOf(
0 to stringResource(R.string.preference_launch_delay_instant),
1 to pluralStringResource(R.plurals.preference_launch_delay_value, 1, 1),
3 to pluralStringResource(R.plurals.preference_launch_delay_value, 3, 3),
5 to pluralStringResource(R.plurals.preference_launch_delay_value, 5, 5),
)
)
/*
SettingsCard( SettingsCard(
title = stringResource(R.string.preference_disable_redesign), title = stringResource(R.string.preference_enable_redesign),
preferenceKey = PreferenceUtils.Key.DISABLE_REDESIGN, preferenceKey = PreferenceUtils.Key.ENABLE_REDESIGN,
) )
*/
} }
OptionsGroup(title = stringResource(R.string.preference_profiles)) { OptionsGroup(title = stringResource(R.string.preference_profiles)) {
@@ -1,482 +0,0 @@
package com.geode.launcher.preferences
import android.content.Context
import android.util.Log
import android.util.Xml
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import com.geode.launcher.R
import com.geode.launcher.utils.LaunchUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okhttp3.FormBody
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
import okio.Buffer
import okio.ByteString.Companion.decodeBase64
import okio.GzipSource
import okio.buffer
import okio.source
import org.xmlpull.v1.XmlPullParser
import java.io.File
import kotlin.experimental.xor
import kotlin.time.Duration.Companion.seconds
const val TAG = "com.geode.launcher.SaveBackup"
data class UserDetails(
val userName: String,
val gjp2: String,
val accountId: Int,
val uuid: Int,
val udid: String,
val binaryVersion: Int,
)
enum class BackupParseFailure {
SAVE_MISSING,
DECODE_FAILED,
DETAILS_MISSING,
SAVE_TOO_LARGE
}
class BackupParseException(val failureCode: BackupParseFailure) : Exception("parse error: $failureCode")
enum class BackupUploadFailure {
GENERIC_ERROR,
NETWORK_ERROR,
INVALID_LOGIN
}
class BackupUploadException(val failureCode: BackupUploadFailure, val gjCode: Int = -1) : Exception("upload error: $failureCode")
suspend fun getAccountBackupUrl(backupClient: OkHttpClient, details: UserDetails): Result<HttpUrl> {
return try {
// accountID=%i&type=1&secret=Wmfd2893gb7
val body = FormBody.Builder()
.add("secret", "Wmfd2893gb7")
.add("type", "1")
.add("accountID", details.accountId.toString())
.build()
val request = Request.Builder()
.url("https://www.boomlings.com/database/getAccountURL.php")
.addHeader("User-Agent", "")
.post(body)
.build()
val call = backupClient.newCall(request)
call.executeAsync()
} catch (e: Exception) {
Log.w(TAG, "getAccountBackupUrl failed: ${e.stackTraceToString()}")
return Result.failure(BackupUploadException(BackupUploadFailure.NETWORK_ERROR))
}.use { response ->
if (response.code != 200) {
return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
}
val url = response.body.string().toHttpUrlOrNull()
?: return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
Result.success(url)
}
}
fun decryptFileToBuffer(file: File, buffer: Buffer) = file.source().buffer()
.use { baseBuffer ->
val key = 0xb.toByte()
val nullByte = 0x0.toByte()
while (!baseBuffer.exhausted()) {
val b = baseBuffer.readByte()
val d = b.xor(key)
// gd sometimes encodes garbage after null byte, treat it like a c string
if (d == nullByte)
break
buffer.writeByte(d.toInt())
}
}
fun generateSaveString(gameManager: File, localLevels: File): String = Buffer()
.use { saveBuffer ->
decryptFileToBuffer(gameManager, saveBuffer)
saveBuffer.writeByte(';'.code)
decryptFileToBuffer(localLevels, saveBuffer)
saveBuffer.readUtf8()
}
fun generateBackupRequest(details: UserDetails, url: HttpUrl, gameManager: File, localLevels: File): Request {
val saveData = generateSaveString(gameManager, localLevels)
// %s/database/accounts/backupGJAccountNew.php
val backupUrl = url.newBuilder()
.addPathSegments("database/accounts/backupGJAccountNew.php")
.build()
// gameVersion=22&binaryVersion=%i&dvs=2&udid=%s&uuid=%i&accountID=%i&gjp2=%s&secret=Wmfv3899gc9&saveData=%s
val body = FormBody.Builder()
.add("gameVersion", "22")
.add("binaryVersion", details.binaryVersion.toString())
.add("dvs", "2")
.add("udid", details.udid)
.add("uuid", details.uuid.toString())
.add("accountID", details.accountId.toString())
.add("secret", "Wmfv3899gc9")
.add("gjp2", details.gjp2)
.add("saveData", saveData)
.build()
return Request.Builder()
.url(backupUrl)
.addHeader("User-Agent", "")
.post(body)
.build()
}
suspend fun performBackupRequest(backupClient: OkHttpClient, details: UserDetails, url: HttpUrl, gameManager: File, localLevels: File): Result<Unit> {
return try {
// we split this into multiple functions to free up as many references as we can
// go little gc go
backupClient.newCall(
generateBackupRequest(details, url, gameManager, localLevels)
).executeAsync()
} catch (e: Exception) {
Log.w(TAG, "performBackupRequest failed: ${e.stackTraceToString()}")
return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
}.use { response ->
if (response.code != 200) {
return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
}
val responseBody = response.body.string().toIntOrNull()
?: return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
when (responseBody) {
1 -> Result.success(Unit)
-5 -> Result.failure(BackupUploadException(BackupUploadFailure.INVALID_LOGIN, responseBody))
else -> Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR, responseBody))
}
}
}
fun skip(parser: XmlPullParser) {
if (parser.eventType != XmlPullParser.START_TAG) {
throw IllegalStateException()
}
var depth = 1
while (depth != 0) {
when (parser.next()) {
XmlPullParser.END_TAG -> depth--
XmlPullParser.START_TAG -> depth++
}
}
}
fun getNextValue(parser: XmlPullParser): String? {
while (parser.eventType != XmlPullParser.START_TAG && parser.eventType != XmlPullParser.END_DOCUMENT) {
parser.next()
}
if (parser.eventType == XmlPullParser.START_TAG) {
// we could support t/f here but we also don't have to
if (parser.name != "s" && parser.name != "i") {
return null
}
return parser.nextText()
}
return null
}
fun parseSaveFile(parser: XmlPullParser): Result<UserDetails> = try {
var userName: String? = null
var gjp2: String? = null
var accountId: Int? = null
var uuid: Int? = null
var udid: String? = null
var binaryVersion: Int? = null
var inPlist = false
var inDict = false
var eventType = parser.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
when (eventType) {
XmlPullParser.START_TAG -> {
if (inDict) {
// ignore inner elements, just in case they conflict
if (parser.name != "k") {
skip(parser)
} else {
val keyName = parser.nextText()
when (keyName) {
"GJA_001" -> userName = getNextValue(parser)
"GJA_003" -> accountId = getNextValue(parser)?.toIntOrNull()
"GJA_005" -> gjp2 = getNextValue(parser)
"playerUserID" -> uuid = getNextValue(parser)?.toIntOrNull()
"playerUDID" -> udid = getNextValue(parser)
"binaryVersion" -> binaryVersion = getNextValue(parser)?.toIntOrNull()
}
}
}
// traverse to initial plist -> dict element
if (!inPlist && parser.name == "plist") {
inPlist = true
}
if (!inDict && parser.name == "dict") {
inDict = true
}
}
}
eventType = parser.next()
}
if (userName == null || gjp2 == null || accountId == null || uuid == null || udid == null || binaryVersion == null) {
Result.failure(BackupParseException(BackupParseFailure.DETAILS_MISSING))
} else {
Result.success(
UserDetails(
userName = userName,
gjp2 = gjp2,
accountId = accountId,
uuid = uuid,
udid = udid,
binaryVersion = binaryVersion
)
)
}
} catch (e: Exception) {
Log.w(TAG, "parseSaveFile failed: ${e.stackTraceToString()}")
Result.failure(BackupParseException(BackupParseFailure.DECODE_FAILED))
}
fun decryptFileToString(file: File) = Buffer()
.use { decryptedBuffer ->
decryptFileToBuffer(file, decryptedBuffer)
decryptedBuffer.readUtf8()
}
suspend fun getUserDetails(gameManager: File): Result<UserDetails> {
return try {
val base64Decoded = run {
// free decryptString after decoding
val decryptString = decryptFileToString(gameManager)
decryptString.decodeBase64()
} ?: return Result.failure(BackupParseException(BackupParseFailure.DECODE_FAILED))
// gzipSource will close the underlying buffer when closed
GzipSource(
Buffer().write(base64Decoded)
).buffer().use { gzipSource ->
runInterruptible {
val parser = Xml.newPullParser()
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
parser.setInput(gzipSource.inputStream(), null)
parseSaveFile(parser)
}
}
} catch (e: Exception) {
Log.w(TAG, "getUserDetails failed: ${e.stackTraceToString()}")
return Result.failure(BackupParseException(BackupParseFailure.DECODE_FAILED))
}
}
const val MB_TO_BYTES = 1048576 // 2^20
suspend fun backupSaveToCloud(context: Context): Result<Unit> {
val baseDirectory = LaunchUtils.getBaseDirectory(context)
val gameManager = File(baseDirectory,"/save/CCGameManager.dat")
val localLevels = File(baseDirectory, "/save/CCLocalLevels.dat")
if (!gameManager.isFile || !localLevels.isFile) {
return Result.failure(BackupParseException(BackupParseFailure.SAVE_MISSING))
}
// hardcode 32mb max size
// this is how the game checks it, which probably isn't the best
val totalSize = (gameManager.length() + localLevels.length()) / MB_TO_BYTES.toDouble()
println("Backup - calculated size of %.2fmb".format(totalSize))
if (totalSize > 32.0) {
return Result.failure(BackupParseException(BackupParseFailure.SAVE_TOO_LARGE))
}
return withContext(Dispatchers.IO) {
val userDetails = getUserDetails(gameManager).getOrElse {
return@withContext Result.failure(it)
}
println("Backup - performing as ${userDetails.userName}, accountID=${userDetails.accountId}")
val backupClient = OkHttpClient.Builder()
.writeTimeout(500.seconds)
.build()
val backupUrl = getAccountBackupUrl(backupClient, userDetails).getOrElse {
return@withContext Result.failure(it)
}
performBackupRequest(backupClient, userDetails, backupUrl, gameManager, localLevels).getOrElse {
return@withContext Result.failure(it)
}
println("Backup completed!")
Result.success(Unit)
}
}
@Composable
fun BackupConfirmDialog(onDismiss: () -> Unit, onEnable: () -> Unit) {
AlertDialog(
icon = {
Icon(
painterResource(R.drawable.icon_cloud_alert),
contentDescription = null,
)
},
title = { Text(stringResource(R.string.preference_backup_confirm_title)) },
text = { Text(stringResource(R.string.preference_backup_confirm_description)) },
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.message_box_cancel))
}
},
confirmButton = {
TextButton(onClick = onEnable) {
Text(stringResource(R.string.message_box_continue))
}
},
onDismissRequest = onDismiss
)
}
fun messageFromResult(context: Context, result: Result<Unit>): String {
val exception = result.exceptionOrNull()
?: return context.getString(R.string.preference_backup_success)
return when (exception) {
is BackupParseException -> when (exception.failureCode) {
BackupParseFailure.SAVE_MISSING -> context.getString(R.string.preference_backup_save_missing)
BackupParseFailure.DECODE_FAILED -> context.getString(R.string.preference_backup_save_decode_failed)
BackupParseFailure.DETAILS_MISSING -> context.getString(R.string.preference_backup_save_details_missing)
BackupParseFailure.SAVE_TOO_LARGE -> context.getString(R.string.preference_backup_save_too_large)
}
is BackupUploadException -> when (exception.failureCode) {
BackupUploadFailure.GENERIC_ERROR -> context.getString(R.string.preference_backup_failed, exception.gjCode)
BackupUploadFailure.NETWORK_ERROR -> context.getString(R.string.preference_backup_failed_network)
BackupUploadFailure.INVALID_LOGIN -> context.getString(R.string.preference_backup_failed_invalid_login)
}
else -> context.getString(R.string.preference_backup_failed_generic)
}
}
@Composable
fun BackupDialog(onDismiss: () -> Unit, isCancelling: Boolean) {
AlertDialog(
icon = {
Icon(
painterResource(R.drawable.icon_cloud_upload),
contentDescription = null
)
},
title = {
if (isCancelling) {
Text(stringResource(R.string.preference_backup_cancelling))
} else {
Text(stringResource(R.string.preference_backup_in_progress))
}
},
text = {
LinearProgressIndicator()
},
confirmButton = {
TextButton(onClick = onDismiss, enabled = !isCancelling) {
Text(stringResource(R.string.message_box_cancel))
}
},
onDismissRequest = {}
)
}
@Composable
fun BackupButton(snackbarHostState: SnackbarHostState) {
val context = LocalContext.current
var showBackupDialog by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
var backupInProgress by remember { mutableStateOf(false) }
var backupJob by remember { mutableStateOf<Job?>(null) }
OptionsButton(
title = stringResource(R.string.preference_backup_cloud),
icon = {
Icon(
painterResource(R.drawable.icon_cloud_upload),
contentDescription = null
)
},
) {
showBackupDialog = true
}
if (showBackupDialog) {
BackupConfirmDialog(onDismiss = {
showBackupDialog = false
}) {
showBackupDialog = false
backupJob = coroutineScope.launch {
backupInProgress = true
val responseStatus = backupSaveToCloud(context)
backupInProgress = false
snackbarHostState.showSnackbar(messageFromResult(context, responseStatus))
}
}
}
var isCancelling by remember { mutableStateOf(false) }
if (backupInProgress) {
BackupDialog(onDismiss = {
coroutineScope.launch {
isCancelling = true
backupJob?.cancelAndJoin()
isCancelling = false
backupInProgress = false
}
}, isCancelling)
}
}
@@ -7,7 +7,6 @@ import android.content.Intent
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.text.format.DateUtils
import android.view.WindowManager import android.view.WindowManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@@ -19,29 +18,10 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.*
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -61,16 +41,14 @@ import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.geode.launcher.BuildConfig import com.geode.launcher.BuildConfig
import com.geode.launcher.MainActivity
import com.geode.launcher.R import com.geode.launcher.R
import com.geode.launcher.UserDirectoryProvider import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.updater.ReleaseViewModel
import com.geode.launcher.ui.theme.GeodeLauncherTheme import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.updater.ReleaseViewModel
import com.geode.launcher.utils.ApplicationIcon import com.geode.launcher.utils.ApplicationIcon
import com.geode.launcher.utils.Constants import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.GamePackageUtils import com.geode.launcher.utils.GamePackageUtils
@@ -80,8 +58,6 @@ import com.geode.launcher.utils.PreferenceUtils
import java.net.ConnectException import java.net.ConnectException
import java.net.UnknownHostException import java.net.UnknownHostException
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.time.Clock
class SettingsActivity : ComponentActivity() { class SettingsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -131,7 +107,7 @@ fun onOpenFileManager(context: Context) {
try { try {
context.startActivity(intent) context.startActivity(intent)
} catch (_: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
Toast.makeText( Toast.makeText(
context, context,
context.getText(R.string.no_activity_found), Toast.LENGTH_SHORT context.getText(R.string.no_activity_found), Toast.LENGTH_SHORT
@@ -261,10 +237,73 @@ fun DeveloperModeDialog(onDismiss: () -> Unit, onEnable: () -> Unit) {
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun GeneralSettingsGroup() { fun SettingsScreen(
onBackPressedDispatcher: OnBackPressedDispatcher?,
releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
) {
val context = LocalContext.current val context = LocalContext.current
val resources = LocalResources.current
val currentRelease by PreferenceUtils.useStringPreference(PreferenceUtils.Key.CURRENT_VERSION_TAG)
val updateStatus by releaseViewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
var showUpdateInProgress by remember { mutableStateOf(false) }
if (showUpdateInProgress) {
LaunchedEffect(snackbarHostState) {
snackbarHostState.showSnackbar(
resources.getString(R.string.preference_check_for_updates_already_updating)
)
showUpdateInProgress = false
}
}
// fix theme transition by giving it the exact same animation as the top bar
val containerColor by animateColorAsState(
targetValue = MaterialTheme.colorScheme.background,
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
label = "background color"
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
containerColor = containerColor,
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
Icon(
painterResource(R.drawable.icon_arrow_back),
contentDescription = stringResource(R.string.back_icon_alt)
)
}
},
title = {
Text(
stringResource(R.string.title_activity_settings),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.background),
)
},
content = { innerPadding ->
Column(
Modifier
.padding(innerPadding)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
OptionsGroup(stringResource(R.string.preference_category_testing)) { OptionsGroup(stringResource(R.string.preference_category_testing)) {
SettingsSelectCard( SettingsSelectCard(
title = stringResource(R.string.preference_theme_name), title = stringResource(R.string.preference_theme_name),
@@ -319,11 +358,6 @@ fun GeneralSettingsGroup() {
onClick = { onOpenFileManager(context) } onClick = { onOpenFileManager(context) }
) )
} }
}
@Composable
fun GameplaySettingsGroup() {
val context = LocalContext.current
OptionsGroup(stringResource(R.string.preference_category_gameplay)) { OptionsGroup(stringResource(R.string.preference_category_gameplay)) {
SettingsCard( SettingsCard(
@@ -395,51 +429,30 @@ fun GameplaySettingsGroup() {
maxFrameRate = maxFrameRate maxFrameRate = maxFrameRate
) )
} }
}
@Composable
fun UpdateSettingsGroup(releaseViewModel: ReleaseViewModel, snackbarHostState: SnackbarHostState, onUpdate: () -> Unit) {
val updateStatus by releaseViewModel.uiState.collectAsState()
OptionsGroup(stringResource(R.string.preference_category_updater)) { OptionsGroup(stringResource(R.string.preference_category_updater)) {
SettingsCard( SettingsCard(
title = stringResource(R.string.preference_update_automatically_name), title = stringResource(R.string.preference_update_automatically_name),
preferenceKey = PreferenceUtils.Key.UPDATE_AUTOMATICALLY, preferenceKey = PreferenceUtils.Key.UPDATE_AUTOMATICALLY,
) )
val lastUpdateCheck by PreferenceUtils.useLongPreference(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME)
val checkAgoString = remember(lastUpdateCheck) {
if (lastUpdateCheck != 0L) {
val now = Clock.System.now().toEpochMilliseconds()
DateUtils.getRelativeTimeSpanString(
lastUpdateCheck,
now,
DateUtils.MINUTE_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE
)
} else {
"never"
}
}
OptionsCard( OptionsCard(
title = { title = {
OptionsTitle( OptionsTitle(
title = stringResource(R.string.preference_check_for_updates_button), title = stringResource(R.string.preference_check_for_updates_button),
icon = { description = stringResource(
Icon( R.string.preference_check_for_updates_description,
painterResource(R.drawable.icon_update), currentRelease ?: "unknown"
contentDescription = null
) )
},
description = stringResource(R.string.preference_check_for_updates_description, checkAgoString)
.takeIf { lastUpdateCheck != 0L }
) )
}, },
modifier = Modifier modifier = Modifier
.clickable( .clickable(
onClick = { onClick = {
onUpdate() if (releaseViewModel.isInUpdate) {
showUpdateInProgress = true
} else {
releaseViewModel.runReleaseCheck(true)
}
}, },
role = Role.Button role = Role.Button
) )
@@ -447,11 +460,8 @@ fun UpdateSettingsGroup(releaseViewModel: ReleaseViewModel, snackbarHostState: S
UpdateIndicator(snackbarHostState, updateStatus) UpdateIndicator(snackbarHostState, updateStatus)
} }
} }
}
@Composable val developerModeEnabled by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
fun AdvancedSettingsGroup(developerModeEnabled: Boolean, snackbarHostState: SnackbarHostState) {
val context = LocalContext.current
OptionsGroup(stringResource(R.string.preference_category_developer)) { OptionsGroup(stringResource(R.string.preference_category_developer)) {
if (developerModeEnabled) { if (developerModeEnabled) {
@@ -480,43 +490,7 @@ fun AdvancedSettingsGroup(developerModeEnabled: Boolean, snackbarHostState: Snac
context.startActivity(launchIntent) context.startActivity(launchIntent)
} }
) )
var showSafeModeDialog by remember { mutableStateOf(false) }
OptionsButton(
title = stringResource(R.string.preference_launch_safe_mode),
icon = {
Icon(
painterResource(R.drawable.icon_shield),
contentDescription = null
)
} }
) {
showSafeModeDialog = true
}
if (showSafeModeDialog) {
SafeModeDialog(onDismiss = {
showSafeModeDialog = false
}) {
val launchIntent = Intent(context, MainActivity::class.java).apply {
// hardcode it, why not
action = Intent.ACTION_VIEW
data = "geode-launcher://main/launch?safe-mode=true".toUri()
}
context.startActivity(launchIntent)
}
}
BackupButton(snackbarHostState)
}
}
@Composable
fun AboutSettingsGroup(developerModeEnabled: Boolean) {
val context = LocalContext.current
val resources = LocalResources.current
val currentRelease by PreferenceUtils.useStringPreference(PreferenceUtils.Key.CURRENT_VERSION_TAG)
OptionsGroup(stringResource(R.string.preference_category_about)) { OptionsGroup(stringResource(R.string.preference_category_about)) {
var launcherButtonTapped by remember { mutableIntStateOf(0) } var launcherButtonTapped by remember { mutableIntStateOf(0) }
@@ -529,17 +503,13 @@ fun AboutSettingsGroup(developerModeEnabled: Boolean) {
) { ) {
launcherButtonTapped += 1 launcherButtonTapped += 1
if (launcherButtonTapped >= 7) { if (launcherButtonTapped >= 7 && !developerModeEnabled) {
if (developerModeEnabled) {
Toast.makeText(context, R.string.preference_enable_developer_again, Toast.LENGTH_SHORT)
.show()
} else {
showDeveloperDialog = true showDeveloperDialog = true
}
launcherButtonTapped = 0 launcherButtonTapped = 0
} }
} }
val context = LocalContext.current
if (showDeveloperDialog) { if (showDeveloperDialog) {
DeveloperModeDialog( DeveloperModeDialog(
onDismiss = { onDismiss = {
@@ -588,86 +558,6 @@ fun AboutSettingsGroup(developerModeEnabled: Boolean) {
) )
} }
} }
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBackPressedDispatcher: OnBackPressedDispatcher?,
releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
) {
val resources = LocalResources.current
val snackbarHostState = remember { SnackbarHostState() }
var showUpdateInProgress by remember { mutableStateOf(false) }
if (showUpdateInProgress) {
LaunchedEffect(snackbarHostState) {
snackbarHostState.showSnackbar(
resources.getString(R.string.preference_check_for_updates_already_updating)
)
showUpdateInProgress = false
}
}
// fix theme transition by giving it the exact same animation as the top bar
val containerColor by animateColorAsState(
targetValue = MaterialTheme.colorScheme.background,
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
label = "background color"
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
containerColor = containerColor,
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
Icon(
painterResource(R.drawable.icon_arrow_back),
contentDescription = stringResource(R.string.back_icon_alt)
)
}
},
title = {
Text(
stringResource(R.string.title_activity_settings),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.background),
)
},
content = { innerPadding ->
Column(
Modifier
.padding(innerPadding)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
GeneralSettingsGroup()
GameplaySettingsGroup()
UpdateSettingsGroup(releaseViewModel, snackbarHostState, onUpdate = {
if (releaseViewModel.isInUpdate) {
showUpdateInProgress = true
} else {
releaseViewModel.runReleaseCheck(true)
}
})
val developerModeEnabled by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
AdvancedSettingsGroup(developerModeEnabled, snackbarHostState)
AboutSettingsGroup(developerModeEnabled)
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
} }
@@ -20,7 +20,6 @@ import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -757,8 +756,8 @@ fun ProfileSelectCard() {
.setCurrentProfile(selectedProfileId) .setCurrentProfile(selectedProfileId)
activity?.run { activity?.run {
packageManager.getLaunchIntentForPackage(packageName)?.also { intent -> packageManager.getLaunchIntentForPackage(packageName)?.also {
val mainIntent = Intent.makeRestartActivityTask(intent.component) val mainIntent = Intent.makeRestartActivityTask(it.component)
startActivity(mainIntent) startActivity(mainIntent)
exitProcess(0) exitProcess(0)
} }
@@ -926,7 +925,7 @@ fun OptionsLabel(title: String, description: String? = null, icon: (@Composable
} }
@Composable @Composable
fun SettingsCard(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, asCard: Boolean = true, preferenceKey: PreferenceUtils.Key) { fun SettingsCard(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, asCard: Boolean = true, preferenceKey: PreferenceUtils.Key, onChange: ((Boolean) -> Unit)? = null) {
val context = LocalContext.current val context = LocalContext.current
val settingEnabled = remember { val settingEnabled = remember {
mutableStateOf(getSetting(context, preferenceKey)) mutableStateOf(getSetting(context, preferenceKey))
@@ -942,7 +941,10 @@ fun SettingsCard(title: String, description: String? = null, icon: (@Composable
}, },
modifier = Modifier.toggleable( modifier = Modifier.toggleable(
value = settingEnabled.value, value = settingEnabled.value,
onValueChange = { settingEnabled.value = toggleSetting(context, preferenceKey) }, onValueChange = {
settingEnabled.value = toggleSetting(context, preferenceKey)
onChange?.invoke(settingEnabled.value)
},
role = Role.Switch, role = Role.Switch,
), ),
backgroundColor = if (asCard) backgroundColor = if (asCard)
@@ -958,7 +960,7 @@ fun SettingsCard(title: String, description: String? = null, icon: (@Composable
@Composable @Composable
fun OptionsTitle(modifier: Modifier = Modifier, title: String, description: String? = null, icon: (@Composable () -> Unit)? = null) { fun OptionsTitle(modifier: Modifier = Modifier, title: String, description: String? = null, icon: (@Composable () -> Unit)? = null) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (icon != null) { if (icon != null) {
icon() icon()
} }
@@ -1011,31 +1013,6 @@ fun OptionsCard(
} }
} }
@Composable
fun SafeModeDialog(onDismiss: () -> Unit, onLaunch: () -> Unit) {
AlertDialog(
icon = {
Icon(
painterResource(R.drawable.icon_shield_alert),
contentDescription = null
)
},
title = { Text(stringResource(R.string.safe_mode_enable_title)) },
text = { Text(stringResource(R.string.safe_mode_enable_description)) },
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.message_box_cancel))
}
},
confirmButton = {
TextButton(onClick = onLaunch) {
Text(stringResource(R.string.message_box_continue))
}
},
onDismissRequest = onDismiss
)
}
@Composable @Composable
fun InlineText(label: String, icon: @Composable (() -> Unit)? = null, modifier: Modifier = Modifier) { fun InlineText(label: String, icon: @Composable (() -> Unit)? = null, modifier: Modifier = Modifier) {
LabelledText(label = label, icon = icon, modifier = modifier.padding(horizontal = 16.dp, vertical = 4.dp)) LabelledText(label = label, icon = icon, modifier = modifier.padding(horizontal = 16.dp, vertical = 4.dp))
@@ -48,19 +48,8 @@ data class LoaderPayload<T>(
data class DownloadableAsset(val url: String, val filename: String, val size: Long? = null) data class DownloadableAsset(val url: String, val filename: String, val size: Long? = null)
abstract class Downloadable { abstract class Downloadable {
/**
* Human-readable representation of the current version.
*/
abstract fun getDescription(): String abstract fun getDescription(): String
/**
* Unique value that refers to the current version, used for comparison.
*/
abstract fun getDescriptor(): Long abstract fun getDescriptor(): Long
/**
* Release download link for the current platform
*/
abstract fun getDownload(): DownloadableAsset? abstract fun getDownload(): DownloadableAsset?
} }
@@ -21,10 +21,7 @@ import okhttp3.OkHttpClient
import okio.buffer import okio.buffer
import okio.source import okio.source
import java.io.File import java.io.File
import java.io.FileNotFoundException
import java.io.InterruptedIOException import java.io.InterruptedIOException
import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes
private const val TAG_LATEST = "latest" private const val TAG_LATEST = "latest"
private const val TAG_BETA = "prerelease" private const val TAG_BETA = "prerelease"
@@ -85,13 +82,8 @@ class ReleaseManager private constructor(
val isInUpdate: Boolean val isInUpdate: Boolean
get() = _uiState.value !is ReleaseManagerState.Failure && _uiState.value !is ReleaseManagerState.Finished get() = _uiState.value !is ReleaseManagerState.Failure && _uiState.value !is ReleaseManagerState.Finished
private val _availableLauncherUpdateTag = MutableStateFlow<String?>(null) private val _availableLauncherUpdate = MutableStateFlow<DownloadableLauncherRelease?>(null)
val availableLauncherUpdateTag = _availableLauncherUpdateTag.asStateFlow() val availableLauncherUpdate = _availableLauncherUpdate.asStateFlow()
private val _availableLauncherUpdateDetails = MutableStateFlow<DownloadableLauncherRelease?>(null)
val availableLauncherUpdateDetails = _availableLauncherUpdateDetails.asStateFlow()
var dismissedLauncherUpdate = false
private fun sendError(e: Exception) { private fun sendError(e: Exception) {
_uiState.value = ReleaseManagerState.Failure(e) _uiState.value = ReleaseManagerState.Failure(e)
@@ -139,27 +131,32 @@ class ReleaseManager private constructor(
} }
} }
private suspend fun downloadLauncherUpdate(release: Downloadable): Result<File> { private suspend fun downloadLauncherUpdate(release: Downloadable) {
val download = release.getDownload() val download = release.getDownload() ?: return
?: return Result.failure(FileNotFoundException())
val outputDirectory = applicationContext.filesDir val outputDirectory = LaunchUtils.getBaseDirectory(applicationContext)
val outputFile = File(outputDirectory, download.filename) val outputFile = File(outputDirectory, download.filename)
try { if (outputFile.exists()) {
DownloadUtils.downloadStream( // only download the apk once
httpClient, return
download.url
).use { fileStream ->
outputFile.outputStream().use {
fileStream.copyTo(it)
}
}
} catch (e: Exception) {
return Result.failure(e)
} }
return Result.success(outputFile) _uiState.value = ReleaseManagerState.InDownload(0, download.size)
try {
val fileStream = DownloadUtils.downloadStream(
httpClient,
download.url
) { progress, outOf ->
_uiState.value = ReleaseManagerState.InDownload(progress, outOf)
}
fileStream.copyTo(outputFile.outputStream())
} catch (e: Exception) {
sendError(e)
return
}
} }
private suspend fun performUpdate(release: Downloadable) { private suspend fun performUpdate(release: Downloadable) {
@@ -212,6 +209,8 @@ class ReleaseManager private constructor(
} catch (_: Exception) { } } catch (_: Exception) { }
} }
downloadLauncherUpdateIfNecessary()
// extraction performed // extraction performed
updatePreferences(release) updatePreferences(release)
_uiState.value = ReleaseManagerState.Finished(true) _uiState.value = ReleaseManagerState.Finished(true)
@@ -234,73 +233,18 @@ class ReleaseManager private constructor(
return originalFileHash != currentFileHash return originalFileHash != currentFileHash
} }
private suspend fun getLatestLauncherUpdate(): DownloadableLauncherRelease? { private fun checkLauncherUpdate(launcherUpdate: DownloadableLauncherRelease) {
return try { if (launcherUpdate.release.tagName != BuildConfig.VERSION_NAME) {
releaseRepository.getLatestLauncherRelease() _availableLauncherUpdate.value = launcherUpdate
} catch (e: Exception) {
sendError(e)
return null
} }
} }
suspend fun checkLauncherUpdate() { private suspend fun downloadLauncherUpdateIfNecessary() {
val launcherRelease = getLatestLauncherUpdate() ?: return val update = _availableLauncherUpdate.value ?: return
downloadLauncherUpdate(update)
// unconditionally track it, we're not using it for checks and it might be good to have
_availableLauncherUpdateDetails.value = launcherRelease
val launcherTag = launcherRelease.release.tagName
if (launcherTag != BuildConfig.VERSION_NAME) {
_availableLauncherUpdateTag.value = launcherTag
PreferenceUtils.get(applicationContext)
.setString(PreferenceUtils.Key.LAST_LAUNCHER_UPDATE, launcherTag)
}
}
fun checkCachedLauncherUpdate() {
val lastUpdate = PreferenceUtils.get(applicationContext)
.getString(PreferenceUtils.Key.LAST_LAUNCHER_UPDATE) ?: return
if (lastUpdate != BuildConfig.VERSION_NAME) {
_availableLauncherUpdateTag.value = lastUpdate
}
}
suspend fun downloadLatestLauncherUpdate(): Result<File> {
val update = _availableLauncherUpdateDetails.value
?: getLatestLauncherUpdate()
?: return Result.failure(FileNotFoundException())
return downloadLauncherUpdate(update)
}
fun shouldUseCache(): Boolean {
val sharedPreferences = PreferenceUtils.get(applicationContext)
if (sharedPreferences.getBoolean(PreferenceUtils.Key.DISABLE_UPDATE_CACHE)) {
return false
}
val lastCheckTime = sharedPreferences.getLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME)
// only check for updates if it's been over 15 minutes since last check
val checkMinTime = Clock.System.now().minus(15.minutes).toEpochMilliseconds()
return lastCheckTime > checkMinTime && !fileWasExternallyModified()
}
fun afterUseCachedUpdate() {
checkCachedLauncherUpdate()
_uiState.tryEmit(ReleaseManagerState.Finished())
} }
private suspend fun checkForNewRelease(isManual: Boolean = false) { private suspend fun checkForNewRelease(isManual: Boolean = false) {
val sharedPreferences = PreferenceUtils.get(applicationContext)
if (!isManual && shouldUseCache()) {
afterUseCachedUpdate()
return
}
val release = try { val release = try {
getLatestRelease() getLatestRelease()
} catch (e: Exception) { } catch (e: Exception) {
@@ -308,21 +252,33 @@ class ReleaseManager private constructor(
return return
} }
checkLauncherUpdate() val launcherRelease = try {
releaseRepository.getLatestLauncherRelease()
} catch (e: Exception) {
sendError(e)
return
}
val currentTime = Clock.System.now().toEpochMilliseconds() if (launcherRelease != null) {
sharedPreferences.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime) checkLauncherUpdate(launcherRelease)
}
if (release == null) { if (release == null) {
downloadLauncherUpdateIfNecessary()
_uiState.value = ReleaseManagerState.Finished() _uiState.value = ReleaseManagerState.Finished()
return return
} }
val sharedPreferences = PreferenceUtils.get(applicationContext)
val currentVersion = sharedPreferences.getLong(PreferenceUtils.Key.CURRENT_VERSION_TIMESTAMP) val currentVersion = sharedPreferences.getLong(PreferenceUtils.Key.CURRENT_VERSION_TIMESTAMP)
val latestVersion = release.getDescriptor() val latestVersion = release.getDescriptor()
// check if an update is needed // check if an update is needed
if (latestVersion == currentVersion && !fileWasExternallyModified()) { if (latestVersion == currentVersion && !fileWasExternallyModified()) {
downloadLauncherUpdateIfNecessary()
_uiState.value = ReleaseManagerState.Finished() _uiState.value = ReleaseManagerState.Finished()
return return
} }
@@ -331,6 +287,8 @@ class ReleaseManager private constructor(
// check if the file was externally modified // check if the file was externally modified
if (!allowOverwriting && fileWasExternallyModified(true)) { if (!allowOverwriting && fileWasExternallyModified(true)) {
downloadLauncherUpdateIfNecessary()
sendError(UpdateException(UpdateException.Reason.EXTERNAL_FILE_IN_USE)) sendError(UpdateException(UpdateException.Reason.EXTERNAL_FILE_IN_USE))
return return
} }
@@ -359,7 +317,7 @@ class ReleaseManager private constructor(
} }
private fun computeFileHash(file: File): String = private fun computeFileHash(file: File): String =
file.source().buffer().use { it.readByteString().md5().hex() } file.source().buffer().readByteString().md5().hex()
private fun getGeodeOutputPath(): File { private fun getGeodeOutputPath(): File {
val geodeName = LaunchUtils.geodeFilename val geodeName = LaunchUtils.geodeFilename
@@ -394,14 +352,10 @@ class ReleaseManager private constructor(
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
fun checkForUpdates(isManual: Boolean = false): StateFlow<ReleaseManagerState> { fun checkForUpdates(isManual: Boolean = false): StateFlow<ReleaseManagerState> {
if (!isInUpdate) { if (!isInUpdate) {
if (isManual || !shouldUseCache()) {
_uiState.value = ReleaseManagerState.InUpdateCheck _uiState.value = ReleaseManagerState.InUpdateCheck
updateJob = GlobalScope.launch { updateJob = GlobalScope.launch {
checkForNewRelease(isManual) checkForNewRelease(isManual)
} }
} else {
afterUseCachedUpdate()
}
} }
return _uiState.asStateFlow() return _uiState.asStateFlow()
@@ -1,7 +1,6 @@
package com.geode.launcher.updater package com.geode.launcher.updater
import com.geode.launcher.BuildConfig import com.geode.launcher.utils.DownloadUtils.executeCoroutine
import com.geode.launcher.utils.LaunchUtils
import kotlin.time.Clock import kotlin.time.Clock
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlin.time.Instant import kotlin.time.Instant
@@ -11,10 +10,8 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy import kotlinx.serialization.json.JsonNamingStrategy
import kotlinx.serialization.json.okio.decodeFromBufferedSource import kotlinx.serialization.json.okio.decodeFromBufferedSource
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttp
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.coroutines.executeAsync
import java.io.IOException import java.io.IOException
import java.net.URL import java.net.URL
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
@@ -31,8 +28,6 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
private const val GEODE_API_BASE = "https://api.geode-sdk.org/v1" private const val GEODE_API_BASE = "https://api.geode-sdk.org/v1"
} }
private val userAgent = "okhttp/${OkHttp.VERSION} (launcher=${BuildConfig.VERSION_NAME};arch=${LaunchUtils.applicationArchitecture})"
suspend fun getLatestLauncherRelease(): DownloadableLauncherRelease? { suspend fun getLatestLauncherRelease(): DownloadableLauncherRelease? {
val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/android-launcher/releases/latest" val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/android-launcher/releases/latest"
@@ -64,13 +59,12 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
.url(url) .url(url)
.addHeader("Accept", "application/json") .addHeader("Accept", "application/json")
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION) .addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
.addHeader("User-Agent", userAgent)
.build() .build()
val call = httpClient.newCall(request) val call = httpClient.newCall(request)
val response = call.executeCoroutine()
return call.executeAsync().use { response -> return when (response.code) {
when (response.code) {
200 -> { 200 -> {
val format = Json { val format = Json {
namingStrategy = JsonNamingStrategy.SnakeCase namingStrategy = JsonNamingStrategy.SnakeCase
@@ -78,7 +72,7 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
} }
val release = format.decodeFromBufferedSource<List<Release>>( val release = format.decodeFromBufferedSource<List<Release>>(
response.body.source() response.body!!.source()
) )
(release.find { it.tagName != "nightly" }) (release.find { it.tagName != "nightly" })
@@ -100,7 +94,7 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
throw IOException(msg) throw IOException(msg)
} }
throw IOException("response 403: ${response.body.string()}") throw IOException("response 403: ${response.body!!.string()}")
} }
404 -> { 404 -> {
null null
@@ -110,7 +104,6 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
} }
} }
} }
}
@OptIn(ExperimentalTime::class) @OptIn(ExperimentalTime::class)
private fun generateRateLimitMessage(resetTime: Instant): String { private fun generateRateLimitMessage(resetTime: Instant): String {
@@ -128,12 +121,12 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
.url(url) .url(url)
.addHeader("Accept", "application/json") .addHeader("Accept", "application/json")
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION) .addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
.addHeader("User-Agent", userAgent)
.build() .build()
val call = httpClient.newCall(request) val call = httpClient.newCall(request)
return call.executeAsync().use { response -> val response = call.executeCoroutine()
when (response.code) {
return when (response.code) {
200 -> { 200 -> {
val format = Json { val format = Json {
namingStrategy = JsonNamingStrategy.SnakeCase namingStrategy = JsonNamingStrategy.SnakeCase
@@ -141,7 +134,7 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
} }
val release = format.decodeFromBufferedSource<Release>( val release = format.decodeFromBufferedSource<Release>(
response.body.source() response.body!!.source()
) )
release release
@@ -162,7 +155,7 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
throw IOException(msg) throw IOException(msg)
} }
throw IOException("response 403: ${response.body.string()}") throw IOException("response 403: ${response.body!!.string()}")
} }
404 -> { 404 -> {
null null
@@ -172,7 +165,6 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
} }
} }
} }
}
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
suspend fun getLatestIndexRelease(gd: Long, prerelease: Boolean = false): DownloadableLoaderRelease? { suspend fun getLatestIndexRelease(gd: Long, prerelease: Boolean = false): DownloadableLoaderRelease? {
@@ -186,12 +178,12 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(url)
.addHeader("Accept", "application/json") .addHeader("Accept", "application/json")
.addHeader("User-Agent", userAgent)
.build() .build()
val call = httpClient.newCall(request) val call = httpClient.newCall(request)
return call.executeAsync().use { response -> val response = call.executeCoroutine()
when (response.code) {
return when (response.code) {
200 -> { 200 -> {
val format = Json { val format = Json {
namingStrategy = JsonNamingStrategy.SnakeCase namingStrategy = JsonNamingStrategy.SnakeCase
@@ -199,7 +191,7 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
} }
val body = format.decodeFromBufferedSource<LoaderPayload<LoaderVersion>>( val body = format.decodeFromBufferedSource<LoaderPayload<LoaderVersion>>(
response.body.source() response.body!!.source()
) )
val release = body.payload ?: throw IOException("index error: ${body.error}") val release = body.payload ?: throw IOException("index error: ${body.error}")
@@ -215,4 +207,3 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
} }
} }
} }
}
@@ -53,7 +53,7 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
val isInUpdate val isInUpdate
get() = ReleaseManager.get(application).isInUpdate get() = ReleaseManager.get(application).isInUpdate
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdateTag val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdate
var hasPerformedCheck = false var hasPerformedCheck = false
private set private set
@@ -3,18 +3,17 @@ package com.geode.launcher.utils
import android.os.Build import android.os.Build
import android.os.FileUtils import android.os.FileUtils
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType import okhttp3.MediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody import okhttp3.Response
import okhttp3.ResponseBody import okhttp3.ResponseBody
import okhttp3.coroutines.executeAsync
import okio.Buffer import okio.Buffer
import okio.BufferedSink
import okio.BufferedSource import okio.BufferedSource
import okio.ForwardingSink
import okio.ForwardingSource import okio.ForwardingSource
import okio.Sink
import okio.Source import okio.Source
import okio.buffer import okio.buffer
import java.io.IOException import java.io.IOException
@@ -22,6 +21,8 @@ import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
typealias ProgressCallback = (progress: Long, outOf: Long) -> Unit typealias ProgressCallback = (progress: Long, outOf: Long) -> Unit
@@ -49,7 +50,7 @@ object DownloadUtils {
val originalResponse = chain.proceed(chain.request()) val originalResponse = chain.proceed(chain.request())
originalResponse.newBuilder() originalResponse.newBuilder()
.body(ProgressResponseBody( .body(ProgressResponseBody(
originalResponse.body, onProgress originalResponse.body!!, onProgress
)) ))
.build() .build()
} }
@@ -58,9 +59,31 @@ object DownloadUtils {
val progressClient = progressClientBuilder.build() val progressClient = progressClientBuilder.build()
val call = progressClient.newCall(request) val call = progressClient.newCall(request)
val response = call.executeAsync() val response = call.executeCoroutine()
return response.body.byteStream() return response.body!!.byteStream()
}
suspend fun Call.executeCoroutine(): Response {
return suspendCancellableCoroutine { continuation ->
this.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (continuation.isCancelled) {
return
}
continuation.resumeWithException(e)
}
override fun onResponse(call: Call, response: Response) {
continuation.resume(response)
}
})
continuation.invokeOnCancellation {
this.cancel()
}
}
} }
suspend fun extractFileFromZipStream(inputStream: InputStream, outputStream: OutputStream, zipPath: String) { suspend fun extractFileFromZipStream(inputStream: InputStream, outputStream: OutputStream, zipPath: String) {
@@ -144,38 +167,3 @@ private class ProgressResponseBody (
} }
} }
} }
// now based on <https://github.com/square/okhttp/blob/master/samples/guide/src/main/java/okhttp3/recipes/UploadProgress.java#L84>
class ProgressRequestBody(
private val requestBody: RequestBody,
private val progressCallback: ProgressCallback
) : RequestBody() {
override fun contentType(): MediaType? {
return requestBody.contentType()
}
override fun contentLength(): Long {
return requestBody.contentLength()
}
override fun writeTo(sink: BufferedSink) {
val bufferedSink = this.sink(sink).buffer()
requestBody.writeTo(bufferedSink)
bufferedSink.flush()
}
private fun sink(sink: Sink): Sink {
return object : ForwardingSink(sink) {
var totalBytesWritten = 0L
override fun write(source: Buffer, byteCount: Long) {
super.write(source, byteCount)
totalBytesWritten += byteCount
progressCallback(
totalBytesWritten,
contentLength()
)
}
}
}
}
@@ -51,8 +51,7 @@ object GamePackageUtils {
37L to "2.200", 37L to "2.200",
38L to "2.205", 38L to "2.205",
39L to "2.206", 39L to "2.206",
40L to "2.2074", // ok 40L to "2.2074" // ok
41L to "2.2081",
) )
/** /**
@@ -7,9 +7,6 @@ import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.hardware.lights.Light
import android.hardware.lights.LightState
import android.hardware.lights.LightsRequest
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
@@ -19,7 +16,6 @@ import android.os.VibratorManager
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION import android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION
import android.util.Log import android.util.Log
import android.view.InputDevice
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -394,15 +390,6 @@ object GeodeUtils {
return "" return ""
} }
@JvmStatic
fun getGameVersionCode(): Long {
activity.get()?.run {
return GamePackageUtils.getGameVersionCode(packageManager)
}
return 0L
}
/** /**
* Returns the current version code of the launcher. * Returns the current version code of the launcher.
*/ */
@@ -559,7 +546,7 @@ object GeodeUtils {
const val CAPABILITY_EXTENDED_INPUT = "extended_input" const val CAPABILITY_EXTENDED_INPUT = "extended_input"
const val CAPABILITY_TIMESTAMP_INPUT = "timestamp_inputs" const val CAPABILITY_TIMESTAMP_INPUT = "timestamp_inputs"
const val CAPABILITY_RICH_INPUT = "internal_callbacks_v2" const val CAPABILITY_INTERNAL_CALLBACKS = "internal_callbacks_v1"
private var capabilityListener: WeakReference<CapabilityListener?> = WeakReference(null) private var capabilityListener: WeakReference<CapabilityListener?> = WeakReference(null)
@@ -589,188 +576,6 @@ object GeodeUtils {
.launchUrl(activity, url.toUri()) .launchUrl(activity, url.toUri())
} }
/**
* Determines if any gamepad or joystick input devices are connected.
* This is useful during start, but tracking inputDeviceAdded events is better during runtime.
*/
@JvmStatic
fun controllersConnected(): Int {
val deviceIds = InputDevice.getDeviceIds()
return deviceIds.count {
InputDevice.getDevice(it)?.run {
sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD || sources and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
} ?: false
}
}
@JvmStatic
fun getConnectedDevices(): IntArray = InputDevice.getDeviceIds()
@JvmStatic
fun getDevice(deviceId: Int): InputDevice? = InputDevice.getDevice(deviceId)
@JvmStatic
fun getDeviceBatteryCapacity(deviceId: Int): Float {
val device = InputDevice.getDevice(deviceId) ?: return 0.0f
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
device.batteryState.capacity
} else {
0.0f
}
}
@JvmStatic
fun deviceHasBattery(deviceId: Int): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
return false
}
val device = InputDevice.getDevice(deviceId) ?: return false
return device.batteryState.isPresent
}
@JvmStatic
fun getDeviceBatteryStatus(deviceId: Int): Int {
val device = InputDevice.getDevice(deviceId) ?: return 0
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
device.batteryState.status
} else {
0
}
}
@JvmStatic
fun getDeviceLightsCount(deviceId: Int): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
return 0
}
val device = InputDevice.getDevice(deviceId) ?: return 0
val lightsManager = device.lightsManager
return lightsManager.lights.size
}
// this is based on paddleboat's api
@JvmStatic
fun getLightType(deviceId: Int): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
return 0
}
val device = InputDevice.getDevice(deviceId) ?: return 0
val lightsManager = device.lightsManager
var flags = 0
lightsManager.lights.forEach { light ->
if (light.type == Light.LIGHT_TYPE_PLAYER_ID) {
flags = flags or 1
} else if (light.hasRgbControl()) {
flags = flags or 2
}
}
return flags
}
/**
* Sets the color for a device.
* @return false if the given device does not exist or setting lights was otherwise unsuccessful
*/
@JvmStatic
fun setDeviceLightColor(deviceId: Int, color: Int, type: Int): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
return false
}
val device = InputDevice.getDevice(deviceId) ?: return false
val lightsManager = device.lightsManager
val lights = lightsManager.lights
if (lights.isEmpty()) {
return false
}
val actedLights = lights.count { light ->
val state = LightState.Builder()
if (light.type == Light.LIGHT_TYPE_PLAYER_ID && (type and 1 == 1)) {
state.setPlayerId(color)
} else if (light.hasRgbControl() && (type and 2 == 2)) {
state.setColor(color)
} else {
return@count false
}
val request = LightsRequest.Builder()
.addLight(light, state.build())
.build()
lightsManager.openSession()
.requestLights(request)
true
}
return actedLights > 0
}
@JvmStatic
fun getDeviceHapticsCount(deviceId: Int): Int {
val device = InputDevice.getDevice(deviceId) ?: return 0
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
return if (device.vibrator.hasVibrator()) 1 else 0
}
val vibratorManager = device.vibratorManager
val vibratorIds = vibratorManager.vibratorIds
return vibratorIds.size
}
private fun performVibration(vibrator: Vibrator?, durationMs: Long, intensity: Int) {
vibrator?.apply {
if (intensity == 0) {
cancel()
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val vibrationEffect = VibrationEffect.createOneShot(durationMs, intensity)
vibrate(vibrationEffect)
} else {
vibrate(durationMs)
}
}
}
}
/**
* Vibrates a given device. Set motorIdx == -1 to vibrate all available motors on a device.
*/
@JvmStatic
fun vibrateDevice(deviceId: Int, durationMs: Long, intensity: Int, motorIdx: Int): Boolean {
val device = InputDevice.getDevice(deviceId) ?: return false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager = device.vibratorManager
val vibratorIds = vibratorManager.vibratorIds
if (motorIdx == -1) {
vibratorIds.forEach {
val vibrator = vibratorManager.getVibrator(it)
performVibration(vibrator, durationMs, intensity)
}
} else {
val deviceId = vibratorIds.getOrNull(motorIdx) ?: return false
val vibrator = vibratorManager.getVibrator(deviceId)
performVibration(vibrator, durationMs, intensity)
}
} else {
val vibrator = device.vibrator
performVibration(vibrator, durationMs, intensity)
}
return true
}
external fun nativeKeyUp(keyCode: Int, modifiers: Int) external fun nativeKeyUp(keyCode: Int, modifiers: Int)
external fun nativeKeyDown(keyCode: Int, modifiers: Int, isRepeating: Boolean) external fun nativeKeyDown(keyCode: Int, modifiers: Int, isRepeating: Boolean)
external fun nativeActionScroll(scrollX: Float, scrollY: Float) external fun nativeActionScroll(scrollX: Float, scrollY: Float)
@@ -782,18 +587,5 @@ object GeodeUtils {
* @see reportPlatformCapability * @see reportPlatformCapability
*/ */
external fun setNextInputTimestamp(timestamp: Long) external fun setNextInputTimestamp(timestamp: Long)
external fun setNextInputTimestampInternal(timestamp: Long)
const val TOUCH_TYPE_BEGIN = 0
const val TOUCH_TYPE_MOVE = 1
const val TOUCH_TYPE_END = 2
const val TOUCH_TYPE_CANCEL = 3
external fun internalKeyEvent(timestamp: Long, deviceId: Int, eventSource: Int, keyCode: Int, modifiers: Int, isDown: Boolean, repeatCount: Int)
external fun internalTouchEvent(timestamp: Long, deviceId: Int, eventSource: Int, eventType: Int, ids: IntArray, xs: FloatArray, ys: FloatArray)
external fun internalScrollEvent(timestamp: Long, deviceId: Int, eventSource: Int, scrollX: Float, scrollY: Float)
external fun internalJoystickEvent(timestamp: Long, deviceId: Int, eventSource: Int, leftX: FloatArray, leftY: FloatArray, rightX: FloatArray, rightY: FloatArray, hatX: FloatArray, hatY: FloatArray, leftTrigger: FloatArray, rightTrigger: FloatArray)
external fun inputDeviceAdded(deviceId: Int)
external fun inputDeviceChanged(deviceId: Int)
external fun inputDeviceRemoved(deviceId: Int)
} }
@@ -127,33 +127,35 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
enum class Key { enum class Key {
LOAD_AUTOMATICALLY, LOAD_AUTOMATICALLY,
UPDATE_AUTOMATICALLY, UPDATE_AUTOMATICALLY,
RELEASE_CHANNEL,
CURRENT_VERSION_TAG, CURRENT_VERSION_TAG,
CURRENT_VERSION_TIMESTAMP, CURRENT_VERSION_TIMESTAMP,
THEME, THEME,
BLACK_BACKGROUND, BLACK_BACKGROUND,
CURRENT_RELEASE_MODIFIED, CURRENT_RELEASE_MODIFIED,
LAST_DISMISSED_UPDATE,
DISMISSED_GJ_UPDATE, DISMISSED_GJ_UPDATE,
LAUNCH_ARGUMENTS, LAUNCH_ARGUMENTS,
LIMIT_ASPECT_RATIO, LIMIT_ASPECT_RATIO,
DISPLAY_MODE, DISPLAY_MODE,
FORCE_HRR,
ENABLE_REDESIGN,
RELEASE_CHANNEL_TAG, RELEASE_CHANNEL_TAG,
DEVELOPER_MODE, DEVELOPER_MODE,
CLEANUP_APKS, CLEANUP_APKS,
CUSTOM_SYMBOL_LIST,
CUSTOM_ASPECT_RATIO, CUSTOM_ASPECT_RATIO,
SCREEN_ZOOM, SCREEN_ZOOM,
SCREEN_ZOOM_FIT, SCREEN_ZOOM_FIT,
LIMIT_FRAME_RATE, LIMIT_FRAME_RATE,
DISABLE_USER_THEME, DISABLE_USER_THEME,
SELECTED_ICON, SELECTED_ICON,
WAIT_PERIOD, DEVELOPER_SERVER,
LAST_UPDATE_CHECK_TIME,
LAST_LAUNCHER_UPDATE,
DISABLE_UPDATE_CACHE,
} }
private fun defaultValueForBooleanKey(key: Key): Boolean { private fun defaultValueForBooleanKey(key: Key): Boolean {
return when (key) { return when (key) {
Key.UPDATE_AUTOMATICALLY -> true Key.UPDATE_AUTOMATICALLY, Key.FORCE_HRR -> true
Key.DEVELOPER_MODE -> BuildConfig.DEBUG Key.DEVELOPER_MODE -> BuildConfig.DEBUG
else -> false else -> false
} }
@@ -162,7 +164,6 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
private fun defaultValueForIntKey(key: Key) = when (key) { private fun defaultValueForIntKey(key: Key) = when (key) {
Key.DISPLAY_MODE -> if (this.getBoolean(Key.LIMIT_ASPECT_RATIO)) 1 else 0 Key.DISPLAY_MODE -> if (this.getBoolean(Key.LIMIT_ASPECT_RATIO)) 1 else 0
Key.SCREEN_ZOOM -> 100 Key.SCREEN_ZOOM -> 100
Key.WAIT_PERIOD -> 3
// people wanted a reset on nightly anyways, so this is a good excuse to do so // people wanted a reset on nightly anyways, so this is a good excuse to do so
else -> 0 else -> 0
} }
@@ -177,33 +178,30 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
return when (key) { return when (key) {
Key.LOAD_AUTOMATICALLY -> "PreferenceLoadAutomatically" Key.LOAD_AUTOMATICALLY -> "PreferenceLoadAutomatically"
Key.UPDATE_AUTOMATICALLY -> "PreferenceUpdateAutomatically" Key.UPDATE_AUTOMATICALLY -> "PreferenceUpdateAutomatically"
Key.RELEASE_CHANNEL -> "PreferenceReleaseChannel"
Key.CURRENT_VERSION_TAG -> "PreferenceCurrentVersionName" Key.CURRENT_VERSION_TAG -> "PreferenceCurrentVersionName"
Key.CURRENT_VERSION_TIMESTAMP -> "PreferenceCurrentVersionDescriptor" Key.CURRENT_VERSION_TIMESTAMP -> "PreferenceCurrentVersionDescriptor"
Key.THEME -> "PreferenceTheme" Key.THEME -> "PreferenceTheme"
Key.BLACK_BACKGROUND -> "PreferenceBlackBackground" Key.BLACK_BACKGROUND -> "PreferenceBlackBackground"
Key.CURRENT_RELEASE_MODIFIED -> "PreferenceReleaseModifiedHash" Key.CURRENT_RELEASE_MODIFIED -> "PreferenceReleaseModifiedHash"
Key.LAST_DISMISSED_UPDATE -> "PreferenceLastDismissedUpdate"
Key.DISMISSED_GJ_UPDATE -> "PreferenceDismissedGJUpdate" Key.DISMISSED_GJ_UPDATE -> "PreferenceDismissedGJUpdate"
Key.LAUNCH_ARGUMENTS -> "PreferenceLaunchArguments" Key.LAUNCH_ARGUMENTS -> "PreferenceLaunchArguments"
Key.LIMIT_ASPECT_RATIO -> "PreferenceLimitAspectRatio" Key.LIMIT_ASPECT_RATIO -> "PreferenceLimitAspectRatio"
Key.DISPLAY_MODE -> "PreferenceDisplayMode" Key.DISPLAY_MODE -> "PreferenceDisplayMode"
Key.FORCE_HRR -> "PreferenceForceHighRefreshRate"
Key.ENABLE_REDESIGN -> "PreferenceEnableRedesign"
Key.RELEASE_CHANNEL_TAG -> "PreferenceReleaseChannelTag" Key.RELEASE_CHANNEL_TAG -> "PreferenceReleaseChannelTag"
Key.DEVELOPER_MODE -> "PreferenceDeveloperMode" Key.DEVELOPER_MODE -> "PreferenceDeveloperMode"
Key.CLEANUP_APKS -> "PreferenceCleanupPackages" Key.CLEANUP_APKS -> "PreferenceCleanupPackages"
Key.CUSTOM_SYMBOL_LIST -> "PreferenceCustomSymbolList"
Key.CUSTOM_ASPECT_RATIO -> "PreferenceCustomAspectRatio" Key.CUSTOM_ASPECT_RATIO -> "PreferenceCustomAspectRatio"
Key.SCREEN_ZOOM -> "PreferenceScreenZoom" Key.SCREEN_ZOOM -> "PreferenceScreenZoom"
Key.SCREEN_ZOOM_FIT -> "PreferenceScreenZoomFit" Key.SCREEN_ZOOM_FIT -> "PreferenceScreenZoomFit"
Key.LIMIT_FRAME_RATE -> "PreferenceLimitFrameRate" Key.LIMIT_FRAME_RATE -> "PreferenceLimitFrameRate"
Key.DISABLE_USER_THEME -> "PreferenceDisableUserTheme" Key.DISABLE_USER_THEME -> "PreferenceDisableUserTheme"
Key.SELECTED_ICON -> "PreferenceSelectedIcon" Key.SELECTED_ICON -> "PreferenceSelectedIcon"
Key.WAIT_PERIOD -> "PreferenceWaitPeriod" Key.DEVELOPER_SERVER -> "PreferenceDeveloperServer"
Key.LAST_UPDATE_CHECK_TIME -> "PreferenceLastUpdateCheckTime"
Key.LAST_LAUNCHER_UPDATE -> "PreferenceLastLauncherUpdate"
Key.DISABLE_UPDATE_CACHE -> "PreferenceDisableUpdateCache"
// Key.RELEASE_CHANNEL -> "PreferenceReleaseChannel"
// Key.LAST_DISMISSED_UPDATE -> "PreferenceLastDismissedUpdate"
// Key.FORCE_HRR -> "PreferenceForceHighRefreshRate"
// Key.DISABLE_REDESIGN -> "PreferenceDisableRedesign"
// Key.CUSTOM_SYMBOL_LIST -> "PreferenceCustomSymbolList"
} }
} }
@@ -2,14 +2,12 @@ package org.cocos2dx.lib
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.hardware.input.InputManager
import android.opengl.GLSurfaceView import android.opengl.GLSurfaceView
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.Message import android.os.Message
import android.util.Log import android.util.Log
import android.view.InputDevice
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MotionEvent import android.view.MotionEvent
import android.view.Surface import android.view.Surface
@@ -18,12 +16,10 @@ import androidx.annotation.RequiresApi
import androidx.appcompat.widget.AppCompatEditText import androidx.appcompat.widget.AppCompatEditText
import com.customRobTop.BaseRobTopActivity import com.customRobTop.BaseRobTopActivity
import com.geode.launcher.utils.GeodeUtils import com.geode.launcher.utils.GeodeUtils
import kotlin.math.abs
private const val HANDLER_OPEN_IME_KEYBOARD = 2 private const val HANDLER_OPEN_IME_KEYBOARD = 2
private const val HANDLER_CLOSE_IME_KEYBOARD = 3 private const val HANDLER_CLOSE_IME_KEYBOARD = 3
private const val MS_TO_NS = 1_000_000 private const val MS_TO_NS = 1_000_000
private const val SEC_TO_NS = 1_000_000_000.0
class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) { class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
companion object { companion object {
@@ -60,9 +56,8 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
} }
var sendTimestampEvents = false var sendTimestampEvents = false
var sendInternalTimestampEvents = false
var manualBackEvents = false var manualBackEvents = false
var controllerEventsEnabled = false
private set
var cocos2dxEditText: AppCompatEditText? = null var cocos2dxEditText: AppCompatEditText? = null
set(value) { set(value) {
@@ -159,6 +154,9 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
private fun sendNextEventTimestamp(timestamp: Long) { private fun sendNextEventTimestamp(timestamp: Long) {
if (sendTimestampEvents) if (sendTimestampEvents)
GeodeUtils.setNextInputTimestamp(timestamp) GeodeUtils.setNextInputTimestamp(timestamp)
if (sendInternalTimestampEvents)
GeodeUtils.setNextInputTimestampInternal(timestamp)
} }
override fun onTouchEvent(motionEvent: MotionEvent): Boolean { override fun onTouchEvent(motionEvent: MotionEvent): Boolean {
@@ -168,7 +166,6 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
val ys = FloatArray(pointerNumber) val ys = FloatArray(pointerNumber)
val timestamp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) val timestamp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
motionEvent.eventTimeNanos else motionEvent.eventTime * MS_TO_NS motionEvent.eventTimeNanos else motionEvent.eventTime * MS_TO_NS
val timestampSec = timestamp / SEC_TO_NS
for (i in 0 until pointerNumber) { for (i in 0 until pointerNumber) {
ids[i] = motionEvent.getPointerId(i) ids[i] = motionEvent.getPointerId(i)
@@ -182,15 +179,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
val f = ys[0] val f = ys[0]
queueEvent { queueEvent {
sendNextEventTimestamp(timestamp) sendNextEventTimestamp(timestamp)
cocos2dxRenderer.handleActionDown(idDown, xDown, f)
if (controllerEventsEnabled)
GeodeUtils.internalTouchEvent(
timestamp, motionEvent.deviceId, motionEvent.source,
// slice here to convert to single element array
GeodeUtils.TOUCH_TYPE_BEGIN, ids.sliceArray(0..0), xs.sliceArray(0..0), ys.sliceArray(0..0)
)
else
cocos2dxRenderer.handleActionDown(idDown, xDown, f, timestampSec)
} }
true true
} }
@@ -200,42 +189,21 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
val f3 = ys[0] val f3 = ys[0]
queueEvent { queueEvent {
sendNextEventTimestamp(timestamp) sendNextEventTimestamp(timestamp)
cocos2dxRenderer.handleActionUp(idUp, f2, f3)
if (controllerEventsEnabled)
GeodeUtils.internalTouchEvent(
timestamp, motionEvent.deviceId, motionEvent.source,
GeodeUtils.TOUCH_TYPE_END, ids.sliceArray(0..0), xs.sliceArray(0..0), ys.sliceArray(0..0)
)
else
cocos2dxRenderer.handleActionUp(idUp, f2, f3, timestampSec)
} }
true true
} }
MotionEvent.ACTION_MOVE -> { MotionEvent.ACTION_MOVE -> {
queueEvent { queueEvent {
sendNextEventTimestamp(timestamp) sendNextEventTimestamp(timestamp)
cocos2dxRenderer.handleActionMove(ids, xs, ys)
if (controllerEventsEnabled)
GeodeUtils.internalTouchEvent(
timestamp, motionEvent.deviceId, motionEvent.source,
GeodeUtils.TOUCH_TYPE_MOVE, ids, xs, ys
)
else
cocos2dxRenderer.handleActionMove(ids, xs, ys, timestampSec)
} }
true true
} }
MotionEvent.ACTION_CANCEL -> { MotionEvent.ACTION_CANCEL -> {
queueEvent { queueEvent {
sendNextEventTimestamp(timestamp) sendNextEventTimestamp(timestamp)
cocos2dxRenderer.handleActionCancel(ids, xs, ys)
if (controllerEventsEnabled)
GeodeUtils.internalTouchEvent(
timestamp, motionEvent.deviceId, motionEvent.source,
GeodeUtils.TOUCH_TYPE_CANCEL, ids, xs, ys
)
else
cocos2dxRenderer.handleActionCancel(ids, xs, ys, timestampSec)
} }
true true
} }
@@ -246,14 +214,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
val y = motionEvent.getY(indexPointerDown) val y = motionEvent.getY(indexPointerDown)
queueEvent { queueEvent {
sendNextEventTimestamp(timestamp) sendNextEventTimestamp(timestamp)
cocos2dxRenderer.handleActionDown(idPointerDown, xPointerDown, y)
if (controllerEventsEnabled)
GeodeUtils.internalTouchEvent(
timestamp, motionEvent.deviceId, motionEvent.source,
GeodeUtils.TOUCH_TYPE_BEGIN, intArrayOf(idPointerDown), floatArrayOf(xPointerDown), floatArrayOf(y)
)
else
cocos2dxRenderer.handleActionDown(idPointerDown, xPointerDown, y, timestampSec)
} }
true true
} }
@@ -264,14 +225,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
val y2 = motionEvent.getY(indexPointUp) val y2 = motionEvent.getY(indexPointUp)
queueEvent { queueEvent {
sendNextEventTimestamp(timestamp) sendNextEventTimestamp(timestamp)
cocos2dxRenderer.handleActionUp(idPointerUp, xPointerUp, y2)
if (controllerEventsEnabled)
GeodeUtils.internalTouchEvent(
timestamp, motionEvent.deviceId, motionEvent.source,
GeodeUtils.TOUCH_TYPE_END, intArrayOf(idPointerUp), floatArrayOf(xPointerUp), floatArrayOf(y2)
)
else
cocos2dxRenderer.handleActionUp(idPointerUp, xPointerUp, y2, timestampSec)
} }
true true
} }
@@ -312,42 +266,21 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
return return
} }
if (useKeyboardEvents) {
queueEvent {
cocos2dxRenderer.handleKeyDown(KeyEvent.KEYCODE_BACK)
}
} else {
val currentTime = System.nanoTime() val currentTime = System.nanoTime()
queueEvent { queueEvent {
sendNextEventTimestamp(currentTime) sendNextEventTimestamp(currentTime)
if (controllerEventsEnabled) {
GeodeUtils.internalKeyEvent(
currentTime,
-1,
InputDevice.SOURCE_ANY,
KeyEvent.KEYCODE_BACK,
0,
true,
0
)
GeodeUtils.internalKeyEvent(
currentTime,
-1,
InputDevice.SOURCE_ANY,
KeyEvent.KEYCODE_BACK,
0,
false,
0
)
}
if (useKeyboardEvents) {
GeodeUtils.nativeKeyDown(KeyEvent.KEYCODE_BACK, 0, false) GeodeUtils.nativeKeyDown(KeyEvent.KEYCODE_BACK, 0, false)
GeodeUtils.nativeKeyUp(KeyEvent.KEYCODE_BACK, 0)
} else if (!controllerEventsEnabled) {
cocos2dxRenderer.handleKeyDown(KeyEvent.KEYCODE_BACK)
} }
} }
} }
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
if (!useKeyboardEvents && !controllerEventsEnabled) { if (!useKeyboardEvents) {
return legacyKeyDown(keyCode, event) return legacyKeyDown(keyCode, event)
} }
@@ -363,22 +296,15 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
queueEvent { queueEvent {
sendNextEventTimestamp(event.eventTime * MS_TO_NS) sendNextEventTimestamp(event.eventTime * MS_TO_NS)
if (useKeyboardEvents) {
GeodeUtils.nativeKeyDown(keyCode, event.modifiers, event.repeatCount != 0) GeodeUtils.nativeKeyDown(keyCode, event.modifiers, event.repeatCount != 0)
} }
if (controllerEventsEnabled) {
GeodeUtils.internalKeyEvent(event.eventTime * MS_TO_NS, event.deviceId, event.source, event.keyCode, event.modifiers, true, event.repeatCount)
}
}
true true
} }
} }
} }
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
if (!useKeyboardEvents && !controllerEventsEnabled) { if (!useKeyboardEvents) {
return super.onKeyUp(keyCode, event) return super.onKeyUp(keyCode, event)
} }
@@ -393,82 +319,16 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
queueEvent { queueEvent {
sendNextEventTimestamp(event.eventTime * MS_TO_NS) sendNextEventTimestamp(event.eventTime * MS_TO_NS)
if (useKeyboardEvents) {
GeodeUtils.nativeKeyUp(keyCode, event.modifiers) GeodeUtils.nativeKeyUp(keyCode, event.modifiers)
} }
if (controllerEventsEnabled) {
GeodeUtils.internalKeyEvent(event.eventTime * MS_TO_NS, event.deviceId, event.source, event.keyCode, event.modifiers, false, event.repeatCount)
}
}
true true
} }
} }
} }
// i'm lazy.
// https://developer.android.com/develop/ui/views/touch-and-input/game-controllers/controller-input
private fun getCenteredAxis(
event: MotionEvent,
device: InputDevice,
axis: Int,
historyPos: Int
): Float {
device.getMotionRange(axis, event.source)?.apply {
val value: Float = if (historyPos < 0) {
event.getAxisValue(axis)
} else {
event.getHistoricalAxisValue(axis, historyPos)
}
if (abs(value) > flat) {
return value
}
}
return 0.0f
}
private fun getJoystickMotion(event: MotionEvent, axisHorizontal: Int, axisVertical: Int): Pair<FloatArray, FloatArray> {
val positionChangesX = ArrayList<Float>()
val positionChangesY = ArrayList<Float>()
val inputDevice = event.device
(0 until event.historySize).forEach { i ->
val x = getCenteredAxis(event, inputDevice, axisHorizontal, i)
val y = getCenteredAxis(event, inputDevice, axisVertical, i)
positionChangesX.add(x)
positionChangesY.add(y)
}
val x = getCenteredAxis(event, inputDevice, axisHorizontal, -1)
val y = getCenteredAxis(event, inputDevice, axisVertical, -1)
positionChangesX.add(x)
positionChangesY.add(y)
return Pair(positionChangesX.toFloatArray(), positionChangesY.toFloatArray())
}
private fun getJoystickMotion(event: MotionEvent, axis: Int): FloatArray {
val positionChanges = ArrayList<Float>()
val inputDevice = event.device
(0 until event.historySize).forEach { i ->
val x = getCenteredAxis(event, inputDevice, axis, i)
positionChanges.add(x)
}
val x = getCenteredAxis(event, inputDevice, axis, -1)
positionChanges.add(x)
return positionChanges.toFloatArray()
}
override fun onGenericMotionEvent(event: MotionEvent?): Boolean { override fun onGenericMotionEvent(event: MotionEvent?): Boolean {
if (!useKeyboardEvents && !controllerEventsEnabled) { if (!useKeyboardEvents) {
return super.onGenericMotionEvent(event) return super.onGenericMotionEvent(event)
} }
@@ -480,41 +340,9 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
queueEvent { queueEvent {
sendNextEventTimestamp(timestamp) sendNextEventTimestamp(timestamp)
if (useKeyboardEvents) {
GeodeUtils.nativeActionScroll(scrollX, scrollY) GeodeUtils.nativeActionScroll(scrollX, scrollY)
} }
if (controllerEventsEnabled) {
GeodeUtils.internalScrollEvent(timestamp, event.deviceId, event.source, scrollX, scrollY)
}
}
return true
}
if (controllerEventsEnabled && event?.action == MotionEvent.ACTION_MOVE && event.source and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK) {
val (motionLeftX, motionLeftY) = getJoystickMotion(event, MotionEvent.AXIS_X, MotionEvent.AXIS_Y)
val (motionRightX, motionRightY) = getJoystickMotion(event, MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ)
val (motionDPadX, motionDPadY) = getJoystickMotion(event, MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y)
val motionLTrigger = getJoystickMotion(event, MotionEvent.AXIS_LTRIGGER)
val motionRTrigger = getJoystickMotion(event, MotionEvent.AXIS_RTRIGGER)
// i'm sure this is great for performance
val timestamp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
event.eventTimeNanos else event.eventTime * MS_TO_NS
queueEvent {
GeodeUtils.internalJoystickEvent(
timestamp, event.deviceId, event.source,
motionLeftX, motionLeftY,
motionRightX, motionRightY,
motionDPadX, motionDPadY,
motionLTrigger, motionRTrigger
)
}
return true return true
} }
@@ -537,40 +365,4 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
fun deleteBackward() { fun deleteBackward() {
queueEvent { cocos2dxRenderer.handleDeleteBackward() } queueEvent { cocos2dxRenderer.handleDeleteBackward() }
} }
fun enableControllerEvents() {
if (controllerEventsEnabled) {
return
}
controllerEventsEnabled = true
val inputManager = context.getSystemService(Context.INPUT_SERVICE) as InputManager
inputManager.registerInputDeviceListener(InputListener, null)
}
fun disableControllerEvents() {
if (!controllerEventsEnabled) {
return
}
controllerEventsEnabled = true
val inputManager = context.getSystemService(Context.INPUT_SERVICE) as InputManager
inputManager.unregisterInputDeviceListener(InputListener)
}
object InputListener : InputManager.InputDeviceListener {
override fun onInputDeviceAdded(deviceId: Int) {
GeodeUtils.inputDeviceAdded(deviceId)
}
override fun onInputDeviceChanged(deviceId: Int) {
GeodeUtils.inputDeviceChanged(deviceId)
}
override fun onInputDeviceRemoved(deviceId: Int) {
GeodeUtils.inputDeviceRemoved(deviceId)
}
}
} }
@@ -40,25 +40,21 @@ class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceVi
@JvmStatic @JvmStatic
private external fun nativeTextClosed() private external fun nativeTextClosed()
// timestamp was added in 2.208, but the jni method has no required type signature.
// it doesn't hurt to unconditionally send one
@JvmStatic @JvmStatic
private external fun nativeTouchesBegin(id: Int, x: Float, y: Float, timestamp: Double) private external fun nativeTouchesBegin(id: Int, x: Float, y: Float)
@JvmStatic @JvmStatic
private external fun nativeTouchesCancel( private external fun nativeTouchesCancel(
ids: IntArray, ids: IntArray,
xs: FloatArray, xs: FloatArray,
ys: FloatArray, ys: FloatArray
timestamp: Double,
) )
@JvmStatic @JvmStatic
private external fun nativeTouchesEnd(id: Int, x: Float, y: Float, timestamp: Double) private external fun nativeTouchesEnd(id: Int, x: Float, y: Float)
@JvmStatic @JvmStatic
private external fun nativeTouchesMove(ids: IntArray, xs: FloatArray, ys: FloatArray, timestamp: Double) private external fun nativeTouchesMove(ids: IntArray, xs: FloatArray, ys: FloatArray)
@JvmStatic @JvmStatic
private external fun nativeInit(width: Int, height: Int) private external fun nativeInit(width: Int, height: Int)
@@ -113,20 +109,20 @@ class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceVi
} }
} }
fun handleActionDown(id: Int, x: Float, y: Float, timestamp: Double) { fun handleActionDown(id: Int, x: Float, y: Float) {
nativeTouchesBegin(id, x, y, timestamp) nativeTouchesBegin(id, x, y)
} }
fun handleActionUp(id: Int, x: Float, y: Float, timestamp: Double) { fun handleActionUp(id: Int, x: Float, y: Float) {
nativeTouchesEnd(id, x, y, timestamp) nativeTouchesEnd(id, x, y)
} }
fun handleActionCancel(ids: IntArray, xs: FloatArray, ys: FloatArray, timestamp: Double) { fun handleActionCancel(ids: IntArray, xs: FloatArray, ys: FloatArray) {
nativeTouchesCancel(ids, xs, ys, timestamp) nativeTouchesCancel(ids, xs, ys)
} }
fun handleActionMove(ids: IntArray, xs: FloatArray, ys: FloatArray, timestamp: Double) { fun handleActionMove(ids: IntArray, xs: FloatArray, ys: FloatArray) {
nativeTouchesMove(ids, xs, ys, timestamp) nativeTouchesMove(ids, xs, ys)
} }
fun handleKeyDown(keyCode: Int) { fun handleKeyDown(keyCode: Int) {
+5 -108
View File
@@ -7,7 +7,6 @@ import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.res.AssetManager import android.content.res.AssetManager
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo import android.media.AudioDeviceInfo
import android.media.AudioManager import android.media.AudioManager
import android.net.Uri import android.net.Uri
@@ -15,45 +14,27 @@ import android.os.Build
import android.util.Log import android.util.Log
import java.io.FileNotFoundException import java.io.FileNotFoundException
// i never agreed to your licenses! take that // i never agreed to your licenses! take that
@SuppressLint("StaticFieldLeak") @SuppressLint("StaticFieldLeak")
object FMOD { object FMOD {
private var gContext: Context? = null private var gContext: Context? = null
private var gPluginAudioDeviceCallback: PluginAudioDeviceCallback? = null
private val gPluginBroadcastReceiver = PluginBroadcastReceiver() private val gPluginBroadcastReceiver = PluginBroadcastReceiver()
external fun OutputAAudioHeadphonesChanged() external fun OutputAAudioHeadphonesChanged()
external fun SetInputEnumerationChanged()
external fun SetOutputEnumerationChanged()
@JvmStatic @JvmStatic
fun init(context: Context?) { fun init(context: Context?) {
gContext = context gContext = context
if (context == null) { gContext?.registerReceiver(
return gPluginBroadcastReceiver,
} IntentFilter(Intent.ACTION_HEADSET_PLUG)
)
val intentFilter = IntentFilter(Intent.ACTION_HEADSET_PLUG)
if (Build.VERSION.SDK_INT >= 34) {
context.registerReceiver(gPluginBroadcastReceiver, intentFilter, Context.RECEIVER_NOT_EXPORTED)
} else {
context.registerReceiver(gPluginBroadcastReceiver, intentFilter)
}
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val pluginAudioDeviceCallback = PluginAudioDeviceCallback(audioManager.getDevices(3))
gPluginAudioDeviceCallback = pluginAudioDeviceCallback
audioManager.registerAudioDeviceCallback(pluginAudioDeviceCallback, null)
} }
@JvmStatic @JvmStatic
fun close() { fun close() {
val context = gContext val context = gContext
if (context != null) { context?.unregisterReceiver(gPluginBroadcastReceiver)
context.unregisterReceiver(gPluginBroadcastReceiver)
(context.getSystemService(Context.AUDIO_SERVICE) as AudioManager)
.unregisterAudioDeviceCallback(gPluginAudioDeviceCallback)
}
gContext = null gContext = null
} }
@@ -128,12 +109,6 @@ object FMOD {
} }
} }
@JvmStatic
fun getAudioDevices(i: Int): Array<AudioDeviceInfo?>? {
val context = gContext ?: return emptyArray()
return (context.getSystemService(Context.AUDIO_SERVICE) as AudioManager).getDevices(i)
}
@JvmStatic @JvmStatic
fun fileDescriptorFromUri(str: String?): Int { fun fileDescriptorFromUri(str: String?): Int {
gContext?.apply { gContext?.apply {
@@ -152,82 +127,4 @@ object FMOD {
OutputAAudioHeadphonesChanged() OutputAAudioHeadphonesChanged()
} }
} }
internal class PluginAudioDeviceCallback : AudioDeviceCallback {
val deviceSet: HashSet<Int> = HashSet()
constructor(audioDeviceInfoArr: Array<out AudioDeviceInfo?>?) {
if (audioDeviceInfoArr != null) {
deviceSet.addAll(audioDeviceInfoArr.mapNotNull { it?.id })
}
}
override fun onAudioDevicesAdded(audioDeviceInfoArr: Array<out AudioDeviceInfo?>?) {
var z: Boolean
var i = 0
if (audioDeviceInfoArr != null) {
var i2 = 0
z = false
while (i < audioDeviceInfoArr.size) {
if (!deviceSet.contains(audioDeviceInfoArr[i]!!.id)) {
if (audioDeviceInfoArr[i]!!.isSource) {
i2 = 1
}
if (audioDeviceInfoArr[i]!!.isSink) {
z = true
}
deviceSet.add(audioDeviceInfoArr[i]!!.id)
}
i++
}
i = i2
} else {
z = false
}
if (i != 0) {
try {
SetInputEnumerationChanged()
} catch (_: UnsatisfiedLinkError) { }
}
if (z) {
try {
SetOutputEnumerationChanged()
} catch (_: UnsatisfiedLinkError) { }
}
}
override fun onAudioDevicesRemoved(audioDeviceInfoArr: Array<out AudioDeviceInfo?>?) {
var z: Boolean
var i = 0
if (audioDeviceInfoArr != null) {
var i2 = 0
z = false
while (i < audioDeviceInfoArr.size) {
if (deviceSet.contains(audioDeviceInfoArr[i]!!.id)) {
if (audioDeviceInfoArr[i]!!.isSource) {
i2 = 1
}
if (audioDeviceInfoArr[i]!!.isSink) {
z = true
}
deviceSet.remove(audioDeviceInfoArr[i]!!.id)
}
i++
}
i = i2
} else {
z = false
}
if (i != 0) {
try {
SetInputEnumerationChanged()
} catch (_: UnsatisfiedLinkError) { }
}
if (z) {
try {
SetOutputEnumerationChanged()
} catch (_: UnsatisfiedLinkError) { }
}
}
}
} }
-43
View File
@@ -1,43 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="204dp"
android:height="205dp"
android:viewportWidth="204"
android:viewportHeight="205">
<path
android:pathData="M114.92,6.98L109,2.47C104.7,-0.82 98.72,-0.82 94.42,2.47L4.72,70.96C0.72,74.02 -0.95,79.25 0.54,84.06L35.03,195.65C36.59,200.69 41.24,204.12 46.51,204.12H156.91C162.18,204.12 166.83,200.69 168.38,195.65L202.88,84.06C204.37,79.25 202.7,74.02 198.7,70.96L190.43,64.65L180.92,94.56L154.79,179.53C153.77,182.85 150.71,185.12 147.23,185.12H55.6C52.13,185.12 49.06,182.85 48.04,179.53L19.44,86.54C18.47,83.38 19.57,79.95 22.19,77.94L96.6,20.81C96.9,20.58 97.21,20.38 97.53,20.19L114.92,6.98Z"
android:fillType="evenOdd">
<aapt:attr name="android:fillColor">
<gradient
android:startX="65.53"
android:startY="18.28"
android:endX="150.22"
android:endY="187.89"
android:type="linear">
<item android:offset="0" android:color="#FFFFFDFF"/>
<item android:offset="0.08" android:color="#FFF2EAF5"/>
<item android:offset="0.12" android:color="#FFEDE5EF"/>
<item android:offset="0.31" android:color="#FFCDB5CD"/>
<item android:offset="0.49" android:color="#FFBA9BBC"/>
<item android:offset="1" android:color="#FF8D7ACF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M98.37,41.8C100.69,40.03 103.9,40.03 106.22,41.8L161.71,84.35C163.85,85.98 164.74,88.78 163.95,91.36L142.61,160.64C141.78,163.35 139.28,165.19 136.45,165.19H68.14C65.31,165.19 62.81,163.35 61.98,160.64L58.33,148.8H76.42C76.63,148.84 76.85,148.87 77.07,148.87H126.35C127.77,148.87 129.03,147.93 129.44,146.57L144.82,95.62C145.2,94.35 144.76,92.97 143.72,92.15L103.69,60.85C102.53,59.94 100.89,59.94 99.72,60.85L63.01,89.56L44.33,103.33L40.65,91.36C39.85,88.78 40.75,85.98 42.88,84.35L98.37,41.8Z"
android:fillType="evenOdd">
<aapt:attr name="android:fillColor">
<gradient
android:startX="102.3"
android:startY="-83.96"
android:endX="102.3"
android:endY="165.06"
android:type="linear">
<item android:offset="0.48" android:color="#FFFFFFFF"/>
<item android:offset="0.63" android:color="#FFF5E4C2"/>
<item android:offset="0.73" android:color="#FFE5C7AD"/>
<item android:offset="1" android:color="#FFB790A9"/>
</gradient>
</aapt:attr>
</path>
</vector>
@@ -1,42 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="204dp"
android:height="205dp"
android:viewportWidth="204"
android:viewportHeight="205">
<path
android:pathData="M114.92,6.98L109,2.47C104.7,-0.82 98.72,-0.82 94.42,2.47L4.72,70.96C0.72,74.02 -0.95,79.25 0.54,84.06L35.03,195.65C36.59,200.69 41.24,204.12 46.51,204.12H156.91C162.18,204.12 166.83,200.69 168.38,195.65L202.88,84.06C204.37,79.25 202.7,74.02 198.7,70.96L190.43,64.65L180.92,94.56L154.79,179.53C153.77,182.85 150.71,185.12 147.23,185.12H55.6C52.13,185.12 49.06,182.85 48.04,179.53L19.44,86.54C18.47,83.38 19.57,79.95 22.19,77.94L96.6,20.81C96.9,20.58 97.21,20.38 97.53,20.19L114.92,6.98Z"
android:fillType="evenOdd">
<aapt:attr name="android:fillColor">
<gradient
android:startX="65.53"
android:startY="18.28"
android:endX="150.22"
android:endY="187.89"
android:type="linear">
<item android:offset="0.0" android:color="#fff5b11d"/>
<item android:offset="0.10" android:color="#fff0834d"/>
<item android:offset="0.24" android:color="#ffe85f6b"/>
<item android:offset="0.49" android:color="#ffc9659e"/>
<item android:offset="1" android:color="#ffb9588f"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M98.37,41.8C100.69,40.03 103.9,40.03 106.22,41.8L161.71,84.35C163.85,85.98 164.74,88.78 163.95,91.36L142.61,160.64C141.78,163.35 139.28,165.19 136.45,165.19H68.14C65.31,165.19 62.81,163.35 61.98,160.64L58.33,148.8H76.42C76.63,148.84 76.85,148.87 77.07,148.87H126.35C127.77,148.87 129.03,147.93 129.44,146.57L144.82,95.62C145.2,94.35 144.76,92.97 143.72,92.15L103.69,60.85C102.53,59.94 100.89,59.94 99.72,60.85L63.01,89.56L44.33,103.33L40.65,91.36C39.85,88.78 40.75,85.98 42.88,84.35L98.37,41.8Z"
android:fillType="evenOdd">
<aapt:attr name="android:fillColor">
<gradient
android:startX="102.3"
android:startY="-83.96"
android:endX="102.3"
android:endY="165.06"
android:type="linear">
<item android:offset="0.48" android:color="#fff2ce00"/>
<item android:offset="0.63" android:color="#fff4b97e"/>
<item android:offset="0.73" android:color="#fff7b66a"/>
<item android:offset="1" android:color="#ffeb8fac"/>
</gradient>
</aapt:attr>
</path>
</vector>
@@ -1,13 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="96dp"
android:height="96dp"
android:viewportWidth="696"
android:viewportHeight="698"
android:tint="?attr/colorControlNormal">
<path
android:pathData="M336.61,143.26C344.51,137.21 355.49,137.21 363.39,143.26L552.8,288.49C560.1,294.09 563.14,303.63 560.44,312.42L487.62,548.92C484.78,558.15 476.25,564.45 466.59,564.45H233.41C223.75,564.45 215.22,558.15 212.38,548.92L199.93,508.5H261.68C262.39,508.65 263.13,508.72 263.89,508.72H432.11C436.96,508.72 441.24,505.55 442.64,500.9L495.14,326.98C496.45,322.64 494.96,317.93 491.39,315.14L354.78,208.3C350.8,205.19 345.2,205.19 341.22,208.3L215.93,306.29L152.15,353.31L139.56,312.42C136.86,303.63 139.9,294.09 147.2,288.49L336.61,143.26Z"
android:fillType="evenOdd"
android:fillColor="@android:color/white">
</path>
</vector>
@@ -1,13 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="96dp"
android:height="96dp"
android:viewportWidth="696"
android:viewportHeight="698"
android:tint="?attr/colorControlNormal">
<path
android:pathData="M393.08,24.42L372.88,9C358.19,-2.22 337.81,-2.22 323.12,9L16.94,242.81C3.28,253.24 -2.42,271.09 2.66,287.51L120.41,668.43C125.72,685.61 141.6,697.33 159.58,697.33H536.42C554.4,697.33 570.28,685.61 575.59,668.43L693.34,287.51C698.42,271.09 692.72,253.24 679.06,242.81L650.84,221.26L618.37,323.39L529.2,613.39C525.71,624.72 515.24,632.46 503.39,632.46H190.61C178.76,632.46 168.29,624.72 164.8,613.39L67.2,295.97C63.88,285.19 67.62,273.49 76.56,266.62L330.56,71.62C331.58,70.84 332.64,70.14 333.74,69.52L393.08,24.42Z"
android:fillType="evenOdd"
android:fillColor="@android:color/white">
</path>
</vector>
@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M260,800Q169,800 104.5,737Q40,674 40,583Q40,505 87,444Q134,383 210,366Q235,274 310,217Q385,160 480,160Q597,160 678.5,241.5Q760,323 760,440L760,440L760,440Q829,448 874.5,499.5Q920,551 920,620Q920,695 867.5,747.5Q815,800 740,800L260,800ZM260,720L740,720Q782,720 811,691Q840,662 840,620Q840,578 811,549Q782,520 740,520L680,520L680,440Q680,357 621.5,298.5Q563,240 480,240Q397,240 338.5,298.5Q280,357 280,440L280,440L260,440Q202,440 161,481Q120,522 120,580Q120,638 161,679Q202,720 260,720ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480ZM480,640Q497,640 508.5,628.5Q520,617 520,600Q520,583 508.5,571.5Q497,560 480,560Q463,560 451.5,571.5Q440,583 440,600Q440,617 451.5,628.5Q463,640 480,640ZM440,500L520,500L520,320L440,320L440,500Z"/>
</vector>
@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M260,800Q169,800 104.5,737Q40,674 40,583Q40,505 87,444Q134,383 210,366Q235,274 310,217Q385,160 480,160Q597,160 678.5,241.5Q760,323 760,440L760,440L760,440Q829,448 874.5,499.5Q920,551 920,620Q920,695 867.5,747.5Q815,800 740,800L520,800Q487,800 463.5,776.5Q440,753 440,720L440,514L376,576L320,520L480,360L640,520L584,576L520,514L520,720Q520,720 520,720Q520,720 520,720L740,720Q782,720 811,691Q840,662 840,620Q840,578 811,549Q782,520 740,520L680,520L680,440Q680,357 621.5,298.5Q563,240 480,240Q397,240 338.5,298.5Q280,357 280,440L260,440Q202,440 161,481Q120,522 120,580Q120,638 161,679Q202,720 260,720L360,720L360,800L260,800ZM480,520L480,520L480,520Q480,520 480,520Q480,520 480,520L480,520L480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,520Z"/>
</vector>
@@ -1,10 +0,0 @@
<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="M500,840L444,784L586,642L444,500L500,444L642,586L784,444L840,500L698,642L840,784L784,840L642,698L500,840ZM280,840L280,760L360,760L360,840L280,840ZM200,200L120,200Q120,167 143.5,143.5Q167,120 200,120L200,200ZM280,200L280,120L360,120L360,200L280,200ZM440,200L440,120L520,120L520,200L440,200ZM600,200L600,120L680,120L680,200L600,200ZM760,200L760,120Q793,120 816.5,143.5Q840,167 840,200L760,200ZM200,760L200,840Q167,840 143.5,816.5Q120,793 120,760L200,760ZM120,680L120,600L200,600L200,680L120,680ZM120,520L120,440L200,440L200,520L120,520ZM120,360L120,280L200,280L200,360L120,360ZM760,360L760,280L840,280L840,360L760,360Z"/>
</vector>
-10
View File
@@ -1,10 +0,0 @@
<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,880Q341,845 250.5,720.5Q160,596 160,444L160,200L480,80L800,200L800,444Q800,596 709.5,720.5Q619,845 480,880ZM480,796Q584,763 652,664Q720,565 720,444L720,255L480,165L240,255L240,444Q240,565 308,664Q376,763 480,796ZM480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>
@@ -1,10 +0,0 @@
<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,640Q497,640 508.5,628.5Q520,617 520,600Q520,583 508.5,571.5Q497,560 480,560Q463,560 451.5,571.5Q440,583 440,600Q440,617 451.5,628.5Q463,640 480,640ZM440,480L520,480L520,280L440,280L440,480ZM480,880Q341,845 250.5,720.5Q160,596 160,444L160,200L480,80L800,200L800,444Q800,596 709.5,720.5Q619,845 480,880ZM480,796Q584,763 652,664Q720,565 720,444L720,255L480,165L240,255L240,444Q240,565 308,664Q376,763 480,796ZM480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>
-10
View File
@@ -1,10 +0,0 @@
<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="M240,880L240,710Q201,693 171.5,664.5Q142,636 121.5,600Q101,564 90.5,523Q80,482 80,440Q80,282 192,181Q304,80 480,80Q656,80 768,181Q880,282 880,440Q880,482 869.5,523Q859,564 838.5,600Q818,636 788.5,664.5Q759,693 720,710L720,880L240,880ZM320,800L360,800L360,720L440,720L440,800L520,800L520,720L600,720L600,800L640,800L640,658Q678,649 707.5,628Q737,607 757.5,578Q778,549 789,514Q800,479 800,440Q800,315 711.5,237.5Q623,160 480,160Q337,160 248.5,237.5Q160,315 160,440Q160,479 171,514Q182,549 202.5,578Q223,607 253,628Q283,649 320,658L320,800ZM420,600L540,600L480,480L420,600ZM340,520Q373,520 396.5,496.5Q420,473 420,440Q420,407 396.5,383.5Q373,360 340,360Q307,360 283.5,383.5Q260,407 260,440Q260,473 283.5,496.5Q307,520 340,520ZM620,520Q653,520 676.5,496.5Q700,473 700,440Q700,407 676.5,383.5Q653,360 620,360Q587,360 563.5,383.5Q540,407 540,440Q540,473 563.5,496.5Q587,520 620,520ZM480,800L480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800L480,800L480,800L480,800L480,800L480,800L480,800L480,800L480,800L480,800Z"/>
</vector>
-10
View File
@@ -1,10 +0,0 @@
<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,840Q405,840 339.5,811.5Q274,783 225.5,734.5Q177,686 148.5,620.5Q120,555 120,480Q120,405 148.5,339.5Q177,274 225.5,225.5Q274,177 339.5,148.5Q405,120 480,120Q562,120 635.5,155Q709,190 760,254L760,160L840,160L840,400L600,400L600,320L710,320Q669,264 609,232Q549,200 480,200Q363,200 281.5,281.5Q200,363 200,480Q200,597 281.5,678.5Q363,760 480,760Q585,760 663.5,692Q742,624 756,520L838,520Q823,657 720.5,748.5Q618,840 480,840ZM592,648L440,496L440,280L520,280L520,464L648,592L592,648Z"/>
</vector>
+11 -60
View File
@@ -62,25 +62,15 @@
<string name="launcher_notification_update_success">Geode updated!</string> <string name="launcher_notification_update_success">Geode updated!</string>
<string name="launcher_notification_compatibility">Unsupported Geometry Dash version.</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_compatibility_description">An update is required to receive Geode updates.</string>
<string name="launcher_notification_forwards_compatibility_description">Geode failed to load.</string>
<string name="launcher_notification_forwards_compatibility">Unsupported Geometry Dash version.</string>
<string name="launcher_notification_update_cta">Tap for more information.</string> <string name="launcher_notification_update_cta">Tap for more information.</string>
<string name="launcher_self_update_failed_title">Update failed</string>
<string name="launcher_self_update_failed">Failed to install launcher update: %1$s.</string>
<string name="launcher_update_download_failed">Downloading update failed: %1$s</string>
<string name="launcher_self_update_progress">Installing update…</string>
<string name="launcher_starting_game">Starting Geometry Dash…</string> <string name="launcher_starting_game">Starting Geometry Dash…</string>
<string name="launcher_downloading_update">Downloading update…</string> <string name="launcher_downloading_update">Downloading update…</string>
<string name="launcher_downloading_update_details">(%1$s/%2$s)</string> <string name="launcher_downloading_update_details">(%1$s/%2$s)</string>
<string name="launcher_cancelled_manual">Launch paused.</string> <string name="launcher_cancelled_manual">Launch paused.</string>
<string name="launcher_cancelled_navigated_away">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_crash">Launch paused due to a crash.</string>
<string name="launcher_cancelled_crash_instant">Geode crashed.</string> <string name="launcher_cancelled_error">Could not start Geode.</string>
<string name="launcher_cancelled_error">Geode cannot be used.</string>
<string name="launcher_cancelled_outdated">A Geometry Dash update is required.</string> <string name="launcher_cancelled_outdated">A Geometry Dash update is required.</string>
<string name="launcher_cancelled_restart">Restart</string> <string name="launcher_cancelled_restart">Restart</string>
<string name="launcher_cancelled_resume">Resume</string> <string name="launcher_cancelled_resume">Resume</string>
@@ -103,11 +93,6 @@
<string name="release_fetch_try_later">Please try again later.</string> <string name="release_fetch_try_later">Please try again later.</string>
<string name="release_fetch_manual_check_required">Externally managed library in use, please manually check for updates if you want to overwrite it.</string> <string name="release_fetch_manual_check_required">Externally managed library in use, please manually check for updates if you want to overwrite it.</string>
<string name="release_fetch_external_in_use_short">Update failed. External library in use.</string>
<string name="release_fetch_no_internet_short">Update failed. Internet not connected.</string>
<string name="release_fetch_generic_short">Update failed. Please try again later.</string>
<!-- GeometryDashActivity strings --> <!-- GeometryDashActivity strings -->
<string name="load_failed_link_error">Failed to link libraries</string> <string name="load_failed_link_error">Failed to link libraries</string>
<string name="load_failed_link_error_description">Game libraries could not be found. Try to:</string> <string name="load_failed_link_error_description">Game libraries could not be found. Try to:</string>
@@ -121,7 +106,7 @@
<string name="load_failed_recommendation_switch_64bit_abi">Install the non 32-bit version of the launcher.</string> <string name="load_failed_recommendation_switch_64bit_abi">Install the non 32-bit version of the launcher.</string>
<string name="load_failed_recommendation_update">Verify that the latest versions of the launcher and Geometry Dash are installed.</string> <string name="load_failed_recommendation_update">Verify that the latest versions of the launcher and Geometry Dash are installed.</string>
<string name="load_failed_recommendation_report">Report this issue to the developers.</string> <string name="load_failed_recommendation_report">Report this issue to the developers.</string>
<string name="load_failed_recommendation_safe_mode">Enable the safe mode button in Settings.</string> <string name="load_failed_recommendation_safe_mode">Hold the Launch button to launch in safe mode.</string>
<string name="load_failed_recommendation_dev_stl">Devs: If this is a locally built Geode version, please use the c++_shared ANDROID_STL!</string> <string name="load_failed_recommendation_dev_stl">Devs: If this is a locally built Geode version, please use the c++_shared ANDROID_STL!</string>
<string name="message_box_cancel">Cancel</string> <string name="message_box_cancel">Cancel</string>
@@ -156,15 +141,6 @@
<string name="preference_set_application_icon">Application icon</string> <string name="preference_set_application_icon">Application icon</string>
<string name="preference_force_hrr">Force maximum refresh rate</string> <string name="preference_force_hrr">Force maximum refresh rate</string>
<string name="preference_enable_redesign">✨ Enable UI refresh</string> <string name="preference_enable_redesign">✨ Enable UI refresh</string>
<string name="preference_disable_redesign">Disable UI refresh</string>
<string name="preference_launch_delay">Launch delay</string>
<string name="preference_launch_delay_select">Select launch delay</string>
<string name="preference_launch_delay_instant">None</string>
<plurals name="preference_launch_delay_value">
<item quantity="one">%1$d second</item>
<item quantity="other">%1$d seconds</item>
</plurals>
<string name="preference_display_mode_name">Display mode</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_select">Select display mode</string>
@@ -215,7 +191,6 @@
<string name="preference_launcher_version_name">Android Launcher</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_description">v%1$s</string>
<string name="preference_launcher_version">Android Launcher v%1$s</string> <string name="preference_launcher_version">Android Launcher v%1$s</string>
<string name="preference_enable_developer_again">You are already a developer!</string>
<string name="preference_loader_version_name">Geode</string> <string name="preference_loader_version_name">Geode</string>
<string name="preference_loader_version_description">%1$s</string> <string name="preference_loader_version_description">%1$s</string>
@@ -233,7 +208,7 @@
<string name="preference_architecture">%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_button">Check for updates</string>
<string name="preference_check_for_updates_description">Last checked %1$s</string> <string name="preference_check_for_updates_description">Current loader version: %1$s</string>
<string name="preference_check_for_updates_failed">Update failed. See logs for more details.</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_none_found">No updates found.</string>
<string name="preference_check_for_updates_success">Geode has been updated!</string> <string name="preference_check_for_updates_success">Geode has been updated!</string>
@@ -242,38 +217,10 @@
<string name="preference_check_for_updates_already_updating">An update is already in progress!</string> <string name="preference_check_for_updates_already_updating">An update is already in progress!</string>
<string name="preference_category_developer">Advanced</string> <string name="preference_category_developer">Advanced</string>
<string name="preference_launch_safe_mode">Launch in safe mode</string>
<string name="preference_launch_safe_mode_confirm_title">Start in safe mode?</string>
<string name="preference_backup_cloud">Backup save to cloud</string>
<string name="preference_backup_confirm_title">Backup save data?</string>
<string name="preference_backup_in_progress">Backing up data…</string>
<string name="preference_backup_cancelling">Cancelling backup…</string>
<string name="preference_backup_confirm_description">This will backup your save data using your in-game account. You can then access it on an unmodified version of the game through the account backup menu.\nContinuing will overwrite any save data you currently have on the Geometry Dash servers!</string>
<string name="preference_backup_save_missing">Failed to find save data.</string>
<string name="preference_backup_save_decode_failed">Failed to decode save file.</string>
<string name="preference_backup_save_too_large">Save file is too large.</string>
<string name="preference_backup_save_details_missing">Failed to find account information.</string>
<string name="preference_backup_success">Backup success!</string>
<string name="preference_backup_cancelled">Backup cancelled.</string>
<string name="preference_backup_failed_generic">An unknown error has occurred.</string>
<string name="preference_backup_failed_invalid_login">Invalid login information.</string>
<string name="preference_backup_failed_network">A network error has occurred. Please try again later.</string>
<string name="preference_backup_failed_title">Backup failed</string>
<string name="preference_backup_failed">Backup failed with error code %1$d.</string>
<string name="preference_launch_arguments_name">Launch arguments</string> <string name="preference_launch_arguments_name">Launch arguments</string>
<string name="preference_launch_arguments_set_title">Set launch arguments</string> <string name="preference_launch_arguments_set_title">Set launch arguments</string>
<string name="preference_text_clear">Clear</string> <string name="preference_text_clear">Clear</string>
<string name="preference_disable_update_cache">Disable update cache</string>
<string name="preference_open_developer_options">Open developer options</string> <string name="preference_open_developer_options">Open developer options</string>
<string name="preference_developer_options_dialog_title">Enable developer options?</string> <string name="preference_developer_options_dialog_title">Enable developer options?</string>
@@ -315,12 +262,16 @@
<string name="preference_override_exceptions_description">Uses exception_symbols.txt to patch</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> <string name="preference_use_index_update_api">Use index release API</string>
<string name="preference_development_server_name">Enable development server</string>
<string name="preference_development_server_description">Opens a public development server on port 9043.</string>
<string name="notification_channel_development_server">Development Server Service</string>
<string name="notification_channel_development_server_description">Indicates the development server is currently active.</string>
<!-- CrashLogsActivity strings --> <!-- CrashLogsActivity strings -->
<string name="title_activity_crash_logs">Crash dumps</string> <string name="title_activity_crash_logs">Crash dumps</string>
<string name="application_crashes_no_dumps">No crash dumps available.</string> <string name="application_crashes_no_dumps">No crash dumps available.</string>
<string name="application_crashes_delete_one">Delete</string> <string name="application_crashes_delete_one">Delete</string>
<string name="application_crashes_clear_indicator">Ignore last crash</string>
<string name="application_crashes_create_indicator">Fake crash</string>
<!-- ApplicationIconActivity strings --> <!-- ApplicationIconActivity strings -->
<string name="title_activity_application_icon">Application icons</string> <string name="title_activity_application_icon">Application icons</string>
@@ -329,6 +280,6 @@
<string name="application_icon_geode">Geode</string> <string name="application_icon_geode">Geode</string>
<string name="application_icon_pride">Pride</string> <string name="application_icon_pride">Pride</string>
<string name="application_icon_trans">Transgender</string> <string name="application_icon_trans">Transgender</string>
<string name="application_icon_confirm">Change icon to %1$s?</string>
<string name="application_icon_confirm_description">Geode Launcher will close itself to change the application icon.</string>
</resources> </resources>
+6 -5
View File
@@ -1,13 +1,14 @@
buildscript { buildscript {
val composeBOM by extra("2026.02.00") val composeBOM by extra("2025.12.00")
} }
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins { plugins {
id("com.android.application") version "9.0.1" apply false id("com.android.application") version "8.13.2" apply false
id("com.android.library") version "9.0.1" apply false id("com.android.library") version "8.13.2" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.3.10" apply false id("org.jetbrains.kotlin.android") version "2.3.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.10" apply false id("org.jetbrains.kotlin.plugin.serialization") version "2.3.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.0" apply false
} }
tasks.register<Delete>("clean") { tasks.register<Delete>("clean") {
+1 -2
View File
@@ -1,7 +1,6 @@
#Wed Jan 28 04:12:33 MST 2026
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME