mirror of
https://github.com/geode-sdk/android-launcher.git
synced 2026-10-22 16:42:38 +00:00
Compare commits
3
Commits
1.7.3
..
web-portal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
208819436a | ||
|
|
d7c9e82fc2 | ||
|
|
8448b3f2a9 |
No files matched your search
@@ -60,29 +60,8 @@ jobs:
|
||||
mv $APK_PATH $OUT_PATH
|
||||
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Rename mappings
|
||||
id: rename_mappings
|
||||
if: ${{ matrix.config.lower == 'release' }}
|
||||
run: |
|
||||
SHORT_SHA=${GITHUB_SHA::7}
|
||||
MAPPING_PATH=${{github.workspace}}/app/build/outputs/mapping/${{ matrix.config.lower }}/mapping.txt
|
||||
OUT_PATH=${{github.workspace}}/mapping-${{ matrix.config.lower }}-$SHORT_SHA.txt
|
||||
mv $MAPPING_PATH $OUT_PATH
|
||||
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload release build
|
||||
- name: Upload development build
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ matrix.config.lower == 'release' }}
|
||||
with:
|
||||
name: geode-launcher-${{ matrix.config.lower }}
|
||||
path: |
|
||||
${{ steps.rename_apk_universal.outputs.path }}
|
||||
${{ steps.rename_apk.outputs.path }}
|
||||
${{ steps.rename_mappings.outputs.path }}
|
||||
|
||||
- name: Upload debug build
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ matrix.config.lower == 'debug' }}
|
||||
with:
|
||||
name: geode-launcher-${{ matrix.config.lower }}
|
||||
path: |
|
||||
|
||||
@@ -1,18 +1,3 @@
|
||||
# Geode Android Launcher
|
||||
|
||||
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 |
|
||||
+26
-15
@@ -1,3 +1,5 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
val composeBOM: String by rootProject.extra
|
||||
|
||||
kotlin {
|
||||
@@ -8,6 +10,7 @@ kotlin {
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
@@ -18,9 +21,9 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "com.geode.launcher"
|
||||
minSdk = 23
|
||||
targetSdk = 36
|
||||
versionCode = 26
|
||||
versionName = "1.7.3"
|
||||
targetSdk = 35
|
||||
versionCode = 22
|
||||
versionName = "1.6.1"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
@@ -29,7 +32,7 @@ android {
|
||||
@Suppress("UnstableApiUsage")
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
arguments("-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
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.JVM_1_8
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
@@ -78,7 +86,7 @@ android {
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
version = "4.0.0+"
|
||||
version = "3.25.0+"
|
||||
path = file("src/main/cpp/CMakeLists.txt")
|
||||
}
|
||||
}
|
||||
@@ -94,22 +102,25 @@ dependencies {
|
||||
implementation ("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
|
||||
implementation ("androidx.activity:activity-compose:1.12.4")
|
||||
implementation ("androidx.activity:activity-ktx:1.12.4")
|
||||
implementation ("androidx.activity:activity-compose:1.12.1")
|
||||
implementation ("androidx.activity:activity-ktx:1.12.1")
|
||||
implementation ("androidx.appcompat:appcompat:1.7.1")
|
||||
implementation ("androidx.documentfile:documentfile:1.1.0")
|
||||
|
||||
implementation ("com.squareup.okio:okio:3.16.4")
|
||||
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.10.0")
|
||||
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.10.0")
|
||||
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-datetime:0.7.1")
|
||||
|
||||
implementation ("com.mikepenz:multiplatform-markdown-renderer-android:0.39.2")
|
||||
implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.39.2")
|
||||
implementation ("com.mikepenz:multiplatform-markdown-renderer-android:0.38.1")
|
||||
implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.38.1")
|
||||
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")
|
||||
coreLibraryDesugaring ("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
}
|
||||
Vendored
+1
@@ -5,3 +5,4 @@
|
||||
|
||||
# note: if you're going to add more rules, consider the @Keep annotation
|
||||
# this should really only be kept to non-custom code
|
||||
-dontwarn java.lang.management.**
|
||||
@@ -20,9 +20,10 @@
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<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
|
||||
android:name="android.hardware.wifi"
|
||||
@@ -30,83 +31,38 @@
|
||||
<uses-feature
|
||||
android:glEsVersion="0x00020000"
|
||||
android:required="true" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.gamepad"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:appCategory="game"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:hasFragileUserData="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:hasFragileUserData="true"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:theme="@style/Theme.GeodeLauncher"
|
||||
tools:targetApi="35">
|
||||
<receiver
|
||||
android:name=".InstallReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
<activity
|
||||
android:name=".AltMainActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.GeodeLauncher" />
|
||||
<activity
|
||||
android:name=".preferences.DeveloperSettingsActivity"
|
||||
android:exported="true"
|
||||
android:exported="false"
|
||||
android:label="@string/title_activity_developer_settings"
|
||||
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>
|
||||
android:theme="@style/Theme.GeodeLauncher" />
|
||||
<activity
|
||||
android:name=".preferences.ApplicationLogsActivity"
|
||||
android:exported="true"
|
||||
android:exported="false"
|
||||
android:label="@string/title_activity_application_logs"
|
||||
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>
|
||||
android:theme="@style/Theme.GeodeLauncher" />
|
||||
<activity
|
||||
android:name=".preferences.CrashLogsActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/title_activity_crash_logs"
|
||||
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
|
||||
android:name=".preferences.GeodeLogsListingActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/title_activity_geode_logs"
|
||||
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="geode-logs" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".preferences.GeodeLogViewActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.GeodeLauncher">
|
||||
</activity>
|
||||
android:label="@string/title_activity_crash_logs"
|
||||
android:theme="@style/Theme.GeodeLauncher" />
|
||||
<activity
|
||||
android:name=".preferences.ApplicationIconActivity"
|
||||
android:exported="false"
|
||||
@@ -114,24 +70,17 @@
|
||||
android:theme="@style/Theme.GeodeLauncher" />
|
||||
<activity
|
||||
android:name=".preferences.SettingsActivity"
|
||||
android:exported="true"
|
||||
android:exported="false"
|
||||
android:label="@string/title_activity_settings"
|
||||
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
|
||||
android:name=".GeometryDashActivity"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize"
|
||||
android:exported="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:screenOrientation="landscape"
|
||||
android:theme="@style/Theme.NoActionBar.Fullscreen"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize"
|
||||
android:windowSoftInputMode="stateAlwaysHidden">
|
||||
<meta-data
|
||||
android:name="android.app.lib_name"
|
||||
@@ -143,77 +92,47 @@
|
||||
android:theme="@style/Theme.GeodeLauncher">
|
||||
<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>
|
||||
|
||||
<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
|
||||
android:name=".MainActivityTrans"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher_trans"
|
||||
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=".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>
|
||||
|
||||
<provider
|
||||
@@ -239,6 +158,12 @@
|
||||
<meta-data
|
||||
android:name="android.game_mode_config"
|
||||
android:resource="@xml/game_mode_config" />
|
||||
|
||||
<service
|
||||
android:name=".DeveloperServerService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:exported="false">
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</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>
|
||||
@@ -1,17 +1,47 @@
|
||||
cmake_minimum_required(VERSION 4.1.2)
|
||||
cmake_minimum_required(VERSION 3.18.1)
|
||||
|
||||
project(launcherfix)
|
||||
|
||||
add_library(launcherfix SHARED launcher-fix.cpp patcher.cpp)
|
||||
add_library(launcherfix SHARED launcher-fix.cpp)
|
||||
|
||||
target_compile_features(launcherfix PUBLIC cxx_std_20)
|
||||
set_target_properties(launcherfix PROPERTIES CXX_EXTENSIONS OFF)
|
||||
|
||||
set_target_properties(launcherfix PROPERTIES
|
||||
INTERPROCEDURAL_OPTIMIZATION ON
|
||||
UNITY_BUILD ON
|
||||
C_VISIBILITY_PRESET hidden
|
||||
CXX_VISIBILITY_PRESET hidden
|
||||
)
|
||||
include(FetchContent)
|
||||
|
||||
option(USE_TULIPHOOK "enables choice of hooking library" OFF)
|
||||
if (USE_TULIPHOOK)
|
||||
FetchContent_Declare(TulipHook
|
||||
GIT_REPOSITORY https://github.com/geode-sdk/TulipHook.git
|
||||
GIT_TAG v3.1.4
|
||||
)
|
||||
|
||||
set(TULIP_LINK_SOURCE ON CACHE INTERNAL "")
|
||||
FetchContent_MakeAvailable(TulipHook)
|
||||
|
||||
target_compile_definitions(launcherfix PRIVATE -DUSE_TULIPHOOK=1)
|
||||
|
||||
target_link_libraries(launcherfix PRIVATE TulipHook TulipHookInclude)
|
||||
else()
|
||||
FetchContent_Declare(dobby
|
||||
# this is the last version that builds on android at all
|
||||
GIT_REPOSITORY https://github.com/jmpews/Dobby
|
||||
GIT_TAG 0932d69c320e786672361ab53825ba8f4245e9d3
|
||||
)
|
||||
|
||||
FetchContent_GetProperties(dobby)
|
||||
if(NOT dobby_POPULATED)
|
||||
FetchContent_Populate(dobby)
|
||||
|
||||
# fixes Dobby build
|
||||
# add_compile_definitions(typeof=__typeof__)
|
||||
set(DOBBY_DEBUG OFF CACHE INTERNAL "" FORCE)
|
||||
set(DOBBY_GENERATE_SHARED OFF CACHE INTERNAL "" FORCE)
|
||||
|
||||
add_subdirectory("${dobby_SOURCE_DIR}" ${dobby_BINARY_DIR} EXCLUDE_FROM_ALL)
|
||||
target_include_directories(launcherfix PRIVATE ${dobby_SOURCE_DIR}/include)
|
||||
target_link_libraries(launcherfix PRIVATE dobby)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
target_link_libraries(launcherfix PRIVATE log)
|
||||
@@ -1,39 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <array>
|
||||
#include <string_view>
|
||||
|
||||
#include <link.h>
|
||||
|
||||
class DataPaths {
|
||||
public:
|
||||
static std::string original_data_path;
|
||||
static std::string data_path;
|
||||
|
||||
static bool patch_exceptions;
|
||||
};
|
||||
|
||||
// this is every function that i thought would be relevant
|
||||
constexpr std::array<const std::string_view, 10> remove_symbols{
|
||||
"__gxx_personality_v0",
|
||||
"__cxa_throw",
|
||||
"__cxa_rethrow",
|
||||
"__cxa_allocate_exception",
|
||||
"__cxa_end_catch",
|
||||
"__cxa_begin_catch",
|
||||
"__cxa_guard_abort",
|
||||
"__cxa_guard_acquire",
|
||||
"__cxa_guard_release",
|
||||
"__cxa_free_exception"
|
||||
|
||||
// android 10's libc doesn't have these symbols
|
||||
// and the ndk is forbidden from exporting them!! so keep it for old mods...
|
||||
// "_Unwind_RaiseException",
|
||||
// "_Unwind_Resume"
|
||||
};
|
||||
|
||||
FILE* fopen_hook(const char* pathname, const char* mode);
|
||||
int rename_hook(const char* old_path, const char* new_path);
|
||||
|
||||
int on_dl_iterate(dl_phdr_info* info, size_t size, void* data);
|
||||
@@ -1,13 +1,36 @@
|
||||
#include <dlfcn.h>
|
||||
#include <jni.h>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
#include <android/log.h>
|
||||
#include <link.h>
|
||||
|
||||
#include "base.h"
|
||||
#include "log.hpp"
|
||||
#ifndef DISABLE_LAUNCHER_FIX
|
||||
|
||||
std::string DataPaths::original_data_path{};
|
||||
std::string DataPaths::data_path{};
|
||||
#ifdef USE_TULIPHOOK
|
||||
#include <tulip/TulipHook.hpp>
|
||||
#else
|
||||
#include <dobby.h>
|
||||
#endif
|
||||
|
||||
bool DataPaths::patch_exceptions{};
|
||||
#endif
|
||||
|
||||
class DataPaths {
|
||||
public:
|
||||
std::string original_data_path{};
|
||||
std::string data_path{};
|
||||
std::string load_symbols_from{};
|
||||
|
||||
static DataPaths& get_instance() {
|
||||
static auto paths_instance = DataPaths();
|
||||
return paths_instance;
|
||||
}
|
||||
|
||||
private:
|
||||
DataPaths() {}
|
||||
};
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setDataPath(
|
||||
@@ -18,11 +41,25 @@ JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setDataPath(
|
||||
auto is_copy = jboolean();
|
||||
auto data_path_str = env->GetStringUTFChars(data_path, &is_copy);
|
||||
|
||||
DataPaths::data_path = std::string(data_path_str);
|
||||
DataPaths::get_instance().data_path = std::string(data_path_str);
|
||||
|
||||
env->ReleaseStringUTFChars(data_path, data_path_str);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_enableCustomSymbolList(
|
||||
JNIEnv* env,
|
||||
jobject,
|
||||
jstring symbol_path
|
||||
) {
|
||||
auto is_copy = jboolean();
|
||||
auto symbol_path_str = env->GetStringUTFChars(symbol_path, &is_copy);
|
||||
|
||||
DataPaths::get_instance().load_symbols_from = std::string(symbol_path_str);
|
||||
|
||||
env->ReleaseStringUTFChars(symbol_path, symbol_path_str);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setOriginalDataPath(
|
||||
JNIEnv *env,
|
||||
@@ -32,52 +69,227 @@ JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setOriginalDataPath(
|
||||
auto is_copy = jboolean();
|
||||
auto data_path_str = env->GetStringUTFChars(data_path, &is_copy);
|
||||
|
||||
DataPaths::original_data_path = std::string(data_path_str);
|
||||
DataPaths::get_instance().original_data_path = std::string(data_path_str);
|
||||
|
||||
env->ReleaseStringUTFChars(data_path, data_path_str);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_enableExceptionsRenaming(JNIEnv*, jobject) {
|
||||
DataPaths::patch_exceptions = true;
|
||||
// i copied this function from the android linker
|
||||
std::uint32_t elfhash(const char *_name) {
|
||||
const unsigned char *name = reinterpret_cast<const unsigned char*>(_name);
|
||||
std::uint32_t h = 0, g;
|
||||
|
||||
while (*name) {
|
||||
h = (h << 4) + *name++;
|
||||
g = h & 0xf0000000;
|
||||
h ^= g;
|
||||
h ^= g >> 24;
|
||||
}
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
std::array<std::uint8_t, 3> SYMBOL_PATCH_VALUE{":3"};
|
||||
|
||||
bool patch_symbol(std::uint32_t* hash_table, char* str_table, ElfW(Sym)* sym_table, const char* orig_name) {
|
||||
auto hash = elfhash(orig_name);
|
||||
|
||||
auto nbucket = hash_table[0];
|
||||
auto nchain = hash_table[1];
|
||||
auto bucket = &hash_table[2];
|
||||
auto chain = &bucket[nbucket];
|
||||
|
||||
for (auto i = bucket[hash % nbucket]; i != 0; i = chain[i]) {
|
||||
auto sym = sym_table + i;
|
||||
auto sym_name = str_table + sym->st_name;
|
||||
|
||||
if (strcmp(sym_name, orig_name) == 0) {
|
||||
// there's probably no point to checking this, honestly
|
||||
if (ELF_ST_BIND(sym->st_info) == STB_GLOBAL && ELF_ST_TYPE(sym->st_info) == STT_FUNC) {
|
||||
// we found it! now go rename the symbol
|
||||
|
||||
#ifdef USE_TULIPHOOK
|
||||
auto res = tulip::hook::writeMemory(sym_name, SYMBOL_PATCH_VALUE.data(), SYMBOL_PATCH_VALUE.size());
|
||||
if (!res) {
|
||||
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "failed to patch symbol: %s", res.unwrapErr().c_str());
|
||||
return false;
|
||||
}
|
||||
#else
|
||||
DobbyCodePatch(reinterpret_cast<void*>(sym_name), SYMBOL_PATCH_VALUE.data(), SYMBOL_PATCH_VALUE.size());
|
||||
#endif
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "could not find symbol %s to patch (hash: %u)", orig_name, hash);
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<std::string> get_symbols_listing() {
|
||||
auto symbol_path = DataPaths::get_instance().load_symbols_from;
|
||||
if (symbol_path.empty()) {
|
||||
// this is every function that i thought would be relevant
|
||||
return {
|
||||
"__gxx_personality_v0",
|
||||
"__cxa_throw",
|
||||
"__cxa_rethrow",
|
||||
"__cxa_allocate_exception",
|
||||
"__cxa_end_catch",
|
||||
"__cxa_begin_catch",
|
||||
"__cxa_guard_abort",
|
||||
"__cxa_guard_acquire",
|
||||
"__cxa_guard_release",
|
||||
"__cxa_free_exception"
|
||||
|
||||
// android 10's libc doesn't have these symbols
|
||||
// and the ndk is forbidden from exporting them!! so keep it for old mods...
|
||||
// "_Unwind_RaiseException",
|
||||
// "_Unwind_Resume"
|
||||
};
|
||||
}
|
||||
|
||||
std::ifstream symbol_file{symbol_path};
|
||||
if (!symbol_file) {
|
||||
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "failed to read symbol file at %s", symbol_path.c_str());
|
||||
return {};
|
||||
}
|
||||
|
||||
std::vector<std::string> symbols_list{};
|
||||
std::string current_line{};
|
||||
while (std::getline(symbol_file, current_line)) {
|
||||
if (!current_line.empty()) {
|
||||
symbols_list.push_back(current_line);
|
||||
}
|
||||
}
|
||||
|
||||
return symbols_list;
|
||||
}
|
||||
|
||||
int on_dl_iterate(dl_phdr_info* info, size_t size, void* data) {
|
||||
// this is probably going to be gd
|
||||
if (info->dlpi_name != nullptr && strstr(info->dlpi_name, "libcocos2dcpp.so") != nullptr) {
|
||||
// step 1: get the dynamic table
|
||||
std::uintptr_t dyn_addr = 0u;
|
||||
|
||||
for (auto i = 0u; i < info->dlpi_phnum; i++) {
|
||||
auto phdr = info->dlpi_phdr + i;
|
||||
|
||||
if (phdr->p_type == PT_DYNAMIC) {
|
||||
dyn_addr = info->dlpi_addr + phdr->p_vaddr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (dyn_addr == 0u) {
|
||||
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "failed to find libcocos dynamic section");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// step 2: get the symbol table
|
||||
auto dyn_entry = reinterpret_cast<ElfW(Dyn)*>(dyn_addr);
|
||||
auto dyn_end_reached = false;
|
||||
|
||||
std::uintptr_t sym_table_addr = 0u;
|
||||
std::uintptr_t str_table_addr = 0u;
|
||||
std::uintptr_t hash_table_addr = 0u;
|
||||
std::uintptr_t soname_idx = 0u;
|
||||
|
||||
while (!dyn_end_reached) {
|
||||
auto tag = dyn_entry->d_tag;
|
||||
|
||||
switch (dyn_entry->d_tag) {
|
||||
case DT_SYMTAB:
|
||||
sym_table_addr = info->dlpi_addr + dyn_entry->d_un.d_val;
|
||||
break;
|
||||
case DT_STRTAB:
|
||||
str_table_addr = info->dlpi_addr + dyn_entry->d_un.d_val;
|
||||
break;
|
||||
case DT_HASH:
|
||||
hash_table_addr = info->dlpi_addr + dyn_entry->d_un.d_val;
|
||||
break;
|
||||
case DT_SONAME:
|
||||
soname_idx = dyn_entry->d_un.d_val;
|
||||
break;
|
||||
case DT_NULL:
|
||||
dyn_end_reached = true;
|
||||
break;
|
||||
}
|
||||
|
||||
dyn_entry++;
|
||||
}
|
||||
|
||||
if (hash_table_addr == 0u || str_table_addr == 0u || sym_table_addr == 0u) {
|
||||
__android_log_print(ANDROID_LOG_ERROR, "GeodeLauncher-fix", "failed to parse dynamic section (at least one table is null)");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// patch symbol names
|
||||
auto hash_table = reinterpret_cast<std::uint32_t*>(hash_table_addr);
|
||||
auto str_table = reinterpret_cast<char*>(str_table_addr);
|
||||
auto sym_table = reinterpret_cast<ElfW(Sym)*>(sym_table_addr);
|
||||
|
||||
auto so_name = str_table + soname_idx;
|
||||
if (strcmp(so_name, "libcocos2dcpp.so") != 0) {
|
||||
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "DT_SONAME gives us %s! that's not good", so_name);
|
||||
}
|
||||
|
||||
auto has_error = false;
|
||||
auto symbols_listing = get_symbols_listing();
|
||||
for (const auto& symbol : symbols_listing) {
|
||||
if (!patch_symbol(hash_table, str_table, sym_table, symbol.c_str())) {
|
||||
has_error = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (has_error) {
|
||||
__android_log_print(
|
||||
ANDROID_LOG_INFO,
|
||||
"GeodeLauncher-fix",
|
||||
"symbol patch diagnostics\nlibrary path: %s\naddrs: base=%p sym=%p str=%p hash=%p",
|
||||
info->dlpi_name, reinterpret_cast<void*>(info->dlpi_addr), sym_table, str_table, hash_table
|
||||
);
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
}
|
||||
|
||||
// this should be called after gd is loaded but before geode
|
||||
extern "C" JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_performPatches(JNIEnv*, jobject) {
|
||||
dl_iterate_phdr(on_dl_iterate, nullptr);
|
||||
extern "C"
|
||||
JNIEXPORT int JNICALL Java_com_geode_launcher_LauncherFix_performExceptionsRenaming(JNIEnv*, jobject) {
|
||||
return dl_iterate_phdr(on_dl_iterate, nullptr);
|
||||
}
|
||||
|
||||
std::optional<std::string> redirect_path(const char* pathname) {
|
||||
std::string_view data_path = DataPaths::data_path;
|
||||
std::string_view original_data_path = DataPaths::original_data_path;
|
||||
auto& data_path = DataPaths::get_instance().data_path;
|
||||
auto& original_data_path = DataPaths::get_instance().original_data_path;
|
||||
|
||||
if (data_path.empty() || original_data_path.empty()) [[unlikely]] {
|
||||
return std::nullopt;
|
||||
}
|
||||
if (!data_path.empty() && !original_data_path.empty()) {
|
||||
// call this a c string optimization
|
||||
if (std::strncmp(pathname, original_data_path.c_str(), original_data_path.size()) == 0) {
|
||||
auto path = data_path + (pathname + original_data_path.size());
|
||||
|
||||
// call this a c string optimization
|
||||
if (std::strncmp(pathname, original_data_path.data(), original_data_path.size()) == 0) {
|
||||
auto remaining_path = (pathname + original_data_path.size());
|
||||
auto remaining_len = strlen(remaining_path);
|
||||
|
||||
std::string x;
|
||||
x.reserve(data_path.size() + remaining_len);
|
||||
x.append(data_path);
|
||||
x.append(remaining_path, remaining_len);
|
||||
|
||||
return x;
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
FILE* (*fopen_original)(const char *pathname, const char *mode);
|
||||
FILE* fopen_hook(const char* pathname, const char* mode) {
|
||||
auto path_str = redirect_path(pathname);
|
||||
auto redirect = path_str.has_value() ? path_str->c_str() : pathname;
|
||||
|
||||
return fopen(redirect, mode);
|
||||
return fopen_original(redirect, mode);
|
||||
}
|
||||
|
||||
int (*rename_original)(const char* old_path, const char* new_path);
|
||||
int rename_hook(const char* old_path, const char* new_path) {
|
||||
auto old_path_str = redirect_path(old_path);
|
||||
auto old_redirect = old_path_str ? old_path_str->c_str() : old_path;
|
||||
@@ -85,5 +297,63 @@ int rename_hook(const char* old_path, const char* new_path) {
|
||||
auto new_path_str = redirect_path(new_path);
|
||||
auto new_redirect = new_path_str ? new_path_str->c_str() : new_path;
|
||||
|
||||
return rename(old_redirect, new_redirect);
|
||||
return rename_original(old_redirect, new_redirect);
|
||||
}
|
||||
|
||||
bool hook_function(void* addr, auto* hook, auto** orig) {
|
||||
#ifdef USE_TULIPHOOK
|
||||
using namespace tulip::hook;
|
||||
|
||||
HandlerMetadata metadata = {
|
||||
std::make_shared<PlatformConvention>(),
|
||||
AbstractFunction::from(hook)
|
||||
};
|
||||
|
||||
auto handler_result = createHandler(addr, metadata);
|
||||
if (!handler_result) {
|
||||
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-Fix",
|
||||
"failed to create handler: %s\n", handler_result.unwrapErr().c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
auto handler = *handler_result;
|
||||
|
||||
HookMetadata hook_metadata = {0};
|
||||
|
||||
createHook(handler, reinterpret_cast<void*>(hook), hook_metadata);
|
||||
|
||||
WrapperMetadata wrapper_metadata = {
|
||||
std::make_unique<PlatformConvention>(),
|
||||
AbstractFunction::from(hook)
|
||||
};
|
||||
|
||||
auto wrapper = createWrapper(addr, std::move(wrapper_metadata));
|
||||
if (!wrapper) {
|
||||
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-Fix",
|
||||
"failed to create wrapper: %s\n", wrapper.unwrapErr().c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
*reinterpret_cast<void**>(orig) = *wrapper;
|
||||
|
||||
return true;
|
||||
#else
|
||||
DobbyHook(
|
||||
addr,
|
||||
reinterpret_cast<dobby_dummy_func_t>(hook),
|
||||
reinterpret_cast<dobby_dummy_func_t*>(orig)
|
||||
);
|
||||
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
[[gnu::constructor]] [[gnu::used]] void setup_hooks() {
|
||||
#ifndef DISABLE_LAUNCHER_FIX
|
||||
auto fopen_addr = dlsym(RTLD_NEXT, "fopen");
|
||||
hook_function(fopen_addr, &fopen_hook, &fopen_original);
|
||||
|
||||
auto rename_addr = dlsym(RTLD_NEXT, "rename");
|
||||
hook_function(rename_addr, &rename_hook, &rename_original);
|
||||
#endif
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#define LOG_TAG "GeodeLauncher-Fix"
|
||||
|
||||
#include <android/log.h>
|
||||
#include <format>
|
||||
|
||||
namespace log {
|
||||
template<typename... Args>
|
||||
void log_internal(android_LogPriority prio, std::format_string<Args...> fmt, Args&&... args) {
|
||||
std::string str = std::format(fmt, std::forward<Args>(args)...);
|
||||
__android_log_write(prio, LOG_TAG, str.c_str());
|
||||
}
|
||||
|
||||
template<typename... Args>
|
||||
inline void debug(std::format_string<Args...> fmt, Args&&... args) {
|
||||
log_internal(ANDROID_LOG_DEBUG, fmt, std::forward<Args>(args)...);
|
||||
}
|
||||
|
||||
template<typename... Args>
|
||||
inline void info(std::format_string<Args...> fmt, Args&&... args) {
|
||||
log_internal(ANDROID_LOG_INFO, fmt, std::forward<Args>(args)...);
|
||||
}
|
||||
|
||||
template<typename... Args>
|
||||
inline void warn(std::format_string<Args...> fmt, Args&&... args) {
|
||||
log_internal(ANDROID_LOG_WARN, fmt, std::forward<Args>(args)...);
|
||||
}
|
||||
|
||||
template<typename... Args>
|
||||
inline void error(std::format_string<Args...> fmt, Args&&... args) {
|
||||
log_internal(ANDROID_LOG_ERROR, fmt, std::forward<Args>(args)...);
|
||||
}
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
|
||||
#include <link.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/mman.h>
|
||||
|
||||
#include "log.hpp"
|
||||
#include "base.h"
|
||||
|
||||
struct ElfDynamicState {
|
||||
std::uint32_t* hash_table{};
|
||||
char* str_table{};
|
||||
ElfW(Sym)* sym_table{};
|
||||
|
||||
void* plt_table{};
|
||||
std::uint64_t plt_table_size_bytes{};
|
||||
bool plt_table_rela{};
|
||||
|
||||
std::uint64_t soname_idx{};
|
||||
|
||||
bool is_empty() const {
|
||||
return !hash_table || !str_table || !sym_table || !plt_table;
|
||||
}
|
||||
};
|
||||
|
||||
// copied from tuliphook source :)
|
||||
// tuliphook sets WRX perms, but we write to pages with W^X protection
|
||||
bool set_mprotect(std::uintptr_t address, size_t size, int protection) {
|
||||
auto const page_size = getpagesize();
|
||||
auto const page_mask = page_size - 1;
|
||||
|
||||
auto const ptr = reinterpret_cast<uintptr_t>(address);
|
||||
auto const aligned_ptr = ptr & (~page_mask);
|
||||
auto const begin_size = ptr - aligned_ptr;
|
||||
auto const page_count = (begin_size + size + page_mask) / page_size;
|
||||
auto const aligned_size = page_count * page_size;
|
||||
|
||||
auto status = mprotect(reinterpret_cast<void*>(aligned_ptr), aligned_size, protection);
|
||||
|
||||
return status == 0;
|
||||
}
|
||||
|
||||
// i copied this function from the android linker
|
||||
constexpr std::uint32_t elfhash(std::string_view name) {
|
||||
std::uint32_t h = 0, g;
|
||||
|
||||
for (const auto c : name) {
|
||||
h = (h << 4) + static_cast<unsigned char>(c);
|
||||
g = h & 0xf0000000;
|
||||
h ^= g;
|
||||
h ^= g >> 24;
|
||||
}
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
constexpr std::array<const std::uint8_t, 3> SYMBOL_PATCH_VALUE{":3"};
|
||||
|
||||
bool patch_symbol(std::uint32_t* hash_table, char* str_table, ElfW(Sym)* sym_table, const std::string_view orig_name) {
|
||||
auto hash = elfhash(orig_name);
|
||||
|
||||
auto nbucket = hash_table[0];
|
||||
auto nchain = hash_table[1];
|
||||
auto bucket = &hash_table[2];
|
||||
auto chain = &bucket[nbucket];
|
||||
|
||||
for (auto i = bucket[hash % nbucket]; i != 0; i = chain[i]) {
|
||||
auto sym = sym_table + i;
|
||||
auto sym_name = str_table + sym->st_name;
|
||||
|
||||
if (orig_name == sym_name) {
|
||||
// there's probably no point to checking this, honestly
|
||||
if (ELF_ST_BIND(sym->st_info) == STB_GLOBAL && ELF_ST_TYPE(sym->st_info) == STT_FUNC) {
|
||||
// we found it! now go rename the symbol
|
||||
|
||||
log::debug("Disabling symbol at {} ({})", reinterpret_cast<void*>(sym_name), sym_name);
|
||||
|
||||
if (!set_mprotect(reinterpret_cast<std::uintptr_t>(sym_name), SYMBOL_PATCH_VALUE.size(), PROT_READ | PROT_WRITE)) {
|
||||
log::warn("failed to patch symbol: could not set writable flag");
|
||||
return false;
|
||||
}
|
||||
|
||||
memcpy(sym_name, SYMBOL_PATCH_VALUE.data(), SYMBOL_PATCH_VALUE.size());
|
||||
|
||||
if (!set_mprotect(reinterpret_cast<std::uintptr_t>(sym_name), SYMBOL_PATCH_VALUE.size(), PROT_READ)) {
|
||||
log::warn("failed to patch symbol: could not remove writable flag");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::warn("could not find symbol {} to patch (hash: {})", orig_name, hash);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
bool perform_symbol_patches(dl_phdr_info* info, const ElfDynamicState& state) {
|
||||
// patch symbol names
|
||||
auto has_error = false;
|
||||
for (const auto symbol : remove_symbols) {
|
||||
if (!patch_symbol(state.hash_table, state.str_table, state.sym_table, symbol)) {
|
||||
has_error = true;
|
||||
}
|
||||
}
|
||||
|
||||
return !has_error;
|
||||
}
|
||||
|
||||
#if defined(__LP64__)
|
||||
#define ELF_R_SYM(i) ELF64_R_SYM(i)
|
||||
#define ELF_R_TYPE(i) ELF64_R_TYPE(i)
|
||||
#else
|
||||
#define ELF_R_SYM(i) ELF32_R_SYM(i)
|
||||
#define ELF_R_TYPE(i) ELF32_R_TYPE(i)
|
||||
#endif
|
||||
|
||||
template <typename T, typename... Types>
|
||||
concept is_one_of = (std::same_as<T, Types> || ...);
|
||||
|
||||
template <typename T> requires is_one_of<T, ElfW(Rela), ElfW(Rel)>
|
||||
bool rewrite_symbol_reloc(std::uintptr_t addr, T& r, ElfW(Sym)* sym, void* func) {
|
||||
auto type = ELF_R_TYPE(r.r_info);
|
||||
|
||||
// we're only interested in JUMP_SLOT symbols
|
||||
if (type != R_AARCH64_JUMP_SLOT && type != R_ARM_JUMP_SLOT) {
|
||||
log::warn("asked to patch unknown symbol type: {:#x}", type);
|
||||
return false;
|
||||
}
|
||||
|
||||
auto write_addr = reinterpret_cast<std::uintptr_t*>(addr);
|
||||
auto val = reinterpret_cast<std::uintptr_t>(func);
|
||||
if constexpr (std::is_same_v<T, ElfW(Rela)>) {
|
||||
// reloc = S + A
|
||||
val += r.r_addend;
|
||||
}
|
||||
|
||||
log::debug("Patching relocation at vaddr {:#x} (offset={:#x}): {:#x} => {:#x}", addr, r.r_offset, *write_addr, val);
|
||||
|
||||
if (!set_mprotect(addr, sizeof(std::uintptr_t), PROT_WRITE | PROT_READ)) {
|
||||
log::warn("Failed to patch relocation: failed to add writable flag");
|
||||
return false;
|
||||
}
|
||||
|
||||
// why use memcpy when you can do this :)
|
||||
*write_addr = val;
|
||||
|
||||
if (!set_mprotect(addr, sizeof(std::uintptr_t), PROT_READ)) {
|
||||
log::warn("Failed to patch relocation: failed to remove writable flag");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
template <typename T> requires is_one_of<T, ElfW(Rela), ElfW(Rel)>
|
||||
bool perform_reloc_patches(dl_phdr_info* info, const ElfDynamicState& state) {
|
||||
auto table_size = state.plt_table_size_bytes / sizeof(T);
|
||||
|
||||
auto failed = false;
|
||||
|
||||
for (auto& plt : std::span{
|
||||
reinterpret_cast<T*>(state.plt_table),
|
||||
static_cast<std::size_t>(table_size)
|
||||
}) {
|
||||
auto sym = ELF_R_SYM(plt.r_info);
|
||||
if (sym == 0) continue;
|
||||
|
||||
auto sym_data = state.sym_table + sym;
|
||||
auto sym_name = state.str_table + sym_data->st_name;
|
||||
|
||||
auto reloc_addr = info->dlpi_addr + plt.r_offset;
|
||||
|
||||
if (strcmp(sym_name, "fopen") == 0) {
|
||||
if (!rewrite_symbol_reloc(reloc_addr, plt, sym_data, reinterpret_cast<void*>(&fopen_hook))) {
|
||||
failed = true;
|
||||
}
|
||||
} else if (strcmp(sym_name, "rename") == 0) {
|
||||
if (!rewrite_symbol_reloc(reloc_addr, plt, sym_data, reinterpret_cast<void*>(&rename_hook))) {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return !failed;
|
||||
}
|
||||
|
||||
int on_dl_iterate(dl_phdr_info* info, size_t size, void* data) {
|
||||
// this is probably going to be gd
|
||||
if (info->dlpi_name != nullptr && strstr(info->dlpi_name, "libcocos2dcpp.so") != nullptr) {
|
||||
log::debug("Base address of libcocos2dcpp: {:#x}", info->dlpi_addr);
|
||||
|
||||
// step 1: get the dynamic table
|
||||
std::uintptr_t dyn_addr = 0u;
|
||||
|
||||
for (auto& phdr : std::span{ info->dlpi_phdr, info->dlpi_phnum}) {
|
||||
if (phdr.p_type == PT_DYNAMIC) {
|
||||
dyn_addr = info->dlpi_addr + phdr.p_vaddr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (dyn_addr == 0u) {
|
||||
log::warn("failed to find libcocos dynamic section");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// step 2: get the symbol table
|
||||
auto dyn_entry = reinterpret_cast<ElfW(Dyn)*>(dyn_addr);
|
||||
auto dyn_end_reached = false;
|
||||
|
||||
ElfDynamicState state{};
|
||||
|
||||
while (!dyn_end_reached) {
|
||||
auto tag = dyn_entry->d_tag;
|
||||
|
||||
switch (dyn_entry->d_tag) {
|
||||
case DT_SYMTAB:
|
||||
state.sym_table = reinterpret_cast<ElfW(Sym)*>(info->dlpi_addr + dyn_entry->d_un.d_val);
|
||||
break;
|
||||
case DT_STRTAB:
|
||||
state.str_table = reinterpret_cast<char*>(info->dlpi_addr + dyn_entry->d_un.d_val);
|
||||
break;
|
||||
case DT_HASH:
|
||||
state.hash_table = reinterpret_cast<std::uint32_t*>(info->dlpi_addr + dyn_entry->d_un.d_val);
|
||||
break;
|
||||
case DT_SONAME:
|
||||
state.soname_idx = dyn_entry->d_un.d_val;
|
||||
break;
|
||||
case DT_JMPREL:
|
||||
state.plt_table = reinterpret_cast<void*>(info->dlpi_addr + dyn_entry->d_un.d_val);
|
||||
break;
|
||||
case DT_PLTREL: {
|
||||
// for Android, arm64 = DT_RELA, arm32 = DT_REL
|
||||
// could just gate this based on architecture but whatever
|
||||
|
||||
auto val = dyn_entry->d_un.d_val;
|
||||
state.plt_table_rela = val == DT_RELA;
|
||||
if (val != DT_RELA && val != DT_REL) {
|
||||
log::warn("unrecognized PLTREL type {:#x}", val);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DT_PLTRELSZ:
|
||||
state.plt_table_size_bytes = dyn_entry->d_un.d_val;
|
||||
break;
|
||||
case DT_NULL:
|
||||
dyn_end_reached = true;
|
||||
break;
|
||||
}
|
||||
|
||||
dyn_entry++;
|
||||
}
|
||||
|
||||
if (state.is_empty()) {
|
||||
log::error("failed to parse dynamic section (at least one table is null)");
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool failed = false;
|
||||
|
||||
auto so_name = state.str_table + state.soname_idx;
|
||||
if (strcmp(so_name, "libcocos2dcpp.so") != 0) {
|
||||
log::warn("DT_SONAME gives us {}! that's not good", so_name);
|
||||
failed = true;
|
||||
}
|
||||
|
||||
auto reloc_failed = state.plt_table_rela
|
||||
? !perform_reloc_patches<ElfW(Rela)>(info, state)
|
||||
: !perform_reloc_patches<ElfW(Rel)>(info, state);
|
||||
if (reloc_failed) {
|
||||
failed = true;
|
||||
}
|
||||
|
||||
if (DataPaths::patch_exceptions) {
|
||||
if (!perform_symbol_patches(info, state)) {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
log::info(
|
||||
"symbol patch diagnostics\nlibrary path: {}\naddrs: base={:#x} sym={} str={} hash={} plt={}",
|
||||
info->dlpi_name,
|
||||
info->dlpi_addr,
|
||||
reinterpret_cast<void*>(state.sym_table),
|
||||
reinterpret_cast<void*>(state.str_table),
|
||||
reinterpret_cast<void*>(state.hash_table),
|
||||
state.plt_table
|
||||
);
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@ import android.app.Activity
|
||||
import android.content.*
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
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.openIMEKeyboard
|
||||
import java.lang.ref.WeakReference
|
||||
@@ -24,7 +24,7 @@ object BaseRobTopActivity {
|
||||
|
||||
var isPaused = false
|
||||
|
||||
lateinit var me: WeakReference<Activity>
|
||||
var me: WeakReference<Activity> = WeakReference(null)
|
||||
private var shouldResumeSound = true
|
||||
|
||||
fun setCurrentActivity(currentActivity: Activity) {
|
||||
@@ -61,40 +61,33 @@ object BaseRobTopActivity {
|
||||
|
||||
@JvmStatic
|
||||
fun openURL(url: String) {
|
||||
Log.d("MAIN", "Open URL")
|
||||
me.get()?.runOnUiThread {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
me.get()?.startActivity(intent)
|
||||
me.get()?.startActivity(
|
||||
Intent(
|
||||
"android.intent.action.VIEW",
|
||||
Uri.parse(url)
|
||||
)
|
||||
)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast.makeText(
|
||||
me.get(),
|
||||
R.string.no_activity_found,
|
||||
"No activity found to open this URL.",
|
||||
Toast.LENGTH_SHORT,
|
||||
).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
|
||||
fun sendMail(subject: String, body: String, to: String) {
|
||||
me.get()?.runOnUiThread {
|
||||
val i = Intent(Intent.ACTION_SEND)
|
||||
val i = Intent("android.intent.action.SEND")
|
||||
i.type = "message/rfc822"
|
||||
i.putExtra(Intent.EXTRA_EMAIL, arrayOf(to))
|
||||
i.putExtra(Intent.EXTRA_SUBJECT, subject)
|
||||
i.putExtra(Intent.EXTRA_TEXT, body)
|
||||
i.putExtra("android.intent.extra.EMAIL", arrayOf(to))
|
||||
i.putExtra("android.intent.extra.SUBJECT", subject)
|
||||
i.putExtra("android.intent.extra.TEXT", body)
|
||||
try {
|
||||
me.get()?.startActivity(Intent.createChooser(i, "Send mail..."))
|
||||
} 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.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowInsets
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
@@ -23,7 +22,6 @@ import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import com.customRobTop.BaseRobTopActivity
|
||||
import com.customRobTop.JniToCpp
|
||||
import com.geode.launcher.main.LaunchNotification
|
||||
@@ -172,69 +170,31 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
||||
tryLoadLibrary(gdPackageInfo, Constants.COCOS_LIB_NAME)
|
||||
|
||||
if (GamePackageUtils.getGameVersionCode(packageManager) >= 39L) {
|
||||
/*
|
||||
val customSymbols = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.CUSTOM_SYMBOL_LIST)
|
||||
if (customSymbols) {
|
||||
val symbolFile = File(LaunchUtils.getBaseDirectory(this), "exception_symbols.txt")
|
||||
LauncherFix.enableCustomSymbolList(symbolFile.path)
|
||||
}
|
||||
*/
|
||||
|
||||
// this fix requires geode v3, which is 2.206+
|
||||
// there is a short period in which 2.206 users will still have geode v2, but whatever. ig
|
||||
LauncherFix.enableExceptionsRenaming()
|
||||
LauncherFix.performExceptionsRenaming()
|
||||
}
|
||||
|
||||
LauncherFix.performPatches()
|
||||
|
||||
loadInternalMods()
|
||||
|
||||
val baseView = 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)
|
||||
}
|
||||
setContentView(createView())
|
||||
|
||||
setupPostLibraryLoad(gdPackageInfo)
|
||||
|
||||
val geodeFailed = try {
|
||||
try {
|
||||
loadGeodeLibrary()
|
||||
false
|
||||
} catch (e: UnsatisfiedLinkError) {
|
||||
handleGeodeException(e)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,6 +415,19 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
||||
this.mGLSurfaceView = glSurfaceView
|
||||
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.setEGLConfigChooser(EGLConfigChooser())
|
||||
|
||||
@@ -496,7 +469,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val chosenDisplay = display.supportedModes?.maxByOrNull { it.refreshRate }
|
||||
if (chosenDisplay != null) {
|
||||
val chosenRefreshRate = chosenDisplay.refreshRate
|
||||
var chosenRefreshRate = chosenDisplay.refreshRate
|
||||
println("setupUIState: Forcing a refresh rate of $chosenRefreshRate (display ${chosenDisplay.modeId})")
|
||||
|
||||
window.attributes.preferredRefreshRate = chosenRefreshRate
|
||||
@@ -615,8 +588,8 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
||||
mGLSurfaceView?.sendTimestampEvents = true
|
||||
true
|
||||
}
|
||||
GeodeUtils.CAPABILITY_RICH_INPUT -> {
|
||||
mGLSurfaceView?.enableControllerEvents()
|
||||
GeodeUtils.CAPABILITY_INTERNAL_CALLBACKS -> {
|
||||
mGLSurfaceView?.sendInternalTimestampEvents = true
|
||||
true
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ object LauncherFix {
|
||||
|
||||
external fun setOriginalDataPath(dataPath: String)
|
||||
|
||||
external fun enableExceptionsRenaming()
|
||||
external fun performExceptionsRenaming()
|
||||
|
||||
external fun performPatches()
|
||||
external fun enableCustomSymbolList(symbolsPath: String)
|
||||
}
|
||||
@@ -1,52 +1,18 @@
|
||||
package com.geode.launcher
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||
import android.os.Build
|
||||
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.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.isSystemInDarkTheme
|
||||
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.layout.*
|
||||
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.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.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -54,58 +20,37 @@ 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.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.LocalResources
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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.Theme.DARK
|
||||
import com.geode.launcher.ui.theme.Theme.LIGHT
|
||||
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.GamePackageUtils
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
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() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
clearDownloadedApks(this)
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val gdInstalled = GamePackageUtils.isGameInstalled(packageManager)
|
||||
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 returnExtendedMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE)
|
||||
|
||||
@@ -119,19 +64,30 @@ class MainActivity : ComponentActivity() {
|
||||
LoadFailureInfo(LaunchUtils.LauncherError.CRASHED)
|
||||
} else { null }
|
||||
|
||||
var launchArguments: LaunchArguments? = null
|
||||
val intentData = intent.data
|
||||
if (intent.action == Intent.ACTION_VIEW && intentData != null) {
|
||||
val isLaunch = intentData.path == "/launch"
|
||||
val safeMode = if (isLaunch) {
|
||||
intentData.getBooleanQueryParameter("safe-mode", false)
|
||||
} else false
|
||||
if (PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.DEVELOPER_SERVER)) {
|
||||
val intent = Intent(this, DeveloperServerService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
launchArguments = LaunchArguments(
|
||||
autoSafeMode = safeMode,
|
||||
forcePause = !isLaunch,
|
||||
forceLaunch = isLaunch,
|
||||
)
|
||||
val name = getString(R.string.notification_channel_development_server)
|
||||
val descriptionText = getString(R.string.notification_channel_development_server_description)
|
||||
val importance = NotificationManager.IMPORTANCE_DEFAULT
|
||||
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 {
|
||||
@@ -141,631 +97,195 @@ class MainActivity : ComponentActivity() {
|
||||
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
|
||||
}
|
||||
|
||||
if (launchArguments != null) {
|
||||
launchViewModel.launchArguments = launchArguments
|
||||
}
|
||||
|
||||
launchViewModel.beginLaunchFlow()
|
||||
|
||||
CompositionLocalProvider(LocalTheme provides theme) {
|
||||
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
AltMainScreen(launchViewModel)
|
||||
MainScreen(gdInstalled, geodeInstalled, loadFailureInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
fun mapCancelReasonToInfo(cancelReason: LaunchViewModel.LaunchCancelReason): LaunchStatusInfo {
|
||||
return when (cancelReason) {
|
||||
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED -> LaunchStatusInfo(
|
||||
title = stringResource(R.string.launcher_cancelled_crash)
|
||||
)
|
||||
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 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 (gdInstalled && geodeInstalled) {
|
||||
intent.getBooleanExtra("restarted", false).let {
|
||||
if (it) {
|
||||
onLaunch(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
fun MainScreen(
|
||||
gdInstalled: Boolean = true,
|
||||
geodePreinstalled: Boolean = true,
|
||||
loadFailureInfo: LoadFailureInfo? = null,
|
||||
releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
if (statusInfo.progress != null) {
|
||||
LinearProgressIndicator(
|
||||
statusInfo.progress,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
val shouldUpdate by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
|
||||
val autoUpdateState by releaseViewModel.uiState.collectAsState()
|
||||
|
||||
val geodeJustInstalled = (autoUpdateState as? ReleaseViewModel.ReleaseUIState.Finished)
|
||||
?.hasUpdated == true
|
||||
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 {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
if (statusInfo.details != null) {
|
||||
Text(statusInfo.details, style = MaterialTheme.typography.bodyMedium)
|
||||
releaseViewModel.useGlobalCheckState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
fun ExtraOptions(onSettings: () -> Unit) {
|
||||
IconButton(
|
||||
onClick = onSettings,
|
||||
) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_settings),
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "animated, light")
|
||||
@Preview(name = "animated, dark", uiMode = UI_MODE_NIGHT_YES)
|
||||
@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
|
||||
.padding(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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() }
|
||||
|
||||
// automatically show load failed dialog (but not crash)
|
||||
var showErrorInfo by remember { mutableStateOf(
|
||||
loadFailureInfo != null && loadFailureInfo.title != LaunchUtils.LauncherError.CRASHED
|
||||
)}
|
||||
|
||||
val hasError = loadFailureInfo != null
|
||||
LaunchedEffect(hasError, showErrorInfo) {
|
||||
if (!showErrorInfo && loadFailureInfo != null) {
|
||||
showFailureSnackbar(context, loadFailureInfo, snackbarHostState) {
|
||||
showErrorInfo = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showErrorInfo && loadFailureInfo != null) {
|
||||
ErrorInfoSheet(loadFailureInfo, onDismiss = { showErrorInfo = false })
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
|
||||
modifier = Modifier.safeDrawingPadding()
|
||||
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
GeodeLogo(shouldAnimate = launchUIState.isInProgress())
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.geode_logo),
|
||||
contentDescription = stringResource(R.string.launcher_logo_alt),
|
||||
modifier = Modifier.size(100.dp, 100.dp)
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.launcher_title),
|
||||
style = launcherTitleStyle,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
|
||||
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
|
||||
when {
|
||||
gdInstalled && geodeInstalled -> {
|
||||
val gdVersion = remember {
|
||||
GamePackageUtils.getGameVersionCode(context.packageManager)
|
||||
}
|
||||
|
||||
launchViewModel.clearCrashInfo()
|
||||
coroutineScope.launch {
|
||||
launchViewModel.beginLaunchFlow(true)
|
||||
}
|
||||
},
|
||||
onMore = {
|
||||
showErrorInfo = true
|
||||
},
|
||||
extraOptions = {
|
||||
ExtraOptions(
|
||||
onSettings = {
|
||||
coroutineScope.launch {
|
||||
launchViewModel.cancelLaunch(true)
|
||||
}
|
||||
if (gdVersion < Constants.SUPPORTED_VERSION_CODE_MIN) {
|
||||
val versionName = remember {
|
||||
GamePackageUtils.getUnifiedVersionName(context.packageManager)
|
||||
}
|
||||
|
||||
onSettings(context)
|
||||
}
|
||||
)
|
||||
},
|
||||
safeModeEnabled = launchInSafeMode
|
||||
)
|
||||
LaunchBlockedLabel(stringResource(R.string.game_outdated, versionName))
|
||||
} else {
|
||||
val stopLaunch = releaseViewModel.isInUpdate || hasError
|
||||
PlayButton(
|
||||
stopAutomaticLaunch = stopLaunch,
|
||||
blockLaunch = releaseViewModel.isInUpdate,
|
||||
onPlayGame = { safeMode ->
|
||||
launchInSafeMode = safeMode
|
||||
beginLaunch = true
|
||||
},
|
||||
)
|
||||
|
||||
// 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) {
|
||||
if (gdVersion < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
|
||||
UnsupportedVersionWarning()
|
||||
}
|
||||
|
||||
val nextLauncherUpdate by launchViewModel.nextLauncherUpdate.collectAsState()
|
||||
if (nextLauncherUpdate != null) {
|
||||
LauncherUpdateIndicator()
|
||||
}
|
||||
|
||||
GeodeUpdateIndicator(snackbarHostState) {
|
||||
launchViewModel.retryUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
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(
|
||||
painterResource(R.drawable.icon_settings),
|
||||
contentDescription = stringResource(R.string.launcher_settings_icon_alt)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> LaunchBlockedLabel(stringResource(R.string.game_not_found))
|
||||
}
|
||||
|
||||
UpdateCard(
|
||||
releaseViewModel,
|
||||
modifier = Modifier
|
||||
.padding(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (launchUIState is LaunchViewModel.LaunchUIState.Ready) {
|
||||
if (beginLaunch) {
|
||||
UpdateWarning(launchInSafeMode) {
|
||||
beginLaunch = false
|
||||
launchInSafeMode = false
|
||||
coroutineScope.launch {
|
||||
launchViewModel.cancelLaunch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val loadFailure = launchViewModel.currentCrashInfo()
|
||||
if (showErrorInfo && loadFailure != null) {
|
||||
ErrorInfoSheet(loadFailure, onDismiss = { showErrorInfo = false })
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showSystemUi = true)
|
||||
@Composable
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package com.geode.launcher.log
|
||||
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
abstract class BaseDirectoryViewModel<T>(protected val baseDirectory: File) : ViewModel() {
|
||||
private val _lineState = mutableStateListOf<T>()
|
||||
val lineState = _lineState
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading = _isLoading.asStateFlow()
|
||||
|
||||
private var logJob: Job? = null
|
||||
|
||||
abstract fun getFileList(): List<T>
|
||||
|
||||
abstract fun getItemKey(item: T): Any
|
||||
|
||||
open fun clearAllFiles() {
|
||||
logJob?.cancel()
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
if (baseDirectory.exists()) {
|
||||
val files = baseDirectory.listFiles()
|
||||
files?.forEach {
|
||||
it.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_lineState.clear()
|
||||
}
|
||||
|
||||
private fun loadLogs() {
|
||||
_isLoading.value = true
|
||||
|
||||
logJob?.cancel()
|
||||
logJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
val lines = getFileList()
|
||||
|
||||
_lineState.clear()
|
||||
_lineState.addAll(lines)
|
||||
|
||||
_isLoading.value = false
|
||||
|
||||
logJob = null
|
||||
}
|
||||
}
|
||||
|
||||
fun removeFile(filename: String) {
|
||||
logJob?.cancel()
|
||||
|
||||
val crashPath = File(baseDirectory, filename)
|
||||
if (crashPath.exists()) {
|
||||
crashPath.delete()
|
||||
loadLogs()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
loadLogs()
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,52 @@
|
||||
package com.geode.launcher.log
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
data class CrashDump(
|
||||
data class CrashDump @OptIn(ExperimentalTime::class) constructor(
|
||||
val filename: String,
|
||||
val lastModified: Instant
|
||||
)
|
||||
|
||||
class CrashViewModel(crashDirectory: File) : BaseDirectoryViewModel<CrashDump>(crashDirectory) {
|
||||
class CrashViewModel(private val application: Application) : ViewModel() {
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as Application
|
||||
val crashDirectory = LaunchUtils.getCrashDirectory(application)
|
||||
|
||||
CrashViewModel(crashDirectory)
|
||||
CrashViewModel(
|
||||
application = application
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var hasIndicator = false
|
||||
private val _lineState = mutableStateListOf<CrashDump>()
|
||||
val lineState = _lineState
|
||||
|
||||
override fun getItemKey(item: CrashDump) = item.filename
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading = _isLoading.asStateFlow()
|
||||
|
||||
override fun getFileList(): List<CrashDump> {
|
||||
val crashDirectory = baseDirectory
|
||||
private var failedToLoad = false
|
||||
|
||||
private var logJob: Job? = null
|
||||
|
||||
private fun getCrashList(): List<CrashDump> {
|
||||
val crashDirectory = LaunchUtils.getCrashDirectory(application)
|
||||
if (!crashDirectory.exists()) {
|
||||
return emptyList()
|
||||
}
|
||||
@@ -44,8 +56,6 @@ class CrashViewModel(crashDirectory: File) : BaseDirectoryViewModel<CrashDump>(c
|
||||
_, name -> name != LaunchUtils.CRASH_INDICATOR_NAME && name != "last-pid"
|
||||
} ?: return emptyList()
|
||||
|
||||
hasIndicator = File(crashDirectory, LaunchUtils.CRASH_INDICATOR_NAME).exists()
|
||||
|
||||
return children
|
||||
.map {
|
||||
CrashDump(
|
||||
@@ -56,24 +66,49 @@ class CrashViewModel(crashDirectory: File) : BaseDirectoryViewModel<CrashDump>(c
|
||||
.sortedByDescending { it.lastModified }
|
||||
}
|
||||
|
||||
fun clearIndicator() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
File(baseDirectory, LaunchUtils.CRASH_INDICATOR_NAME).delete()
|
||||
fun clearCrashes() {
|
||||
logJob?.cancel()
|
||||
|
||||
hasIndicator = false
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val crashDirectory = LaunchUtils.getCrashDirectory(application)
|
||||
if (crashDirectory.exists()) {
|
||||
val files = crashDirectory.listFiles()
|
||||
files?.forEach {
|
||||
it.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_lineState.clear()
|
||||
}
|
||||
|
||||
private fun loadLogs() {
|
||||
_isLoading.value = true
|
||||
|
||||
logJob?.cancel()
|
||||
logJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
val lines = getCrashList()
|
||||
|
||||
_lineState.clear()
|
||||
_lineState.addAll(lines)
|
||||
|
||||
_isLoading.value = false
|
||||
|
||||
logJob = null
|
||||
}
|
||||
}
|
||||
|
||||
fun createIndicator() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
File(baseDirectory, LaunchUtils.CRASH_INDICATOR_NAME).createNewFile()
|
||||
fun removeCrash(filename: String) {
|
||||
logJob?.cancel()
|
||||
|
||||
hasIndicator = true
|
||||
val crashPath = File(LaunchUtils.getCrashDirectory(application), filename)
|
||||
if (crashPath.exists()) {
|
||||
crashPath.delete()
|
||||
loadLogs()
|
||||
}
|
||||
}
|
||||
|
||||
override fun clearAllFiles() {
|
||||
super.clearAllFiles()
|
||||
hasIndicator = false
|
||||
init {
|
||||
loadLogs()
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package com.geode.launcher.log
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import kotlin.time.Instant
|
||||
|
||||
data class GeodeLog(
|
||||
val filename: String,
|
||||
val lastModified: Instant,
|
||||
val fileSize: Long,
|
||||
)
|
||||
|
||||
class GeodeLogsViewModel(crashDirectory: File) : BaseDirectoryViewModel<GeodeLog>(crashDirectory) {
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as Application
|
||||
val crashDirectory = LaunchUtils.getGeodeLogsDirectory(application)
|
||||
|
||||
GeodeLogsViewModel(crashDirectory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemKey(item: GeodeLog) = item.filename
|
||||
|
||||
override fun getFileList(): List<GeodeLog> {
|
||||
if (!baseDirectory.exists()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val children = baseDirectory.listFiles()
|
||||
?: return emptyList()
|
||||
|
||||
return children
|
||||
.map {
|
||||
GeodeLog(
|
||||
filename = it.name,
|
||||
lastModified = Instant.fromEpochMilliseconds(it.lastModified()),
|
||||
fileSize = it.length()
|
||||
)
|
||||
}
|
||||
.sortedByDescending { it.lastModified }
|
||||
}
|
||||
|
||||
suspend fun getFileText(filename: String): List<String> {
|
||||
val logFile = File(baseDirectory, filename)
|
||||
if (!logFile.exists()) {
|
||||
return listOf("File does not exist!")
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
logFile.readLines()
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import okio.Buffer
|
||||
import okio.BufferedSource
|
||||
import java.io.IOException
|
||||
@@ -26,10 +29,29 @@ fun BufferedSource.readCString(): String {
|
||||
return buffer.toString()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ProcessInformation(val processId: Int, val threadId: Int, val processUid: Int)
|
||||
|
||||
@Serializable
|
||||
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(
|
||||
start: LogPriority,
|
||||
@@ -116,7 +138,8 @@ enum class LogPriority {
|
||||
/**
|
||||
* Represents a log entry from logcat.
|
||||
*/
|
||||
data class LogLine @OptIn(ExperimentalTime::class) constructor(
|
||||
@Serializable
|
||||
data class LogLine(
|
||||
val process: ProcessInformation,
|
||||
val time: Instant,
|
||||
val logId: Int,
|
||||
@@ -198,11 +221,10 @@ data class LogLine @OptIn(ExperimentalTime::class) constructor(
|
||||
time = time,
|
||||
logId = lid,
|
||||
tag = tag,
|
||||
message = message
|
||||
message = message.trimEnd('\u0000')
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun showException(exception: Exception) = LogLine(
|
||||
process = ProcessInformation(0, 0, 0),
|
||||
time = Clock.System.now(),
|
||||
@@ -213,15 +235,15 @@ data class LogLine @OptIn(ExperimentalTime::class) constructor(
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Transient
|
||||
val identifier = time.toJavaInstant()
|
||||
|
||||
val messageTrimmed by lazy {
|
||||
this.message.trim { it <= ' ' }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
val formattedTime by lazy { this.time.toLocalDateTime(TimeZone.currentSystemDefault()) }
|
||||
|
||||
val asSimpleString by lazy {
|
||||
"$formattedTime [${this.priority.toChar()}/${this.tag}]: ${this.messageTrimmed}"
|
||||
}
|
||||
|
||||
@@ -3,13 +3,16 @@ package com.geode.launcher.main
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.AbsoluteRoundedCornerShape
|
||||
@@ -24,13 +27,12 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geode.launcher.R
|
||||
import com.geode.launcher.utils.Constants
|
||||
import androidx.core.net.toUri
|
||||
|
||||
fun onDownloadGame(context: Context) {
|
||||
val appUrl = "https://play.google.com/store/apps/details?id=${Constants.PACKAGE_NAME}"
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
data = appUrl.toUri()
|
||||
data = Uri.parse(appUrl)
|
||||
setPackage("com.android.vending")
|
||||
}
|
||||
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
|
||||
fun InlineDialog(headline: @Composable () -> Unit, body: @Composable () -> Unit, modifier: Modifier = Modifier) {
|
||||
ElevatedCard(modifier = modifier) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.geode.launcher.main
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
@@ -18,7 +17,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
@@ -34,15 +32,13 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
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.LocalContext
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
@@ -59,7 +55,6 @@ import com.geode.launcher.ui.theme.Typography
|
||||
import com.geode.launcher.utils.GamePackageUtils
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class LoadFailureInfo(
|
||||
val title: LaunchUtils.LauncherError,
|
||||
@@ -179,47 +174,42 @@ fun ErrorInfoDescription(description: String, modifier: Modifier = Modifier) {
|
||||
GamePackageUtils.getUnifiedVersionName(context.packageManager)
|
||||
}
|
||||
|
||||
SelectionContainer {
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
stringResource(R.string.launcher_error_details),
|
||||
style = Typography.headlineSmall
|
||||
)
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
stringResource(R.string.launcher_error_details),
|
||||
style = Typography.headlineSmall
|
||||
)
|
||||
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
Text(
|
||||
description,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
style = Typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
description,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
style = Typography.bodyMedium
|
||||
)
|
||||
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Spacer(Modifier.size(4.dp))
|
||||
|
||||
Text(
|
||||
stringResource(R.string.launcher_error_device_info,
|
||||
Build.MODEL,
|
||||
Build.VERSION.RELEASE,
|
||||
currentRelease ?: "unknown",
|
||||
gameVersion,
|
||||
LaunchUtils.applicationArchitecture,
|
||||
BuildConfig.VERSION_NAME
|
||||
),
|
||||
style = Typography.labelMedium
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.launcher_error_device_info,
|
||||
Build.MODEL,
|
||||
Build.VERSION.RELEASE,
|
||||
currentRelease ?: "unknown",
|
||||
gameVersion,
|
||||
LaunchUtils.applicationArchitecture,
|
||||
BuildConfig.VERSION_NAME
|
||||
),
|
||||
style = Typography.labelMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
fun ErrorInfoActions(extraDetails: String?, modifier: Modifier = Modifier) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
|
||||
val clipboard = LocalClipboard.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val resources = LocalResources.current
|
||||
|
||||
FlowRow(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(
|
||||
@@ -237,15 +227,7 @@ fun ErrorInfoActions(extraDetails: String?, modifier: Modifier = Modifier) {
|
||||
}
|
||||
|
||||
FilledTonalButton(onClick = {
|
||||
val clipboardLabel = resources.getString(R.string.application_log_copy_label)
|
||||
|
||||
coroutineScope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText(
|
||||
clipboardLabel, extraDetails
|
||||
))
|
||||
)
|
||||
}
|
||||
clipboardManager.setText(AnnotatedString(extraDetails))
|
||||
}) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_content_copy),
|
||||
|
||||
@@ -4,12 +4,17 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -19,21 +24,26 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geode.launcher.GeometryDashActivity
|
||||
import com.geode.launcher.R
|
||||
import com.geode.launcher.preferences.SettingsActivity
|
||||
import com.geode.launcher.ui.theme.Typography
|
||||
import com.geode.launcher.utils.Constants
|
||||
import com.geode.launcher.utils.GamePackageUtils
|
||||
import com.geode.launcher.utils.GeodeUtils
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import com.geode.launcher.utils.useCountdownTimer
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
||||
@@ -103,11 +113,11 @@ fun SafeModeDialog(onDismiss: () -> Unit, onLaunch: () -> Unit) {
|
||||
AlertDialog(
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_shield_alert),
|
||||
contentDescription = null
|
||||
painterResource(R.drawable.icon_warning),
|
||||
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)) },
|
||||
dismissButton = {
|
||||
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
|
||||
fun UnsupportedVersionWarning(modifier: Modifier = Modifier) {
|
||||
InlineDialog(
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.displayCutoutPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
@@ -26,6 +27,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.utils.Constants
|
||||
import com.geode.launcher.utils.GamePackageUtils
|
||||
import com.geode.launcher.utils.GeodeUtils
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
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>()
|
||||
|
||||
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 availableUpdate = releaseManager.availableLauncherUpdateTag.value
|
||||
if (availableUpdate != null && !releaseManager.dismissedLauncherUpdate) {
|
||||
val loadAutomatically = PreferenceUtils.get(context).getBoolean(PreferenceUtils.Key.LOAD_AUTOMATICALLY)
|
||||
|
||||
val availableUpdate = releaseManager.availableLauncherUpdate.value
|
||||
if (loadAutomatically && availableUpdate != null) {
|
||||
cards.add(LaunchNotificationType.LAUNCHER_UPDATE_AVAILABLE)
|
||||
}
|
||||
|
||||
@@ -67,10 +70,6 @@ fun determineDisplayedCards(context: Context, geodeFailed: Boolean = false): Lis
|
||||
cards.add(LaunchNotificationType.UPDATE_FAILED)
|
||||
}
|
||||
|
||||
if (geodeFailed) {
|
||||
cards.add(LaunchNotificationType.FAILED_LOAD)
|
||||
}
|
||||
|
||||
return cards
|
||||
}
|
||||
|
||||
@@ -105,30 +104,27 @@ fun NotificationCardFromType(type: LaunchNotificationType) {
|
||||
OutdatedVersionContent()
|
||||
}
|
||||
}
|
||||
LaunchNotificationType.FAILED_LOAD -> {
|
||||
AnimatedNotificationCard {
|
||||
ForwardCompatibilityContent()
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LaunchNotification(cards: List<LaunchNotificationType>) {
|
||||
fun LaunchNotification() {
|
||||
val context = LocalContext.current
|
||||
|
||||
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||
val theme = Theme.fromInt(themeOption)
|
||||
|
||||
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||
|
||||
val cards = determineDisplayedCards(context)
|
||||
|
||||
CompositionLocalProvider(LocalTheme provides theme) {
|
||||
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
|
||||
// surface is not in use, so this is unfortunately not provided
|
||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
|
||||
Column(
|
||||
modifier = if (GeodeUtils.handleSafeArea)
|
||||
Modifier.displayCutoutPadding()
|
||||
else Modifier
|
||||
modifier = Modifier.displayCutoutPadding()
|
||||
) {
|
||||
cards.forEach {
|
||||
NotificationCardFromType(it)
|
||||
@@ -208,7 +204,6 @@ fun LauncherUpdateContent(modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
@Composable
|
||||
fun UpdateNotificationContent(modifier: Modifier = Modifier) {
|
||||
ListItem(
|
||||
@@ -225,7 +220,6 @@ fun UpdateNotificationContent(modifier: Modifier = Modifier) {
|
||||
modifier = modifier.width(IntrinsicSize.Max)
|
||||
)
|
||||
}
|
||||
*/
|
||||
|
||||
@Composable
|
||||
fun OutdatedVersionContent(modifier: Modifier = Modifier) {
|
||||
@@ -261,22 +255,3 @@ fun UpdateFailedContent(modifier: Modifier = Modifier) {
|
||||
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.launch
|
||||
|
||||
data class LaunchArguments(
|
||||
val autoSafeMode: Boolean,
|
||||
val forcePause: Boolean,
|
||||
val forceLaunch: Boolean
|
||||
)
|
||||
private const val COOLDOWN_LENGTH_MS = 3000L
|
||||
|
||||
class LaunchViewModel(private val application: Application): ViewModel() {
|
||||
companion object {
|
||||
@@ -41,13 +37,7 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
||||
private var readyTimer: CountDownTimer? = null
|
||||
|
||||
private fun initReadyTimer() {
|
||||
val cooldownLength = PreferenceUtils.get(application).getInt(PreferenceUtils.Key.WAIT_PERIOD) * 1000L
|
||||
if (cooldownLength == 0L) {
|
||||
readyTimerPassed = true
|
||||
return
|
||||
}
|
||||
|
||||
readyTimer = object : CountDownTimer(cooldownLength, 1000) {
|
||||
readyTimer = object : CountDownTimer(COOLDOWN_LENGTH_MS, 1000) {
|
||||
override fun onTick(millisUntilFinished: Long) {
|
||||
// no tick necessary
|
||||
}
|
||||
@@ -65,25 +55,17 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
||||
}
|
||||
|
||||
enum class LaunchCancelReason {
|
||||
MANUAL, AUTOMATIC, LAST_LAUNCH_CRASHED, LAST_LAUNCH_CRASHED_AUTOMATIC,
|
||||
GEODE_NOT_FOUND, GAME_MISSING, GAME_OUTDATED;
|
||||
MANUAL, AUTOMATIC, LAST_LAUNCH_CRASHED, GEODE_NOT_FOUND, GAME_MISSING, GAME_OUTDATED;
|
||||
|
||||
fun allowsRetry() = when (this) {
|
||||
MANUAL,
|
||||
AUTOMATIC,
|
||||
LAST_LAUNCH_CRASHED,
|
||||
LAST_LAUNCH_CRASHED_AUTOMATIC,
|
||||
GEODE_NOT_FOUND -> true
|
||||
GAME_MISSING,
|
||||
GAME_OUTDATED -> false
|
||||
}
|
||||
|
||||
fun isCrash() = when (this) {
|
||||
LAST_LAUNCH_CRASHED,
|
||||
LAST_LAUNCH_CRASHED_AUTOMATIC -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
fun isGameInstallIssue() = when (this) {
|
||||
GAME_OUTDATED,
|
||||
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 object Working : 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)
|
||||
val uiState = _uiState.asStateFlow()
|
||||
|
||||
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdateTag
|
||||
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdate
|
||||
|
||||
var loadFailure: LoadFailureInfo? = null
|
||||
var launchArguments: LaunchArguments? = null
|
||||
|
||||
private var hasCheckedForUpdates = false
|
||||
private var isCancelling = false
|
||||
private var hasManuallyStarted = false
|
||||
|
||||
private var restoreUpdateState: LaunchUIState? = null
|
||||
|
||||
private suspend fun determineUpdateStatus() {
|
||||
ReleaseManager.get(application).uiState.takeWhile {
|
||||
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
|
||||
hasCheckedForUpdates = true
|
||||
|
||||
val currentState = restoreUpdateState
|
||||
if (currentState != null) {
|
||||
_uiState.emit(currentState)
|
||||
return
|
||||
}
|
||||
|
||||
preReadyCheck()
|
||||
}
|
||||
|
||||
fun currentCrashInfo(): LoadFailureInfo? {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -157,12 +120,12 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
||||
|
||||
fun clearCrashInfo() {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private fun preReadyCheck() {
|
||||
private suspend fun preReadyCheck() {
|
||||
if (isCancelling) {
|
||||
// don't let the game start if cancelled
|
||||
return
|
||||
@@ -171,8 +134,7 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
||||
val packageManager = application.packageManager
|
||||
|
||||
if (!GamePackageUtils.isGameInstalled(packageManager)) {
|
||||
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
|
||||
return
|
||||
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
|
||||
}
|
||||
|
||||
if (ReleaseManager.get(application).isInUpdate) {
|
||||
@@ -182,48 +144,32 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
||||
return
|
||||
}
|
||||
|
||||
// last launch crashed, so cancel
|
||||
if (loadFailure != null) {
|
||||
// show a different message if this is the first thing users see
|
||||
val cancelType = if (_uiState.value == LaunchUIState.Initial) {
|
||||
LaunchCancelReason.LAST_LAUNCH_CRASHED_AUTOMATIC
|
||||
} else LaunchCancelReason.LAST_LAUNCH_CRASHED
|
||||
|
||||
_uiState.tryEmit(LaunchUIState.Cancelled(cancelType))
|
||||
return
|
||||
// last launch crashed, so cancel
|
||||
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.LAST_LAUNCH_CRASHED))
|
||||
}
|
||||
|
||||
if (GamePackageUtils.getGameVersionCode(packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN) {
|
||||
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_OUTDATED))
|
||||
return
|
||||
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_OUTDATED))
|
||||
}
|
||||
|
||||
if (!LaunchUtils.isGeodeInstalled(application)) {
|
||||
_uiState.tryEmit(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
|
||||
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GEODE_NOT_FOUND))
|
||||
}
|
||||
|
||||
val loadAutomatically = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.LOAD_AUTOMATICALLY)
|
||||
val forcePause = launchArguments?.forcePause == true
|
||||
if (!hasManuallyStarted && (forcePause || !loadAutomatically)) {
|
||||
if (!loadAutomatically && !hasManuallyStarted) {
|
||||
return cancelLaunch(true)
|
||||
}
|
||||
|
||||
if (!readyTimerPassed && !hasManuallyStarted) {
|
||||
_uiState.tryEmit(LaunchUIState.Working)
|
||||
return
|
||||
return _uiState.emit(LaunchUIState.Working)
|
||||
}
|
||||
|
||||
_uiState.tryEmit(LaunchUIState.Ready)
|
||||
_uiState.emit(LaunchUIState.Ready)
|
||||
}
|
||||
|
||||
fun beginLaunchFlow(isRestart: Boolean = false) {
|
||||
suspend fun beginLaunchFlow(isRestart: Boolean = false) {
|
||||
if (isRestart) {
|
||||
hasManuallyStarted = true
|
||||
}
|
||||
@@ -239,44 +185,23 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
||||
isCancelling = false
|
||||
|
||||
if (!GamePackageUtils.isGameInstalled(application.packageManager)) {
|
||||
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
|
||||
return
|
||||
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
|
||||
}
|
||||
|
||||
if (readyTimer == null) {
|
||||
initReadyTimer()
|
||||
}
|
||||
|
||||
val releaseManager = ReleaseManager.get(application)
|
||||
|
||||
val hasGeode = LaunchUtils.isGeodeInstalled(application)
|
||||
val shouldUpdate = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
|
||||
|
||||
if ((shouldUpdate && !hasCheckedForUpdates) || !hasGeode) {
|
||||
releaseManager.checkForUpdates(false)
|
||||
} else {
|
||||
releaseManager.afterUseCachedUpdate()
|
||||
}
|
||||
|
||||
if (isRestart) {
|
||||
releaseManager.dismissedLauncherUpdate = true
|
||||
ReleaseManager.get(application).checkForUpdates(false)
|
||||
}
|
||||
|
||||
preReadyCheck()
|
||||
}
|
||||
|
||||
fun retryUpdate() {
|
||||
ReleaseManager.get(application).checkForUpdates(true)
|
||||
if (ReleaseManager.get(application).isInUpdate) {
|
||||
restoreUpdateState = _uiState.value
|
||||
viewModelScope.launch {
|
||||
determineUpdateStatus()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelLaunch(isAutomatic: Boolean = false) {
|
||||
suspend fun cancelLaunch(isAutomatic: Boolean = false) {
|
||||
// no need to double cancel
|
||||
if (_uiState.value is LaunchUIState.Cancelled) {
|
||||
return
|
||||
@@ -285,17 +210,14 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
||||
val reason = if (isAutomatic) LaunchCancelReason.AUTOMATIC else LaunchCancelReason.MANUAL
|
||||
|
||||
isCancelling = true
|
||||
_uiState.emit(LaunchUIState.Cancelled(reason, true))
|
||||
|
||||
val releaseManager = ReleaseManager.get(application)
|
||||
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() {
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
package com.geode.launcher.main
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.os.Build
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import android.text.format.Formatter
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -28,17 +27,16 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.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
|
||||
@@ -47,30 +45,26 @@ import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geode.launcher.InstallReceiver
|
||||
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.updater.ReleaseManager
|
||||
import com.geode.launcher.updater.ReleaseViewModel
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import com.mikepenz.markdown.compose.LocalBulletListHandler
|
||||
import com.mikepenz.markdown.m3.Markdown
|
||||
import com.mikepenz.markdown.m3.markdownTypography
|
||||
import kotlinx.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 java.io.File
|
||||
import java.net.ConnectException
|
||||
import java.net.UnknownHostException
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.toJavaInstant
|
||||
|
||||
fun clearDownloadedApks(context: Context) {
|
||||
@@ -85,291 +79,132 @@ fun clearDownloadedApks(context: Context) {
|
||||
|
||||
baseDirectory.listFiles {
|
||||
// only select apk files
|
||||
_, name -> name.lowercase().endsWith(".apk")
|
||||
_, name -> name.lowercase().endsWith(".apk")
|
||||
}?.forEach {
|
||||
it.delete()
|
||||
}
|
||||
|
||||
context.filesDir.listFiles { _, name -> name.lowercase().endsWith(".apk") }?.forEach {
|
||||
it.delete()
|
||||
}
|
||||
|
||||
preferenceUtils.setBoolean(PreferenceUtils.Key.CLEANUP_APKS, false)
|
||||
}
|
||||
|
||||
// lazy singleton idc
|
||||
object LauncherUpdater {
|
||||
enum class LauncherUpdateState {
|
||||
Began,
|
||||
Downloading,
|
||||
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) {
|
||||
_uiState.emit(LauncherUpdateState.Began)
|
||||
|
||||
val installer = context.packageManager.packageInstaller
|
||||
|
||||
installer.registerSessionCallback(object : PackageInstaller.SessionCallback() {
|
||||
override fun onActiveChanged(sessionId: Int, active: Boolean) {
|
||||
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,
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}}
|
||||
fun generateInstallIntent(uri: Uri): Intent {
|
||||
// maybe one day i'll rewrite this to use packageinstaller. not today
|
||||
return Intent(Intent.ACTION_INSTALL_PACKAGE).apply {
|
||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
fun installLauncherUpdate(context: Context) {
|
||||
val nextUpdate = ReleaseManager.get(context).availableLauncherUpdate.value
|
||||
val launcherDownload = nextUpdate?.getDownload()
|
||||
|
||||
if (launcherDownload != null) {
|
||||
val outputFile = launcherDownload.filename
|
||||
val baseDirectory = LaunchUtils.getBaseDirectory(context)
|
||||
|
||||
val outputPathFile = File(baseDirectory, outputFile)
|
||||
if (!outputPathFile.exists()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!launcherDownload.exists()) {
|
||||
return
|
||||
}
|
||||
val outputPath = "${UserDirectoryProvider.ROOT}${outputFile}"
|
||||
|
||||
PreferenceUtils.get(context).setBoolean(PreferenceUtils.Key.CLEANUP_APKS, true)
|
||||
val uri = DocumentsContract.buildDocumentUri("${context.packageName}.user", outputPath)
|
||||
|
||||
try {
|
||||
LauncherUpdater.installPackage(context, launcherDownload)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
val msg = context.getString(R.string.launcher_self_update_failed, e.message)
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
PreferenceUtils.get(context).setBoolean(PreferenceUtils.Key.CLEANUP_APKS, true)
|
||||
|
||||
LauncherUpdater.resetState()
|
||||
}
|
||||
}
|
||||
try {
|
||||
val intent = generateInstallIntent(uri)
|
||||
context.startActivity(intent)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
// if it fails to install, just open it in the browser
|
||||
try {
|
||||
val downloadUrl = launcherDownload.url
|
||||
val downloadIntent = Intent(Intent.ACTION_VIEW).apply {
|
||||
data = Uri.parse(downloadUrl)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LauncherUpdateDialog(isCancelling: Boolean, onDismiss: () -> Unit) {
|
||||
val updateState by LauncherUpdater.uiState.collectAsState()
|
||||
|
||||
LaunchedEffect(updateState) {
|
||||
if (updateState == LauncherUpdater.LauncherUpdateState.Finished) {
|
||||
onDismiss()
|
||||
context.startActivity(downloadIntent)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
Toast.makeText(context, context.getString(R.string.no_activity_found), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(context, context.getString(R.string.release_fetch_no_releases), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
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 {
|
||||
Text(stringResource(R.string.launcher_self_update_progress))
|
||||
}
|
||||
},
|
||||
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
|
||||
fun LauncherUpdateInformation(onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val releaseManager = remember { ReleaseManager.get(context) }
|
||||
|
||||
val nextReleaseName by releaseManager.availableLauncherUpdateTag.collectAsState()
|
||||
val nextRelease by releaseManager.availableLauncherUpdateDetails.collectAsState()
|
||||
|
||||
val nextRelease = remember { ReleaseManager.get(context).availableLauncherUpdate.value }
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var updateJob by remember { mutableStateOf<Job?>(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) {
|
||||
if (nextRelease != null) {
|
||||
ModalBottomSheet(onDismissRequest = { onDismiss() }, sheetState = sheetState) {
|
||||
Box(Modifier.verticalScroll(rememberScrollState())) {
|
||||
Column(modifier = Modifier.padding(horizontal = 24.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.launcher_update_name, nextReleaseNameSaved),
|
||||
stringResource(R.string.launcher_update_name, nextRelease.release.tagName),
|
||||
style = Typography.headlineSmall,
|
||||
)
|
||||
|
||||
val nextRelease = nextRelease
|
||||
if (nextRelease != null) {
|
||||
val releasedTime = remember {
|
||||
DateFormat.getDateInstance().format(
|
||||
Date.from(nextRelease.release.publishedAt.toJavaInstant())
|
||||
val releasedTime = remember {
|
||||
DateFormat.getDateInstance().format(
|
||||
Date.from(nextRelease.release.publishedAt.toJavaInstant())
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
stringResource(R.string.launcher_update_released, "$releasedTime"),
|
||||
style = Typography.labelLarge
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.padding(vertical = 12.dp)) {
|
||||
Button(
|
||||
onClick = { installLauncherUpdate(context) },
|
||||
) {
|
||||
Icon(painterResource(R.drawable.icon_download), contentDescription = null)
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(stringResource(R.string.launcher_install))
|
||||
}
|
||||
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
IconButton(onClick = { uriHandler.openUri(nextRelease.release.htmlUrl) }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_link),
|
||||
stringResource(R.string.launcher_update_external_link)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (nextRelease.release.body != null) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
Text(
|
||||
stringResource(R.string.launcher_update_released, "$releasedTime"),
|
||||
style = Typography.labelLarge
|
||||
stringResource(R.string.launcher_update_changelog),
|
||||
style = Typography.titleLarge
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.padding(vertical = 12.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
updateJob = coroutineScope.launch {
|
||||
cancellingUpdate = false
|
||||
beginUpdate = true
|
||||
|
||||
installLauncherUpdate(context)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(painterResource(R.drawable.icon_download), contentDescription = null)
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(stringResource(R.string.launcher_install))
|
||||
}
|
||||
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
IconButton(onClick = { uriHandler.openUri(nextRelease.release.htmlUrl) }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_link),
|
||||
stringResource(R.string.launcher_update_external_link)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (nextRelease.release.body != null) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
Text(
|
||||
stringResource(R.string.launcher_update_changelog),
|
||||
style = Typography.titleLarge
|
||||
)
|
||||
|
||||
// every new release of the markdown library adds another arg to it
|
||||
CompositionLocalProvider(LocalBulletListHandler provides { _, _, _, _, _ -> "• " }) {
|
||||
Markdown(
|
||||
content = nextRelease.release.body.replace("\r", ""),
|
||||
typography = markdownTypography(
|
||||
textLink = TextLinkStyles(
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
).toSpanStyle()
|
||||
),
|
||||
// every new release of the markdown library adds another arg to it
|
||||
CompositionLocalProvider(LocalBulletListHandler provides { _, _, _, _, _ -> "• " }) {
|
||||
Markdown(
|
||||
content = nextRelease.release.body.replace("\r", ""),
|
||||
typography = markdownTypography(
|
||||
textLink = TextLinkStyles(
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
).toSpanStyle()
|
||||
),
|
||||
flavour = GFMFlavourDescriptor(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp)
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
),
|
||||
flavour = GFMFlavourDescriptor(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,7 +218,6 @@ fun LauncherUpdateInformation(onDismiss: () -> Unit) {
|
||||
@Composable
|
||||
fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var showInfoDialog by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -393,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) {
|
||||
Column(
|
||||
// buttons add enough padding already, lower to compensate
|
||||
@@ -433,14 +251,7 @@ fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
updateJob = coroutineScope.launch {
|
||||
cancellingUpdate = false
|
||||
beginUpdate = true
|
||||
|
||||
installLauncherUpdate(context)
|
||||
}
|
||||
},
|
||||
onClick = { installLauncherUpdate(context) },
|
||||
) {
|
||||
Text(stringResource(R.string.launcher_install))
|
||||
}
|
||||
@@ -448,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.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
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.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -32,7 +29,6 @@ import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
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
|
||||
@@ -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) {
|
||||
val packageManager = context.packageManager
|
||||
|
||||
@@ -145,14 +107,7 @@ fun onSelectIcon(context: Context, selectedIcon: ApplicationIconDetails) {
|
||||
|
||||
@Composable
|
||||
fun IconCard(iconData: ApplicationIconDetails, selected: Boolean, modifier: Modifier = Modifier) {
|
||||
var showSelection by remember { mutableStateOf(false) }
|
||||
|
||||
if (showSelection) {
|
||||
IconSelectConfirmDialog(iconData) {
|
||||
showSelection = false
|
||||
}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -162,8 +117,7 @@ fun IconCard(iconData: ApplicationIconDetails, selected: Boolean, modifier: Modi
|
||||
.selectable(
|
||||
selected = selected,
|
||||
onClick = {
|
||||
if (!selected)
|
||||
showSelection = true
|
||||
onSelectIcon(context, iconData)
|
||||
},
|
||||
role = Role.RadioButton
|
||||
)
|
||||
@@ -197,6 +151,8 @@ fun ApplicationIconScreen(
|
||||
) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
|
||||
var showMoreOptions by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
@@ -225,8 +181,7 @@ fun ApplicationIconScreen(
|
||||
Column(
|
||||
Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
val selectedIcon by PreferenceUtils.useStringPreference(PreferenceUtils.Key.SELECTED_ICON)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.geode.launcher.preferences
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -8,6 +9,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -16,7 +18,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -41,11 +42,17 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -55,8 +62,6 @@ import com.geode.launcher.R
|
||||
import com.geode.launcher.log.LogLine
|
||||
import com.geode.launcher.log.LogPriority
|
||||
import com.geode.launcher.log.LogViewModel
|
||||
import com.geode.launcher.preferences.components.SelectDialog
|
||||
import com.geode.launcher.preferences.components.SelectOption
|
||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||
import com.geode.launcher.ui.theme.LocalTheme
|
||||
import com.geode.launcher.ui.theme.Theme
|
||||
@@ -140,24 +145,33 @@ fun SelectLogLevelDialog(
|
||||
|
||||
@Composable
|
||||
fun LogCard(logLine: LogLine, modifier: Modifier = Modifier) {
|
||||
SelectionContainer {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"[${logLine.priority.toChar()}] ${logLine.formattedTime}",
|
||||
style = Typography.labelSmall,
|
||||
color = getLogColor(logLine.priority),
|
||||
fontFamily = robotoMonoFamily
|
||||
)
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
Text(
|
||||
"[${logLine.tag}]: ${logLine.messageTrimmed}",
|
||||
fontFamily = robotoMonoFamily,
|
||||
style = Typography.bodyMedium
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(8.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onLongPress = {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
clipboardManager.setText(AnnotatedString(logLine.asSimpleString))
|
||||
}
|
||||
)
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
"[${logLine.priority.toChar()}] ${logLine.formattedTime}",
|
||||
style = Typography.labelSmall,
|
||||
color = getLogColor(logLine.priority),
|
||||
fontFamily = robotoMonoFamily
|
||||
)
|
||||
|
||||
Text(
|
||||
"[${logLine.tag}]: ${logLine.messageTrimmed}",
|
||||
fontFamily = robotoMonoFamily,
|
||||
style = Typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,28 +15,44 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -45,15 +61,16 @@ import com.geode.launcher.R
|
||||
import com.geode.launcher.UserDirectoryProvider
|
||||
import com.geode.launcher.log.CrashDump
|
||||
import com.geode.launcher.log.CrashViewModel
|
||||
import com.geode.launcher.preferences.components.DirectoryListingScreen
|
||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||
import com.geode.launcher.ui.theme.LocalTheme
|
||||
import com.geode.launcher.ui.theme.Theme
|
||||
import com.geode.launcher.ui.theme.Typography
|
||||
import com.geode.launcher.ui.theme.robotoMonoFamily
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.toJavaInstant
|
||||
|
||||
class CrashLogsActivity : ComponentActivity() {
|
||||
@@ -108,6 +125,7 @@ fun shareCrash(context: Context, filename: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Composable
|
||||
fun CrashCard(crashDump: CrashDump, crashViewModel: CrashViewModel, modifier: Modifier = Modifier) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier
|
||||
@@ -152,7 +170,7 @@ fun CrashCard(crashDump: CrashDump, crashViewModel: CrashViewModel, modifier: Mo
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
crashViewModel.removeFile(crashDump.filename)
|
||||
crashViewModel.removeCrash(crashDump.filename)
|
||||
}) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_delete),
|
||||
@@ -163,58 +181,119 @@ fun CrashCard(crashDump: CrashDump, crashViewModel: CrashViewModel, modifier: Mo
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CrashLogsScreen(
|
||||
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||
crashViewModel: CrashViewModel = viewModel(factory = CrashViewModel.Factory)
|
||||
) {
|
||||
DirectoryListingScreen(
|
||||
onBackPressedDispatcher,
|
||||
crashViewModel,
|
||||
titleId = R.string.title_activity_crash_logs,
|
||||
noneAvailableId = R.string.application_crashes_no_dumps,
|
||||
additionalOptions = { onDismiss ->
|
||||
val developerMode by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
|
||||
if (developerMode) {
|
||||
if (crashViewModel.hasIndicator) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
val logLines = crashViewModel.lineState
|
||||
val isLoading by crashViewModel.isLoading.collectAsState()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
|
||||
var showMoreOptions by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
Column {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_remove_selection),
|
||||
contentDescription = null
|
||||
painterResource(R.drawable.icon_arrow_back),
|
||||
contentDescription = stringResource(R.string.back_icon_alt)
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(stringResource(R.string.application_crashes_clear_indicator))
|
||||
}, onClick = {
|
||||
crashViewModel.clearIndicator()
|
||||
onDismiss()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { showMoreOptions = !showMoreOptions }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_skull),
|
||||
contentDescription = null
|
||||
painterResource(R.drawable.icon_more_vert),
|
||||
contentDescription = stringResource(R.string.application_logs_more)
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(stringResource(R.string.application_crashes_create_indicator))
|
||||
}, onClick = {
|
||||
crashViewModel.createIndicator()
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showMoreOptions,
|
||||
onDismissRequest = { showMoreOptions = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_delete),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(stringResource(R.string.application_logs_delete))
|
||||
}, onClick = {
|
||||
crashViewModel.clearCrashes()
|
||||
showMoreOptions = false
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.title_activity_crash_logs),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { line, shape ->
|
||||
CrashCard(
|
||||
line,
|
||||
crashViewModel,
|
||||
modifier = Modifier.clip(shape)
|
||||
)
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
if (!isLoading && logLines.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.application_crashes_no_dumps),
|
||||
textAlign = TextAlign.Center,
|
||||
style = Typography.bodyLarge,
|
||||
modifier = Modifier.fillMaxSize().padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val itemsSize = logLines.size
|
||||
|
||||
itemsIndexed(
|
||||
logLines,
|
||||
key = { index, line -> line.filename }
|
||||
) { index, line ->
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (index == 0) 16.dp else 4.dp,
|
||||
topEnd = if (index == 0) 16.dp else 4.dp,
|
||||
bottomStart = if (index + 1 == itemsSize) 16.dp else 4.dp,
|
||||
bottomEnd = if (index + 1 == itemsSize) 16.dp else 4.dp
|
||||
)
|
||||
|
||||
CrashCard(
|
||||
line,
|
||||
crashViewModel,
|
||||
modifier = Modifier.clip(shape)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -36,18 +38,11 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.geode.launcher.R
|
||||
import com.geode.launcher.preferences.components.OptionsButton
|
||||
import com.geode.launcher.preferences.components.OptionsGroup
|
||||
import com.geode.launcher.preferences.components.ProfileCreateCard
|
||||
import com.geode.launcher.preferences.components.ProfileSelectCard
|
||||
import com.geode.launcher.preferences.components.SettingsCard
|
||||
import com.geode.launcher.preferences.components.SettingsSelectCard
|
||||
import com.geode.launcher.preferences.components.SettingsStringCard
|
||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||
import com.geode.launcher.ui.theme.LocalTheme
|
||||
import com.geode.launcher.ui.theme.Theme
|
||||
@@ -158,14 +153,10 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
|
||||
title = stringResource(R.string.preference_launch_arguments_name),
|
||||
dialogTitle = stringResource(R.string.preference_launch_arguments_set_title),
|
||||
preferenceKey = PreferenceUtils.Key.LAUNCH_ARGUMENTS,
|
||||
filterInput = {
|
||||
it.filter { c ->
|
||||
// if only there was a better way to define this!
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*(){}<>[]?:;'\"~`-_+=\\| ".contains(
|
||||
c
|
||||
)
|
||||
}
|
||||
}
|
||||
filterInput = { it.filter { c ->
|
||||
// if only there was a better way to define this!
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*(){}<>[]?:;'\"~`-_+=\\| ".contains(c)
|
||||
}}
|
||||
)
|
||||
|
||||
/*
|
||||
@@ -190,37 +181,33 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
|
||||
title = stringResource(R.string.preferences_view_logs),
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_mobile_code),
|
||||
painterResource(R.drawable.icon_description),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
onClick = { onOpenLogs(context) }
|
||||
)
|
||||
|
||||
OptionsButton(
|
||||
title = stringResource(R.string.preferences_view_geode_logs),
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_description),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
val launchIntent = Intent(context, GeodeLogsListingActivity::class.java)
|
||||
context.startActivity(launchIntent)
|
||||
}
|
||||
)
|
||||
|
||||
OptionsButton(
|
||||
title = stringResource(R.string.preferences_copy_external_button),
|
||||
description = LaunchUtils.getBaseDirectory(context).path,
|
||||
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)) {
|
||||
val context = LocalContext.current
|
||||
|
||||
SettingsSelectCard(
|
||||
title = stringResource(R.string.preference_release_channel_tag_name),
|
||||
dialogTitle = stringResource(R.string.preference_release_channel_select),
|
||||
@@ -229,50 +216,15 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
|
||||
0 to stringResource(R.string.preference_release_channel_stable),
|
||||
1 to stringResource(R.string.preference_release_channel_beta),
|
||||
2 to stringResource(R.string.preference_release_channel_nightly),
|
||||
),
|
||||
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)) {
|
||||
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(
|
||||
title = stringResource(R.string.preference_disable_redesign),
|
||||
preferenceKey = PreferenceUtils.Key.DISABLE_REDESIGN,
|
||||
title = stringResource(R.string.preference_enable_redesign),
|
||||
preferenceKey = PreferenceUtils.Key.ENABLE_REDESIGN,
|
||||
)
|
||||
*/
|
||||
}
|
||||
|
||||
OptionsGroup(title = stringResource(R.string.preference_profiles)) {
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
package com.geode.launcher.preferences
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedDispatcher
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
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.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.geode.launcher.R
|
||||
import com.geode.launcher.log.GeodeLogsViewModel
|
||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||
import com.geode.launcher.ui.theme.LocalTheme
|
||||
import com.geode.launcher.ui.theme.Theme
|
||||
import com.geode.launcher.ui.theme.Typography
|
||||
import com.geode.launcher.ui.theme.robotoMonoFamily
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
|
||||
|
||||
class GeodeLogViewActivity : ComponentActivity() {
|
||||
companion object {
|
||||
const val EXTRA_LOG_VIEW_FILENAME = "EXTRA_LOG_FILENAME"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val filename = intent.getStringExtra(EXTRA_LOG_VIEW_FILENAME)
|
||||
if (filename.isNullOrEmpty()) {
|
||||
val launchIntent = Intent(this, GeodeLogsListingActivity::class.java)
|
||||
startActivity(launchIntent)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
CompositionLocalProvider(LocalTheme provides theme) {
|
||||
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
GeodeLogView(onBackPressedDispatcher, filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GeodeLogView(
|
||||
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||
filename: String,
|
||||
geodeLogsViewModel: GeodeLogsViewModel = viewModel(factory = GeodeLogsViewModel.Factory)
|
||||
) {
|
||||
var loadingFile by remember { mutableStateOf(true) }
|
||||
var description by remember { mutableStateOf(emptyList<String>()) }
|
||||
|
||||
LaunchedEffect(filename) {
|
||||
loadingFile = true
|
||||
description = geodeLogsViewModel.getFileText(filename)
|
||||
loadingFile = false
|
||||
}
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
Column {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_arrow_back),
|
||||
contentDescription = stringResource(R.string.back_icon_alt)
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
filename,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
|
||||
if (loadingFile) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
if (!loadingFile) {
|
||||
if (description.isEmpty()) {
|
||||
Text(
|
||||
stringResource(R.string.application_geode_logs_empty),
|
||||
textAlign = TextAlign.Center,
|
||||
style = Typography.bodyLarge,
|
||||
modifier = Modifier.fillMaxSize().padding(8.dp)
|
||||
)
|
||||
} else {
|
||||
SelectionContainer {
|
||||
LazyColumn(modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.padding(horizontal = 8.dp)
|
||||
) {
|
||||
|
||||
items(description) { line ->
|
||||
Text(
|
||||
line,
|
||||
fontFamily = robotoMonoFamily,
|
||||
style = Typography.bodyMedium
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
package com.geode.launcher.preferences
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.provider.DocumentsContract
|
||||
import android.text.format.Formatter
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedDispatcher
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.geode.launcher.R
|
||||
import com.geode.launcher.UserDirectoryProvider
|
||||
import com.geode.launcher.log.GeodeLog
|
||||
import com.geode.launcher.log.GeodeLogsViewModel
|
||||
import com.geode.launcher.preferences.components.DirectoryListingScreen
|
||||
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.robotoMonoFamily
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import java.io.File
|
||||
|
||||
class GeodeLogsListingActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
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)
|
||||
|
||||
CompositionLocalProvider(LocalTheme provides theme) {
|
||||
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
GeodeLogsListingScreen(onBackPressedDispatcher)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun shareLog(context: Context, filename: String) {
|
||||
val baseDirectory = LaunchUtils.getBaseDirectory(context)
|
||||
val crashPath = File(LaunchUtils.getGeodeLogsDirectory(context), filename)
|
||||
|
||||
if (!crashPath.exists()) {
|
||||
Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
val providerLocation = crashPath.toRelativeString(baseDirectory)
|
||||
val documentsPath = "${UserDirectoryProvider.ROOT}${providerLocation}"
|
||||
|
||||
val uri = DocumentsContract.buildDocumentUri("${context.packageName}.user", documentsPath)
|
||||
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
type = "text/plain"
|
||||
}
|
||||
|
||||
try {
|
||||
context.startActivity(shareIntent)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
Toast.makeText(context, R.string.no_activity_found, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GeodeLogCard(geodeLog: GeodeLog, geodeLogsViewModel: GeodeLogsViewModel, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 1.dp, horizontal = 8.dp)
|
||||
.height(64.dp)
|
||||
.then(modifier)
|
||||
.clickable {
|
||||
val launchIntent = Intent(context, GeodeLogViewActivity::class.java).run {
|
||||
putExtra(GeodeLogViewActivity.EXTRA_LOG_VIEW_FILENAME, geodeLog.filename)
|
||||
}
|
||||
context.startActivity(launchIntent)
|
||||
}
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||
.padding(vertical = 8.dp, horizontal = 8.dp)
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceAround,
|
||||
modifier = Modifier.fillMaxHeight().offset(x = 12.dp).weight(0.7f)
|
||||
) {
|
||||
Text(
|
||||
geodeLog.filename,
|
||||
fontFamily = robotoMonoFamily,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1
|
||||
)
|
||||
|
||||
val context = LocalContext.current
|
||||
val formattedSize = remember(geodeLog.fileSize) {
|
||||
Formatter.formatShortFileSize(context, geodeLog.fileSize)
|
||||
}
|
||||
Text(formattedSize, style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.weight(0.3f), horizontalArrangement = Arrangement.End) {
|
||||
val context = LocalContext.current
|
||||
IconButton(onClick = {
|
||||
shareLog(context, geodeLog.filename)
|
||||
}) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_share),
|
||||
contentDescription = stringResource(R.string.application_logs_share)
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
geodeLogsViewModel.removeFile(geodeLog.filename)
|
||||
}) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_delete),
|
||||
contentDescription = stringResource(R.string.application_crashes_delete_one)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GeodeLogsListingScreen(
|
||||
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||
geodeLogsViewModel: GeodeLogsViewModel = viewModel(factory = GeodeLogsViewModel.Factory)
|
||||
) {
|
||||
DirectoryListingScreen(
|
||||
onBackPressedDispatcher,
|
||||
geodeLogsViewModel,
|
||||
titleId = R.string.title_activity_geode_logs,
|
||||
noneAvailableId = R.string.application_geode_logs_no_logs,
|
||||
) { line, shape ->
|
||||
GeodeLogCard(
|
||||
line,
|
||||
geodeLogsViewModel,
|
||||
modifier = Modifier.clip(shape)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,11 @@ package com.geode.launcher.preferences
|
||||
|
||||
import android.app.UiModeManager
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.DocumentsContract
|
||||
import android.text.format.DateUtils
|
||||
import android.view.WindowManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -20,31 +18,10 @@ import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
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.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -53,12 +30,9 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -67,42 +41,23 @@ import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.geode.launcher.BuildConfig
|
||||
import com.geode.launcher.MainActivity
|
||||
import com.geode.launcher.R
|
||||
import com.geode.launcher.UserDirectoryProvider
|
||||
import com.geode.launcher.preferences.components.BackupButton
|
||||
import com.geode.launcher.preferences.components.OptionsButton
|
||||
import com.geode.launcher.preferences.components.OptionsCard
|
||||
import com.geode.launcher.preferences.components.OptionsGroup
|
||||
import com.geode.launcher.preferences.components.OptionsLabel
|
||||
import com.geode.launcher.preferences.components.OptionsTitle
|
||||
import com.geode.launcher.preferences.components.SafeModeDialog
|
||||
import com.geode.launcher.preferences.components.SettingsCard
|
||||
import com.geode.launcher.preferences.components.SettingsFPSCard
|
||||
import com.geode.launcher.preferences.components.SettingsRangeCard
|
||||
import com.geode.launcher.preferences.components.SettingsSelectCard
|
||||
import com.geode.launcher.preferences.components.SettingsStringSelectCard
|
||||
import com.geode.launcher.updater.ReleaseViewModel
|
||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||
import com.geode.launcher.ui.theme.LocalTheme
|
||||
import com.geode.launcher.ui.theme.Theme
|
||||
import com.geode.launcher.ui.theme.Typography
|
||||
import com.geode.launcher.ui.theme.robotoMonoFamily
|
||||
import com.geode.launcher.updater.ReleaseViewModel
|
||||
import com.geode.launcher.utils.ApplicationIcon
|
||||
import com.geode.launcher.utils.Constants
|
||||
import com.geode.launcher.utils.GamePackageUtils
|
||||
import com.geode.launcher.utils.IconUtils
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.ConnectException
|
||||
import java.net.UnknownHostException
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.time.Clock
|
||||
|
||||
|
||||
class SettingsActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -152,7 +107,7 @@ fun onOpenFileManager(context: Context) {
|
||||
|
||||
try {
|
||||
context.startActivity(intent)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getText(R.string.no_activity_found), Toast.LENGTH_SHORT
|
||||
@@ -160,53 +115,6 @@ fun onOpenFileManager(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UpdateErrorDialog(
|
||||
error: Exception,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val clipboard = LocalClipboard.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val clipboardLabel = stringResource(R.string.application_log_copy_label)
|
||||
|
||||
AlertDialog(
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_warning),
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
title = { Text(stringResource(R.string.preference_check_for_updates_help_title)) },
|
||||
text = {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
error.stackTraceToString(),
|
||||
fontFamily = robotoMonoFamily,
|
||||
style = Typography.bodyMedium,
|
||||
modifier = Modifier.verticalScroll(rememberScrollState())
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = {
|
||||
coroutineScope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText(clipboardLabel, error.stackTraceToString()))
|
||||
)
|
||||
}
|
||||
}) {
|
||||
Text(stringResource(R.string.preference_check_for_updates_help_copy))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.message_box_accept))
|
||||
}
|
||||
},
|
||||
onDismissRequest = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UpdateIndicator(
|
||||
snackbarHostState: SnackbarHostState,
|
||||
@@ -216,17 +124,6 @@ fun UpdateIndicator(
|
||||
val resources = LocalResources.current
|
||||
|
||||
var enablePopup by remember { mutableStateOf(false) }
|
||||
var enableHelp by remember { mutableStateOf(false) }
|
||||
|
||||
var currentError by remember { mutableStateOf<Exception?>(null) }
|
||||
|
||||
val currentErrorS = currentError
|
||||
if (currentErrorS != null && enableHelp) {
|
||||
UpdateErrorDialog(currentErrorS) {
|
||||
enableHelp = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
when (updateStatus) {
|
||||
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
|
||||
@@ -264,12 +161,7 @@ fun UpdateIndicator(
|
||||
else -> resources.getString(R.string.preference_check_for_updates_failed)
|
||||
}
|
||||
|
||||
currentError = updateStatus.exception
|
||||
|
||||
val snackbarResult = snackbarHostState.showSnackbar(message, resources.getString(R.string.preference_check_for_updates_failed_action))
|
||||
if (snackbarResult == SnackbarResult.ActionPerformed) {
|
||||
enableHelp = true
|
||||
}
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
is ReleaseViewModel.ReleaseUIState.Finished -> {
|
||||
if (updateStatus.hasUpdated) {
|
||||
@@ -345,367 +237,18 @@ fun DeveloperModeDialog(onDismiss: () -> Unit, onEnable: () -> Unit) {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GeneralSettingsGroup() {
|
||||
val context = LocalContext.current
|
||||
|
||||
OptionsGroup(stringResource(R.string.preference_category_testing)) {
|
||||
SettingsSelectCard(
|
||||
title = stringResource(R.string.preference_theme_name),
|
||||
dialogTitle = stringResource(R.string.preference_theme_select),
|
||||
preferenceKey = PreferenceUtils.Key.THEME,
|
||||
options = linkedMapOf(
|
||||
0 to stringResource(R.string.preference_theme_default),
|
||||
1 to stringResource(R.string.preference_theme_light),
|
||||
2 to stringResource(R.string.preference_theme_dark),
|
||||
),
|
||||
extraSelectBehavior = { updateTheme(context, it) }
|
||||
)
|
||||
SettingsCard(
|
||||
title = stringResource(R.string.preference_black_background_name),
|
||||
preferenceKey = PreferenceUtils.Key.BLACK_BACKGROUND
|
||||
)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
SettingsCard(
|
||||
title = stringResource(R.string.preference_disable_styles),
|
||||
preferenceKey = PreferenceUtils.Key.DISABLE_USER_THEME
|
||||
)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val currentIcon by PreferenceUtils.useStringPreference(PreferenceUtils.Key.SELECTED_ICON)
|
||||
val iconName = remember(currentIcon) {
|
||||
IconUtils.getIconDetails(
|
||||
ApplicationIcon.fromId(currentIcon ?: "default")
|
||||
).nameId
|
||||
}
|
||||
|
||||
OptionsButton(
|
||||
title = stringResource(R.string.preference_set_application_icon),
|
||||
onClick = {
|
||||
val launchIntent = Intent(context, ApplicationIconActivity::class.java)
|
||||
context.startActivity(launchIntent)
|
||||
},
|
||||
description = stringResource(iconName),
|
||||
displayInline = true,
|
||||
)
|
||||
}
|
||||
|
||||
OptionsButton(
|
||||
title = stringResource(R.string.preferences_open_file_manager),
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_folder),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
onClick = { onOpenFileManager(context) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GameplaySettingsGroup() {
|
||||
val context = LocalContext.current
|
||||
|
||||
OptionsGroup(stringResource(R.string.preference_category_gameplay)) {
|
||||
SettingsCard(
|
||||
title = stringResource(R.string.preference_load_automatically_name),
|
||||
description = stringResource(R.string.preference_load_automatically_description),
|
||||
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY,
|
||||
)
|
||||
SettingsSelectCard(
|
||||
title = stringResource(R.string.preference_display_mode_name),
|
||||
dialogTitle = stringResource(R.string.preference_display_mode_select),
|
||||
preferenceKey = PreferenceUtils.Key.DISPLAY_MODE,
|
||||
options = buildMap {
|
||||
put(0, stringResource(R.string.preference_display_mode_default))
|
||||
put(1, stringResource(R.string.preference_display_mode_legacy))
|
||||
|
||||
// necessary api doesn't exist on older versions of android
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
put(2, stringResource(R.string.preference_display_mode_fullscreen))
|
||||
}
|
||||
}
|
||||
)
|
||||
val currentDisplayMode by PreferenceUtils.useIntPreference(PreferenceUtils.Key.DISPLAY_MODE)
|
||||
|
||||
if (currentDisplayMode == 1) {
|
||||
SettingsStringSelectCard(
|
||||
title = stringResource(R.string.preference_custom_aspect_ratio_name),
|
||||
description = stringResource(R.string.preference_custom_aspect_ratio_description),
|
||||
dialogTitle = stringResource(R.string.preference_custom_aspect_ratio_select),
|
||||
preferenceKey = PreferenceUtils.Key.CUSTOM_ASPECT_RATIO,
|
||||
options = linkedMapOf(
|
||||
"16_9" to stringResource(R.string.preference_custom_aspect_ratio_16x9),
|
||||
"16_10" to stringResource(R.string.preference_custom_aspect_ratio_16x10),
|
||||
"4_3" to stringResource(R.string.preference_custom_aspect_ratio_4x3),
|
||||
"1_1" to stringResource(R.string.preference_custom_aspect_ratio_1x1),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
SettingsRangeCard(
|
||||
title = stringResource(R.string.preference_screen_zoom_name),
|
||||
description = stringResource(R.string.preference_screen_zoom_description),
|
||||
dialogTitle = stringResource(R.string.preference_screen_zoom_select),
|
||||
preferenceKey = PreferenceUtils.Key.SCREEN_ZOOM,
|
||||
labelSuffix = "x",
|
||||
range = 25..100,
|
||||
scale = 100,
|
||||
step = 5,
|
||||
) {
|
||||
SettingsCard(
|
||||
title = stringResource(R.string.preference_screen_zoom_fit_name),
|
||||
preferenceKey = PreferenceUtils.Key.SCREEN_ZOOM_FIT,
|
||||
asCard = false
|
||||
)
|
||||
}
|
||||
|
||||
val maxFrameRate = remember {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
context.display.supportedModes.maxOf { it.refreshRate }
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay.refreshRate
|
||||
}.roundToInt()
|
||||
}
|
||||
|
||||
SettingsFPSCard(
|
||||
title = stringResource(R.string.preference_limit_framerate_name),
|
||||
dialogTitle = stringResource(R.string.preference_limit_framerate_select),
|
||||
preferenceKey = PreferenceUtils.Key.LIMIT_FRAME_RATE,
|
||||
maxFrameRate = maxFrameRate
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UpdateSettingsGroup(releaseViewModel: ReleaseViewModel, snackbarHostState: SnackbarHostState, onUpdate: () -> Unit) {
|
||||
val updateStatus by releaseViewModel.uiState.collectAsState()
|
||||
|
||||
OptionsGroup(stringResource(R.string.preference_category_updater)) {
|
||||
SettingsCard(
|
||||
title = stringResource(R.string.preference_update_automatically_name),
|
||||
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(
|
||||
title = {
|
||||
OptionsTitle(
|
||||
title = stringResource(R.string.preference_check_for_updates_button),
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_update),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
description = stringResource(
|
||||
R.string.preference_check_for_updates_description,
|
||||
checkAgoString
|
||||
)
|
||||
.takeIf { lastUpdateCheck != 0L }
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
onClick = {
|
||||
onUpdate()
|
||||
},
|
||||
role = Role.Button
|
||||
)
|
||||
) {
|
||||
UpdateIndicator(snackbarHostState, updateStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AdvancedSettingsGroup(developerModeEnabled: Boolean, snackbarHostState: SnackbarHostState) {
|
||||
val context = LocalContext.current
|
||||
|
||||
OptionsGroup(stringResource(R.string.preference_category_developer)) {
|
||||
if (developerModeEnabled) {
|
||||
OptionsButton(
|
||||
title = stringResource(R.string.preference_open_developer_options),
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_data_object),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
onClick = { onOpenDeveloperOptions(context) }
|
||||
)
|
||||
}
|
||||
|
||||
OptionsButton(
|
||||
title = stringResource(R.string.preferences_view_crashes),
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_bug_report),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
val launchIntent = Intent(context, CrashLogsActivity::class.java)
|
||||
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)) {
|
||||
var launcherButtonTapped by remember { mutableIntStateOf(0) }
|
||||
var showDeveloperDialog by remember { mutableStateOf(false) }
|
||||
|
||||
OptionsButton(
|
||||
stringResource(R.string.preference_launcher_version_name),
|
||||
stringResource(
|
||||
R.string.preference_launcher_version_description,
|
||||
BuildConfig.VERSION_NAME
|
||||
),
|
||||
displayInline = true
|
||||
) {
|
||||
launcherButtonTapped += 1
|
||||
|
||||
if (launcherButtonTapped >= 7) {
|
||||
if (developerModeEnabled) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.preference_enable_developer_again,
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
} else {
|
||||
showDeveloperDialog = true
|
||||
}
|
||||
launcherButtonTapped = 0
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeveloperDialog) {
|
||||
DeveloperModeDialog(
|
||||
onDismiss = {
|
||||
showDeveloperDialog = false
|
||||
},
|
||||
onEnable = {
|
||||
PreferenceUtils.get(context)
|
||||
.setBoolean(PreferenceUtils.Key.DEVELOPER_MODE, true)
|
||||
showDeveloperDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
OptionsLabel(
|
||||
stringResource(R.string.preference_loader_version_name),
|
||||
stringResource(
|
||||
R.string.preference_loader_version_description,
|
||||
currentRelease ?: "unknown"
|
||||
),
|
||||
displayInline = true
|
||||
)
|
||||
|
||||
val gameInstalled = remember {
|
||||
GamePackageUtils.isGameInstalled(context.packageManager)
|
||||
}
|
||||
|
||||
if (gameInstalled) {
|
||||
val gameVersion = remember {
|
||||
GamePackageUtils.getUnifiedVersionName(context.packageManager)
|
||||
}
|
||||
|
||||
val gameSource = remember {
|
||||
resources.getString(
|
||||
when (GamePackageUtils.identifyGameSource(context.packageManager)) {
|
||||
GamePackageUtils.GameSource.GOOGLE -> R.string.preference_game_source_google
|
||||
GamePackageUtils.GameSource.AMAZON -> R.string.preference_game_source_amazon
|
||||
else -> R.string.preference_game_source_unknown
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
OptionsLabel(
|
||||
stringResource(R.string.preference_game_version_name),
|
||||
stringResource(
|
||||
R.string.preference_game_version_description,
|
||||
gameVersion,
|
||||
gameSource
|
||||
),
|
||||
displayInline = true
|
||||
)
|
||||
|
||||
OptionsLabel(
|
||||
stringResource(R.string.preference_architecture_name),
|
||||
stringResource(
|
||||
R.string.preference_architecture_description,
|
||||
Build.MODEL,
|
||||
LaunchUtils.applicationArchitecture
|
||||
),
|
||||
displayInline = true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||
releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
|
||||
) {
|
||||
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) }
|
||||
|
||||
@@ -761,21 +304,260 @@ fun SettingsScreen(
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
GeneralSettingsGroup()
|
||||
GameplaySettingsGroup()
|
||||
OptionsGroup(stringResource(R.string.preference_category_testing)) {
|
||||
SettingsSelectCard(
|
||||
title = stringResource(R.string.preference_theme_name),
|
||||
dialogTitle = stringResource(R.string.preference_theme_select),
|
||||
preferenceKey = PreferenceUtils.Key.THEME,
|
||||
options = linkedMapOf(
|
||||
0 to stringResource(R.string.preference_theme_default),
|
||||
1 to stringResource(R.string.preference_theme_light),
|
||||
2 to stringResource(R.string.preference_theme_dark),
|
||||
),
|
||||
extraSelectBehavior = { updateTheme(context, it) }
|
||||
)
|
||||
SettingsCard(
|
||||
title = stringResource(R.string.preference_black_background_name),
|
||||
preferenceKey = PreferenceUtils.Key.BLACK_BACKGROUND
|
||||
)
|
||||
|
||||
UpdateSettingsGroup(releaseViewModel, snackbarHostState, onUpdate = {
|
||||
if (releaseViewModel.isInUpdate) {
|
||||
showUpdateInProgress = true
|
||||
} else {
|
||||
releaseViewModel.runReleaseCheck(true)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
SettingsCard(
|
||||
title = stringResource(R.string.preference_disable_styles),
|
||||
preferenceKey = PreferenceUtils.Key.DISABLE_USER_THEME
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val currentIcon by PreferenceUtils.useStringPreference(PreferenceUtils.Key.SELECTED_ICON)
|
||||
val iconName = remember(currentIcon) {
|
||||
IconUtils.getIconDetails(
|
||||
ApplicationIcon.fromId(currentIcon ?: "default")
|
||||
).nameId
|
||||
}
|
||||
|
||||
OptionsButton(
|
||||
title = stringResource(R.string.preference_set_application_icon),
|
||||
onClick = {
|
||||
val launchIntent = Intent(context, ApplicationIconActivity::class.java)
|
||||
context.startActivity(launchIntent)
|
||||
},
|
||||
description = stringResource(iconName),
|
||||
displayInline = true,
|
||||
)
|
||||
}
|
||||
|
||||
OptionsButton(
|
||||
title = stringResource(R.string.preferences_open_file_manager),
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_folder),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
onClick = { onOpenFileManager(context) }
|
||||
)
|
||||
}
|
||||
|
||||
OptionsGroup(stringResource(R.string.preference_category_gameplay)) {
|
||||
SettingsCard(
|
||||
title = stringResource(R.string.preference_load_automatically_name),
|
||||
description = stringResource(R.string.preference_load_automatically_description),
|
||||
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY,
|
||||
)
|
||||
SettingsSelectCard(
|
||||
title = stringResource(R.string.preference_display_mode_name),
|
||||
dialogTitle = stringResource(R.string.preference_display_mode_select),
|
||||
preferenceKey = PreferenceUtils.Key.DISPLAY_MODE,
|
||||
options = buildMap {
|
||||
put(0, stringResource(R.string.preference_display_mode_default))
|
||||
put(1, stringResource(R.string.preference_display_mode_legacy))
|
||||
|
||||
// necessary api doesn't exist on older versions of android
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
put(2, stringResource(R.string.preference_display_mode_fullscreen))
|
||||
}
|
||||
}
|
||||
)
|
||||
val currentDisplayMode by PreferenceUtils.useIntPreference(PreferenceUtils.Key.DISPLAY_MODE)
|
||||
|
||||
if (currentDisplayMode == 1) {
|
||||
SettingsStringSelectCard(
|
||||
title = stringResource(R.string.preference_custom_aspect_ratio_name),
|
||||
description = stringResource(R.string.preference_custom_aspect_ratio_description),
|
||||
dialogTitle = stringResource(R.string.preference_custom_aspect_ratio_select),
|
||||
preferenceKey = PreferenceUtils.Key.CUSTOM_ASPECT_RATIO,
|
||||
options = linkedMapOf(
|
||||
"16_9" to stringResource(R.string.preference_custom_aspect_ratio_16x9),
|
||||
"16_10" to stringResource(R.string.preference_custom_aspect_ratio_16x10),
|
||||
"4_3" to stringResource(R.string.preference_custom_aspect_ratio_4x3),
|
||||
"1_1" to stringResource(R.string.preference_custom_aspect_ratio_1x1),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
SettingsRangeCard(
|
||||
title = stringResource(R.string.preference_screen_zoom_name),
|
||||
description = stringResource(R.string.preference_screen_zoom_description),
|
||||
dialogTitle = stringResource(R.string.preference_screen_zoom_select),
|
||||
preferenceKey = PreferenceUtils.Key.SCREEN_ZOOM,
|
||||
labelSuffix = "x",
|
||||
range = 25..100,
|
||||
scale = 100,
|
||||
step = 5,
|
||||
) {
|
||||
SettingsCard(
|
||||
title = stringResource(R.string.preference_screen_zoom_fit_name),
|
||||
preferenceKey = PreferenceUtils.Key.SCREEN_ZOOM_FIT,
|
||||
asCard = false
|
||||
)
|
||||
}
|
||||
|
||||
val maxFrameRate = remember {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
context.display.supportedModes.maxOf { it.refreshRate }
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay.refreshRate
|
||||
}.roundToInt()
|
||||
}
|
||||
|
||||
SettingsFPSCard(
|
||||
title = stringResource(R.string.preference_limit_framerate_name),
|
||||
dialogTitle = stringResource(R.string.preference_limit_framerate_select),
|
||||
preferenceKey = PreferenceUtils.Key.LIMIT_FRAME_RATE,
|
||||
maxFrameRate = maxFrameRate
|
||||
)
|
||||
}
|
||||
|
||||
OptionsGroup(stringResource(R.string.preference_category_updater)) {
|
||||
SettingsCard(
|
||||
title = stringResource(R.string.preference_update_automatically_name),
|
||||
preferenceKey = PreferenceUtils.Key.UPDATE_AUTOMATICALLY,
|
||||
)
|
||||
OptionsCard(
|
||||
title = {
|
||||
OptionsTitle(
|
||||
title = stringResource(R.string.preference_check_for_updates_button),
|
||||
description = stringResource(
|
||||
R.string.preference_check_for_updates_description,
|
||||
currentRelease ?: "unknown"
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
onClick = {
|
||||
if (releaseViewModel.isInUpdate) {
|
||||
showUpdateInProgress = true
|
||||
} else {
|
||||
releaseViewModel.runReleaseCheck(true)
|
||||
}
|
||||
},
|
||||
role = Role.Button
|
||||
)
|
||||
) {
|
||||
UpdateIndicator(snackbarHostState, updateStatus)
|
||||
}
|
||||
}
|
||||
|
||||
val developerModeEnabled by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
|
||||
|
||||
AdvancedSettingsGroup(developerModeEnabled, snackbarHostState)
|
||||
AboutSettingsGroup(developerModeEnabled)
|
||||
OptionsGroup(stringResource(R.string.preference_category_developer)) {
|
||||
if (developerModeEnabled) {
|
||||
OptionsButton(
|
||||
title = stringResource(R.string.preference_open_developer_options),
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_data_object),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
onClick = { onOpenDeveloperOptions(context) }
|
||||
)
|
||||
}
|
||||
|
||||
OptionsButton(
|
||||
title = stringResource(R.string.preferences_view_crashes),
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_bug_report),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
val launchIntent = Intent(context, CrashLogsActivity::class.java)
|
||||
context.startActivity(launchIntent)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
OptionsGroup(stringResource(R.string.preference_category_about)) {
|
||||
var launcherButtonTapped by remember { mutableIntStateOf(0) }
|
||||
var showDeveloperDialog by remember { mutableStateOf(false) }
|
||||
|
||||
OptionsButton(
|
||||
stringResource(R.string.preference_launcher_version_name),
|
||||
stringResource(R.string.preference_launcher_version_description, BuildConfig.VERSION_NAME),
|
||||
displayInline = true
|
||||
) {
|
||||
launcherButtonTapped += 1
|
||||
|
||||
if (launcherButtonTapped >= 7 && !developerModeEnabled) {
|
||||
showDeveloperDialog = true
|
||||
launcherButtonTapped = 0
|
||||
}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
if (showDeveloperDialog) {
|
||||
DeveloperModeDialog(
|
||||
onDismiss = {
|
||||
showDeveloperDialog = false
|
||||
},
|
||||
onEnable = {
|
||||
PreferenceUtils.get(context).setBoolean(PreferenceUtils.Key.DEVELOPER_MODE, true)
|
||||
showDeveloperDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
OptionsLabel(
|
||||
stringResource(R.string.preference_loader_version_name),
|
||||
stringResource(R.string.preference_loader_version_description, currentRelease ?: "unknown"),
|
||||
displayInline = true
|
||||
)
|
||||
|
||||
val gameInstalled = remember {
|
||||
GamePackageUtils.isGameInstalled(context.packageManager)
|
||||
}
|
||||
|
||||
if (gameInstalled) {
|
||||
val gameVersion = remember {
|
||||
GamePackageUtils.getUnifiedVersionName(context.packageManager)
|
||||
}
|
||||
|
||||
val gameSource = remember {
|
||||
resources.getString(when (GamePackageUtils.identifyGameSource(context.packageManager)) {
|
||||
GamePackageUtils.GameSource.GOOGLE -> R.string.preference_game_source_google
|
||||
GamePackageUtils.GameSource.AMAZON -> R.string.preference_game_source_amazon
|
||||
else -> R.string.preference_game_source_unknown
|
||||
})
|
||||
}
|
||||
|
||||
OptionsLabel(
|
||||
stringResource(R.string.preference_game_version_name),
|
||||
stringResource(R.string.preference_game_version_description, gameVersion, gameSource),
|
||||
displayInline = true
|
||||
)
|
||||
|
||||
OptionsLabel(
|
||||
stringResource(R.string.preference_architecture_name),
|
||||
stringResource(R.string.preference_architecture_description, Build.MODEL, LaunchUtils.applicationArchitecture),
|
||||
displayInline = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
+464
-34
@@ -1,4 +1,4 @@
|
||||
package com.geode.launcher.preferences.components
|
||||
package com.geode.launcher.preferences
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -8,28 +8,40 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@@ -38,17 +50,21 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.geode.launcher.R
|
||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||
import com.geode.launcher.ui.theme.Typography
|
||||
import com.geode.launcher.utils.LabelledText
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import com.geode.launcher.utils.Profile
|
||||
import com.geode.launcher.utils.ProfileManager
|
||||
import kotlin.collections.get
|
||||
import kotlin.math.log10
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
@@ -220,6 +236,244 @@ fun SettingsStringCard(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StringDialog(
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
onSelect: (String) -> Unit,
|
||||
initialValue: String,
|
||||
filterInput: ((String) -> String)? = null
|
||||
) {
|
||||
var enteredValue by remember { mutableStateOf(initialValue) }
|
||||
|
||||
BaseDialog(
|
||||
onDismissRequest = { onDismissRequest() },
|
||||
title = title,
|
||||
content = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = enteredValue,
|
||||
onValueChange = {
|
||||
enteredValue = filterInput?.invoke(it) ?: it
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
autoCorrectEnabled = false
|
||||
),
|
||||
trailingIcon = {
|
||||
if (enteredValue.isNotEmpty()) {
|
||||
IconButton(onClick = { enteredValue = "" }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_close_small),
|
||||
contentDescription = stringResource(R.string.preference_text_clear)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onSelect(enteredValue) }) {
|
||||
Text(stringResource(R.string.message_box_accept))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.message_box_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RangeDialog(
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
onSelect: (Int) -> Unit,
|
||||
labelSuffix: String,
|
||||
initialValue: Int,
|
||||
range: IntRange,
|
||||
scale: Int,
|
||||
step: Int,
|
||||
children: @Composable () -> Unit
|
||||
) {
|
||||
var enteredValue by remember {
|
||||
mutableFloatStateOf(initialValue / scale.toFloat())
|
||||
}
|
||||
|
||||
BaseDialog(
|
||||
onDismissRequest = { onDismissRequest() },
|
||||
title = title,
|
||||
content = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
) {
|
||||
val precision = log10(scale.toFloat()).roundToInt()
|
||||
|
||||
Slider(
|
||||
value = enteredValue,
|
||||
onValueChange = { enteredValue = it },
|
||||
valueRange = (range.first.toFloat()/scale)..(range.last.toFloat()/scale),
|
||||
steps = ((range.last - range.first) - 1) / step,
|
||||
modifier = Modifier.weight(1.0f)
|
||||
)
|
||||
|
||||
Text("%.${precision}f$labelSuffix".format(enteredValue))
|
||||
}
|
||||
|
||||
children()
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onSelect((enteredValue * scale).roundToInt()) }) {
|
||||
Text(stringResource(R.string.message_box_accept))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.message_box_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FrameRateDialog(
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
onSelect: (Int) -> Unit,
|
||||
initialValue: Int,
|
||||
maxFrameRate: Int,
|
||||
) {
|
||||
var enteredValue by remember {
|
||||
mutableStateOf(
|
||||
if (initialValue == 0) maxFrameRate.toString()
|
||||
else initialValue.toString()
|
||||
)
|
||||
}
|
||||
|
||||
val minFrameRate = 5
|
||||
val currentValue = enteredValue.toIntOrNull()
|
||||
val maximumReached = currentValue != null && currentValue > maxFrameRate
|
||||
val minimumReached = currentValue != null && currentValue < minFrameRate
|
||||
|
||||
BaseDialog(
|
||||
onDismissRequest = { onDismissRequest() },
|
||||
title = title,
|
||||
content = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
val prevValue = max((currentValue ?: 0) - 5, minFrameRate)
|
||||
enteredValue = prevValue.toString()
|
||||
}, enabled = currentValue == null || currentValue > minFrameRate) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_remove),
|
||||
contentDescription = stringResource(R.string.preference_limit_framerate_subtract)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = enteredValue,
|
||||
onValueChange = {
|
||||
enteredValue = it
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
),
|
||||
suffix = {
|
||||
Text(stringResource(R.string.preference_limit_framerate_suffix))
|
||||
},
|
||||
label = {
|
||||
Text(stringResource(R.string.preference_limit_framerate_label))
|
||||
},
|
||||
isError = minimumReached || maximumReached,
|
||||
modifier = Modifier.weight(1.0f),
|
||||
)
|
||||
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
IconButton(onClick = {
|
||||
val nextValue = min((currentValue ?: 0) + 5, maxFrameRate)
|
||||
enteredValue = nextValue.toString()
|
||||
}, enabled = currentValue == null || currentValue < maxFrameRate) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_add),
|
||||
contentDescription = stringResource(R.string.preference_limit_framerate_add)
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = { onSelect(0) }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_delete),
|
||||
contentDescription = stringResource(R.string.preference_limit_framerate_reset)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (minimumReached) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
Text(
|
||||
stringResource(R.string.preference_limit_framerate_error_min, minFrameRate),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
} else if (maximumReached) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.preference_limit_framerate_error_max,
|
||||
maxFrameRate
|
||||
),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
// automatically disable if already max
|
||||
if (currentValue == maxFrameRate) {
|
||||
onSelect(0)
|
||||
} else {
|
||||
onSelect(currentValue ?: 0)
|
||||
}
|
||||
},
|
||||
enabled = !minimumReached && !maximumReached && currentValue != null
|
||||
) {
|
||||
Text(stringResource(R.string.message_box_accept))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.message_box_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsFPSCard(
|
||||
title: String,
|
||||
@@ -315,6 +569,120 @@ fun SettingsRangeCard(
|
||||
}
|
||||
}
|
||||
|
||||
internal val LocalSelectValue = compositionLocalOf<Any> { 0 }
|
||||
internal val LocalSelectSetValue = staticCompositionLocalOf<(Any) -> Unit> { {} }
|
||||
|
||||
@Composable
|
||||
fun <T> SelectOption(name: String, value: T, enabled: Boolean = true, leadingContent: @Composable (Boolean) -> Unit = {}) {
|
||||
val currentValue = LocalSelectValue.current
|
||||
val setValue = LocalSelectSetValue.current
|
||||
|
||||
val isSelected = currentValue.equals(value)
|
||||
|
||||
// do not give the row or column padding!! it messes up the selection effect
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
onClick = { setValue(value as Any) },
|
||||
role = Role.RadioButton,
|
||||
enabled = enabled
|
||||
)
|
||||
.padding(horizontal = 12.dp)
|
||||
) {
|
||||
Row(modifier = Modifier.weight(1.0f, true), verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(
|
||||
selected = isSelected,
|
||||
onClick = { setValue(value as Any) },
|
||||
enabled = enabled
|
||||
)
|
||||
Text(name, style = Typography.bodyMedium)
|
||||
}
|
||||
|
||||
leadingContent(isSelected)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileCreateDialog(onDismissRequest: () -> Unit) {
|
||||
var enteredValue by remember { mutableStateOf("") }
|
||||
|
||||
val filename = enteredValue.take(16)
|
||||
.lowercase()
|
||||
.map {
|
||||
if ("qwertyuiopasdfghjklzxcvbnm1234567890-_.".contains(it))
|
||||
it else '_'
|
||||
}
|
||||
.joinToString("")
|
||||
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val minimumReached = enteredValue.isEmpty() || filename.isEmpty()
|
||||
|
||||
val profileManager = ProfileManager.get(context)
|
||||
val currentProfiles = remember {
|
||||
profileManager.getProfiles().map { it.path }.toSet()
|
||||
}
|
||||
|
||||
val isDuplicate = currentProfiles.contains(filename)
|
||||
|
||||
BaseDialog(
|
||||
onDismissRequest = { onDismissRequest() },
|
||||
title = stringResource(R.string.preference_profiles_create),
|
||||
content = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = enteredValue,
|
||||
onValueChange = {
|
||||
enteredValue = it
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
),
|
||||
label = {
|
||||
Text(stringResource(R.string.preference_profiles_create_name))
|
||||
},
|
||||
isError = isDuplicate,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
|
||||
if (isDuplicate) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
Text(stringResource(R.string.preference_profiles_create_duplicate))
|
||||
} else if (!minimumReached) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
Text(stringResource(R.string.preference_profiles_create_info, filename))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
ProfileManager.get(context).storeProfile(Profile(filename, enteredValue))
|
||||
onDismissRequest()
|
||||
},
|
||||
enabled = !minimumReached && !isDuplicate
|
||||
) {
|
||||
Text(stringResource(R.string.preference_profiles_create_action))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.message_box_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileCreateCard() {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
@@ -344,7 +712,7 @@ fun ProfileSelectCard() {
|
||||
profileManager.getCurrentProfile()
|
||||
}
|
||||
|
||||
val currentProfileName = remember {
|
||||
var currentProfileName = remember {
|
||||
val savedProfile = currentProfileId
|
||||
if (savedProfile == null) {
|
||||
"Default"
|
||||
@@ -388,8 +756,8 @@ fun ProfileSelectCard() {
|
||||
.setCurrentProfile(selectedProfileId)
|
||||
|
||||
activity?.run {
|
||||
packageManager.getLaunchIntentForPackage(packageName)?.also { intent ->
|
||||
val mainIntent = Intent.makeRestartActivityTask(intent.component)
|
||||
packageManager.getLaunchIntentForPackage(packageName)?.also {
|
||||
val mainIntent = Intent.makeRestartActivityTask(it.component)
|
||||
startActivity(mainIntent)
|
||||
exitProcess(0)
|
||||
}
|
||||
@@ -434,6 +802,90 @@ fun ProfileSelectCard() {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BaseDialog(
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
dismissButton: @Composable () -> Unit,
|
||||
confirmButton: @Composable () -> Unit,
|
||||
content: @Composable (ColumnScope.() -> Unit),
|
||||
) {
|
||||
Dialog(onDismissRequest = onDismissRequest) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
)
|
||||
) {
|
||||
// styling a dialog is actually a little hard if you're doing what i'm doing
|
||||
// maybe there's a better way to make these padding values...
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(
|
||||
title,
|
||||
style = Typography.titleLarge,
|
||||
modifier = Modifier.padding(
|
||||
start = 28.dp,
|
||||
top = 24.dp,
|
||||
bottom = 12.dp
|
||||
)
|
||||
)
|
||||
|
||||
content()
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
bottom = 16.dp,
|
||||
end = 16.dp,
|
||||
top = 4.dp
|
||||
)
|
||||
) {
|
||||
dismissButton()
|
||||
confirmButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> SelectDialog(
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
onSelect: (T) -> Unit,
|
||||
initialValue: T,
|
||||
options: @Composable () -> Unit,
|
||||
) {
|
||||
val (selectedValue, setSelectedValue) = remember { mutableStateOf(initialValue) }
|
||||
|
||||
BaseDialog(
|
||||
title = title,
|
||||
onDismissRequest = onDismissRequest,
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.message_box_cancel))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onSelect(selectedValue) }) {
|
||||
Text(stringResource(R.string.message_box_accept))
|
||||
}
|
||||
}
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalSelectValue provides (selectedValue as Any),
|
||||
LocalSelectSetValue provides ({
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
setSelectedValue(it as T)
|
||||
})
|
||||
) {
|
||||
options()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OptionsButton(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, displayInline: Boolean = false, onClick: () -> Unit) {
|
||||
OptionsCard(
|
||||
@@ -473,7 +925,7 @@ fun OptionsLabel(title: String, description: String? = null, icon: (@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 settingEnabled = remember {
|
||||
mutableStateOf(getSetting(context, preferenceKey))
|
||||
@@ -489,7 +941,10 @@ fun SettingsCard(title: String, description: String? = null, icon: (@Composable
|
||||
},
|
||||
modifier = Modifier.toggleable(
|
||||
value = settingEnabled.value,
|
||||
onValueChange = { settingEnabled.value = toggleSetting(context, preferenceKey) },
|
||||
onValueChange = {
|
||||
settingEnabled.value = toggleSetting(context, preferenceKey)
|
||||
onChange?.invoke(settingEnabled.value)
|
||||
},
|
||||
role = Role.Switch,
|
||||
),
|
||||
backgroundColor = if (asCard)
|
||||
@@ -505,7 +960,7 @@ fun SettingsCard(title: String, description: String? = null, icon: (@Composable
|
||||
|
||||
@Composable
|
||||
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) {
|
||||
icon()
|
||||
}
|
||||
@@ -558,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
|
||||
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))
|
||||
@@ -1,196 +0,0 @@
|
||||
package com.geode.launcher.preferences.components
|
||||
|
||||
import androidx.activity.OnBackPressedDispatcher
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geode.launcher.R
|
||||
import com.geode.launcher.log.BaseDirectoryViewModel
|
||||
import com.geode.launcher.ui.theme.Typography
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CrashLogsTopBar(
|
||||
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||
directoryViewModel: BaseDirectoryViewModel<*>,
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
@StringRes titleId: Int,
|
||||
additionalOptions: (@Composable (() -> Unit) -> Unit)? = null,
|
||||
) {
|
||||
val isLoading by directoryViewModel.isLoading.collectAsState()
|
||||
var showMoreOptions by remember { mutableStateOf(false) }
|
||||
|
||||
Column {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_arrow_back),
|
||||
contentDescription = stringResource(R.string.back_icon_alt)
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { showMoreOptions = !showMoreOptions }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_more_vert),
|
||||
contentDescription = stringResource(R.string.application_logs_more)
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showMoreOptions,
|
||||
onDismissRequest = { showMoreOptions = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_delete),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(stringResource(R.string.application_logs_delete))
|
||||
}, onClick = {
|
||||
directoryViewModel.clearAllFiles()
|
||||
showMoreOptions = false
|
||||
}
|
||||
)
|
||||
|
||||
additionalOptions?.invoke {
|
||||
showMoreOptions = false
|
||||
}
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
stringResource(titleId),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> CrashLogsListing(
|
||||
modifier: Modifier = Modifier,
|
||||
directoryViewModel: BaseDirectoryViewModel<T>,
|
||||
@StringRes noneAvailableId: Int,
|
||||
logCard: @Composable (T, Shape) -> Unit,
|
||||
) {
|
||||
val logLines = directoryViewModel.lineState
|
||||
val isLoading by directoryViewModel.isLoading.collectAsState()
|
||||
|
||||
LazyColumn(
|
||||
modifier,
|
||||
verticalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
if (!isLoading && logLines.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(noneAvailableId),
|
||||
textAlign = TextAlign.Center,
|
||||
style = Typography.bodyLarge,
|
||||
modifier = Modifier.fillMaxSize().padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val itemsSize = logLines.size
|
||||
|
||||
itemsIndexed(
|
||||
logLines,
|
||||
key = { index, line -> directoryViewModel.getItemKey(line) }
|
||||
) { index, line ->
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (index == 0) 16.dp else 4.dp,
|
||||
topEnd = if (index == 0) 16.dp else 4.dp,
|
||||
bottomStart = if (index + 1 == itemsSize) 16.dp else 4.dp,
|
||||
bottomEnd = if (index + 1 == itemsSize) 16.dp else 4.dp
|
||||
)
|
||||
|
||||
logCard(line, shape)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun <T> DirectoryListingScreen(
|
||||
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||
directoryViewModel: BaseDirectoryViewModel<T>,
|
||||
@StringRes titleId: Int,
|
||||
@StringRes noneAvailableId: Int,
|
||||
additionalOptions: (@Composable (() -> Unit) -> Unit)? = null,
|
||||
logCard: @Composable (T, Shape) -> Unit,
|
||||
) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
CrashLogsTopBar(
|
||||
onBackPressedDispatcher,
|
||||
directoryViewModel,
|
||||
scrollBehavior,
|
||||
titleId,
|
||||
additionalOptions = additionalOptions
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
CrashLogsListing(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxWidth(),
|
||||
directoryViewModel,
|
||||
noneAvailableId = noneAvailableId,
|
||||
logCard = logCard
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,482 +0,0 @@
|
||||
package com.geode.launcher.preferences.components
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,490 +0,0 @@
|
||||
package com.geode.launcher.preferences.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.geode.launcher.R
|
||||
import com.geode.launcher.ui.theme.Typography
|
||||
import com.geode.launcher.utils.Profile
|
||||
import com.geode.launcher.utils.ProfileManager
|
||||
import kotlin.math.log10
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
@Composable
|
||||
fun StringDialog(
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
onSelect: (String) -> Unit,
|
||||
initialValue: String,
|
||||
filterInput: ((String) -> String)? = null
|
||||
) {
|
||||
var enteredValue by remember { mutableStateOf(initialValue) }
|
||||
|
||||
BaseDialog(
|
||||
onDismissRequest = { onDismissRequest() },
|
||||
title = title,
|
||||
content = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = enteredValue,
|
||||
onValueChange = {
|
||||
enteredValue = filterInput?.invoke(it) ?: it
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
autoCorrectEnabled = false
|
||||
),
|
||||
trailingIcon = {
|
||||
if (enteredValue.isNotEmpty()) {
|
||||
IconButton(onClick = { enteredValue = "" }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_close_small),
|
||||
contentDescription = stringResource(R.string.preference_text_clear)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onSelect(enteredValue) }) {
|
||||
Text(stringResource(R.string.message_box_accept))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.message_box_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RangeDialog(
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
onSelect: (Int) -> Unit,
|
||||
labelSuffix: String,
|
||||
initialValue: Int,
|
||||
range: IntRange,
|
||||
scale: Int,
|
||||
step: Int,
|
||||
children: @Composable () -> Unit
|
||||
) {
|
||||
var enteredValue by remember {
|
||||
mutableFloatStateOf(initialValue / scale.toFloat())
|
||||
}
|
||||
|
||||
BaseDialog(
|
||||
onDismissRequest = { onDismissRequest() },
|
||||
title = title,
|
||||
content = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
) {
|
||||
val precision = log10(scale.toFloat()).roundToInt()
|
||||
|
||||
Slider(
|
||||
value = enteredValue,
|
||||
onValueChange = { enteredValue = it },
|
||||
valueRange = (range.first.toFloat()/scale)..(range.last.toFloat()/scale),
|
||||
steps = ((range.last - range.first) - 1) / step,
|
||||
modifier = Modifier.weight(1.0f)
|
||||
)
|
||||
|
||||
Text("%.${precision}f$labelSuffix".format(enteredValue))
|
||||
}
|
||||
|
||||
children()
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onSelect((enteredValue * scale).roundToInt()) }) {
|
||||
Text(stringResource(R.string.message_box_accept))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.message_box_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FrameRateDialog(
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
onSelect: (Int) -> Unit,
|
||||
initialValue: Int,
|
||||
maxFrameRate: Int,
|
||||
) {
|
||||
var enteredValue by remember {
|
||||
mutableStateOf(
|
||||
if (initialValue == 0) maxFrameRate.toString()
|
||||
else initialValue.toString()
|
||||
)
|
||||
}
|
||||
|
||||
val minFrameRate = 5
|
||||
val currentValue = enteredValue.toIntOrNull()
|
||||
val maximumReached = currentValue != null && currentValue > maxFrameRate
|
||||
val minimumReached = currentValue != null && currentValue < minFrameRate
|
||||
|
||||
BaseDialog(
|
||||
onDismissRequest = { onDismissRequest() },
|
||||
title = title,
|
||||
content = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
val prevValue = max((currentValue ?: 0) - 5, minFrameRate)
|
||||
enteredValue = prevValue.toString()
|
||||
}, enabled = currentValue == null || currentValue > minFrameRate) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_remove),
|
||||
contentDescription = stringResource(R.string.preference_limit_framerate_subtract)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = enteredValue,
|
||||
onValueChange = {
|
||||
enteredValue = it
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
),
|
||||
suffix = {
|
||||
Text(stringResource(R.string.preference_limit_framerate_suffix))
|
||||
},
|
||||
label = {
|
||||
Text(stringResource(R.string.preference_limit_framerate_label))
|
||||
},
|
||||
isError = minimumReached || maximumReached,
|
||||
modifier = Modifier.weight(1.0f),
|
||||
)
|
||||
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
IconButton(onClick = {
|
||||
val nextValue = min((currentValue ?: 0) + 5, maxFrameRate)
|
||||
enteredValue = nextValue.toString()
|
||||
}, enabled = currentValue == null || currentValue < maxFrameRate) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_add),
|
||||
contentDescription = stringResource(R.string.preference_limit_framerate_add)
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = { onSelect(0) }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_delete),
|
||||
contentDescription = stringResource(R.string.preference_limit_framerate_reset)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (minimumReached) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
Text(
|
||||
stringResource(R.string.preference_limit_framerate_error_min, minFrameRate),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
} else if (maximumReached) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.preference_limit_framerate_error_max,
|
||||
maxFrameRate
|
||||
),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
// automatically disable if already max
|
||||
if (currentValue == maxFrameRate) {
|
||||
onSelect(0)
|
||||
} else {
|
||||
onSelect(currentValue ?: 0)
|
||||
}
|
||||
},
|
||||
enabled = !minimumReached && !maximumReached && currentValue != null
|
||||
) {
|
||||
Text(stringResource(R.string.message_box_accept))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.message_box_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun ProfileCreateDialog(onDismissRequest: () -> Unit) {
|
||||
var enteredValue by remember { mutableStateOf("") }
|
||||
|
||||
val filename = enteredValue.take(16)
|
||||
.lowercase()
|
||||
.map {
|
||||
if ("qwertyuiopasdfghjklzxcvbnm1234567890-_.".contains(it))
|
||||
it else '_'
|
||||
}
|
||||
.joinToString("")
|
||||
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val minimumReached = enteredValue.isEmpty() || filename.isEmpty()
|
||||
|
||||
val profileManager = ProfileManager.get(context)
|
||||
val currentProfiles = remember {
|
||||
profileManager.getProfiles().map { it.path }.toSet()
|
||||
}
|
||||
|
||||
val isDuplicate = currentProfiles.contains(filename)
|
||||
|
||||
BaseDialog(
|
||||
onDismissRequest = { onDismissRequest() },
|
||||
title = stringResource(R.string.preference_profiles_create),
|
||||
content = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = enteredValue,
|
||||
onValueChange = {
|
||||
enteredValue = it
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
),
|
||||
label = {
|
||||
Text(stringResource(R.string.preference_profiles_create_name))
|
||||
},
|
||||
isError = isDuplicate,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
|
||||
if (isDuplicate) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
Text(stringResource(R.string.preference_profiles_create_duplicate))
|
||||
} else if (!minimumReached) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
Text(stringResource(R.string.preference_profiles_create_info, filename))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
ProfileManager.get(context).storeProfile(Profile(filename, enteredValue))
|
||||
onDismissRequest()
|
||||
},
|
||||
enabled = !minimumReached && !isDuplicate
|
||||
) {
|
||||
Text(stringResource(R.string.preference_profiles_create_action))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.message_box_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun BaseDialog(
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
dismissButton: @Composable () -> Unit,
|
||||
confirmButton: @Composable () -> Unit,
|
||||
content: @Composable (ColumnScope.() -> Unit),
|
||||
) {
|
||||
Dialog(onDismissRequest = onDismissRequest) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
)
|
||||
) {
|
||||
// styling a dialog is actually a little hard if you're doing what i'm doing
|
||||
// maybe there's a better way to make these padding values...
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(
|
||||
title,
|
||||
style = Typography.titleLarge,
|
||||
modifier = Modifier.padding(
|
||||
start = 28.dp,
|
||||
top = 24.dp,
|
||||
bottom = 12.dp
|
||||
)
|
||||
)
|
||||
|
||||
content()
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
bottom = 16.dp,
|
||||
end = 16.dp,
|
||||
top = 4.dp
|
||||
)
|
||||
) {
|
||||
dismissButton()
|
||||
confirmButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal val LocalSelectValue = compositionLocalOf<Any> { 0 }
|
||||
internal val LocalSelectSetValue = staticCompositionLocalOf<(Any) -> Unit> { {} }
|
||||
|
||||
@Composable
|
||||
fun <T> SelectOption(name: String, value: T, enabled: Boolean = true, leadingContent: @Composable (Boolean) -> Unit = {}) {
|
||||
val currentValue = LocalSelectValue.current
|
||||
val setValue = LocalSelectSetValue.current
|
||||
|
||||
val isSelected = currentValue.equals(value)
|
||||
|
||||
// do not give the row or column padding!! it messes up the selection effect
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
onClick = { setValue(value as Any) },
|
||||
role = Role.RadioButton,
|
||||
enabled = enabled
|
||||
)
|
||||
.padding(horizontal = 12.dp)
|
||||
) {
|
||||
Row(modifier = Modifier.weight(1.0f, true), verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(
|
||||
selected = isSelected,
|
||||
onClick = { setValue(value as Any) },
|
||||
enabled = enabled
|
||||
)
|
||||
Text(name, style = Typography.bodyMedium)
|
||||
}
|
||||
|
||||
leadingContent(isSelected)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> SelectDialog(
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
onSelect: (T) -> Unit,
|
||||
initialValue: T,
|
||||
options: @Composable () -> Unit,
|
||||
) {
|
||||
val (selectedValue, setSelectedValue) = remember { mutableStateOf(initialValue) }
|
||||
|
||||
BaseDialog(
|
||||
title = title,
|
||||
onDismissRequest = onDismissRequest,
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.message_box_cancel))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onSelect(selectedValue) }) {
|
||||
Text(stringResource(R.string.message_box_accept))
|
||||
}
|
||||
}
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalSelectValue provides (selectedValue as Any),
|
||||
LocalSelectSetValue provides ({
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
setSelectedValue(it as T)
|
||||
})
|
||||
) {
|
||||
options()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,19 +48,8 @@ data class LoaderPayload<T>(
|
||||
data class DownloadableAsset(val url: String, val filename: String, val size: Long? = null)
|
||||
|
||||
abstract class Downloadable {
|
||||
/**
|
||||
* Human-readable representation of the current version.
|
||||
*/
|
||||
abstract fun getDescription(): String
|
||||
|
||||
/**
|
||||
* Unique value that refers to the current version, used for comparison.
|
||||
*/
|
||||
abstract fun getDescriptor(): Long
|
||||
|
||||
/**
|
||||
* Release download link for the current platform
|
||||
*/
|
||||
abstract fun getDownload(): DownloadableAsset?
|
||||
}
|
||||
|
||||
|
||||
@@ -19,13 +19,9 @@ import kotlinx.coroutines.launch
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import okio.source
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.InterruptedIOException
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
private const val TAG_LATEST = "latest"
|
||||
private const val TAG_BETA = "prerelease"
|
||||
@@ -86,13 +82,8 @@ class ReleaseManager private constructor(
|
||||
val isInUpdate: Boolean
|
||||
get() = _uiState.value !is ReleaseManagerState.Failure && _uiState.value !is ReleaseManagerState.Finished
|
||||
|
||||
private val _availableLauncherUpdateTag = MutableStateFlow<String?>(null)
|
||||
val availableLauncherUpdateTag = _availableLauncherUpdateTag.asStateFlow()
|
||||
|
||||
private val _availableLauncherUpdateDetails = MutableStateFlow<DownloadableLauncherRelease?>(null)
|
||||
val availableLauncherUpdateDetails = _availableLauncherUpdateDetails.asStateFlow()
|
||||
|
||||
var dismissedLauncherUpdate = false
|
||||
private val _availableLauncherUpdate = MutableStateFlow<DownloadableLauncherRelease?>(null)
|
||||
val availableLauncherUpdate = _availableLauncherUpdate.asStateFlow()
|
||||
|
||||
private fun sendError(e: Exception) {
|
||||
_uiState.value = ReleaseManagerState.Failure(e)
|
||||
@@ -140,30 +131,32 @@ class ReleaseManager private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadLauncherUpdate(release: Downloadable): Result<File> {
|
||||
val download = release.getDownload()
|
||||
?: return Result.failure(FileNotFoundException())
|
||||
private suspend fun downloadLauncherUpdate(release: Downloadable) {
|
||||
val download = release.getDownload() ?: return
|
||||
|
||||
val outputDirectory = applicationContext.filesDir
|
||||
val outputDirectory = LaunchUtils.getBaseDirectory(applicationContext)
|
||||
val outputFile = File(outputDirectory, download.filename)
|
||||
|
||||
try {
|
||||
DownloadUtils.downloadStream(
|
||||
httpClient,
|
||||
download.url,
|
||||
onResponse = { body ->
|
||||
body.source().use { source ->
|
||||
outputFile.sink().buffer().use { sink ->
|
||||
sink.writeAll(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
return Result.failure(e)
|
||||
if (outputFile.exists()) {
|
||||
// only download the apk once
|
||||
return
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -182,60 +175,44 @@ class ReleaseManager private constructor(
|
||||
// clone the file instance as renameTo may move the original file
|
||||
val tempFilePath = outputFile.path
|
||||
|
||||
val geodeFile = getGeodeOutputPath()
|
||||
|
||||
try {
|
||||
// tempFile should be in same path as geodeFile
|
||||
val geodeParent = geodeFile.parentFile
|
||||
if (geodeParent != null && !geodeParent.exists()) {
|
||||
geodeParent.mkdirs()
|
||||
val fileStream = DownloadUtils.downloadStream(
|
||||
httpClient,
|
||||
releaseAsset.url
|
||||
) { progress, outOf ->
|
||||
_uiState.value = ReleaseManagerState.InDownload(progress, outOf)
|
||||
}
|
||||
|
||||
DownloadUtils.downloadStream(
|
||||
httpClient,
|
||||
releaseAsset.url,
|
||||
onProgress = { progress, outOf ->
|
||||
_uiState.value = ReleaseManagerState.InDownload(progress, outOf)
|
||||
},
|
||||
onResponse = { body ->
|
||||
DownloadUtils.extractFileFromZipStream(
|
||||
body.byteStream(),
|
||||
outputFile,
|
||||
geodeFile.name
|
||||
)
|
||||
outputFile.parentFile?.mkdirs()
|
||||
|
||||
// work around a permission issue from adb push
|
||||
if (geodeFile.exists()) {
|
||||
geodeFile.delete()
|
||||
}
|
||||
val geodeFile = getGeodeOutputPath()
|
||||
|
||||
val renameSuccessful = runCatching {
|
||||
outputFile.renameTo(geodeFile)
|
||||
}.getOrDefault(false)
|
||||
|
||||
if (!renameSuccessful) {
|
||||
// attempt a manual copy if rename fails for whatever reason
|
||||
DownloadUtils.copyFile(outputFile, geodeFile)
|
||||
}
|
||||
}
|
||||
DownloadUtils.extractFileFromZipStream(
|
||||
fileStream,
|
||||
outputFile.outputStream(),
|
||||
geodeFile.name
|
||||
)
|
||||
|
||||
// work around a permission issue from adb push
|
||||
if (geodeFile.exists()) {
|
||||
geodeFile.delete()
|
||||
}
|
||||
|
||||
outputFile.renameTo(geodeFile)
|
||||
} catch (e: Exception) {
|
||||
sendError(e)
|
||||
return
|
||||
} finally {
|
||||
val tempFileClone = File(tempFilePath)
|
||||
runCatching {
|
||||
try {
|
||||
if (tempFileClone.exists()) tempFileClone.delete()
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
downloadLauncherUpdateIfNecessary()
|
||||
|
||||
// extraction performed
|
||||
updatePreferences(release)
|
||||
|
||||
val currentTime = Clock.System.now().toEpochMilliseconds()
|
||||
PreferenceUtils.get(applicationContext)
|
||||
.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
|
||||
|
||||
_uiState.value = ReleaseManagerState.Finished(true)
|
||||
}
|
||||
|
||||
@@ -256,74 +233,18 @@ class ReleaseManager private constructor(
|
||||
return originalFileHash != currentFileHash
|
||||
}
|
||||
|
||||
private suspend fun getLatestLauncherUpdate(): DownloadableLauncherRelease? {
|
||||
return try {
|
||||
releaseRepository.getLatestLauncherRelease()
|
||||
} catch (e: Exception) {
|
||||
sendError(e)
|
||||
return null
|
||||
private fun checkLauncherUpdate(launcherUpdate: DownloadableLauncherRelease) {
|
||||
if (launcherUpdate.release.tagName != BuildConfig.VERSION_NAME) {
|
||||
_availableLauncherUpdate.value = launcherUpdate
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun checkLauncherUpdate() {
|
||||
val launcherRelease = getLatestLauncherUpdate() ?: return
|
||||
|
||||
// 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
|
||||
|
||||
PreferenceUtils.get(applicationContext)
|
||||
.setString(PreferenceUtils.Key.LAST_LAUNCHER_UPDATE, launcherTag)
|
||||
|
||||
if (launcherTag != BuildConfig.VERSION_NAME) {
|
||||
_availableLauncherUpdateTag.value = 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 downloadLauncherUpdateIfNecessary() {
|
||||
val update = _availableLauncherUpdate.value ?: return
|
||||
downloadLauncherUpdate(update)
|
||||
}
|
||||
|
||||
private suspend fun checkForNewRelease(isManual: Boolean = false) {
|
||||
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
||||
|
||||
if (!isManual && shouldUseCache()) {
|
||||
afterUseCachedUpdate()
|
||||
return
|
||||
}
|
||||
|
||||
val release = try {
|
||||
getLatestRelease()
|
||||
} catch (e: Exception) {
|
||||
@@ -331,23 +252,32 @@ class ReleaseManager private constructor(
|
||||
return
|
||||
}
|
||||
|
||||
checkLauncherUpdate()
|
||||
val launcherRelease = try {
|
||||
releaseRepository.getLatestLauncherRelease()
|
||||
} catch (e: Exception) {
|
||||
sendError(e)
|
||||
return
|
||||
}
|
||||
|
||||
if (launcherRelease != null) {
|
||||
checkLauncherUpdate(launcherRelease)
|
||||
}
|
||||
|
||||
if (release == null) {
|
||||
val currentTime = Clock.System.now().toEpochMilliseconds()
|
||||
sharedPreferences.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
|
||||
downloadLauncherUpdateIfNecessary()
|
||||
|
||||
_uiState.value = ReleaseManagerState.Finished()
|
||||
return
|
||||
}
|
||||
|
||||
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
||||
|
||||
val currentVersion = sharedPreferences.getLong(PreferenceUtils.Key.CURRENT_VERSION_TIMESTAMP)
|
||||
val latestVersion = release.getDescriptor()
|
||||
|
||||
// check if an update is needed
|
||||
if (latestVersion == currentVersion && !fileWasExternallyModified()) {
|
||||
val currentTime = Clock.System.now().toEpochMilliseconds()
|
||||
sharedPreferences.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
|
||||
downloadLauncherUpdateIfNecessary()
|
||||
|
||||
_uiState.value = ReleaseManagerState.Finished()
|
||||
return
|
||||
@@ -357,6 +287,8 @@ class ReleaseManager private constructor(
|
||||
|
||||
// check if the file was externally modified
|
||||
if (!allowOverwriting && fileWasExternallyModified(true)) {
|
||||
downloadLauncherUpdateIfNecessary()
|
||||
|
||||
sendError(UpdateException(UpdateException.Reason.EXTERNAL_FILE_IN_USE))
|
||||
return
|
||||
}
|
||||
@@ -385,7 +317,7 @@ class ReleaseManager private constructor(
|
||||
}
|
||||
|
||||
private fun computeFileHash(file: File): String =
|
||||
file.source().buffer().use { it.readByteString().md5().hex() }
|
||||
file.source().buffer().readByteString().md5().hex()
|
||||
|
||||
private fun getGeodeOutputPath(): File {
|
||||
val geodeName = LaunchUtils.geodeFilename
|
||||
@@ -398,17 +330,7 @@ class ReleaseManager private constructor(
|
||||
val geodeName = LaunchUtils.geodeFilename
|
||||
val geodeDirectory = LaunchUtils.getBaseDirectory(applicationContext)
|
||||
|
||||
// warning!! while File::createTempFile may look tempting, a certain brand of phones has a messed up implementation of it
|
||||
// so we're making a temp file manually (as long as it doesn't collide with the geode download, it's okay)
|
||||
|
||||
val alphabet = ('A'..'Z') + ('a'..'z') + ('0'..'9')
|
||||
val suffix = buildString(8) {
|
||||
repeat(8) { append(alphabet.random()) }
|
||||
}
|
||||
|
||||
val tmpName = "tmp-$suffix.$geodeName"
|
||||
|
||||
val tempFile = File(geodeDirectory, tmpName)
|
||||
val tempFile = File.createTempFile(geodeName, null, geodeDirectory)
|
||||
|
||||
return tempFile
|
||||
}
|
||||
@@ -430,13 +352,9 @@ class ReleaseManager private constructor(
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun checkForUpdates(isManual: Boolean = false): StateFlow<ReleaseManagerState> {
|
||||
if (!isInUpdate) {
|
||||
if (isManual || !shouldUseCache()) {
|
||||
_uiState.value = ReleaseManagerState.InUpdateCheck
|
||||
updateJob = GlobalScope.launch {
|
||||
checkForNewRelease(isManual)
|
||||
}
|
||||
} else {
|
||||
afterUseCachedUpdate()
|
||||
_uiState.value = ReleaseManagerState.InUpdateCheck
|
||||
updateJob = GlobalScope.launch {
|
||||
checkForNewRelease(isManual)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.geode.launcher.updater
|
||||
|
||||
import com.geode.launcher.BuildConfig
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import com.geode.launcher.utils.DownloadUtils.executeCoroutine
|
||||
import kotlin.time.Clock
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlin.time.Instant
|
||||
@@ -11,10 +10,8 @@ import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNamingStrategy
|
||||
import kotlinx.serialization.json.okio.decodeFromBufferedSource
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttp
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.coroutines.executeAsync
|
||||
import java.io.IOException
|
||||
import java.net.URL
|
||||
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 val userAgent = "okhttp/${OkHttp.VERSION} (launcher=${BuildConfig.VERSION_NAME};arch=${LaunchUtils.applicationArchitecture})"
|
||||
|
||||
suspend fun getLatestLauncherRelease(): DownloadableLauncherRelease? {
|
||||
val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/android-launcher/releases/latest"
|
||||
|
||||
@@ -64,50 +59,48 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
|
||||
.url(url)
|
||||
.addHeader("Accept", "application/json")
|
||||
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
|
||||
.addHeader("User-Agent", userAgent)
|
||||
.build()
|
||||
|
||||
val call = httpClient.newCall(request)
|
||||
val response = call.executeCoroutine()
|
||||
|
||||
return call.executeAsync().use { response ->
|
||||
when (response.code) {
|
||||
200 -> {
|
||||
val format = Json {
|
||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
val release = format.decodeFromBufferedSource<List<Release>>(
|
||||
response.body.source()
|
||||
)
|
||||
|
||||
(release.find { it.tagName != "nightly" })
|
||||
?.let(::DownloadableGitHubLoaderRelease)
|
||||
return when (response.code) {
|
||||
200 -> {
|
||||
val format = Json {
|
||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
403 -> {
|
||||
// determine if the error code is a ratelimit
|
||||
// (github docs say it sends 429 too, but haven't seen that)
|
||||
|
||||
val limitRemaining = response.headers.get(GITHUB_RATELIMIT_REMAINING)?.toInt()
|
||||
val limitReset = response.headers.get(GITHUB_RATELIMIT_RESET)?.toLong()
|
||||
val release = format.decodeFromBufferedSource<List<Release>>(
|
||||
response.body!!.source()
|
||||
)
|
||||
|
||||
if (limitRemaining == 0 && limitReset != null) {
|
||||
// handle ratelimit with a custom error
|
||||
// there's also a retry-after header but again, haven't seen
|
||||
val resetTime = Instant.fromEpochSeconds(limitReset, 0L)
|
||||
val msg = generateRateLimitMessage(resetTime)
|
||||
(release.find { it.tagName != "nightly" })
|
||||
?.let(::DownloadableGitHubLoaderRelease)
|
||||
}
|
||||
403 -> {
|
||||
// determine if the error code is a ratelimit
|
||||
// (github docs say it sends 429 too, but haven't seen that)
|
||||
|
||||
throw IOException(msg)
|
||||
}
|
||||
val limitRemaining = response.headers.get(GITHUB_RATELIMIT_REMAINING)?.toInt()
|
||||
val limitReset = response.headers.get(GITHUB_RATELIMIT_RESET)?.toLong()
|
||||
|
||||
throw IOException("response 403: ${response.body.string()}")
|
||||
}
|
||||
404 -> {
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
throw IOException("unknown response ${response.code}")
|
||||
if (limitRemaining == 0 && limitReset != null) {
|
||||
// handle ratelimit with a custom error
|
||||
// there's also a retry-after header but again, haven't seen
|
||||
val resetTime = Instant.fromEpochSeconds(limitReset, 0L)
|
||||
val msg = generateRateLimitMessage(resetTime)
|
||||
|
||||
throw IOException(msg)
|
||||
}
|
||||
|
||||
throw IOException("response 403: ${response.body!!.string()}")
|
||||
}
|
||||
404 -> {
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
throw IOException("unknown response ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,48 +121,47 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
|
||||
.url(url)
|
||||
.addHeader("Accept", "application/json")
|
||||
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
|
||||
.addHeader("User-Agent", userAgent)
|
||||
.build()
|
||||
|
||||
val call = httpClient.newCall(request)
|
||||
return call.executeAsync().use { response ->
|
||||
when (response.code) {
|
||||
200 -> {
|
||||
val format = Json {
|
||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
val response = call.executeCoroutine()
|
||||
|
||||
val release = format.decodeFromBufferedSource<Release>(
|
||||
response.body.source()
|
||||
)
|
||||
|
||||
release
|
||||
return when (response.code) {
|
||||
200 -> {
|
||||
val format = Json {
|
||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
403 -> {
|
||||
// determine if the error code is a ratelimit
|
||||
// (github docs say it sends 429 too, but haven't seen that)
|
||||
|
||||
val limitRemaining = response.headers[GITHUB_RATELIMIT_REMAINING]?.toInt()
|
||||
val limitReset = response.headers[GITHUB_RATELIMIT_RESET]?.toLong()
|
||||
val release = format.decodeFromBufferedSource<Release>(
|
||||
response.body!!.source()
|
||||
)
|
||||
|
||||
if (limitRemaining == 0 && limitReset != null) {
|
||||
// handle ratelimit with a custom error
|
||||
// there's also a retry-after header but again, haven't seen
|
||||
val resetTime = Instant.fromEpochSeconds(limitReset, 0L)
|
||||
val msg = generateRateLimitMessage(resetTime)
|
||||
release
|
||||
}
|
||||
403 -> {
|
||||
// determine if the error code is a ratelimit
|
||||
// (github docs say it sends 429 too, but haven't seen that)
|
||||
|
||||
throw IOException(msg)
|
||||
}
|
||||
val limitRemaining = response.headers[GITHUB_RATELIMIT_REMAINING]?.toInt()
|
||||
val limitReset = response.headers[GITHUB_RATELIMIT_RESET]?.toLong()
|
||||
|
||||
throw IOException("response 403: ${response.body.string()}")
|
||||
}
|
||||
404 -> {
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
throw IOException("unknown response ${response.code}")
|
||||
if (limitRemaining == 0 && limitReset != null) {
|
||||
// handle ratelimit with a custom error
|
||||
// there's also a retry-after header but again, haven't seen
|
||||
val resetTime = Instant.fromEpochSeconds(limitReset, 0L)
|
||||
val msg = generateRateLimitMessage(resetTime)
|
||||
|
||||
throw IOException(msg)
|
||||
}
|
||||
|
||||
throw IOException("response 403: ${response.body!!.string()}")
|
||||
}
|
||||
404 -> {
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
throw IOException("unknown response ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,32 +178,31 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("Accept", "application/json")
|
||||
.addHeader("User-Agent", userAgent)
|
||||
.build()
|
||||
|
||||
val call = httpClient.newCall(request)
|
||||
return call.executeAsync().use { response ->
|
||||
when (response.code) {
|
||||
200 -> {
|
||||
val format = Json {
|
||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
val response = call.executeCoroutine()
|
||||
|
||||
val body = format.decodeFromBufferedSource<LoaderPayload<LoaderVersion>>(
|
||||
response.body.source()
|
||||
)
|
||||
return when (response.code) {
|
||||
200 -> {
|
||||
val format = Json {
|
||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
val release = body.payload ?: throw IOException("index error: ${body.error}")
|
||||
val body = format.decodeFromBufferedSource<LoaderPayload<LoaderVersion>>(
|
||||
response.body!!.source()
|
||||
)
|
||||
|
||||
DownloadableLoaderRelease(release)
|
||||
}
|
||||
404 -> {
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
throw IOException("unknown response ${response.code}")
|
||||
}
|
||||
val release = body.payload ?: throw IOException("index error: ${body.error}")
|
||||
|
||||
DownloadableLoaderRelease(release)
|
||||
}
|
||||
404 -> {
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
throw IOException("unknown response ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
|
||||
val isInUpdate
|
||||
get() = ReleaseManager.get(application).isInUpdate
|
||||
|
||||
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdateTag
|
||||
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdate
|
||||
|
||||
var hasPerformedCheck = false
|
||||
private set
|
||||
|
||||
@@ -20,7 +20,7 @@ object Constants {
|
||||
const val SUPPORTED_VERSION_CODE_MIN_WARNING = 40L
|
||||
|
||||
// anything above this version code will show a warning when starting the game
|
||||
const val SUPPORTED_VERSION_CODE = 41L
|
||||
const val SUPPORTED_VERSION_CODE = 40L
|
||||
|
||||
const val SUPPORTED_VERSION_STRING = "2.2.144"
|
||||
const val SUPPORTED_VERSION_STRING = "2.2.143"
|
||||
}
|
||||
@@ -2,31 +2,27 @@ package com.geode.launcher.utils
|
||||
|
||||
import android.os.Build
|
||||
import android.os.FileUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.coroutines.executeAsync
|
||||
import okio.Buffer
|
||||
import okio.BufferedSink
|
||||
import okio.BufferedSource
|
||||
import okio.FileSystem
|
||||
import okio.ForwardingSink
|
||||
import okio.ForwardingSource
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import okio.Sink
|
||||
import okio.Source
|
||||
import okio.buffer
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.zip.ZipInputStream
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
|
||||
typealias ProgressCallback = (progress: Long, outOf: Long) -> Unit
|
||||
@@ -35,9 +31,8 @@ object DownloadUtils {
|
||||
suspend fun downloadStream(
|
||||
httpClient: OkHttpClient,
|
||||
url: String,
|
||||
onProgress: ProgressCallback? = null,
|
||||
onResponse: suspend (ResponseBody) -> Unit,
|
||||
) {
|
||||
onProgress: ProgressCallback? = null
|
||||
): InputStream {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.build()
|
||||
@@ -55,7 +50,7 @@ object DownloadUtils {
|
||||
val originalResponse = chain.proceed(chain.request())
|
||||
originalResponse.newBuilder()
|
||||
.body(ProgressResponseBody(
|
||||
originalResponse.body, onProgress
|
||||
originalResponse.body!!, onProgress
|
||||
))
|
||||
.build()
|
||||
}
|
||||
@@ -64,35 +59,50 @@ object DownloadUtils {
|
||||
val progressClient = progressClientBuilder.build()
|
||||
|
||||
val call = progressClient.newCall(request)
|
||||
call.executeAsync().use { response ->
|
||||
when (response.code) {
|
||||
200 -> onResponse(response.body)
|
||||
else -> throw IOException("unexpected response ${response.code}")
|
||||
val response = call.executeCoroutine()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a file named by zipPath from inputStream and copies it to outputStream
|
||||
*
|
||||
* inputStream and outputStream are both closed after this function executes
|
||||
*/
|
||||
suspend fun extractFileFromZipStream(inputStream: InputStream, output: File, zipPath: String) {
|
||||
suspend fun extractFileFromZipStream(inputStream: InputStream, outputStream: OutputStream, zipPath: String) {
|
||||
// note to self: ZipInputStreams are a little silly
|
||||
// (runInterruptible allows it to cancel, otherwise it waits for the stream to finish)
|
||||
runInterruptible {
|
||||
inputStream.use { input ->
|
||||
output.outputStream().use { out ->
|
||||
inputStream.use {
|
||||
outputStream.use {
|
||||
// nice indentation
|
||||
ZipInputStream(input).use { zip ->
|
||||
var entry = zip.nextEntry
|
||||
val zip = ZipInputStream(inputStream)
|
||||
zip.use {
|
||||
var entry = it.nextEntry
|
||||
while (entry != null) {
|
||||
if (entry.name == zipPath) {
|
||||
zip.copyTo(out)
|
||||
it.copyTo(outputStream)
|
||||
return@runInterruptible
|
||||
}
|
||||
|
||||
entry = zip.nextEntry
|
||||
entry = it.nextEntry
|
||||
}
|
||||
|
||||
// no matching entries found, throw exception
|
||||
@@ -103,22 +113,6 @@ object DownloadUtils {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun copyFile(from: File, to: File) {
|
||||
val fs = FileSystem.SYSTEM
|
||||
|
||||
val fromPath = from.toOkioPath()
|
||||
val toPath = to.toOkioPath()
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
fs.copy(fromPath, toPath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies data from inputStream to outputStream
|
||||
*
|
||||
* Both streams are closed upon the conclusion of this function.
|
||||
*/
|
||||
fun copyFile(inputStream: InputStream, outputStream: OutputStream) {
|
||||
// gotta love copying
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
@@ -173,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",
|
||||
38L to "2.205",
|
||||
39L to "2.206",
|
||||
40L to "2.2074", // ok
|
||||
41L to "2.2081",
|
||||
40L to "2.2074" // ok
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,10 +7,6 @@ import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.hardware.lights.Light
|
||||
import android.hardware.lights.LightState
|
||||
import android.hardware.lights.LightsRequest
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
@@ -20,7 +16,6 @@ import android.os.VibratorManager
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION
|
||||
import android.util.Log
|
||||
import android.view.InputDevice
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -395,15 +390,6 @@ object GeodeUtils {
|
||||
return ""
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getGameVersionCode(): Long {
|
||||
activity.get()?.run {
|
||||
return GamePackageUtils.getGameVersionCode(packageManager)
|
||||
}
|
||||
|
||||
return 0L
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current version code of the launcher.
|
||||
*/
|
||||
@@ -560,7 +546,7 @@ object GeodeUtils {
|
||||
|
||||
const val CAPABILITY_EXTENDED_INPUT = "extended_input"
|
||||
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)
|
||||
|
||||
@@ -590,193 +576,6 @@ object GeodeUtils {
|
||||
.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
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getConnectivityManager(): ConnectivityManager? = activity.get()?.run {
|
||||
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
}
|
||||
|
||||
external fun nativeKeyUp(keyCode: Int, modifiers: Int)
|
||||
external fun nativeKeyDown(keyCode: Int, modifiers: Int, isRepeating: Boolean)
|
||||
external fun nativeActionScroll(scrollX: Float, scrollY: Float)
|
||||
@@ -788,18 +587,5 @@ object GeodeUtils {
|
||||
* @see reportPlatformCapability
|
||||
*/
|
||||
external fun setNextInputTimestamp(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)
|
||||
external fun setNextInputTimestampInternal(timestamp: Long)
|
||||
}
|
||||
@@ -80,11 +80,6 @@ object LaunchUtils {
|
||||
return File(base, "game/geode/crashlogs/")
|
||||
}
|
||||
|
||||
fun getGeodeLogsDirectory(context: Context): File {
|
||||
val base = getBaseDirectory(context)
|
||||
return File(base, "game/geode/logs/")
|
||||
}
|
||||
|
||||
fun getLastCrash(context: Context): File? {
|
||||
val crashDirectory = getCrashDirectory(context)
|
||||
if (!crashDirectory.exists()) {
|
||||
|
||||
@@ -127,33 +127,35 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
||||
enum class Key {
|
||||
LOAD_AUTOMATICALLY,
|
||||
UPDATE_AUTOMATICALLY,
|
||||
RELEASE_CHANNEL,
|
||||
CURRENT_VERSION_TAG,
|
||||
CURRENT_VERSION_TIMESTAMP,
|
||||
THEME,
|
||||
BLACK_BACKGROUND,
|
||||
CURRENT_RELEASE_MODIFIED,
|
||||
LAST_DISMISSED_UPDATE,
|
||||
DISMISSED_GJ_UPDATE,
|
||||
LAUNCH_ARGUMENTS,
|
||||
LIMIT_ASPECT_RATIO,
|
||||
DISPLAY_MODE,
|
||||
FORCE_HRR,
|
||||
ENABLE_REDESIGN,
|
||||
RELEASE_CHANNEL_TAG,
|
||||
DEVELOPER_MODE,
|
||||
CLEANUP_APKS,
|
||||
CUSTOM_SYMBOL_LIST,
|
||||
CUSTOM_ASPECT_RATIO,
|
||||
SCREEN_ZOOM,
|
||||
SCREEN_ZOOM_FIT,
|
||||
LIMIT_FRAME_RATE,
|
||||
DISABLE_USER_THEME,
|
||||
SELECTED_ICON,
|
||||
WAIT_PERIOD,
|
||||
LAST_UPDATE_CHECK_TIME,
|
||||
LAST_LAUNCHER_UPDATE,
|
||||
DISABLE_UPDATE_CACHE,
|
||||
DEVELOPER_SERVER,
|
||||
}
|
||||
|
||||
private fun defaultValueForBooleanKey(key: Key): Boolean {
|
||||
return when (key) {
|
||||
Key.UPDATE_AUTOMATICALLY -> true
|
||||
Key.UPDATE_AUTOMATICALLY, Key.FORCE_HRR -> true
|
||||
Key.DEVELOPER_MODE -> BuildConfig.DEBUG
|
||||
else -> false
|
||||
}
|
||||
@@ -162,7 +164,6 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
||||
private fun defaultValueForIntKey(key: Key) = when (key) {
|
||||
Key.DISPLAY_MODE -> if (this.getBoolean(Key.LIMIT_ASPECT_RATIO)) 1 else 0
|
||||
Key.SCREEN_ZOOM -> 100
|
||||
Key.WAIT_PERIOD -> 3
|
||||
// people wanted a reset on nightly anyways, so this is a good excuse to do so
|
||||
else -> 0
|
||||
}
|
||||
@@ -177,33 +178,30 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
||||
return when (key) {
|
||||
Key.LOAD_AUTOMATICALLY -> "PreferenceLoadAutomatically"
|
||||
Key.UPDATE_AUTOMATICALLY -> "PreferenceUpdateAutomatically"
|
||||
Key.RELEASE_CHANNEL -> "PreferenceReleaseChannel"
|
||||
Key.CURRENT_VERSION_TAG -> "PreferenceCurrentVersionName"
|
||||
Key.CURRENT_VERSION_TIMESTAMP -> "PreferenceCurrentVersionDescriptor"
|
||||
Key.THEME -> "PreferenceTheme"
|
||||
Key.BLACK_BACKGROUND -> "PreferenceBlackBackground"
|
||||
Key.CURRENT_RELEASE_MODIFIED -> "PreferenceReleaseModifiedHash"
|
||||
Key.LAST_DISMISSED_UPDATE -> "PreferenceLastDismissedUpdate"
|
||||
Key.DISMISSED_GJ_UPDATE -> "PreferenceDismissedGJUpdate"
|
||||
Key.LAUNCH_ARGUMENTS -> "PreferenceLaunchArguments"
|
||||
Key.LIMIT_ASPECT_RATIO -> "PreferenceLimitAspectRatio"
|
||||
Key.DISPLAY_MODE -> "PreferenceDisplayMode"
|
||||
Key.FORCE_HRR -> "PreferenceForceHighRefreshRate"
|
||||
Key.ENABLE_REDESIGN -> "PreferenceEnableRedesign"
|
||||
Key.RELEASE_CHANNEL_TAG -> "PreferenceReleaseChannelTag"
|
||||
Key.DEVELOPER_MODE -> "PreferenceDeveloperMode"
|
||||
Key.CLEANUP_APKS -> "PreferenceCleanupPackages"
|
||||
Key.CUSTOM_SYMBOL_LIST -> "PreferenceCustomSymbolList"
|
||||
Key.CUSTOM_ASPECT_RATIO -> "PreferenceCustomAspectRatio"
|
||||
Key.SCREEN_ZOOM -> "PreferenceScreenZoom"
|
||||
Key.SCREEN_ZOOM_FIT -> "PreferenceScreenZoomFit"
|
||||
Key.LIMIT_FRAME_RATE -> "PreferenceLimitFrameRate"
|
||||
Key.DISABLE_USER_THEME -> "PreferenceDisableUserTheme"
|
||||
Key.SELECTED_ICON -> "PreferenceSelectedIcon"
|
||||
Key.WAIT_PERIOD -> "PreferenceWaitPeriod"
|
||||
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"
|
||||
Key.DEVELOPER_SERVER -> "PreferenceDeveloperServer"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,12 @@ package org.cocos2dx.lib
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.hardware.input.InputManager
|
||||
import android.opengl.GLSurfaceView
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.Message
|
||||
import android.util.Log
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.Surface
|
||||
@@ -18,12 +16,10 @@ import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.widget.AppCompatEditText
|
||||
import com.customRobTop.BaseRobTopActivity
|
||||
import com.geode.launcher.utils.GeodeUtils
|
||||
import kotlin.math.abs
|
||||
|
||||
private const val HANDLER_OPEN_IME_KEYBOARD = 2
|
||||
private const val HANDLER_CLOSE_IME_KEYBOARD = 3
|
||||
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) {
|
||||
companion object {
|
||||
@@ -60,9 +56,8 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
}
|
||||
|
||||
var sendTimestampEvents = false
|
||||
var sendInternalTimestampEvents = false
|
||||
var manualBackEvents = false
|
||||
var controllerEventsEnabled = false
|
||||
private set
|
||||
|
||||
var cocos2dxEditText: AppCompatEditText? = null
|
||||
set(value) {
|
||||
@@ -159,6 +154,9 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
private fun sendNextEventTimestamp(timestamp: Long) {
|
||||
if (sendTimestampEvents)
|
||||
GeodeUtils.setNextInputTimestamp(timestamp)
|
||||
|
||||
if (sendInternalTimestampEvents)
|
||||
GeodeUtils.setNextInputTimestampInternal(timestamp)
|
||||
}
|
||||
|
||||
override fun onTouchEvent(motionEvent: MotionEvent): Boolean {
|
||||
@@ -168,7 +166,6 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
val ys = FloatArray(pointerNumber)
|
||||
val timestamp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||
motionEvent.eventTimeNanos else motionEvent.eventTime * MS_TO_NS
|
||||
val timestampSec = timestamp / SEC_TO_NS
|
||||
|
||||
for (i in 0 until pointerNumber) {
|
||||
ids[i] = motionEvent.getPointerId(i)
|
||||
@@ -182,15 +179,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
val f = ys[0]
|
||||
queueEvent {
|
||||
sendNextEventTimestamp(timestamp)
|
||||
|
||||
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)
|
||||
cocos2dxRenderer.handleActionDown(idDown, xDown, f)
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -200,42 +189,21 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
val f3 = ys[0]
|
||||
queueEvent {
|
||||
sendNextEventTimestamp(timestamp)
|
||||
|
||||
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)
|
||||
cocos2dxRenderer.handleActionUp(idUp, f2, f3)
|
||||
}
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
queueEvent {
|
||||
sendNextEventTimestamp(timestamp)
|
||||
|
||||
if (controllerEventsEnabled)
|
||||
GeodeUtils.internalTouchEvent(
|
||||
timestamp, motionEvent.deviceId, motionEvent.source,
|
||||
GeodeUtils.TOUCH_TYPE_MOVE, ids, xs, ys
|
||||
)
|
||||
else
|
||||
cocos2dxRenderer.handleActionMove(ids, xs, ys, timestampSec)
|
||||
cocos2dxRenderer.handleActionMove(ids, xs, ys)
|
||||
}
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
queueEvent {
|
||||
sendNextEventTimestamp(timestamp)
|
||||
|
||||
if (controllerEventsEnabled)
|
||||
GeodeUtils.internalTouchEvent(
|
||||
timestamp, motionEvent.deviceId, motionEvent.source,
|
||||
GeodeUtils.TOUCH_TYPE_CANCEL, ids, xs, ys
|
||||
)
|
||||
else
|
||||
cocos2dxRenderer.handleActionCancel(ids, xs, ys, timestampSec)
|
||||
cocos2dxRenderer.handleActionCancel(ids, xs, ys)
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -246,14 +214,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
val y = motionEvent.getY(indexPointerDown)
|
||||
queueEvent {
|
||||
sendNextEventTimestamp(timestamp)
|
||||
|
||||
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)
|
||||
cocos2dxRenderer.handleActionDown(idPointerDown, xPointerDown, y)
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -264,14 +225,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
val y2 = motionEvent.getY(indexPointUp)
|
||||
queueEvent {
|
||||
sendNextEventTimestamp(timestamp)
|
||||
|
||||
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)
|
||||
cocos2dxRenderer.handleActionUp(idPointerUp, xPointerUp, y2)
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -312,42 +266,21 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
return
|
||||
}
|
||||
|
||||
val currentTime = System.nanoTime()
|
||||
queueEvent {
|
||||
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.nativeKeyUp(KeyEvent.KEYCODE_BACK, 0)
|
||||
} else if (!controllerEventsEnabled) {
|
||||
if (useKeyboardEvents) {
|
||||
queueEvent {
|
||||
cocos2dxRenderer.handleKeyDown(KeyEvent.KEYCODE_BACK)
|
||||
}
|
||||
} else {
|
||||
val currentTime = System.nanoTime()
|
||||
queueEvent {
|
||||
sendNextEventTimestamp(currentTime)
|
||||
GeodeUtils.nativeKeyDown(KeyEvent.KEYCODE_BACK, 0, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||
if (!useKeyboardEvents && !controllerEventsEnabled) {
|
||||
if (!useKeyboardEvents) {
|
||||
return legacyKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
@@ -363,14 +296,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
|
||||
queueEvent {
|
||||
sendNextEventTimestamp(event.eventTime * MS_TO_NS)
|
||||
|
||||
if (useKeyboardEvents) {
|
||||
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)
|
||||
}
|
||||
GeodeUtils.nativeKeyDown(keyCode, event.modifiers, event.repeatCount != 0)
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -378,7 +304,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
}
|
||||
|
||||
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
|
||||
if (!useKeyboardEvents && !controllerEventsEnabled) {
|
||||
if (!useKeyboardEvents) {
|
||||
return super.onKeyUp(keyCode, event)
|
||||
}
|
||||
|
||||
@@ -393,14 +319,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
|
||||
queueEvent {
|
||||
sendNextEventTimestamp(event.eventTime * MS_TO_NS)
|
||||
|
||||
if (useKeyboardEvents) {
|
||||
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)
|
||||
}
|
||||
GeodeUtils.nativeKeyUp(keyCode, event.modifiers)
|
||||
}
|
||||
|
||||
true
|
||||
@@ -408,67 +327,8 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if (!useKeyboardEvents && !controllerEventsEnabled) {
|
||||
if (!useKeyboardEvents) {
|
||||
return super.onGenericMotionEvent(event)
|
||||
}
|
||||
|
||||
@@ -480,39 +340,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
|
||||
queueEvent {
|
||||
sendNextEventTimestamp(timestamp)
|
||||
|
||||
if (useKeyboardEvents) {
|
||||
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
|
||||
)
|
||||
GeodeUtils.nativeActionScroll(scrollX, scrollY)
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -537,40 +365,4 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
fun deleteBackward() {
|
||||
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
|
||||
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
|
||||
private external fun nativeTouchesBegin(id: Int, x: Float, y: Float, timestamp: Double)
|
||||
private external fun nativeTouchesBegin(id: Int, x: Float, y: Float)
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeTouchesCancel(
|
||||
ids: IntArray,
|
||||
xs: FloatArray,
|
||||
ys: FloatArray,
|
||||
timestamp: Double,
|
||||
ys: FloatArray
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeTouchesEnd(id: Int, x: Float, y: Float, timestamp: Double)
|
||||
private external fun nativeTouchesEnd(id: Int, x: Float, y: Float)
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeTouchesMove(ids: IntArray, xs: FloatArray, ys: FloatArray, timestamp: Double)
|
||||
private external fun nativeTouchesMove(ids: IntArray, xs: FloatArray, ys: FloatArray)
|
||||
|
||||
@JvmStatic
|
||||
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) {
|
||||
nativeTouchesBegin(id, x, y, timestamp)
|
||||
fun handleActionDown(id: Int, x: Float, y: Float) {
|
||||
nativeTouchesBegin(id, x, y)
|
||||
}
|
||||
|
||||
fun handleActionUp(id: Int, x: Float, y: Float, timestamp: Double) {
|
||||
nativeTouchesEnd(id, x, y, timestamp)
|
||||
fun handleActionUp(id: Int, x: Float, y: Float) {
|
||||
nativeTouchesEnd(id, x, y)
|
||||
}
|
||||
|
||||
fun handleActionCancel(ids: IntArray, xs: FloatArray, ys: FloatArray, timestamp: Double) {
|
||||
nativeTouchesCancel(ids, xs, ys, timestamp)
|
||||
fun handleActionCancel(ids: IntArray, xs: FloatArray, ys: FloatArray) {
|
||||
nativeTouchesCancel(ids, xs, ys)
|
||||
}
|
||||
|
||||
fun handleActionMove(ids: IntArray, xs: FloatArray, ys: FloatArray, timestamp: Double) {
|
||||
nativeTouchesMove(ids, xs, ys, timestamp)
|
||||
fun handleActionMove(ids: IntArray, xs: FloatArray, ys: FloatArray) {
|
||||
nativeTouchesMove(ids, xs, ys)
|
||||
}
|
||||
|
||||
fun handleKeyDown(keyCode: Int) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.AssetManager
|
||||
import android.media.AudioDeviceCallback
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioManager
|
||||
import android.net.Uri
|
||||
@@ -15,45 +14,27 @@ import android.os.Build
|
||||
import android.util.Log
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
|
||||
// i never agreed to your licenses! take that
|
||||
@SuppressLint("StaticFieldLeak")
|
||||
object FMOD {
|
||||
private var gContext: Context? = null
|
||||
private var gPluginAudioDeviceCallback: PluginAudioDeviceCallback? = null
|
||||
private val gPluginBroadcastReceiver = PluginBroadcastReceiver()
|
||||
|
||||
external fun OutputAAudioHeadphonesChanged()
|
||||
external fun SetInputEnumerationChanged()
|
||||
external fun SetOutputEnumerationChanged()
|
||||
|
||||
@JvmStatic
|
||||
fun init(context: Context?) {
|
||||
gContext = context
|
||||
if (context == null) {
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
gContext?.registerReceiver(
|
||||
gPluginBroadcastReceiver,
|
||||
IntentFilter(Intent.ACTION_HEADSET_PLUG)
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun close() {
|
||||
val context = gContext
|
||||
if (context != null) {
|
||||
context.unregisterReceiver(gPluginBroadcastReceiver)
|
||||
(context.getSystemService(Context.AUDIO_SERVICE) as AudioManager)
|
||||
.unregisterAudioDeviceCallback(gPluginAudioDeviceCallback)
|
||||
}
|
||||
context?.unregisterReceiver(gPluginBroadcastReceiver)
|
||||
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
|
||||
fun fileDescriptorFromUri(str: String?): Int {
|
||||
gContext?.apply {
|
||||
@@ -152,82 +127,4 @@ object FMOD {
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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="M280,840L280,840Q280,840 280,840Q280,840 280,840L280,120Q280,120 280,120Q280,120 280,120L280,120Q280,120 280,120Q280,120 280,120L280,520L280,520Q280,520 280,590Q280,660 280,760L280,840ZM508.5,228.5Q520,217 520,200Q520,183 508.5,171.5Q497,160 480,160Q463,160 451.5,171.5Q440,183 440,200Q440,217 451.5,228.5Q463,240 480,240Q497,240 508.5,228.5ZM280,920Q247,920 223.5,896.5Q200,873 200,840L200,120Q200,87 223.5,63.5Q247,40 280,40L680,40Q713,40 736.5,63.5Q760,87 760,120L760,244Q778,251 789,266Q800,281 800,300L800,380Q800,399 789,414Q778,429 760,436L760,520L680,520L680,120Q680,120 680,120Q680,120 680,120L280,120Q280,120 280,120Q280,120 280,120L280,840Q280,840 280,840Q280,840 280,840L360,840L360,920L280,920ZM584,904L440,760L584,616L640,673L553,760L640,847L584,904ZM776,904L720,847L807,760L720,673L776,616L920,760L776,904Z"/>
|
||||
</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -62,25 +62,15 @@
|
||||
<string name="launcher_notification_update_success">Geode updated!</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_forwards_compatibility_description">Geode is currently unavailable.</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_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_downloading_update">Downloading update…</string>
|
||||
<string name="launcher_downloading_update_details">(%1$s/%2$s)</string>
|
||||
<string name="launcher_cancelled_manual">Launch paused.</string>
|
||||
<string name="launcher_cancelled_navigated_away">Launch paused.</string>
|
||||
<string name="launcher_cancelled_crash">Launch paused due to a crash.</string>
|
||||
<string name="launcher_cancelled_crash_instant">Geode crashed.</string>
|
||||
<string name="launcher_cancelled_error">Geode cannot be used.</string>
|
||||
<string name="launcher_cancelled_error">Could not start Geode.</string>
|
||||
<string name="launcher_cancelled_outdated">A Geometry Dash update is required.</string>
|
||||
<string name="launcher_cancelled_restart">Restart</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_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 -->
|
||||
<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>
|
||||
@@ -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_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_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="message_box_cancel">Cancel</string>
|
||||
@@ -145,7 +130,6 @@
|
||||
<string name="preferences_open_file_manager">Open file manager</string>
|
||||
<string name="preferences_view_logs">View application logs</string>
|
||||
<string name="preferences_view_crashes">View crash dumps</string>
|
||||
<string name="preferences_view_geode_logs">View Geode logs</string>
|
||||
|
||||
<string name="preference_theme_name">Theme</string>
|
||||
<string name="preference_theme_select">Select theme</string>
|
||||
@@ -157,15 +141,6 @@
|
||||
<string name="preference_set_application_icon">Application icon</string>
|
||||
<string name="preference_force_hrr">Force maximum refresh rate</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_select">Select display mode</string>
|
||||
@@ -216,7 +191,6 @@
|
||||
<string name="preference_launcher_version_name">Android Launcher</string>
|
||||
<string name="preference_launcher_version_description">v%1$s</string>
|
||||
<string name="preference_launcher_version">Android Launcher v%1$s</string>
|
||||
<string name="preference_enable_developer_again">You are already a developer!</string>
|
||||
|
||||
<string name="preference_loader_version_name">Geode</string>
|
||||
<string name="preference_loader_version_description">%1$s</string>
|
||||
@@ -234,13 +208,8 @@
|
||||
<string name="preference_architecture">%1$s (%2$s)</string>
|
||||
|
||||
<string name="preference_check_for_updates_button">Check for updates</string>
|
||||
<string name="preference_check_for_updates_description">Last checked %1$s</string>
|
||||
<string name="preference_check_for_updates_failed">Update failed.</string>
|
||||
<string name="preference_check_for_updates_failed_action">Help</string>
|
||||
|
||||
<string name="preference_check_for_updates_help_title">Update failed.</string>
|
||||
<string name="preference_check_for_updates_help_copy">Copy</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_none_found">No updates found.</string>
|
||||
<string name="preference_check_for_updates_success">Geode has been updated!</string>
|
||||
<string name="preference_check_for_updates_downloading">Downloading update…</string>
|
||||
@@ -248,38 +217,10 @@
|
||||
<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_launch_safe_mode">Launch in safe mode</string>
|
||||
<string name="preference_launch_safe_mode_confirm_title">Start in safe mode?</string>
|
||||
|
||||
<string name="preference_backup_cloud">Backup save to cloud</string>
|
||||
<string name="preference_backup_confirm_title">Backup save data?</string>
|
||||
<string name="preference_backup_in_progress">Backing up data…</string>
|
||||
<string name="preference_backup_cancelling">Cancelling backup…</string>
|
||||
|
||||
<string name="preference_backup_confirm_description">This will backup your save data using your in-game account. You can then access it on an unmodified version of the game through the account backup menu.\nContinuing will overwrite any save data you currently have on the Geometry Dash servers!</string>
|
||||
|
||||
<string name="preference_backup_save_missing">Failed to find save data.</string>
|
||||
<string name="preference_backup_save_decode_failed">Failed to decode save file.</string>
|
||||
<string name="preference_backup_save_too_large">Save file is too large.</string>
|
||||
<string name="preference_backup_save_details_missing">Failed to find account information.</string>
|
||||
|
||||
<string name="preference_backup_success">Backup success!</string>
|
||||
<string name="preference_backup_cancelled">Backup cancelled.</string>
|
||||
<string name="preference_backup_failed_generic">An unknown error has occurred.</string>
|
||||
<string name="preference_backup_failed_invalid_login">Invalid login information.</string>
|
||||
<string name="preference_backup_failed_network">A network error has occurred. Please try again later.</string>
|
||||
|
||||
<string name="preference_backup_failed_title">Backup failed</string>
|
||||
|
||||
<string name="preference_backup_failed">Backup failed with error code %1$d.</string>
|
||||
|
||||
<string name="preference_launch_arguments_name">Launch arguments</string>
|
||||
<string name="preference_launch_arguments_set_title">Set launch arguments</string>
|
||||
<string name="preference_text_clear">Clear</string>
|
||||
|
||||
<string name="preference_disable_update_cache">Disable update cache</string>
|
||||
|
||||
<string name="preference_open_developer_options">Open developer options</string>
|
||||
<string name="preference_developer_options_dialog_title">Enable developer options?</string>
|
||||
|
||||
@@ -321,12 +262,16 @@
|
||||
<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_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 -->
|
||||
<string name="title_activity_crash_logs">Crash dumps</string>
|
||||
<string name="application_crashes_no_dumps">No crash dumps available.</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 -->
|
||||
<string name="title_activity_application_icon">Application icons</string>
|
||||
@@ -335,11 +280,6 @@
|
||||
<string name="application_icon_geode">Geode</string>
|
||||
<string name="application_icon_pride">Pride</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>
|
||||
|
||||
<!-- GeodeLogsListingActivity strings -->
|
||||
<string name="title_activity_geode_logs">Geode logs</string>
|
||||
<string name="application_geode_logs_no_logs">No logs available.</string>
|
||||
<string name="application_geode_logs_empty">File was empty.</string>
|
||||
|
||||
</resources>
|
||||
+6
-5
@@ -1,13 +1,14 @@
|
||||
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.
|
||||
plugins {
|
||||
id("com.android.application") version "9.0.1" apply false
|
||||
id("com.android.library") version "9.0.1" apply false
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.3.10" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.3.10" apply false
|
||||
id("com.android.application") version "8.13.2" apply false
|
||||
id("com.android.library") version "8.13.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.3.0" 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") {
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
#Wed Jan 28 04:12:33 MST 2026
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
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
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
Reference in new issue
Block a user