mirror of
https://github.com/geode-sdk/android-launcher.git
synced 2026-10-23 00:42:38 +00:00
Compare commits
177
Commits
1.0.0-beta.2
...
1.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f5ccd9ef1 | ||
|
|
3661df2667 | ||
|
|
759ee4afd7 | ||
|
|
375f771e8a | ||
|
|
e6fa0d491d | ||
|
|
fa397dc036 | ||
|
|
c61d652229 | ||
|
|
63b697e25c | ||
|
|
d3fe2f0f41 | ||
|
|
cb50acca09 | ||
|
|
bdce61d160 | ||
|
|
a562f5ec75 | ||
|
|
d21ab708dc | ||
|
|
ba642fb2d3 | ||
|
|
70934a95ad | ||
|
|
50f4a5d8e3 | ||
|
|
d4bacc4afb | ||
|
|
8942971368 | ||
|
|
f801993d0e | ||
|
|
0c551c8276 | ||
|
|
4d189f44a9 | ||
|
|
044af3d50b | ||
|
|
e092c15004 | ||
|
|
03bb6cd8b8 | ||
|
|
b1daf9c6b1 | ||
|
|
317a4b52cc | ||
|
|
033057fb6b | ||
|
|
1c463da5c7 | ||
|
|
f8fcd81057 | ||
|
|
97576dbfb4 | ||
|
|
0bd64b7578 | ||
|
|
b59cde68b0 | ||
|
|
8ea6589ab6 | ||
|
|
79bed774aa | ||
|
|
b919736ce1 | ||
|
|
d9a4f12902 | ||
|
|
6258700bc4 | ||
|
|
a62bdcc51c | ||
|
|
8341c15475 | ||
|
|
ce8c9496de | ||
|
|
779a099c16 | ||
|
|
031c7d215f | ||
|
|
75596fefd2 | ||
|
|
efaeeba5ec | ||
|
|
3161ee0974 | ||
|
|
127fd1bfd1 | ||
|
|
b7be446ecd | ||
|
|
18cb201a14 | ||
|
|
545a0d6532 | ||
|
|
574a4b5c22 | ||
|
|
028bf647e8 | ||
|
|
93b90742ed | ||
|
|
61d754bdd0 | ||
|
|
62568198a1 | ||
|
|
649cba97a6 | ||
|
|
c3adddc86b | ||
|
|
1c96c43c2d | ||
|
|
d1abde68b9 | ||
|
|
52b997a5b4 | ||
|
|
b384da1825 | ||
|
|
ef206baf69 | ||
|
|
5392d42d97 | ||
|
|
2e8cc302d0 | ||
|
|
a59cdd5d16 | ||
|
|
d89e0f1134 | ||
|
|
cae1dcd493 | ||
|
|
ff40776458 | ||
|
|
d7021ba54c | ||
|
|
8fcc011f71 | ||
|
|
6c62229c03 | ||
|
|
9353c14dcf | ||
|
|
299f081919 | ||
|
|
919822300d | ||
|
|
1b136cd4e9 | ||
|
|
2d4a027001 | ||
|
|
ae3e25dbf9 | ||
|
|
189350c55c | ||
|
|
9c8a5ab372 | ||
|
|
e37de9fc3a | ||
|
|
13c69cb30f | ||
|
|
eeb6460548 | ||
|
|
92c8c719eb | ||
|
|
645ed772f4 | ||
|
|
4114cc0078 | ||
|
|
aee4c49015 | ||
|
|
4165c22c2b | ||
|
|
c53a23faf9 | ||
|
|
cd73362c1c | ||
|
|
a730eab331 | ||
|
|
7bc7b898a2 | ||
|
|
83557c3ebe | ||
|
|
58725d95d2 | ||
|
|
7db022f392 | ||
|
|
44bbb25c7e | ||
|
|
004526e685 | ||
|
|
9c7d87ff8a | ||
|
|
642fdc36ee | ||
|
|
c6155443a2 | ||
|
|
420bca5ece | ||
|
|
b96561bf33 | ||
|
|
61c018ee0d | ||
|
|
41e3e0a133 | ||
|
|
3853a469cd | ||
|
|
62b31b8924 | ||
|
|
ab1d0c96f4 | ||
|
|
96354096e2 | ||
|
|
25b686f183 | ||
|
|
7694236434 | ||
|
|
7c0f1da312 | ||
|
|
019d9747b5 | ||
|
|
e806ec223a | ||
|
|
9f58e76f97 | ||
|
|
371750a184 | ||
|
|
2494bda4e8 | ||
|
|
b67be09cd3 | ||
|
|
09b1c9bc3a | ||
|
|
483fb56e25 | ||
|
|
7857fae68f | ||
|
|
61b84cd223 | ||
|
|
bd67332adc | ||
|
|
84657fd6b3 | ||
|
|
f197b45f9e | ||
|
|
d0dc8bec5d | ||
|
|
c9e0408eb2 | ||
|
|
b50a5b2b54 | ||
|
|
c8e3d7404c | ||
|
|
c4c41e324e | ||
|
|
4d324913df | ||
|
|
19bd549b1f | ||
|
|
9dc501c9ab | ||
|
|
5521869ac9 | ||
|
|
c9a712f692 | ||
|
|
6a0b3fc8bd | ||
|
|
684fd99b83 | ||
|
|
fd83daa776 | ||
|
|
9a87716635 | ||
|
|
31566ce64b | ||
|
|
d078879e7b | ||
|
|
5fa42b6ca5 | ||
|
|
e164b87436 | ||
|
|
433d6a0f46 | ||
|
|
cf8b1edbe2 | ||
|
|
500e00a9cc | ||
|
|
c3f80e9c74 | ||
|
|
7eb06e175f | ||
|
|
141681be05 | ||
|
|
dcfab5e001 | ||
|
|
498bbf4528 | ||
|
|
dc72608622 | ||
|
|
a50e0a0830 | ||
|
|
db707dfce1 | ||
|
|
929cc7615e | ||
|
|
bb2e3fbb1b | ||
|
|
7d548aef6c | ||
|
|
7f64236e89 | ||
|
|
199b6f142b | ||
|
|
af87320f11 | ||
|
|
665756d85b | ||
|
|
590006413c | ||
|
|
1b079fa3ff | ||
|
|
cb16db3bf9 | ||
|
|
de6fad41f6 | ||
|
|
d1a43230a9 | ||
|
|
442ef83339 | ||
|
|
1eab544692 | ||
|
|
af0ebeab84 | ||
|
|
0844dc97d2 | ||
|
|
42c10c2729 | ||
|
|
cf568bcbe2 | ||
|
|
36152d8773 | ||
|
|
3f371be097 | ||
|
|
5eba097e38 | ||
|
|
7e868d8d0a | ||
|
|
97b851cbc1 | ||
|
|
397cb9b9bd | ||
|
|
05eb3af243 | ||
|
|
e7a3b72fad |
No files matched your search
@@ -7,6 +7,15 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
config:
|
||||||
|
- name: Release
|
||||||
|
lower: release
|
||||||
|
|
||||||
|
- name: Debug
|
||||||
|
lower: debug
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -27,7 +36,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build project
|
- name: Build project
|
||||||
run: |
|
run: |
|
||||||
./gradlew app:assembleDebug \
|
./gradlew app:assemble${{ matrix.config.name }} \
|
||||||
-Pandroid.injected.signing.store.file="$HOME/release.keystore" \
|
-Pandroid.injected.signing.store.file="$HOME/release.keystore" \
|
||||||
-Pandroid.injected.signing.store.password="${{secrets.KEYSTORE_PASSWORD}}" \
|
-Pandroid.injected.signing.store.password="${{secrets.KEYSTORE_PASSWORD}}" \
|
||||||
-Pandroid.injected.signing.key.alias="${{secrets.KEY_ALIAS}}" \
|
-Pandroid.injected.signing.key.alias="${{secrets.KEY_ALIAS}}" \
|
||||||
@@ -37,8 +46,8 @@ jobs:
|
|||||||
id: rename_apk_universal
|
id: rename_apk_universal
|
||||||
run: |
|
run: |
|
||||||
SHORT_SHA=${GITHUB_SHA::7}
|
SHORT_SHA=${GITHUB_SHA::7}
|
||||||
APK_PATH=${{github.workspace}}/app/build/outputs/apk/debug/app-universal-debug.apk
|
APK_PATH=${{github.workspace}}/app/build/outputs/apk/${{ matrix.config.lower }}/app-universal-${{ matrix.config.lower }}.apk
|
||||||
OUT_PATH=${{github.workspace}}/geode-launcher-debug-$SHORT_SHA.apk
|
OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-$SHORT_SHA.apk
|
||||||
mv $APK_PATH $OUT_PATH
|
mv $APK_PATH $OUT_PATH
|
||||||
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
|
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
@@ -46,15 +55,15 @@ jobs:
|
|||||||
id: rename_apk
|
id: rename_apk
|
||||||
run: |
|
run: |
|
||||||
SHORT_SHA=${GITHUB_SHA::7}
|
SHORT_SHA=${GITHUB_SHA::7}
|
||||||
APK_PATH=${{github.workspace}}/app/build/outputs/apk/debug/app-armeabi-v7a-debug.apk
|
APK_PATH=${{github.workspace}}/app/build/outputs/apk/${{ matrix.config.lower }}/app-armeabi-v7a-${{ matrix.config.lower }}.apk
|
||||||
OUT_PATH=${{github.workspace}}/geode-launcher-debug-android32-$SHORT_SHA.apk
|
OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-android32-$SHORT_SHA.apk
|
||||||
mv $APK_PATH $OUT_PATH
|
mv $APK_PATH $OUT_PATH
|
||||||
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
|
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Upload development build
|
- name: Upload development build
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: geode-launcher-debug
|
name: geode-launcher-${{ matrix.config.lower }}
|
||||||
path: |
|
path: |
|
||||||
${{ steps.rename_apk_universal.outputs.path }}
|
${{ steps.rename_apk_universal.outputs.path }}
|
||||||
${{ steps.rename_apk.outputs.path }}
|
${{ steps.rename_apk.outputs.path }}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
name: Update Website
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types:
|
||||||
|
- released
|
||||||
|
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
trigger:
|
||||||
|
name: Trigger site build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
github-token: ${{ secrets.GEODE_BOT_PUSH_BIN_TOKEN }}
|
||||||
|
script: |
|
||||||
|
await github.rest.actions.createWorkflowDispatch({
|
||||||
|
owner: 'geode-sdk',
|
||||||
|
repo: 'website',
|
||||||
|
workflow_id: 'build.yml',
|
||||||
|
ref: 'main'
|
||||||
|
})
|
||||||
@@ -10,3 +10,4 @@
|
|||||||
local.properties
|
local.properties
|
||||||
app/release/
|
app/release/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.kotlin/
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
Boost Software License - Version 1.0 - August 17th, 2003
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person or organization
|
||||||
|
obtaining a copy of the software and accompanying documentation covered by
|
||||||
|
this license (the "Software") to use, reproduce, display, distribute,
|
||||||
|
execute, and transmit the Software, and to prepare derivative works of the
|
||||||
|
Software, and to permit third-parties to whom the Software is furnished to
|
||||||
|
do so, all subject to the following:
|
||||||
|
|
||||||
|
The copyright notices in the Software and this entire statement, including
|
||||||
|
the above license grant, this restriction and the following disclaimer,
|
||||||
|
must be included in all copies of the Software, in whole or in part, and
|
||||||
|
all derivative works of the Software, unless such copies or derivative
|
||||||
|
works are solely in the form of machine-executable object code generated by
|
||||||
|
a source language processor.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT
|
||||||
|
SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE
|
||||||
|
FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
|
||||||
|
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||||
|
DEALINGS IN THE SOFTWARE.
|
||||||
@@ -1,7 +1,3 @@
|
|||||||
# Geode Android Launcher
|
# Geode Android Launcher
|
||||||
|
|
||||||
Launches a vanilla copy of Geometry Dash with the Geode loader added.
|
Launches a vanilla copy of Geometry Dash with the Geode loader added.
|
||||||
|
|
||||||
## TODO:
|
|
||||||
|
|
||||||
* Figure out better way of packaging libgeode
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id 'com.android.application'
|
|
||||||
id 'org.jetbrains.kotlin.android'
|
|
||||||
id 'org.jetbrains.kotlin.plugin.serialization'
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
compileSdk 34
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId "com.geode.launcher"
|
|
||||||
minSdk 23
|
|
||||||
targetSdk 34
|
|
||||||
versionCode 5
|
|
||||||
versionName "1.0.0-beta.2"
|
|
||||||
|
|
||||||
vectorDrawables {
|
|
||||||
useSupportLibrary true
|
|
||||||
}
|
|
||||||
externalNativeBuild {
|
|
||||||
cmake {
|
|
||||||
arguments "-DUSE_TULIPHOOK:BOOL=OFF"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ndkConfig.abiFilters "arm64-v8a", "armeabi-v7a"
|
|
||||||
}
|
|
||||||
|
|
||||||
splits {
|
|
||||||
abi {
|
|
||||||
enable true
|
|
||||||
reset()
|
|
||||||
|
|
||||||
//noinspection ChromeOsAbiSupport (not my fault)
|
|
||||||
include "arm64-v8a", "armeabi-v7a"
|
|
||||||
|
|
||||||
universalApk true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
minifyEnabled false
|
|
||||||
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
compileOptions {
|
|
||||||
// enables a polyfill for java Instant on api levels < 26 (used for updater)
|
|
||||||
coreLibraryDesugaringEnabled true
|
|
||||||
|
|
||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
|
||||||
}
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = '1.8'
|
|
||||||
}
|
|
||||||
buildFeatures {
|
|
||||||
compose true
|
|
||||||
}
|
|
||||||
composeOptions {
|
|
||||||
kotlinCompilerExtensionVersion compose_compiler
|
|
||||||
}
|
|
||||||
packagingOptions {
|
|
||||||
resources {
|
|
||||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
externalNativeBuild {
|
|
||||||
cmake {
|
|
||||||
path file('src/main/cpp/CMakeLists.txt')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
namespace 'com.geode.launcher'
|
|
||||||
ndkVersion '26.1.10909125'
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation 'androidx.core:core-ktx:1.12.0'
|
|
||||||
implementation "androidx.compose.ui:ui:$compose_version"
|
|
||||||
implementation 'androidx.compose.material3:material3:1.1.2'
|
|
||||||
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
|
|
||||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
|
|
||||||
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0"
|
|
||||||
implementation 'androidx.activity:activity-compose:1.8.2'
|
|
||||||
implementation 'androidx.activity:activity-ktx:1.8.2'
|
|
||||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
|
||||||
implementation "androidx.documentfile:documentfile:1.0.1"
|
|
||||||
implementation "com.squareup.okio:okio:3.7.0"
|
|
||||||
implementation "com.squareup.okhttp3:okhttp:4.12.0"
|
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0'
|
|
||||||
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.5.0'
|
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.6.0"
|
|
||||||
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
|
|
||||||
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"
|
|
||||||
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4"
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
val composeBOM: String by rootProject.extra
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization")
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.geode.launcher"
|
||||||
|
minSdk = 23
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = 17
|
||||||
|
versionName = "1.4.0"
|
||||||
|
|
||||||
|
vectorDrawables {
|
||||||
|
useSupportLibrary = true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UnstableApiUsage")
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
arguments("-DUSE_TULIPHOOK:BOOL=OFF", "-DANDROID_STL=c++_shared")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//noinspection ChromeOsAbiSupport (not my fault)
|
||||||
|
ndk.abiFilters += listOf("arm64-v8a", "armeabi-v7a")
|
||||||
|
}
|
||||||
|
|
||||||
|
splits {
|
||||||
|
abi {
|
||||||
|
isEnable = true
|
||||||
|
reset()
|
||||||
|
|
||||||
|
//noinspection ChromeOsAbiSupport. i'm sorry!
|
||||||
|
include("arm64-v8a", "armeabi-v7a")
|
||||||
|
|
||||||
|
isUniversalApk = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
// enables a polyfill for java Instant on api levels < 26 (used for updater)
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
|
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility = JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "1.8"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
path = file("src/main/cpp/CMakeLists.txt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
namespace = "com.geode.launcher"
|
||||||
|
ndkVersion = "26.3.11579264"
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation (platform("androidx.compose:compose-bom:$composeBOM"))
|
||||||
|
implementation ("androidx.core:core-ktx:1.13.1")
|
||||||
|
implementation ("androidx.compose.ui:ui")
|
||||||
|
implementation ("androidx.compose.material3:material3")
|
||||||
|
implementation ("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.8.2")
|
||||||
|
implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.2")
|
||||||
|
implementation ("androidx.activity:activity-compose:1.9.0")
|
||||||
|
implementation ("androidx.activity:activity-ktx:1.9.0")
|
||||||
|
implementation ("androidx.appcompat:appcompat:1.7.0")
|
||||||
|
implementation ("androidx.documentfile:documentfile:1.0.1")
|
||||||
|
implementation ("com.squareup.okio:okio:3.9.0")
|
||||||
|
implementation ("com.squareup.okhttp3:okhttp:4.12.0")
|
||||||
|
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0")
|
||||||
|
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.7.0")
|
||||||
|
implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.6.0")
|
||||||
|
debugImplementation ("androidx.compose.ui:ui-tooling")
|
||||||
|
coreLibraryDesugaring ("com.android.tools:desugar_jdk_libs:2.0.4")
|
||||||
|
}
|
||||||
Vendored
+6
-20
@@ -1,21 +1,7 @@
|
|||||||
# Add project specific ProGuard rules here.
|
# Add wildcard keep rules to all of the library code in use
|
||||||
# You can control the set of applied configuration files using the
|
-keep class org.fmod.** { *; }
|
||||||
# proguardFiles setting in build.gradle.
|
-keep class org.cocos2dx.lib.** { *; }
|
||||||
#
|
-keep class com.customRobTop.** { *; }
|
||||||
# For more details, see
|
|
||||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
|
||||||
|
|
||||||
# If your project uses WebView with JS, uncomment the following
|
# note: if you're going to add more rules, consider the @Keep annotation
|
||||||
# and specify the fully qualified class name to the JavaScript interface
|
# this should really only be kept to non-custom code
|
||||||
# class:
|
|
||||||
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
|
||||||
# public *;
|
|
||||||
#}
|
|
||||||
|
|
||||||
# Uncomment this to preserve the line number information for
|
|
||||||
# debugging stack traces.
|
|
||||||
#-keepattributes SourceFile,LineNumberTable
|
|
||||||
|
|
||||||
# If you keep the line number information, uncomment this to
|
|
||||||
# hide the original source file name.
|
|
||||||
#-renamesourcefileattribute SourceFile
|
|
||||||
@@ -9,9 +9,18 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
<uses-permission
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
<uses-permission android:name="android.permission.READ_LOGS" />
|
android:maxSdkVersion="32" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||||
|
<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-feature
|
<uses-feature
|
||||||
android:name="android.hardware.wifi"
|
android:name="android.hardware.wifi"
|
||||||
@@ -22,14 +31,30 @@
|
|||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
|
android:appCategory="game"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:requestLegacyExternalStorage="true"
|
||||||
android:theme="@style/Theme.GeodeLauncher"
|
android:theme="@style/Theme.GeodeLauncher"
|
||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
<activity
|
<activity
|
||||||
android:name=".SettingsActivity"
|
android:name=".AltMainActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/Theme.GeodeLauncher" />
|
||||||
|
<activity
|
||||||
|
android:name=".preferences.DeveloperSettingsActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:label="@string/title_activity_developer_settings"
|
||||||
|
android:theme="@style/Theme.GeodeLauncher" />
|
||||||
|
<activity
|
||||||
|
android:name=".preferences.ApplicationLogsActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:label="@string/title_activity_application_logs"
|
||||||
|
android:theme="@style/Theme.GeodeLauncher" />
|
||||||
|
<activity
|
||||||
|
android:name=".preferences.SettingsActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:label="@string/title_activity_settings"
|
android:label="@string/title_activity_settings"
|
||||||
android:theme="@style/Theme.GeodeLauncher">
|
android:theme="@style/Theme.GeodeLauncher">
|
||||||
@@ -43,6 +68,7 @@
|
|||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:screenOrientation="landscape"
|
android:screenOrientation="landscape"
|
||||||
android:theme="@style/Theme.NoActionBar.Fullscreen"
|
android:theme="@style/Theme.NoActionBar.Fullscreen"
|
||||||
|
android:configChanges="orientation|screenSize|smallestScreenSize"
|
||||||
android:windowSoftInputMode="stateAlwaysHidden">
|
android:windowSoftInputMode="stateAlwaysHidden">
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="android.app.lib_name"
|
android:name="android.app.lib_name"
|
||||||
@@ -58,11 +84,12 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:authorities="com.geode.launcher.user"
|
|
||||||
android:name=".UserDirectoryProvider"
|
android:name=".UserDirectoryProvider"
|
||||||
android:grantUriPermissions="true"
|
android:authorities="com.geode.launcher.user"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
android:grantUriPermissions="true"
|
||||||
android:permission="android.permission.MANAGE_DOCUMENTS">
|
android:permission="android.permission.MANAGE_DOCUMENTS">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
|
||||||
@@ -77,5 +104,10 @@
|
|||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/provider_paths" />
|
android:resource="@xml/provider_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="android.game_mode_config"
|
||||||
|
android:resource="@xml/game_mode_config" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -13,7 +13,7 @@ option(USE_TULIPHOOK "enables choice of hooking library" OFF)
|
|||||||
if (USE_TULIPHOOK)
|
if (USE_TULIPHOOK)
|
||||||
FetchContent_Declare(TulipHook
|
FetchContent_Declare(TulipHook
|
||||||
GIT_REPOSITORY https://github.com/geode-sdk/TulipHook.git
|
GIT_REPOSITORY https://github.com/geode-sdk/TulipHook.git
|
||||||
GIT_TAG 70122be72da7787dc42813ba0a992f3612e7871e
|
GIT_TAG 0021301fcea265416fa8eb5372694931c1d410cb
|
||||||
)
|
)
|
||||||
|
|
||||||
set(TULIP_LINK_SOURCE ON CACHE INTERNAL "")
|
set(TULIP_LINK_SOURCE ON CACHE INTERNAL "")
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
#include <dlfcn.h>
|
#include <dlfcn.h>
|
||||||
#include <jni.h>
|
#include <jni.h>
|
||||||
|
#include <fstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <cstdint>
|
||||||
#include <android/log.h>
|
#include <android/log.h>
|
||||||
|
#include <link.h>
|
||||||
|
|
||||||
#ifndef DISABLE_LAUNCHER_FIX
|
#ifndef DISABLE_LAUNCHER_FIX
|
||||||
|
|
||||||
@@ -15,8 +18,9 @@
|
|||||||
|
|
||||||
class DataPaths {
|
class DataPaths {
|
||||||
public:
|
public:
|
||||||
std::string original_data_path;
|
std::string original_data_path{};
|
||||||
std::string data_path;
|
std::string data_path{};
|
||||||
|
std::string load_symbols_from{};
|
||||||
|
|
||||||
static DataPaths& get_instance() {
|
static DataPaths& get_instance() {
|
||||||
static auto paths_instance = DataPaths();
|
static auto paths_instance = DataPaths();
|
||||||
@@ -24,7 +28,7 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
DataPaths() : original_data_path(), data_path() {}
|
DataPaths() {}
|
||||||
};
|
};
|
||||||
|
|
||||||
extern "C"
|
extern "C"
|
||||||
@@ -41,6 +45,30 @@ JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setDataPath(
|
|||||||
env->ReleaseStringUTFChars(data_path, 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
#ifdef __arm__
|
||||||
|
// 32bit code
|
||||||
|
typedef Elf32_Dyn Elf_Dyn;
|
||||||
|
typedef Elf32_Sym Elf_Sym;
|
||||||
|
#else
|
||||||
|
// otherwise we're probably 64bit
|
||||||
|
typedef Elf64_Dyn Elf_Dyn;
|
||||||
|
typedef Elf64_Sym Elf_Sym;
|
||||||
|
#endif
|
||||||
|
|
||||||
extern "C"
|
extern "C"
|
||||||
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setOriginalDataPath(
|
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setOriginalDataPath(
|
||||||
JNIEnv *env,
|
JNIEnv *env,
|
||||||
@@ -55,6 +83,172 @@ JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setOriginalDataPath(
|
|||||||
env->ReleaseStringUTFChars(data_path, data_path_str);
|
env->ReleaseStringUTFChars(data_path, data_path_str);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool patch_symbol(std::uint32_t* hash_table, char* str_table, Elf_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
|
||||||
|
std::array<std::uint8_t, 3> new_symbol{":3"};
|
||||||
|
|
||||||
|
#ifdef USE_TULIPHOOK
|
||||||
|
auto res = tulip::hook::writeMemory(sym_name, new_symbol.data(), new_symbol.size());
|
||||||
|
if (!res) {
|
||||||
|
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "failed to patch symbol: %s", res.unwrapErr().c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
DobbyCodePatch(reinterpret_cast<void*>(sym_name), new_symbol.data(), new_symbol.size());
|
||||||
|
#endif
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "could not find symbol %s to patch", orig_name);
|
||||||
|
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",
|
||||||
|
"_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 (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<Elf_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;
|
||||||
|
|
||||||
|
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_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_WARN, "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<Elf_Sym*>(sym_table_addr);
|
||||||
|
|
||||||
|
auto symbols_listing = get_symbols_listing();
|
||||||
|
for (const auto& symbol : symbols_listing) {
|
||||||
|
patch_symbol(hash_table, str_table, sym_table, symbol.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// this should be called after gd is loaded but before geode
|
||||||
|
extern "C"
|
||||||
|
JNIEXPORT int JNICALL Java_com_geode_launcher_LauncherFix_performExceptionsRenaming(JNIEnv*, jobject) {
|
||||||
|
return dl_iterate_phdr(on_dl_iterate, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
std::string redirect_path(const char* pathname) {
|
std::string redirect_path(const char* pathname) {
|
||||||
auto data_path = DataPaths::get_instance().data_path;
|
auto data_path = DataPaths::get_instance().data_path;
|
||||||
auto original_data_path = DataPaths::get_instance().original_data_path;
|
auto original_data_path = DataPaths::get_instance().original_data_path;
|
||||||
|
|||||||
@@ -0,0 +1,525 @@
|
|||||||
|
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.height
|
||||||
|
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.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
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.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.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 launchViewModel = viewModel<LaunchViewModel>(factory = LaunchViewModel.Factory)
|
||||||
|
|
||||||
|
if (loadFailureInfo != null) {
|
||||||
|
launchViewModel.loadFailure = loadFailureInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
|
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
|
||||||
|
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(Icons.Default.Warning, contentDescription = null, modifier)
|
||||||
|
else -> Icon(Icons.Default.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 -> LaunchStatusInfo(
|
||||||
|
title = stringResource(R.string.launcher_cancelled_navigated_away)
|
||||||
|
)
|
||||||
|
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) {
|
||||||
|
Formatter.formatShortFileSize(context, state.outOf)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is LaunchViewModel.LaunchUIState.Cancelled -> {
|
||||||
|
return mapCancelReasonToInfo(state.reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LaunchCancelledBody(statusInfo: LaunchStatusInfo, icon: @Composable () -> Unit, inProgress: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
horizontalAlignment = Alignment.Start,
|
||||||
|
modifier = modifier.width(300.dp)
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyLarge) {
|
||||||
|
icon()
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text(statusInfo.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inProgress) {
|
||||||
|
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusInfo.details != null) {
|
||||||
|
Text(
|
||||||
|
statusInfo.details,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LaunchProgressBody(statusInfo: LaunchStatusInfo, modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
horizontalAlignment = Alignment.Start,
|
||||||
|
modifier = modifier.width(300.dp)
|
||||||
|
) {
|
||||||
|
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.MANUAL -> {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Refresh,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||||
|
Text(stringResource(R.string.launcher_cancelled_restart))
|
||||||
|
}
|
||||||
|
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED,
|
||||||
|
LaunchViewModel.LaunchCancelReason.AUTOMATIC -> {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_resume),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||||
|
Text(stringResource(R.string.launcher_cancelled_resume))
|
||||||
|
}
|
||||||
|
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.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)
|
||||||
|
|
||||||
|
Card(modifier = modifier.width(300.dp)) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(16.dp)) {
|
||||||
|
if (uiState is LaunchViewModel.LaunchUIState.Cancelled) {
|
||||||
|
LaunchCancelledBody(
|
||||||
|
statusInfo = status,
|
||||||
|
icon = { LaunchCancelledIcon(uiState.reason, Modifier.size(32.dp)) },
|
||||||
|
inProgress = uiState.inProgress
|
||||||
|
)
|
||||||
|
|
||||||
|
if (uiState.reason == LaunchViewModel.LaunchCancelReason.GAME_OUTDATED) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
// game should be downloaded if this message is showing
|
||||||
|
val showDownload = remember {
|
||||||
|
!GamePackageUtils.identifyGameLegitimacy(context.packageManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDownload) {
|
||||||
|
GooglePlayBadge(modifier = Modifier.align(Alignment.CenterHorizontally))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uiState.reason.allowsRetry()) {
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.width(300.dp)
|
||||||
|
) {
|
||||||
|
LongPressButton(onClick = { onResume(false) }, onLongPress = {
|
||||||
|
showSafeModeDialog = true
|
||||||
|
}) {
|
||||||
|
RetryButtonContents(uiState.reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.weight(1.0f))
|
||||||
|
|
||||||
|
if (crashInfo != null) {
|
||||||
|
IconButton(onClick = onMore) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_question_mark),
|
||||||
|
contentDescription = stringResource(R.string.launcher_cancelled_help_alt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extraOptions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LaunchProgressBody(statusInfo = status)
|
||||||
|
|
||||||
|
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(
|
||||||
|
Icons.Filled.Settings,
|
||||||
|
contentDescription = stringResource(R.string.launcher_settings_icon_alt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GeodeLogo(modifier: Modifier = Modifier) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.drawable.geode_logo),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(76.dp, 76.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.launcher_title),
|
||||||
|
style = MaterialTheme.typography.displayMedium,
|
||||||
|
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(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.height(350.dp)
|
||||||
|
.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
|
||||||
|
)
|
||||||
|
|
||||||
|
val nextLauncherUpdate by launchViewModel.nextLauncherUpdate.collectAsState()
|
||||||
|
|
||||||
|
// only show launcher update in a case where a user won't see it ingame
|
||||||
|
if (nextLauncherUpdate != null && launchUIState is LaunchViewModel.LaunchUIState.Cancelled) {
|
||||||
|
StatusIndicator(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Info,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
installLauncherUpdate(context)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.launcher_update_available),
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (launchUIState is LaunchViewModel.LaunchUIState.Ready) {
|
||||||
|
UpdateWarning(launchInSafeMode) {
|
||||||
|
launchInSafeMode = false
|
||||||
|
coroutineScope.launch {
|
||||||
|
launchViewModel.cancelLaunch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val loadFailure = launchViewModel.currentCrashInfo()
|
||||||
|
if (showErrorInfo && loadFailure != null) {
|
||||||
|
ErrorInfoSheet(loadFailure, onDismiss = { showErrorInfo = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,18 +12,23 @@ import android.os.Bundle
|
|||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.view.WindowManager
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.compose.ui.platform.ComposeView
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
import com.customRobTop.BaseRobTopActivity
|
import com.customRobTop.BaseRobTopActivity
|
||||||
import com.customRobTop.JniToCpp
|
import com.customRobTop.JniToCpp
|
||||||
|
import com.geode.launcher.main.LaunchNotification
|
||||||
import com.geode.launcher.utils.Constants
|
import com.geode.launcher.utils.Constants
|
||||||
|
import com.geode.launcher.utils.ConstrainedFrameLayout
|
||||||
import com.geode.launcher.utils.DownloadUtils
|
import com.geode.launcher.utils.DownloadUtils
|
||||||
import com.geode.launcher.utils.LaunchUtils
|
import com.geode.launcher.utils.GamePackageUtils
|
||||||
import com.geode.launcher.utils.GeodeUtils
|
import com.geode.launcher.utils.GeodeUtils
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
import com.geode.launcher.utils.PreferenceUtils
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
import org.cocos2dx.lib.Cocos2dxEditText
|
import org.cocos2dx.lib.Cocos2dxEditText
|
||||||
import org.cocos2dx.lib.Cocos2dxGLSurfaceView
|
import org.cocos2dx.lib.Cocos2dxGLSurfaceView
|
||||||
@@ -33,9 +38,21 @@ import org.fmod.FMOD
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
enum class DisplayMode {
|
||||||
|
DEFAULT, LIMITED, FULLSCREEN;
|
||||||
|
|
||||||
class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperListener {
|
companion object {
|
||||||
|
fun fromInt(i: Int) = when (i) {
|
||||||
|
1 -> LIMITED
|
||||||
|
2 -> FULLSCREEN
|
||||||
|
else -> DEFAULT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperListener, GeodeUtils.CapabilityListener {
|
||||||
private var mGLSurfaceView: Cocos2dxGLSurfaceView? = null
|
private var mGLSurfaceView: Cocos2dxGLSurfaceView? = null
|
||||||
private val sTag = GeometryDashActivity::class.simpleName
|
private val sTag = GeometryDashActivity::class.simpleName
|
||||||
private var mIsRunning = false
|
private var mIsRunning = false
|
||||||
@@ -43,51 +60,72 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
private var mHasWindowFocus = false
|
private var mHasWindowFocus = false
|
||||||
private var mReceiver: BroadcastReceiver? = null
|
private var mReceiver: BroadcastReceiver? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
private var displayMode = DisplayMode.DEFAULT
|
||||||
super.onCreate(savedInstanceState)
|
private var forceRefreshRate = false
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
setupUIState()
|
setupUIState()
|
||||||
|
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
// return back to main if Geometry Dash isn't found
|
// return back to main if Geometry Dash isn't found
|
||||||
if (!LaunchUtils.isGeometryDashInstalled(packageManager)) {
|
if (!GamePackageUtils.isGameInstalled(packageManager)) {
|
||||||
returnToMain()
|
returnToMain()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
createVersionFile()
|
||||||
tryLoadGame()
|
tryLoadGame()
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
} catch (e: UnsatisfiedLinkError) {
|
||||||
e.printStackTrace()
|
Log.e("GeodeLauncher", "Library linkage failure", e)
|
||||||
|
|
||||||
// generates helpful information for use in debugging library load failures
|
// generates helpful information for use in debugging library load failures
|
||||||
val gdPackageInfo = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
val gdPackageInfo = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
||||||
val failureReason = LaunchUtils.diagnoseLoadErrors(this, gdPackageInfo)
|
val abiMismatch = GamePackageUtils.detectAbiMismatch(this, gdPackageInfo, e)
|
||||||
|
|
||||||
returnToMain(
|
val is64bit = LaunchUtils.is64bit
|
||||||
getString(R.string.load_failed_link_error),
|
val errorMessage = when {
|
||||||
getString(R.string.load_failed_link_error_description, failureReason.name)
|
abiMismatch && is64bit -> LaunchUtils.LauncherError.LINKER_NEEDS_32BIT
|
||||||
)
|
abiMismatch -> LaunchUtils.LauncherError.LINKER_NEEDS_64BIT
|
||||||
|
else -> LaunchUtils.LauncherError.LINKER_FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
returnToMain(errorMessage, e.message, e.stackTraceToString())
|
||||||
|
|
||||||
return
|
return
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
e.printStackTrace()
|
Log.e("GeodeLauncher", "Uncaught error during game load", e)
|
||||||
|
|
||||||
returnToMain(
|
returnToMain(
|
||||||
getString(R.string.load_failed_generic_error),
|
LaunchUtils.LauncherError.GENERIC,
|
||||||
getString(R.string.load_failed_generic_error_description, e.message ?: "UnknownException")
|
e.message ?: "Unknown Exception",
|
||||||
|
e.stackTraceToString()
|
||||||
)
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun returnToMain(returnTitle: String? = null, returnMessage: String? = null) {
|
private fun createVersionFile() {
|
||||||
|
val versionPath = File(filesDir, "game_version.txt")
|
||||||
|
val gameVersion = GamePackageUtils.getGameVersionCode(packageManager)
|
||||||
|
|
||||||
|
versionPath.writeText("$gameVersion")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun returnToMain(
|
||||||
|
error: LaunchUtils.LauncherError? = null,
|
||||||
|
returnMessage: String? = null,
|
||||||
|
returnExtendedMessage: String? = null
|
||||||
|
) {
|
||||||
val launchIntent = Intent(this, MainActivity::class.java).apply {
|
val launchIntent = Intent(this, MainActivity::class.java).apply {
|
||||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
|
||||||
if (!returnTitle.isNullOrEmpty() && !returnMessage.isNullOrEmpty()) {
|
if (error != null && !returnMessage.isNullOrEmpty()) {
|
||||||
putExtra(Constants.LAUNCHER_KEY_RETURN_TITLE, returnTitle)
|
putExtra(LaunchUtils.LAUNCHER_KEY_RETURN_ERROR, error)
|
||||||
putExtra(Constants.LAUNCHER_KEY_RETURN_MESSAGE, returnMessage)
|
putExtra(LaunchUtils.LAUNCHER_KEY_RETURN_MESSAGE, returnMessage)
|
||||||
|
putExtra(LaunchUtils.LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE, returnExtendedMessage)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,39 +137,56 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
|
|
||||||
setupRedirection(gdPackageInfo)
|
setupRedirection(gdPackageInfo)
|
||||||
|
|
||||||
// request read and write permissions
|
|
||||||
requestPermissions(
|
|
||||||
arrayOf(
|
|
||||||
android.Manifest.permission.READ_EXTERNAL_STORAGE,
|
|
||||||
android.Manifest.permission.WRITE_EXTERNAL_STORAGE
|
|
||||||
),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
|
|
||||||
Cocos2dxHelper.init(this, this)
|
Cocos2dxHelper.init(this, this)
|
||||||
|
|
||||||
GeodeUtils.setContext(this)
|
GeodeUtils.setContext(this)
|
||||||
|
GeodeUtils.setCapabilityListener(this)
|
||||||
|
|
||||||
tryLoadLibrary(gdPackageInfo, Constants.FMOD_LIB_NAME)
|
tryLoadLibrary(gdPackageInfo, Constants.FMOD_LIB_NAME)
|
||||||
tryLoadLibrary(gdPackageInfo, Constants.COCOS_LIB_NAME)
|
tryLoadLibrary(gdPackageInfo, Constants.COCOS_LIB_NAME)
|
||||||
|
|
||||||
if (getLoadTesting()) {
|
if (GamePackageUtils.getGameVersionCode(packageManager) >= 39L) {
|
||||||
loadTestingLibraries()
|
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.performExceptionsRenaming()
|
||||||
|
}
|
||||||
|
|
||||||
|
loadInternalMods()
|
||||||
|
|
||||||
setContentView(createView())
|
setContentView(createView())
|
||||||
|
|
||||||
setupPostLibraryLoad(gdPackageInfo)
|
setupPostLibraryLoad(gdPackageInfo)
|
||||||
|
|
||||||
if (!loadGeodeLibrary()) {
|
try {
|
||||||
Log.w("GeodeLauncher", "could not load Geode object!")
|
loadGeodeLibrary()
|
||||||
|
} catch (e: UnsatisfiedLinkError) {
|
||||||
|
handleGeodeException(e)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
handleGeodeException(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleGeodeException(e: Throwable) {
|
||||||
|
e.printStackTrace()
|
||||||
|
|
||||||
|
// ignore load failures if the game is newer than what's supported
|
||||||
|
// so people in the future can use their save data
|
||||||
|
if (GamePackageUtils.getGameVersionCode(packageManager) <= Constants.SUPPORTED_VERSION_CODE) {
|
||||||
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupRedirection(packageInfo: PackageInfo) {
|
private fun setupRedirection(packageInfo: PackageInfo) {
|
||||||
try {
|
try {
|
||||||
LaunchUtils.addAssetsFromPackage(assets, packageInfo)
|
GamePackageUtils.addAssetsFromPackage(assets, packageInfo)
|
||||||
} catch (e: NoSuchMethodException) {
|
} catch (e: NoSuchMethodException) {
|
||||||
e.printStackTrace()
|
Log.e("GeodeLauncher", "Failed to add asset redirection", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -143,7 +198,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
LauncherFix.setOriginalDataPath(Constants.GJ_DATA_DIR)
|
LauncherFix.setOriginalDataPath(Constants.GJ_DATA_DIR)
|
||||||
LauncherFix.setDataPath(saveDir.path)
|
LauncherFix.setDataPath(saveDir.path)
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
} catch (e: UnsatisfiedLinkError) {
|
||||||
e.printStackTrace()
|
Log.e("GeodeLauncher", "Failed to load LauncherFix", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,9 +219,69 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||||
private fun tryLoadLibrary(packageInfo: PackageInfo, libraryName: String) {
|
private fun tryLoadLibrary(packageInfo: PackageInfo, libraryName: String) {
|
||||||
val nativeDir = getNativeLibraryDirectory(packageInfo.applicationInfo)
|
val nativeDir = getNativeLibraryDirectory(packageInfo.applicationInfo)
|
||||||
|
|
||||||
val libraryPath = if (nativeDir.endsWith('/')) "${nativeDir}lib$libraryName.so" else "$nativeDir/lib$libraryName.so"
|
val libraryPath = if (nativeDir.endsWith('/')) "${nativeDir}lib$libraryName.so" else "$nativeDir/lib$libraryName.so"
|
||||||
|
|
||||||
|
try {
|
||||||
System.load(libraryPath)
|
System.load(libraryPath)
|
||||||
|
} catch (ule: UnsatisfiedLinkError) {
|
||||||
|
// some devices (samsung a series, cough) have an overly restrictive application classloader
|
||||||
|
// this is a workaround for that. hopefully
|
||||||
|
if (ule.message?.contains("not accessible for the namespace") != true) {
|
||||||
|
throw ule
|
||||||
|
}
|
||||||
|
|
||||||
|
println("Using copy for library $libraryName")
|
||||||
|
loadLibraryCopy(libraryName, libraryPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||||
|
private fun loadLibraryCopy(libraryName: String, libraryPath: String) {
|
||||||
|
if (libraryPath.contains("!/")) {
|
||||||
|
// library in apk can't be loaded directly
|
||||||
|
loadLibraryFromAssetsCopy(libraryName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val library = File(libraryPath)
|
||||||
|
val libraryCopy = File(cacheDir, "lib$libraryName.so")
|
||||||
|
|
||||||
|
libraryCopy.outputStream().use { libraryOutput ->
|
||||||
|
library.inputStream().use { inputStream ->
|
||||||
|
DownloadUtils.copyFile(inputStream, libraryOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
System.load(libraryCopy.path)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||||
|
private fun loadLibraryFromAssetsCopy(libraryName: String) {
|
||||||
|
// loads a library loaded in assets
|
||||||
|
// this copies the library to a non-compressed directory
|
||||||
|
|
||||||
|
val arch = LaunchUtils.applicationArchitecture
|
||||||
|
val libraryFd = try {
|
||||||
|
assets.openNonAssetFd("lib/$arch/lib$libraryName.so")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
throw UnsatisfiedLinkError("Could not find library lib$libraryName.so for abi $arch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// copy the library to a path we can access
|
||||||
|
// there doesn't seem to be a way to load a library from a file descriptor
|
||||||
|
val libraryCopy = File(cacheDir, "lib$libraryName.so")
|
||||||
|
|
||||||
|
libraryCopy.outputStream().use { libraryOutput ->
|
||||||
|
libraryFd.createInputStream().use { inputStream ->
|
||||||
|
DownloadUtils.copyFile(inputStream, libraryOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
System.load(libraryCopy.path)
|
||||||
|
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getNativeLibraryDirectory(applicationInfo: ApplicationInfo): String {
|
private fun getNativeLibraryDirectory(applicationInfo: ApplicationInfo): String {
|
||||||
@@ -195,26 +310,26 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||||
private fun loadGeodeLibrary(): Boolean {
|
private fun loadGeodeLibrary() {
|
||||||
// Load Geode if exists
|
// Load Geode if exists
|
||||||
// bundling the object with the application allows for nicer backtraces
|
// bundling the object with the application allows for nicer backtraces
|
||||||
try {
|
try {
|
||||||
// put libgeode.so in jniLibs/armeabi-v7a to get this
|
// put libgeode.so in jniLibs/armeabi-v7a to get this
|
||||||
System.loadLibrary("geode")
|
System.loadLibrary("geode")
|
||||||
return true
|
return
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
} catch (e: UnsatisfiedLinkError) {
|
||||||
// but users may prefer it stored with data
|
// but users may prefer it stored with data
|
||||||
val geodeFilename = LaunchUtils.geodeFilename
|
val geodeFilename = LaunchUtils.geodeFilename
|
||||||
val geodePath = File(filesDir.path, "launcher/$geodeFilename")
|
val geodePath = File(filesDir.path, "launcher/$geodeFilename")
|
||||||
if (geodePath.exists()) {
|
if (geodePath.exists()) {
|
||||||
System.load(geodePath.path)
|
System.load(geodePath.path)
|
||||||
return true
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// you know zmx i have 0 clue what this does so im
|
// you know zmx i have 0 clue what this does so im
|
||||||
// just gonna like copy the binary from external
|
// just gonna like copy the binary from external
|
||||||
// also i get 20 million permission denied errors
|
// also i get 20 million permission denied errors
|
||||||
val externalGeodePath = LaunchUtils.getInstalledGeodePath(this) ?: return false
|
val externalGeodePath = LaunchUtils.getInstalledGeodePath(this)!!
|
||||||
|
|
||||||
val copiedPath = File(filesDir.path, "copied")
|
val copiedPath = File(filesDir.path, "copied")
|
||||||
if (copiedPath.exists()) {
|
if (copiedPath.exists()) {
|
||||||
@@ -231,24 +346,33 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (copiedGeodePath.exists()) {
|
if (copiedGeodePath.exists()) {
|
||||||
try {
|
|
||||||
println("Loading Geode from ${externalGeodePath.name}")
|
println("Loading Geode from ${externalGeodePath.name}")
|
||||||
System.load(copiedGeodePath.path)
|
System.load(copiedGeodePath.path)
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
return
|
||||||
e.printStackTrace()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createView(): FrameLayout {
|
private fun createView(): FrameLayout {
|
||||||
val frameLayoutParams = ViewGroup.LayoutParams(-1, -1)
|
val frameLayoutParams = ViewGroup.LayoutParams(
|
||||||
val frameLayout = FrameLayout(this)
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
val frameLayout = ConstrainedFrameLayout(this)
|
||||||
frameLayout.layoutParams = frameLayoutParams
|
frameLayout.layoutParams = frameLayoutParams
|
||||||
val editTextLayoutParams = ViewGroup.LayoutParams(-1, -2)
|
|
||||||
|
if (displayMode == DisplayMode.LIMITED) {
|
||||||
|
// despite not being perfectly 16:9, this is what Android calls "16:9"
|
||||||
|
frameLayout.aspectRatio = 1.86f
|
||||||
|
}
|
||||||
|
|
||||||
|
val editTextLayoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
val editText = Cocos2dxEditText(this)
|
val editText = Cocos2dxEditText(this)
|
||||||
editText.layoutParams = editTextLayoutParams
|
editText.layoutParams = editTextLayoutParams
|
||||||
frameLayout.addView(editText)
|
frameLayout.addView(editText)
|
||||||
@@ -258,6 +382,17 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
this.mGLSurfaceView = glSurfaceView
|
this.mGLSurfaceView = glSurfaceView
|
||||||
frameLayout.addView(this.mGLSurfaceView)
|
frameLayout.addView(this.mGLSurfaceView)
|
||||||
|
|
||||||
|
val showNotification = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.ENABLE_REDESIGN)
|
||||||
|
if (showNotification) {
|
||||||
|
val notificationView = ComposeView(this)
|
||||||
|
frameLayout.addView(notificationView)
|
||||||
|
|
||||||
|
notificationView.setContent {
|
||||||
|
LaunchNotification()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
glSurfaceView.setEGLContextClientVersion(2)
|
||||||
glSurfaceView.setEGLConfigChooser(5, 6, 5, 0, 16, 8)
|
glSurfaceView.setEGLConfigChooser(5, 6, 5, 0, 16, 8)
|
||||||
|
|
||||||
if (isAndroidEmulator()) {
|
if (isAndroidEmulator()) {
|
||||||
@@ -265,7 +400,13 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
}
|
}
|
||||||
|
|
||||||
glSurfaceView.initView()
|
glSurfaceView.initView()
|
||||||
glSurfaceView.setCocos2dxRenderer(Cocos2dxRenderer())
|
|
||||||
|
val renderer = Cocos2dxRenderer(glSurfaceView)
|
||||||
|
glSurfaceView.setCocos2dxRenderer(renderer)
|
||||||
|
|
||||||
|
if (forceRefreshRate) {
|
||||||
|
renderer.setFrameRate = true
|
||||||
|
}
|
||||||
|
|
||||||
editText.inputType = 145
|
editText.inputType = 145
|
||||||
glSurfaceView.cocos2dxEditText = editText
|
glSurfaceView.cocos2dxEditText = editText
|
||||||
@@ -276,18 +417,39 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
private fun setupUIState() {
|
private fun setupUIState() {
|
||||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
|
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
|
||||||
|
|
||||||
|
displayMode = DisplayMode.fromInt(
|
||||||
|
PreferenceUtils.get(this).getInt(PreferenceUtils.Key.DISPLAY_MODE)
|
||||||
|
)
|
||||||
|
|
||||||
|
forceRefreshRate = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.FORCE_HRR)
|
||||||
|
|
||||||
|
if (displayMode == DisplayMode.FULLSCREEN && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||||
|
}
|
||||||
|
|
||||||
hideSystemUi()
|
hideSystemUi()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hideSystemUi() {
|
private fun hideSystemUi() {
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
|
||||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||||
|
hide(WindowInsetsCompat.Type.systemBars())
|
||||||
systemBarsBehavior =
|
systemBarsBehavior =
|
||||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
hide(WindowInsetsCompat.Type.systemBars())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
|
||||||
|
super.onWindowFocusChanged(hasWindowFocus)
|
||||||
|
mHasWindowFocus = hasWindowFocus
|
||||||
|
if (hasWindowFocus && !mIsOnPause) {
|
||||||
|
resumeGame()
|
||||||
|
}
|
||||||
|
|
||||||
|
hideSystemUi()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
unregisterReceivers()
|
unregisterReceivers()
|
||||||
@@ -315,6 +477,15 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onRestart() {
|
||||||
|
super.onRestart()
|
||||||
|
mIsOnPause = false
|
||||||
|
BaseRobTopActivity.isPaused = false
|
||||||
|
if (!this.mIsRunning) {
|
||||||
|
resumeGame()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
super.onPause()
|
super.onPause()
|
||||||
mIsOnPause = true
|
mIsOnPause = true
|
||||||
@@ -343,14 +514,6 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
|
|
||||||
super.onWindowFocusChanged(hasWindowFocus)
|
|
||||||
mHasWindowFocus = hasWindowFocus
|
|
||||||
if (hasWindowFocus && !mIsOnPause) {
|
|
||||||
resumeGame()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun runOnGLThread(runnable: Runnable) {
|
override fun runOnGLThread(runnable: Runnable) {
|
||||||
mGLSurfaceView?.queueEvent(runnable)
|
mGLSurfaceView?.queueEvent(runnable)
|
||||||
}
|
}
|
||||||
@@ -389,38 +552,48 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
return isEmulator
|
return isEmulator
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getLoadTesting(): Boolean {
|
override fun onCapabilityAdded(capability: String): Boolean {
|
||||||
val preferences = PreferenceUtils.get(this)
|
return when (capability) {
|
||||||
return preferences.getBoolean(PreferenceUtils.Key.LOAD_TESTING)
|
GeodeUtils.CAPABILITY_EXTENDED_INPUT -> {
|
||||||
|
mGLSurfaceView?.useKeyboardEvents = true
|
||||||
|
true
|
||||||
|
}
|
||||||
|
GeodeUtils.CAPABILITY_TIMESTAMP_INPUT -> {
|
||||||
|
mGLSurfaceView?.sendTimestampEvents = true
|
||||||
|
true
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
/**
|
||||||
private fun loadTestingLibraries() {
|
* Copies a mod from the launcher's assets to the Geode mods directory.
|
||||||
// clear data dir
|
* This method is not recommended for casual use, the new mod will not be automatically removed.
|
||||||
val testDirPath = File(filesDir.path + File.separator + "testlib" + File.separator)
|
*/
|
||||||
if (testDirPath.exists()) {
|
private fun loadInternalMods() {
|
||||||
testDirPath.deleteRecursively()
|
val internalModBase = "mods"
|
||||||
|
|
||||||
|
val modListing = try {
|
||||||
|
assets.list(internalModBase)
|
||||||
|
} catch (ioe: IOException) {
|
||||||
|
emptyArray<String>()
|
||||||
}
|
}
|
||||||
testDirPath.mkdir()
|
|
||||||
|
|
||||||
val dir = LaunchUtils.getBaseDirectory(this)
|
val modDirectory = File(
|
||||||
val testingPath = File(dir, "test")
|
LaunchUtils.getBaseDirectory(this),
|
||||||
|
"game/geode/mods"
|
||||||
testingPath.walk().forEach {
|
|
||||||
if (it.isFile) {
|
|
||||||
// welcome to the world of Android classloader permissions
|
|
||||||
val outputFile = File(testDirPath, it.name)
|
|
||||||
DownloadUtils.copyFile(
|
|
||||||
FileInputStream(it),
|
|
||||||
FileOutputStream(outputFile)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
modDirectory.mkdirs()
|
||||||
println("Loading test library ${outputFile.name}")
|
|
||||||
System.load(outputFile.path)
|
modListing?.forEach { fileName ->
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
if (fileName.endsWith(".geode")) {
|
||||||
e.printStackTrace()
|
val modOutput = File(modDirectory, fileName)
|
||||||
}
|
|
||||||
|
val mod = assets.open("$internalModBase/$fileName")
|
||||||
|
DownloadUtils.copyFile(mod, modOutput.outputStream())
|
||||||
|
|
||||||
|
println("Copied internal mod $fileName")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.geode.launcher
|
package com.geode.launcher
|
||||||
|
|
||||||
|
import androidx.annotation.Keep
|
||||||
import com.geode.launcher.utils.Constants
|
import com.geode.launcher.utils.Constants
|
||||||
|
|
||||||
|
@Keep
|
||||||
object LauncherFix {
|
object LauncherFix {
|
||||||
fun loadLibrary() {
|
fun loadLibrary() {
|
||||||
System.loadLibrary(Constants.LAUNCHER_FIX_LIB_NAME)
|
System.loadLibrary(Constants.LAUNCHER_FIX_LIB_NAME)
|
||||||
@@ -10,4 +12,8 @@ object LauncherFix {
|
|||||||
external fun setDataPath(dataPath: String)
|
external fun setDataPath(dataPath: String)
|
||||||
|
|
||||||
external fun setOriginalDataPath(dataPath: String)
|
external fun setOriginalDataPath(dataPath: String)
|
||||||
|
|
||||||
|
external fun performExceptionsRenaming()
|
||||||
|
|
||||||
|
external fun enableCustomSymbolList(symbolsPath: String)
|
||||||
}
|
}
|
||||||
@@ -1,19 +1,16 @@
|
|||||||
package com.geode.launcher
|
package com.geode.launcher
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.format.Formatter.formatShortFileSize
|
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material.icons.filled.Warning
|
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
@@ -27,36 +24,52 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.geode.launcher.api.ReleaseViewModel
|
import com.geode.launcher.main.*
|
||||||
|
import com.geode.launcher.updater.ReleaseViewModel
|
||||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
import com.geode.launcher.ui.theme.LocalTheme
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
import com.geode.launcher.ui.theme.Theme
|
import com.geode.launcher.ui.theme.Theme
|
||||||
import com.geode.launcher.ui.theme.Typography
|
|
||||||
import com.geode.launcher.utils.Constants
|
import com.geode.launcher.utils.Constants
|
||||||
import com.geode.launcher.utils.LaunchUtils
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
import com.geode.launcher.utils.PreferenceUtils
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
import com.geode.launcher.utils.ReleaseManager
|
import com.geode.launcher.utils.GamePackageUtils
|
||||||
import com.geode.launcher.utils.useCountdownTimer
|
|
||||||
import java.net.ConnectException
|
|
||||||
import java.net.UnknownHostException
|
|
||||||
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
clearDownloadedApks(this)
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
val gdInstalled = LaunchUtils.isGeometryDashInstalled(packageManager)
|
val gdInstalled = GamePackageUtils.isGameInstalled(packageManager)
|
||||||
val geodeInstalled = LaunchUtils.isGeodeInstalled(this)
|
val geodeInstalled = LaunchUtils.isGeodeInstalled(this)
|
||||||
|
|
||||||
val returnMessage = intent.extras?.getString(Constants.LAUNCHER_KEY_RETURN_MESSAGE)
|
val returnMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_MESSAGE)
|
||||||
val returnTitle = intent.extras?.getString(Constants.LAUNCHER_KEY_RETURN_TITLE)
|
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 }
|
||||||
|
|
||||||
|
val redirectToAlt = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.ENABLE_REDESIGN)
|
||||||
|
if (redirectToAlt) {
|
||||||
|
val launchIntent = Intent(this, AltMainActivity::class.java)
|
||||||
|
launchIntent.putExtras(intent)
|
||||||
|
startActivity(launchIntent)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||||
@@ -70,11 +83,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background
|
color = MaterialTheme.colorScheme.background
|
||||||
) {
|
) {
|
||||||
MainScreen(gdInstalled, geodeInstalled)
|
MainScreen(gdInstalled, geodeInstalled, loadFailureInfo)
|
||||||
|
|
||||||
if (!returnMessage.isNullOrEmpty() && !returnTitle.isNullOrEmpty()) {
|
|
||||||
LoadFailedDialog(returnTitle, returnMessage)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,257 +98,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun UpdateProgressIndicator(
|
|
||||||
message: String,
|
|
||||||
releaseViewModel: ReleaseViewModel,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
progress: Float? = null
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.Start,
|
|
||||||
modifier = modifier
|
|
||||||
) {
|
|
||||||
Text(message)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.padding(4.dp))
|
|
||||||
|
|
||||||
if (progress == null) {
|
|
||||||
LinearProgressIndicator()
|
|
||||||
} else {
|
|
||||||
LinearProgressIndicator(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)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.release_fetch_button_retry))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LoadFailedDialog(returnTitle: String, returnMessage: String) {
|
|
||||||
var showDialog by remember { mutableStateOf(true) }
|
|
||||||
|
|
||||||
if (showDialog) {
|
|
||||||
AlertDialog(
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
painterResource(R.drawable.icon_error),
|
|
||||||
contentDescription = stringResource(R.string.launcher_error_icon_alt)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
title = { Text(returnTitle) },
|
|
||||||
text = { Text(returnMessage) },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = { showDialog = false }) {
|
|
||||||
Text(stringResource(R.string.message_box_accept))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDismissRequest = { showDialog = false }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun UpdateWarning() {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val packageManager = context.packageManager
|
|
||||||
|
|
||||||
val gdVersionCode = remember { LaunchUtils.getGeometryDashVersionCode(packageManager) }
|
|
||||||
val gdVersionString = remember { LaunchUtils.getGeometryDashVersionString(packageManager) }
|
|
||||||
|
|
||||||
var showUpdateWarning by remember { mutableStateOf(true) }
|
|
||||||
|
|
||||||
if (gdVersionCode != Constants.SUPPORTED_VERSION_CODE) {
|
|
||||||
if (showUpdateWarning) {
|
|
||||||
AlertDialog(
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.Warning,
|
|
||||||
contentDescription = stringResource(R.string.launcher_warning_icon_alt)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
title = {
|
|
||||||
Text(stringResource(R.string.launcher_unsupported_version_title))
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Text(stringResource(
|
|
||||||
R.string.launcher_unsupported_version_description,
|
|
||||||
gdVersionString, Constants.SUPPORTED_VERSION_STRING
|
|
||||||
))
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = {
|
|
||||||
showUpdateWarning = false
|
|
||||||
onLaunch(context)
|
|
||||||
}) {
|
|
||||||
Text(stringResource(R.string.message_box_accept))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showUpdateWarning = false }) {
|
|
||||||
Text(stringResource(R.string.message_box_cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDismissRequest = { showUpdateWarning = false }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LaunchedEffect(gdVersionCode) {
|
|
||||||
onLaunch(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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 -> state.exception.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> state.exception.message
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdateMessageIndicator(
|
|
||||||
stringResource(R.string.release_fetch_failed, message ?: ""),
|
|
||||||
modifier = modifier,
|
|
||||||
allowRetry = true,
|
|
||||||
releaseViewModel = releaseViewModel
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is ReleaseViewModel.ReleaseUIState.InDownload -> {
|
|
||||||
val progress = state.downloaded / state.outOf.toDouble()
|
|
||||||
|
|
||||||
val downloaded = remember(state.downloaded) {
|
|
||||||
formatShortFileSize(context, state.downloaded)
|
|
||||||
}
|
|
||||||
|
|
||||||
val outOf = remember(state.outOf) {
|
|
||||||
formatShortFileSize(context, state.outOf)
|
|
||||||
}
|
|
||||||
|
|
||||||
UpdateProgressIndicator(
|
|
||||||
stringResource(
|
|
||||||
R.string.release_fetch_downloading,
|
|
||||||
downloaded,
|
|
||||||
outOf
|
|
||||||
),
|
|
||||||
modifier = modifier,
|
|
||||||
releaseViewModel = releaseViewModel,
|
|
||||||
progress = progress.toFloat()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onLaunch(context: Context) {
|
|
||||||
val launchIntent = Intent(context, GeometryDashActivity::class.java)
|
|
||||||
launchIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
|
||||||
|
|
||||||
context.startActivity(launchIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onSettings(context: Context) {
|
|
||||||
val launchIntent = Intent(context, SettingsActivity::class.java)
|
|
||||||
context.startActivity(launchIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(
|
fun MainScreen(
|
||||||
gdInstalled: Boolean = true,
|
gdInstalled: Boolean = true,
|
||||||
geodePreinstalled: Boolean = true,
|
geodePreinstalled: Boolean = true,
|
||||||
|
loadFailureInfo: LoadFailureInfo? = null,
|
||||||
releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
|
releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
|
||||||
val shouldAutomaticallyLaunch = PreferenceUtils.useBooleanPreference(
|
|
||||||
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY
|
|
||||||
)
|
|
||||||
|
|
||||||
val shouldUpdate by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
|
val shouldUpdate by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
|
||||||
|
|
||||||
val autoUpdateState by releaseViewModel.uiState.collectAsState()
|
val autoUpdateState by releaseViewModel.uiState.collectAsState()
|
||||||
|
|
||||||
val geodeJustInstalled = (autoUpdateState as? ReleaseViewModel.ReleaseUIState.Finished)
|
val geodeJustInstalled = (autoUpdateState as? ReleaseViewModel.ReleaseUIState.Finished)
|
||||||
@@ -347,17 +115,42 @@ fun MainScreen(
|
|||||||
val geodeInstalled = geodePreinstalled || geodeJustInstalled
|
val geodeInstalled = geodePreinstalled || geodeJustInstalled
|
||||||
|
|
||||||
var beginLaunch by remember { mutableStateOf(false) }
|
var beginLaunch by remember { mutableStateOf(false) }
|
||||||
|
var launchInSafeMode by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
LaunchedEffect(shouldUpdate) {
|
LaunchedEffect(shouldUpdate) {
|
||||||
if (shouldUpdate && !releaseViewModel.hasPerformedCheck) {
|
if (shouldUpdate && !releaseViewModel.hasPerformedCheck && gdInstalled) {
|
||||||
releaseViewModel.runReleaseCheck()
|
releaseViewModel.runReleaseCheck()
|
||||||
} else {
|
} else {
|
||||||
releaseViewModel.useGlobalCheckState()
|
releaseViewModel.useGlobalCheckState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) }
|
||||||
|
) { innerPadding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(rememberScrollState()),
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
@@ -374,53 +167,31 @@ fun MainScreen(
|
|||||||
modifier = Modifier.padding(12.dp)
|
modifier = Modifier.padding(12.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (gdInstalled && geodeInstalled) {
|
when {
|
||||||
if (shouldAutomaticallyLaunch.value && !releaseViewModel.isInUpdate) {
|
gdInstalled && geodeInstalled -> {
|
||||||
val countdownTimer = useCountdownTimer(
|
val gdVersion = remember {
|
||||||
time = 3000,
|
GamePackageUtils.getGameVersionCode(context.packageManager)
|
||||||
onCountdownFinish = {
|
}
|
||||||
// just in case this changed async
|
|
||||||
if (shouldAutomaticallyLaunch.value) {
|
if (gdVersion < Constants.SUPPORTED_VERSION_CODE_MIN) {
|
||||||
|
val versionName = remember {
|
||||||
|
GamePackageUtils.getUnifiedVersionName(context.packageManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
beginLaunch = true
|
||||||
}
|
},
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
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 {
|
|
||||||
Button(
|
|
||||||
onClick = { beginLaunch = true },
|
|
||||||
enabled = !releaseViewModel.isInUpdate
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.PlayArrow,
|
|
||||||
contentDescription = context.getString(R.string.launcher_launch_icon_alt)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
|
||||||
Text(context.getString(R.string.launcher_launch))
|
|
||||||
}
|
|
||||||
Spacer(Modifier.size(2.dp))
|
|
||||||
IconButton(onClick = { onSettings(context) }) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.Settings,
|
|
||||||
contentDescription = context.getString(R.string.launcher_settings_icon_alt)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (gdInstalled) {
|
gdInstalled -> {
|
||||||
Text(
|
Text(
|
||||||
context.getString(R.string.geode_download_title),
|
context.getString(R.string.geode_download_title),
|
||||||
modifier = Modifier.padding(12.dp)
|
modifier = Modifier.padding(12.dp)
|
||||||
@@ -446,29 +217,23 @@ fun MainScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
context.getString(R.string.game_not_found),
|
|
||||||
modifier = Modifier.padding(12.dp)
|
|
||||||
)
|
|
||||||
OutlinedButton(onClick = { onSettings(context) }) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.Settings,
|
|
||||||
contentDescription = context.getString(R.string.launcher_settings_icon_alt)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
|
||||||
Text(context.getString(R.string.launcher_settings))
|
|
||||||
}
|
}
|
||||||
|
else -> LaunchBlockedLabel(stringResource(R.string.game_not_found))
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateCard(
|
UpdateCard(
|
||||||
releaseViewModel,
|
releaseViewModel,
|
||||||
modifier = Modifier.padding(12.dp)
|
modifier = Modifier
|
||||||
|
.padding(12.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (beginLaunch) {
|
if (beginLaunch) {
|
||||||
UpdateWarning()
|
UpdateWarning(launchInSafeMode) {
|
||||||
|
beginLaunch = false
|
||||||
|
launchInSafeMode = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package com.geode.launcher
|
|||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import android.database.MatrixCursor
|
import android.database.MatrixCursor
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.CancellationSignal
|
import android.os.CancellationSignal
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import android.provider.DocumentsProvider
|
import android.provider.DocumentsProvider
|
||||||
import android.webkit.MimeTypeMap
|
import android.webkit.MimeTypeMap
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
import com.geode.launcher.utils.LaunchUtils
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -150,7 +152,7 @@ class UserDirectoryProvider : DocumentsProvider() {
|
|||||||
notifyFileChange(file)
|
notifyFileChange(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun renameDocument(documentId: String, displayName: String): String? {
|
override fun renameDocument(documentId: String, displayName: String): String {
|
||||||
val file = getFileForDocumentId(documentId)
|
val file = getFileForDocumentId(documentId)
|
||||||
val dest = findFileNameForNewFile(File(file.parentFile, displayName))
|
val dest = findFileNameForNewFile(File(file.parentFile, displayName))
|
||||||
file.renameTo(dest)
|
file.renameTo(dest)
|
||||||
@@ -203,4 +205,19 @@ class UserDirectoryProvider : DocumentsProvider() {
|
|||||||
val uri = getDocumentUri(parentDocumentId)
|
val uri = getDocumentUri(parentDocumentId)
|
||||||
context!!.contentResolver.notifyChange(uri, null)
|
context!!.contentResolver.notifyChange(uri, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
override fun findDocumentPath(
|
||||||
|
parentDocumentId: String?,
|
||||||
|
childDocumentId: String
|
||||||
|
): DocumentsContract.Path {
|
||||||
|
if (!parentDocumentId.isNullOrEmpty()) {
|
||||||
|
// not implementing this for now...
|
||||||
|
return super.findDocumentPath(parentDocumentId, childDocumentId)
|
||||||
|
}
|
||||||
|
|
||||||
|
val nonRootPath = listOf(ROOT) + childDocumentId.removePrefix(ROOT).split("/")
|
||||||
|
|
||||||
|
return DocumentsContract.Path(ROOT, nonRootPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.geode.launcher.activityresult
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
|
|
||||||
|
open class GeodeOpenFileActivityResult : ActivityResultContract<GeodeOpenFileActivityResult.OpenFileParams, Uri?>() {
|
||||||
|
class OpenFileParams(val extraMimes: Array<String>, val defaultPath: Uri?) {}
|
||||||
|
override fun createIntent(context: Context, input: OpenFileParams): Intent {
|
||||||
|
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||||
|
.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||||
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
.putExtra(Intent.EXTRA_MIME_TYPES, input.extraMimes)
|
||||||
|
.setType("*/*")
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
if (input.defaultPath != null) {
|
||||||
|
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, input.defaultPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return intent
|
||||||
|
}
|
||||||
|
|
||||||
|
final override fun getSynchronousResult(
|
||||||
|
context: Context,
|
||||||
|
input: OpenFileParams
|
||||||
|
): SynchronousResult<Uri?>? = null
|
||||||
|
|
||||||
|
final override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
|
||||||
|
return intent.takeIf { resultCode == Activity.RESULT_OK }?.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.geode.launcher.activityresult
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
|
|
||||||
|
open class GeodeOpenFilesActivityResult : ActivityResultContract<GeodeOpenFilesActivityResult.OpenFileParams, List<@JvmSuppressWildcards Uri>>() {
|
||||||
|
class OpenFileParams(val extraMimes: Array<String>, val defaultPath: Uri?) {}
|
||||||
|
override fun createIntent(context: Context, input: OpenFileParams): Intent {
|
||||||
|
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||||
|
.putExtra(Intent.EXTRA_MIME_TYPES, input.extraMimes)
|
||||||
|
.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||||
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||||
|
.setType("*/*")
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
if (input.defaultPath != null) {
|
||||||
|
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, input.defaultPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return intent
|
||||||
|
}
|
||||||
|
|
||||||
|
final override fun getSynchronousResult(
|
||||||
|
context: Context,
|
||||||
|
input: OpenFileParams
|
||||||
|
): SynchronousResult<List<@JvmSuppressWildcards Uri>>? = null
|
||||||
|
|
||||||
|
final override fun parseResult(resultCode: Int, intent: Intent?): List<Uri> {
|
||||||
|
// Gracefully stolen from ActivityResultContracts.Intent.getclipDataUris
|
||||||
|
val clipData = intent.takeIf {
|
||||||
|
resultCode == Activity.RESULT_OK
|
||||||
|
}?.clipData
|
||||||
|
val resultSet = LinkedHashSet<Uri>()
|
||||||
|
intent?.data?.let { data ->
|
||||||
|
resultSet.add(data)
|
||||||
|
}
|
||||||
|
if (clipData == null && resultSet.isEmpty()) {
|
||||||
|
return emptyList()
|
||||||
|
} else if (clipData != null) {
|
||||||
|
for (i in 0 until clipData.itemCount) {
|
||||||
|
val uri = clipData.getItemAt(i).uri
|
||||||
|
if (uri != null) {
|
||||||
|
resultSet.add(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ArrayList(resultSet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.geode.launcher.activityresult
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import androidx.activity.result.contract.ActivityResultContract
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
open class GeodeSaveFileActivityResult : ActivityResultContract<GeodeSaveFileActivityResult.SaveFileParams, Uri?>() {
|
||||||
|
class SaveFileParams(val mimeType: String?, val initialPath: File?)
|
||||||
|
override fun createIntent(context: Context, input: SaveFileParams): Intent {
|
||||||
|
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
|
||||||
|
.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||||
|
.setType(input.mimeType ?: "*/*")
|
||||||
|
|
||||||
|
if (input.initialPath != null) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val uri = DocumentFile.fromFile(input.initialPath).uri
|
||||||
|
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
intent.putExtra(Intent.EXTRA_TITLE, input.initialPath.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return intent
|
||||||
|
}
|
||||||
|
|
||||||
|
final override fun getSynchronousResult(
|
||||||
|
context: Context,
|
||||||
|
input: SaveFileParams
|
||||||
|
): SynchronousResult<Uri?>? = null
|
||||||
|
|
||||||
|
final override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
|
||||||
|
return intent.takeIf { resultCode == Activity.RESULT_OK }?.data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package com.geode.launcher.api
|
|
||||||
|
|
||||||
import com.geode.launcher.utils.DownloadUtils.executeCoroutine
|
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonNamingStrategy
|
|
||||||
import kotlinx.serialization.json.okio.decodeFromBufferedSource
|
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import java.io.IOException
|
|
||||||
import java.net.URL
|
|
||||||
|
|
||||||
class ReleaseRepository(private val httpClient: OkHttpClient) {
|
|
||||||
companion object {
|
|
||||||
private const val GITHUB_API_BASE = "https://api.github.com/repos/geode-sdk/geode"
|
|
||||||
private const val GITHUB_API_HEADER = "X-GitHub-Api-Version"
|
|
||||||
private const val GITHUB_API_VERSION = "2022-11-28"
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getLatestNightlyRelease(): Release? {
|
|
||||||
val nightlyPath = "$GITHUB_API_BASE/releases/tags/nightly"
|
|
||||||
val url = URL(nightlyPath)
|
|
||||||
|
|
||||||
return getReleaseByUrl(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun getLatestRelease(): Release? {
|
|
||||||
val releasePath = "$GITHUB_API_BASE/releases/latest"
|
|
||||||
val url = URL(releasePath)
|
|
||||||
|
|
||||||
return getReleaseByUrl(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
|
||||||
private suspend fun getReleaseByUrl(url: URL): Release? {
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url(url)
|
|
||||||
.addHeader("Accept", "application/json")
|
|
||||||
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
val call = httpClient.newCall(request)
|
|
||||||
val response = call.executeCoroutine()
|
|
||||||
|
|
||||||
return when (response.code) {
|
|
||||||
200 -> {
|
|
||||||
val format = Json {
|
|
||||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
}
|
|
||||||
|
|
||||||
val release = format.decodeFromBufferedSource<Release>(
|
|
||||||
response.body!!.source()
|
|
||||||
)
|
|
||||||
|
|
||||||
release
|
|
||||||
}
|
|
||||||
404 -> {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
throw IOException("unknown response ${response.code}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package com.geode.launcher.log
|
||||||
|
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import kotlinx.datetime.TimeZone
|
||||||
|
import kotlinx.datetime.toJavaInstant
|
||||||
|
import kotlinx.datetime.toLocalDateTime
|
||||||
|
import okio.BufferedSource
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
fun BufferedSource.readCChar(): Char {
|
||||||
|
return this.readUtf8CodePoint().toChar()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun BufferedSource.readCString(): String {
|
||||||
|
val buffer = StringBuilder()
|
||||||
|
|
||||||
|
var lastByte = this.readCChar()
|
||||||
|
while (lastByte != '\u0000') {
|
||||||
|
buffer.append(lastByte)
|
||||||
|
lastByte = this.readCChar()
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ProcessInformation(val processId: Int, val threadId: Int, val processUid: Int)
|
||||||
|
|
||||||
|
enum class LogPriority {
|
||||||
|
UNKNOWN, DEFAULT, VERBOSE, DEBUG, INFO, WARN, ERROR, FATAL, SILENT;
|
||||||
|
|
||||||
|
class LogPriorityIterator(
|
||||||
|
start: LogPriority,
|
||||||
|
private val end: LogPriority,
|
||||||
|
private val step: Int
|
||||||
|
) : Iterator<LogPriority> {
|
||||||
|
private var hasNext: Boolean = if (step > 0) start <= end else start >= end
|
||||||
|
private var next = if (hasNext) start else end
|
||||||
|
|
||||||
|
override fun hasNext() = hasNext
|
||||||
|
|
||||||
|
override fun next(): LogPriority {
|
||||||
|
val current = next
|
||||||
|
if (current == end) {
|
||||||
|
if (!hasNext) {
|
||||||
|
throw NoSuchElementException()
|
||||||
|
}
|
||||||
|
|
||||||
|
hasNext = false
|
||||||
|
} else {
|
||||||
|
next = LogPriority.fromInt(next.toInt() + step)
|
||||||
|
}
|
||||||
|
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LogPriorityProgression(
|
||||||
|
override val start: LogPriority,
|
||||||
|
override val endInclusive: LogPriority,
|
||||||
|
private val step: Int = 1
|
||||||
|
) : Iterable<LogPriority>, ClosedRange<LogPriority> {
|
||||||
|
override fun iterator(): Iterator<LogPriority> {
|
||||||
|
return LogPriorityIterator(start, endInclusive, step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
infix fun downTo(to: LogPriority) = LogPriorityProgression(this, to, -1)
|
||||||
|
|
||||||
|
operator fun rangeTo(to: LogPriority) = LogPriorityProgression(this, to)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromByte(byte: Byte): LogPriority = fromInt(byte.toInt())
|
||||||
|
|
||||||
|
fun fromInt(int: Int) = when (int) {
|
||||||
|
0x1 -> DEFAULT
|
||||||
|
0x2 -> VERBOSE
|
||||||
|
0x3 -> DEBUG
|
||||||
|
0x4 -> INFO
|
||||||
|
0x5 -> WARN
|
||||||
|
0x6 -> ERROR
|
||||||
|
0x7 -> FATAL
|
||||||
|
0x8 -> SILENT
|
||||||
|
else -> UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toInt() = when (this) {
|
||||||
|
DEFAULT -> 0x1
|
||||||
|
VERBOSE -> 0x2
|
||||||
|
DEBUG -> 0x3
|
||||||
|
INFO -> 0x4
|
||||||
|
WARN -> 0x5
|
||||||
|
ERROR -> 0x6
|
||||||
|
FATAL -> 0x7
|
||||||
|
SILENT -> 0x8
|
||||||
|
else -> 0x0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toChar(): Char {
|
||||||
|
return when (this) {
|
||||||
|
VERBOSE -> 'V'
|
||||||
|
DEBUG -> 'D'
|
||||||
|
INFO -> 'I'
|
||||||
|
WARN -> 'W'
|
||||||
|
ERROR -> 'E'
|
||||||
|
FATAL -> 'F'
|
||||||
|
SILENT -> 'S'
|
||||||
|
else -> '?'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a log entry from logcat.
|
||||||
|
*/
|
||||||
|
data class LogLine(
|
||||||
|
val process: ProcessInformation,
|
||||||
|
val time: Instant,
|
||||||
|
val logId: Int,
|
||||||
|
val priority: LogPriority,
|
||||||
|
val tag: String,
|
||||||
|
val message: String
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private enum class EntryVersion {
|
||||||
|
V3, V4
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun headerSizeToVersion(size: UShort) = when (size.toInt()) {
|
||||||
|
0x18 -> EntryVersion.V3
|
||||||
|
0x1c -> EntryVersion.V4
|
||||||
|
else -> throw IOException("LogLine::fromInputStream: unknown format for (headerSize = $size)")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fromBufferedSource(source: BufferedSource): LogLine {
|
||||||
|
/*
|
||||||
|
// from android <liblog/include/log/log_read.h>
|
||||||
|
// there are multiple logger entry formats
|
||||||
|
// use the header_size to determine the one you have
|
||||||
|
|
||||||
|
struct logger_entry_v3 {
|
||||||
|
uint16_t len; // length of the payload
|
||||||
|
uint16_t hdr_size; // sizeof(struct logger_entry_v3)
|
||||||
|
int32_t pid; // generating process's pid
|
||||||
|
int32_t tid; // generating process's tid
|
||||||
|
int32_t sec; // seconds since Epoch
|
||||||
|
int32_t nsec; // nanoseconds
|
||||||
|
uint32_t lid; // log id of the payload
|
||||||
|
}
|
||||||
|
|
||||||
|
struct logger_entry_v4 {
|
||||||
|
uint16_t len; // length of the payload
|
||||||
|
uint16_t hdr_size; // sizeof(struct logger_entry_v4 = 28)
|
||||||
|
int32_t pid; // generating process's pid
|
||||||
|
uint32_t tid; // generating process's tid
|
||||||
|
uint32_t sec; // seconds since Epoch
|
||||||
|
uint32_t nsec; // nanoseconds
|
||||||
|
uint32_t lid; // log id of the payload, bottom 4 bits currently
|
||||||
|
uint32_t uid; // generating process's uid
|
||||||
|
};
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* val payloadLength = */ source.readShortLe()
|
||||||
|
val headerSize = source.readShortLe().toUShort()
|
||||||
|
|
||||||
|
val entryVersion = headerSizeToVersion(headerSize)
|
||||||
|
|
||||||
|
val pid = source.readIntLe()
|
||||||
|
val tid = source.readIntLe()
|
||||||
|
val sec = source.readIntLe().toLong()
|
||||||
|
val nSec = source.readIntLe()
|
||||||
|
val lid = source.readIntLe()
|
||||||
|
val uid = if (entryVersion == EntryVersion.V4)
|
||||||
|
source.readIntLe() else 0
|
||||||
|
|
||||||
|
val processInformation = ProcessInformation(pid, tid, uid)
|
||||||
|
val time = Instant.fromEpochSeconds(sec, nSec)
|
||||||
|
|
||||||
|
val priorityByte = source.readByte()
|
||||||
|
val priority = LogPriority.fromByte(priorityByte)
|
||||||
|
|
||||||
|
val tag = source.readCString()
|
||||||
|
val message = source.readCString()
|
||||||
|
|
||||||
|
return LogLine(
|
||||||
|
process = processInformation,
|
||||||
|
priority = priority,
|
||||||
|
time = time,
|
||||||
|
logId = lid,
|
||||||
|
tag = tag,
|
||||||
|
message = message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showException(exception: Exception) = LogLine(
|
||||||
|
process = ProcessInformation(0, 0, 0),
|
||||||
|
time = Clock.System.now(),
|
||||||
|
logId = 0,
|
||||||
|
priority = LogPriority.FATAL,
|
||||||
|
tag = "GeodeLauncher",
|
||||||
|
message = "Failed to parse log entry with ${exception.stackTraceToString()}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val identifier = time.toJavaInstant()
|
||||||
|
|
||||||
|
val messageTrimmed by lazy { this.message.trim() }
|
||||||
|
|
||||||
|
val formattedTime by lazy { this.time.toLocalDateTime(TimeZone.currentSystemDefault()) }
|
||||||
|
val asSimpleString by lazy {
|
||||||
|
"$formattedTime [${this.priority.toChar()}/${this.tag}]: ${this.messageTrimmed}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
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.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.yield
|
||||||
|
import okio.buffer
|
||||||
|
import okio.source
|
||||||
|
import java.io.EOFException
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ViewModel to manage log output from logcat.
|
||||||
|
* Currently, it loads the list of logs at init with no ability to read additional logs.
|
||||||
|
*/
|
||||||
|
class LogViewModel: ViewModel() {
|
||||||
|
// the original version of this code did support streaming btw
|
||||||
|
// the states were way too annoying to manage across multiple threads
|
||||||
|
// yay!
|
||||||
|
|
||||||
|
private val _lineState = mutableStateListOf<LogLine>()
|
||||||
|
val lineState = _lineState
|
||||||
|
|
||||||
|
private val _isLoading = MutableStateFlow(false)
|
||||||
|
val isLoading = _isLoading.asStateFlow()
|
||||||
|
|
||||||
|
private var failedToLoad = false
|
||||||
|
|
||||||
|
private var logJob: Job? = null
|
||||||
|
|
||||||
|
var filterCrashes = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
// skip debug/verbose lines by default
|
||||||
|
var logLevel = LogPriority.INFO
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
|
||||||
|
_lineState.clear()
|
||||||
|
loadLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleCrashBuffer() {
|
||||||
|
filterCrashes = !filterCrashes
|
||||||
|
|
||||||
|
_lineState.clear()
|
||||||
|
loadLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun logOutput(): List<LogLine> {
|
||||||
|
val logLines = ArrayList<LogLine>()
|
||||||
|
|
||||||
|
// -B = binary format, -d = dump logs
|
||||||
|
val logCommand = if (filterCrashes) "logcat -b crash -B -d"
|
||||||
|
else "logcat -B -d"
|
||||||
|
|
||||||
|
val logProcess = try {
|
||||||
|
Runtime.getRuntime().exec(logCommand)
|
||||||
|
} catch (ioe: IOException) {
|
||||||
|
ioe.printStackTrace()
|
||||||
|
logLines += LogLine.showException(ioe)
|
||||||
|
|
||||||
|
failedToLoad = true
|
||||||
|
|
||||||
|
return logLines
|
||||||
|
}
|
||||||
|
|
||||||
|
val logSource = logProcess.inputStream.source().buffer()
|
||||||
|
|
||||||
|
try {
|
||||||
|
coroutineScope {
|
||||||
|
// this runs until the stream is exhausted
|
||||||
|
while (true) {
|
||||||
|
val line = LogLine.fromBufferedSource(logSource)
|
||||||
|
|
||||||
|
if (line.priority >= logLevel) {
|
||||||
|
logLines += line
|
||||||
|
}
|
||||||
|
|
||||||
|
yield()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: EOFException) {
|
||||||
|
// ignore, end of file reached
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
logLines += LogLine.showException(e)
|
||||||
|
|
||||||
|
// technically it maybe didn't completely fail...
|
||||||
|
failedToLoad = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return logLines
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearLogs() {
|
||||||
|
logJob?.cancel()
|
||||||
|
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
// -c = clear
|
||||||
|
Runtime.getRuntime().exec("logcat -c")
|
||||||
|
.waitFor()
|
||||||
|
}
|
||||||
|
|
||||||
|
_lineState.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dumpLogcatText(): String {
|
||||||
|
val logCommand = if (filterCrashes) "logcat -b crash -d"
|
||||||
|
else "logcat -d"
|
||||||
|
|
||||||
|
val logProcess = try {
|
||||||
|
Runtime.getRuntime().exec(logCommand)
|
||||||
|
} catch (ioe: IOException) {
|
||||||
|
ioe.printStackTrace()
|
||||||
|
return ioe.stackTraceToString()
|
||||||
|
}
|
||||||
|
|
||||||
|
return logProcess.inputStream.bufferedReader().readText()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLogData(): String = if (failedToLoad) {
|
||||||
|
dumpLogcatText()
|
||||||
|
} else {
|
||||||
|
_lineState.joinToString("\n") { it.asSimpleString }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadLogs() {
|
||||||
|
_isLoading.value = true
|
||||||
|
|
||||||
|
logJob?.cancel()
|
||||||
|
logJob = viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val lines = logOutput()
|
||||||
|
_lineState.addAll(lines)
|
||||||
|
_isLoading.value = false
|
||||||
|
|
||||||
|
logJob = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadLogs()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.AbsoluteRoundedCornerShape
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.unit.dp
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.utils.Constants
|
||||||
|
|
||||||
|
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 = Uri.parse(appUrl)
|
||||||
|
setPackage("com.android.vending")
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
Toast.makeText(context, R.string.no_activity_found, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GooglePlayBadge(modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = R.drawable.google_play_badge),
|
||||||
|
contentDescription = stringResource(R.string.launcher_download_game),
|
||||||
|
modifier = modifier
|
||||||
|
.width(196.dp)
|
||||||
|
.clip(AbsoluteRoundedCornerShape(4.dp))
|
||||||
|
.clickable { onDownloadGame(context) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
package com.geode.launcher.main
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
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.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
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.text.ClickableText
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.geode.launcher.preferences.ApplicationLogsActivity
|
||||||
|
import com.geode.launcher.BuildConfig
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.UserDirectoryProvider
|
||||||
|
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
|
||||||
|
|
||||||
|
data class LoadFailureInfo(
|
||||||
|
val title: LaunchUtils.LauncherError,
|
||||||
|
val description: String? = null,
|
||||||
|
val extendedMessage: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ErrorInfoBody(failureReason: LaunchUtils.LauncherError, modifier: Modifier = Modifier) {
|
||||||
|
val headline = when (failureReason) {
|
||||||
|
LaunchUtils.LauncherError.LINKER_FAILED -> stringResource(R.string.load_failed_link_error_description)
|
||||||
|
LaunchUtils.LauncherError.CRASHED -> stringResource(R.string.load_failed_crashed_description)
|
||||||
|
else -> stringResource(R.string.load_failed_generic_error_description)
|
||||||
|
}
|
||||||
|
|
||||||
|
val recommendations = when (failureReason) {
|
||||||
|
LaunchUtils.LauncherError.LINKER_FAILED -> listOf(
|
||||||
|
stringResource(R.string.load_failed_recommendation_reinstall),
|
||||||
|
stringResource(R.string.load_failed_recommendation_update),
|
||||||
|
)
|
||||||
|
LaunchUtils.LauncherError.CRASHED -> listOf(
|
||||||
|
stringResource(R.string.load_failed_recommendation_safe_mode),
|
||||||
|
stringResource(R.string.load_failed_recommendation_report)
|
||||||
|
)
|
||||||
|
else -> listOf(
|
||||||
|
stringResource(R.string.load_failed_recommendation_reinstall),
|
||||||
|
stringResource(R.string.load_failed_recommendation_update),
|
||||||
|
stringResource(R.string.load_failed_recommendation_report)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(headline)
|
||||||
|
|
||||||
|
recommendations.forEach { r ->
|
||||||
|
Text("\u2022\u00A0\u00A0$r")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ErrorInfoTitle(failureReason: LaunchUtils.LauncherError) {
|
||||||
|
val message = when (failureReason) {
|
||||||
|
LaunchUtils.LauncherError.LINKER_NEEDS_64BIT,
|
||||||
|
LaunchUtils.LauncherError.LINKER_NEEDS_32BIT,
|
||||||
|
LaunchUtils.LauncherError.LINKER_FAILED -> stringResource(R.string.load_failed_link_error)
|
||||||
|
LaunchUtils.LauncherError.GENERIC -> stringResource(R.string.load_failed_generic_error)
|
||||||
|
LaunchUtils.LauncherError.CRASHED -> stringResource(R.string.load_failed_crashed)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_error),
|
||||||
|
contentDescription = stringResource(R.string.launcher_error_icon_alt),
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
)
|
||||||
|
Text(message, style = Typography.headlineMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onShareCrash(context: Context) {
|
||||||
|
val lastCrash = LaunchUtils.getLastCrash(context)
|
||||||
|
if (lastCrash == null) {
|
||||||
|
Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val baseDirectory = LaunchUtils.getBaseDirectory(context)
|
||||||
|
|
||||||
|
val providerLocation = lastCrash.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 = "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
context.startActivity(shareIntent)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
Toast.makeText(context, R.string.no_activity_found, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onShowLogs(context: Context) {
|
||||||
|
val launchIntent = Intent(context, ApplicationLogsActivity::class.java)
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ErrorInfoDescription(description: String, modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val currentRelease = remember {
|
||||||
|
PreferenceUtils.get(context).getString(PreferenceUtils.Key.CURRENT_VERSION_TAG)
|
||||||
|
}
|
||||||
|
|
||||||
|
val gameVersion = remember {
|
||||||
|
GamePackageUtils.getUnifiedVersionName(context.packageManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.launcher_error_details),
|
||||||
|
style = Typography.headlineSmall
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
style = Typography.bodyMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.size(4.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.launcher_error_device_info,
|
||||||
|
Build.MODEL,
|
||||||
|
Build.VERSION.RELEASE,
|
||||||
|
currentRelease ?: "unknown",
|
||||||
|
gameVersion
|
||||||
|
),
|
||||||
|
style = Typography.labelMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
fun ErrorInfoActions(extraDetails: String?, modifier: Modifier = Modifier) {
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
FlowRow(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(
|
||||||
|
12.dp, alignment = Alignment.CenterHorizontally
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
if (extraDetails != null) {
|
||||||
|
Button(onClick = { onShowLogs(context) }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_description),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||||
|
Text(stringResource(R.string.launcher_error_view_logs))
|
||||||
|
}
|
||||||
|
|
||||||
|
FilledTonalButton(onClick = {
|
||||||
|
clipboardManager.setText(AnnotatedString(extraDetails))
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_content_copy),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||||
|
Text(stringResource(R.string.launcher_error_copy))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Button(onClick = { onShareCrash(context) }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Share,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||||
|
Text(stringResource(R.string.launcher_error_export_crash))
|
||||||
|
}
|
||||||
|
|
||||||
|
FilledTonalButton(onClick = { onShowLogs(context) }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_description),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||||
|
Text(stringResource(R.string.launcher_error_view_logs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DownloadRecommendation(needsUniversal: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val version = BuildConfig.VERSION_NAME
|
||||||
|
|
||||||
|
val showDownload = remember {
|
||||||
|
!GamePackageUtils.isGameInstalled(context.packageManager) ||
|
||||||
|
!GamePackageUtils.identifyGameLegitimacy(context.packageManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
val downloadBase = "https://github.com/geode-sdk/android-launcher/releases/download/$version"
|
||||||
|
val legacyDownloadUrl = "$downloadBase/geode-launcher-v$version-android32.apk"
|
||||||
|
val universalDownloadUrl = "$downloadBase/geode-launcher-v$version.apk"
|
||||||
|
|
||||||
|
val downloadUrl = if (needsUniversal)
|
||||||
|
universalDownloadUrl else legacyDownloadUrl
|
||||||
|
|
||||||
|
val downloadText = if (needsUniversal)
|
||||||
|
stringResource(R.string.launcher_download_universal)
|
||||||
|
else
|
||||||
|
stringResource(R.string.launcher_download_32bit)
|
||||||
|
|
||||||
|
val promptTitle = stringResource(R.string.load_failed_abi_error)
|
||||||
|
val reinstallText = stringResource(R.string.load_failed_recommendation_reinstall)
|
||||||
|
val recommendationText = if (needsUniversal)
|
||||||
|
stringResource(R.string.load_failed_recommendation_switch_64bit_abi)
|
||||||
|
else
|
||||||
|
stringResource(R.string.load_failed_recommendation_switch_32bit_abi)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(
|
||||||
|
12.dp, alignment = Alignment.CenterVertically
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(promptTitle)
|
||||||
|
Text("\u2022\u00A0\u00A0$reinstallText")
|
||||||
|
|
||||||
|
if (showDownload) {
|
||||||
|
GooglePlayBadge()
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("\u2022\u00A0\u00A0$recommendationText")
|
||||||
|
|
||||||
|
ClickableText(
|
||||||
|
text = AnnotatedString(downloadText),
|
||||||
|
onClick = {
|
||||||
|
uriHandler.openUri(downloadUrl)
|
||||||
|
},
|
||||||
|
|
||||||
|
style = TextStyle.Default.copy(
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
textDecoration = TextDecoration.Underline
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ErrorInfoSheet(failureInfo: LoadFailureInfo, onDismiss: () -> Unit) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
|
||||||
|
ModalBottomSheet(onDismissRequest = { onDismiss() }, sheetState = sheetState) {
|
||||||
|
// second container created for scroll (before padding is added)
|
||||||
|
Box(Modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
Column(modifier = Modifier
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
) {
|
||||||
|
ErrorInfoTitle(failureInfo.title)
|
||||||
|
|
||||||
|
Spacer(Modifier.size(16.dp))
|
||||||
|
|
||||||
|
if (failureInfo.title.isAbiFailure()) {
|
||||||
|
DownloadRecommendation(
|
||||||
|
needsUniversal = failureInfo.title == LaunchUtils.LauncherError.LINKER_NEEDS_64BIT,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp, bottom = 16.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ErrorInfoBody(failureInfo.title)
|
||||||
|
|
||||||
|
if (failureInfo.description != null) {
|
||||||
|
ErrorInfoDescription(
|
||||||
|
failureInfo.description,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorInfoActions(
|
||||||
|
extraDetails = failureInfo.extendedMessage ?: failureInfo.description,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 8.dp, bottom = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun showFailureSnackbar(
|
||||||
|
context: Context,
|
||||||
|
loadFailureInfo: LoadFailureInfo,
|
||||||
|
snackbarHostState: SnackbarHostState,
|
||||||
|
onActionPerformed: () -> Unit
|
||||||
|
) {
|
||||||
|
val message = if (loadFailureInfo.title == LaunchUtils.LauncherError.CRASHED) {
|
||||||
|
context.getString(R.string.launcher_crashed)
|
||||||
|
} else {
|
||||||
|
context.getString(R.string.launcher_failed_to_load)
|
||||||
|
}
|
||||||
|
|
||||||
|
val res = snackbarHostState.showSnackbar(
|
||||||
|
message = message,
|
||||||
|
actionLabel = context.getString(R.string.launcher_error_more),
|
||||||
|
duration = SnackbarDuration.Indefinite,
|
||||||
|
)
|
||||||
|
|
||||||
|
when (res) {
|
||||||
|
SnackbarResult.ActionPerformed -> {
|
||||||
|
onActionPerformed()
|
||||||
|
}
|
||||||
|
SnackbarResult.Dismissed -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
package com.geode.launcher.main
|
||||||
|
|
||||||
|
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.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
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
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
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.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.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
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UpdateWarning(inSafeMode: Boolean = false, onDismiss: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val packageManager = context.packageManager
|
||||||
|
|
||||||
|
val gdVersionCode = remember { GamePackageUtils.getGameVersionCode(packageManager) }
|
||||||
|
val gdVersionString = remember { GamePackageUtils.getGameVersionString(packageManager) }
|
||||||
|
|
||||||
|
var lastDismissedVersion by PreferenceUtils.useLongPreference(
|
||||||
|
preferenceKey = PreferenceUtils.Key.DISMISSED_GJ_UPDATE
|
||||||
|
)
|
||||||
|
|
||||||
|
val onUnsupportedVersion = gdVersionCode < Constants.SUPPORTED_VERSION_CODE_MIN || gdVersionCode > Constants.SUPPORTED_VERSION_CODE
|
||||||
|
val canDismissRelease = gdVersionCode >= Constants.SUPPORTED_VERSION_CODE
|
||||||
|
val shouldDismiss = canDismissRelease && gdVersionCode == lastDismissedVersion
|
||||||
|
|
||||||
|
if (onUnsupportedVersion && !shouldDismiss) {
|
||||||
|
AlertDialog(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Warning,
|
||||||
|
contentDescription = stringResource(R.string.launcher_warning_icon_alt)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(stringResource(R.string.launcher_unsupported_version_title))
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string.launcher_unsupported_version_description,
|
||||||
|
gdVersionString,
|
||||||
|
Constants.SUPPORTED_VERSION_STRING
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
onDismiss()
|
||||||
|
lastDismissedVersion = gdVersionCode
|
||||||
|
|
||||||
|
onLaunch(context, inSafeMode)
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.message_box_accept))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { onDismiss() }) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismissRequest = { onDismiss() }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
LaunchedEffect(gdVersionCode) {
|
||||||
|
onLaunch(context, inSafeMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SafeModeDialog(onDismiss: () -> Unit, onLaunch: () -> Unit) {
|
||||||
|
AlertDialog(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Warning,
|
||||||
|
contentDescription = stringResource(R.string.launcher_warning_icon_alt)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onLaunch(context: Context, safeMode: Boolean = false) {
|
||||||
|
if (safeMode) {
|
||||||
|
GeodeUtils.setAdditionalLaunchArguments(GeodeUtils.ARGUMENT_SAFE_MODE)
|
||||||
|
} else {
|
||||||
|
GeodeUtils.clearLaunchArguments()
|
||||||
|
}
|
||||||
|
|
||||||
|
val launchIntent = Intent(context, GeometryDashActivity::class.java)
|
||||||
|
launchIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSettings(context: Context) {
|
||||||
|
val launchIntent = Intent(context, SettingsActivity::class.java)
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LongPressButton(onClick: () -> Unit, onLongPress: () -> Unit, enabled: Boolean = true, modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) {
|
||||||
|
// compose apis don't provide a good way of adding long press to a button
|
||||||
|
var isLongPress by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val viewConfiguration = LocalViewConfiguration.current
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
|
||||||
|
LaunchedEffect(interactionSource) {
|
||||||
|
interactionSource.interactions.collectLatest { interaction ->
|
||||||
|
when (interaction) {
|
||||||
|
is PressInteraction.Press -> {
|
||||||
|
delay(viewConfiguration.longPressTimeoutMillis)
|
||||||
|
|
||||||
|
// perform a second delay to make the action more obvious
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
delay(viewConfiguration.longPressTimeoutMillis)
|
||||||
|
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
|
||||||
|
isLongPress = true
|
||||||
|
onLongPress()
|
||||||
|
}
|
||||||
|
is PressInteraction.Release -> {
|
||||||
|
if (!isLongPress) {
|
||||||
|
onClick()
|
||||||
|
}
|
||||||
|
isLongPress = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {},
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = modifier,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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(
|
||||||
|
Icons.Filled.PlayArrow,
|
||||||
|
contentDescription = context.getString(R.string.launcher_launch_icon_alt)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||||
|
Text(context.getString(R.string.launcher_launch))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(2.dp))
|
||||||
|
IconButton(onClick = { onSettings(context) }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Settings,
|
||||||
|
contentDescription = context.getString(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.isGameInstalled(context.packageManager) &&
|
||||||
|
!GamePackageUtils.identifyGameLegitimacy(context.packageManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(text, textAlign = TextAlign.Center, modifier = Modifier.padding(12.dp))
|
||||||
|
|
||||||
|
if (showDownload) {
|
||||||
|
GooglePlayBadge()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package com.geode.launcher.main
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.core.MutableTransitionState
|
||||||
|
import androidx.compose.animation.expandHorizontally
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.shrinkHorizontally
|
||||||
|
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.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedCard
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
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
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
|
import com.geode.launcher.ui.theme.Theme
|
||||||
|
import com.geode.launcher.updater.ReleaseManager
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
enum class LaunchNotificationType {
|
||||||
|
GEODE_UPDATED, LAUNCHER_UPDATE_AVAILABLE, UPDATE_FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
fun determineDisplayedCards(context: Context): List<LaunchNotificationType> {
|
||||||
|
val cards = mutableListOf<LaunchNotificationType>()
|
||||||
|
|
||||||
|
val releaseManager = ReleaseManager.get(context)
|
||||||
|
|
||||||
|
val availableUpdate = releaseManager.availableLauncherUpdate.value
|
||||||
|
if (availableUpdate != null) {
|
||||||
|
cards.add(LaunchNotificationType.LAUNCHER_UPDATE_AVAILABLE)
|
||||||
|
}
|
||||||
|
|
||||||
|
val releaseState = releaseManager.uiState.value
|
||||||
|
|
||||||
|
if (releaseState is ReleaseManager.ReleaseManagerState.Finished && releaseState.hasUpdated) {
|
||||||
|
cards.add(LaunchNotificationType.GEODE_UPDATED)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (releaseState is ReleaseManager.ReleaseManagerState.Failure) {
|
||||||
|
cards.add(LaunchNotificationType.UPDATE_FAILED)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cards
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NotificationCardFromType(type: LaunchNotificationType) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
when (type) {
|
||||||
|
LaunchNotificationType.LAUNCHER_UPDATE_AVAILABLE -> {
|
||||||
|
AnimatedNotificationCard(
|
||||||
|
displayLength = 5000L,
|
||||||
|
onClick = {
|
||||||
|
installLauncherUpdate(context)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
LauncherUpdateContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchNotificationType.GEODE_UPDATED -> {
|
||||||
|
AnimatedNotificationCard {
|
||||||
|
UpdateNotificationContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchNotificationType.UPDATE_FAILED -> {
|
||||||
|
AnimatedNotificationCard {
|
||||||
|
UpdateFailedContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
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 = Modifier.displayCutoutPadding()
|
||||||
|
) {
|
||||||
|
cards.forEach {
|
||||||
|
NotificationCardFromType(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AnimatedNotificationCard(modifier: Modifier = Modifier, visibilityDelay: Long = 0L, displayLength: Long = 3000L, onClick: (() -> Unit)? = null, contents: @Composable () -> Unit) {
|
||||||
|
val state = remember {
|
||||||
|
MutableTransitionState(false).apply {
|
||||||
|
targetState = visibilityDelay <= 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(true) {
|
||||||
|
if (visibilityDelay > 0) {
|
||||||
|
delay(visibilityDelay)
|
||||||
|
state.targetState = true
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(displayLength)
|
||||||
|
state.targetState = false
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visibleState = state,
|
||||||
|
enter = expandHorizontally() + fadeIn(),
|
||||||
|
exit = shrinkHorizontally() + fadeOut(),
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
CardView(onClick, modifier = Modifier.padding(8.dp)) {
|
||||||
|
contents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CardView(onClick: (() -> Unit)?, modifier: Modifier = Modifier, contents: @Composable () -> Unit) {
|
||||||
|
// val surfaceColor = MaterialTheme.colorScheme.background.copy(alpha = 0.75f)
|
||||||
|
|
||||||
|
if (onClick != null) {
|
||||||
|
OutlinedCard(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
contents()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
OutlinedCard(
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
contents()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LauncherUpdateContent(modifier: Modifier = Modifier) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(text = stringResource(id = R.string.launcher_update_available))
|
||||||
|
},
|
||||||
|
supportingContent = {
|
||||||
|
Text(text = stringResource(id = R.string.launcher_notification_update_cta))
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Info,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = modifier.width(IntrinsicSize.Max)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UpdateNotificationContent(modifier: Modifier = Modifier) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(text = stringResource(id = R.string.launcher_notification_update_success))
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.geode_monochrome),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = modifier.width(IntrinsicSize.Max)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UpdateFailedContent(modifier: Modifier = Modifier) {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = {
|
||||||
|
Text(text = stringResource(id = R.string.launcher_notification_update_failed))
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Warning,
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = modifier.width(IntrinsicSize.Max)
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package com.geode.launcher.main
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.os.CountDownTimer
|
||||||
|
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.updater.ReleaseManager
|
||||||
|
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.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.takeWhile
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private const val COOLDOWN_LENGTH_MS = 1000L
|
||||||
|
|
||||||
|
class LaunchViewModel(private val application: Application): ViewModel() {
|
||||||
|
companion object {
|
||||||
|
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||||
|
initializer {
|
||||||
|
val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as Application
|
||||||
|
|
||||||
|
LaunchViewModel(
|
||||||
|
application = application
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var readyTimerPassed = false
|
||||||
|
private var readyTimer: CountDownTimer? = null
|
||||||
|
|
||||||
|
private fun initReadyTimer() {
|
||||||
|
readyTimer = object : CountDownTimer(COOLDOWN_LENGTH_MS, 1000) {
|
||||||
|
override fun onTick(millisUntilFinished: Long) {
|
||||||
|
// no tick necessary
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFinish() {
|
||||||
|
readyTimerPassed = true
|
||||||
|
|
||||||
|
if (_uiState.value is LaunchUIState.Working) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
preReadyCheck()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class LaunchCancelReason {
|
||||||
|
MANUAL, AUTOMATIC, LAST_LAUNCH_CRASHED, GEODE_NOT_FOUND, GAME_MISSING, GAME_OUTDATED;
|
||||||
|
|
||||||
|
fun allowsRetry() = when (this) {
|
||||||
|
MANUAL,
|
||||||
|
AUTOMATIC,
|
||||||
|
LAST_LAUNCH_CRASHED,
|
||||||
|
GEODE_NOT_FOUND -> true
|
||||||
|
GAME_MISSING,
|
||||||
|
GAME_OUTDATED -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class LaunchUIState {
|
||||||
|
data object Initial : LaunchUIState()
|
||||||
|
data object UpdateCheck : LaunchUIState()
|
||||||
|
data class Updating(val downloaded: Long, val outOf: Long) : LaunchUIState()
|
||||||
|
data class Cancelled(val reason: LaunchCancelReason, val inProgress: Boolean = false) : LaunchUIState()
|
||||||
|
data object Working : LaunchUIState()
|
||||||
|
data object Ready : LaunchUIState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow<LaunchUIState>(LaunchUIState.Initial)
|
||||||
|
val uiState = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdate
|
||||||
|
|
||||||
|
var loadFailure: LoadFailureInfo? = null
|
||||||
|
private var hasCheckedForUpdates = false
|
||||||
|
private var isCancelling = false
|
||||||
|
private var hasManuallyStarted = false
|
||||||
|
|
||||||
|
private suspend fun determineUpdateStatus() {
|
||||||
|
ReleaseManager.get(application).uiState.takeWhile {
|
||||||
|
it is ReleaseManager.ReleaseManagerState.InUpdateCheck || it is ReleaseManager.ReleaseManagerState.InDownload
|
||||||
|
}.map { when (it) {
|
||||||
|
is ReleaseManager.ReleaseManagerState.InUpdateCheck -> LaunchUIState.UpdateCheck
|
||||||
|
is ReleaseManager.ReleaseManagerState.InDownload -> LaunchUIState.Updating(it.downloaded, it.outOf)
|
||||||
|
else -> LaunchUIState.UpdateCheck
|
||||||
|
}
|
||||||
|
}.collect {
|
||||||
|
_uiState.emit(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
// flow terminates once update check is finished
|
||||||
|
hasCheckedForUpdates = true
|
||||||
|
preReadyCheck()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun currentCrashInfo(): LoadFailureInfo? {
|
||||||
|
val currentState = _uiState.value
|
||||||
|
if (currentState is LaunchUIState.Cancelled && currentState.reason == LaunchCancelReason.LAST_LAUNCH_CRASHED) {
|
||||||
|
return loadFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearCrashInfo() {
|
||||||
|
val currentState = _uiState.value
|
||||||
|
if (currentState is LaunchUIState.Cancelled && currentState.reason == LaunchCancelReason.LAST_LAUNCH_CRASHED) {
|
||||||
|
loadFailure = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun preReadyCheck() {
|
||||||
|
if (isCancelling) {
|
||||||
|
// don't let the game start if cancelled
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val packageManager = application.packageManager
|
||||||
|
|
||||||
|
if (!GamePackageUtils.isGameInstalled(packageManager)) {
|
||||||
|
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ReleaseManager.get(application).isInUpdate) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
determineUpdateStatus()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadFailure != null) {
|
||||||
|
// last launch crashed, so cancel
|
||||||
|
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.LAST_LAUNCH_CRASHED))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (GamePackageUtils.getGameVersionCode(packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN) {
|
||||||
|
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_OUTDATED))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!LaunchUtils.isGeodeInstalled(application)) {
|
||||||
|
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GEODE_NOT_FOUND))
|
||||||
|
}
|
||||||
|
|
||||||
|
val loadAutomatically = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.LOAD_AUTOMATICALLY)
|
||||||
|
if (!loadAutomatically && !hasManuallyStarted) {
|
||||||
|
return cancelLaunch(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!readyTimerPassed) {
|
||||||
|
return _uiState.emit(LaunchUIState.Working)
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.emit(LaunchUIState.Ready)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun beginLaunchFlow(isRestart: Boolean = false) {
|
||||||
|
if (isRestart) {
|
||||||
|
hasManuallyStarted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_uiState.value !is LaunchUIState.Initial && _uiState.value !is LaunchUIState.Cancelled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_uiState.value is LaunchUIState.Cancelled && !isRestart) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isCancelling = false
|
||||||
|
|
||||||
|
if (!GamePackageUtils.isGameInstalled(application.packageManager)) {
|
||||||
|
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readyTimer == null) {
|
||||||
|
initReadyTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasGeode = LaunchUtils.isGeodeInstalled(application)
|
||||||
|
val shouldUpdate = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
|
||||||
|
if ((shouldUpdate && !hasCheckedForUpdates) || !hasGeode) {
|
||||||
|
ReleaseManager.get(application).checkForUpdates(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
preReadyCheck()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun cancelLaunch(isAutomatic: Boolean = false) {
|
||||||
|
// no need to double cancel
|
||||||
|
if (_uiState.value is LaunchUIState.Cancelled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
releaseManager.cancelUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.emit(LaunchUIState.Cancelled(reason, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
super.onCleared()
|
||||||
|
|
||||||
|
readyTimer?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
package com.geode.launcher.main
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
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.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.UserDirectoryProvider
|
||||||
|
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 java.io.File
|
||||||
|
import java.net.ConnectException
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
|
||||||
|
fun clearDownloadedApks(context: Context) {
|
||||||
|
// technically we should be using the activity results but it was too inconsistent for my liking
|
||||||
|
val preferenceUtils = PreferenceUtils.get(context)
|
||||||
|
val performCleanup = preferenceUtils.getBoolean(PreferenceUtils.Key.CLEANUP_APKS)
|
||||||
|
if (!performCleanup) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val baseDirectory = LaunchUtils.getBaseDirectory(context)
|
||||||
|
|
||||||
|
baseDirectory.listFiles {
|
||||||
|
// only select apk files
|
||||||
|
_, name -> name.lowercase().endsWith(".apk")
|
||||||
|
}?.forEach {
|
||||||
|
it.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
preferenceUtils.setBoolean(PreferenceUtils.Key.CLEANUP_APKS, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun installLauncherUpdate(context: Context) {
|
||||||
|
val nextUpdate = ReleaseManager.get(context).availableLauncherUpdate.value
|
||||||
|
val launcherDownload = nextUpdate?.getLauncherDownload()
|
||||||
|
|
||||||
|
if (launcherDownload != null) {
|
||||||
|
val outputFile = launcherDownload.name
|
||||||
|
val baseDirectory = LaunchUtils.getBaseDirectory(context)
|
||||||
|
|
||||||
|
val outputPathFile = File(baseDirectory, outputFile)
|
||||||
|
if (!outputPathFile.exists()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val outputPath = "${UserDirectoryProvider.ROOT}${outputFile}"
|
||||||
|
|
||||||
|
val uri = DocumentsContract.buildDocumentUri("${context.packageName}.user", outputPath)
|
||||||
|
|
||||||
|
PreferenceUtils.get(context).setBoolean(PreferenceUtils.Key.CLEANUP_APKS, true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val intent = generateInstallIntent(uri)
|
||||||
|
context.startActivity(intent)
|
||||||
|
} catch (e: 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
ElevatedCard(modifier) {
|
||||||
|
Column(
|
||||||
|
// buttons add enough padding already, lower to compensate
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
top = 20.dp,
|
||||||
|
start = 12.dp,
|
||||||
|
end = 12.dp,
|
||||||
|
bottom = 8.dp
|
||||||
|
),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.launcher_update_available),
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = { installLauncherUpdate(context) },
|
||||||
|
modifier = Modifier.align(Alignment.End)
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.launcher_install))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UpdateProgressIndicator(
|
||||||
|
message: String,
|
||||||
|
releaseViewModel: ReleaseViewModel,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
progress: (() -> Float)? = null
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.Start,
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
Text(message)
|
||||||
|
|
||||||
|
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(
|
||||||
|
Icons.Filled.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) {
|
||||||
|
Formatter.formatShortFileSize(context, state.outOf)
|
||||||
|
}
|
||||||
|
|
||||||
|
UpdateProgressIndicator(
|
||||||
|
stringResource(
|
||||||
|
R.string.release_fetch_downloading,
|
||||||
|
downloaded,
|
||||||
|
outOf
|
||||||
|
),
|
||||||
|
modifier = modifier,
|
||||||
|
releaseViewModel = releaseViewModel,
|
||||||
|
progress = {
|
||||||
|
val progress = state.downloaded / state.outOf.toDouble()
|
||||||
|
progress.toFloat()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
package com.geode.launcher.preferences
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.OnBackPressedDispatcher
|
||||||
|
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
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
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.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.filled.Share
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.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.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.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.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
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
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.ui.theme.GeodeLauncherTheme
|
||||||
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
|
import com.geode.launcher.ui.theme.Theme
|
||||||
|
import com.geode.launcher.ui.theme.Typography
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class ApplicationLogsActivity : 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)
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
|
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
ApplicationLogsScreen(onBackPressedDispatcher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun getLogColor(priority: LogPriority): Color {
|
||||||
|
val isDark = LocalTheme.current != Theme.LIGHT
|
||||||
|
|
||||||
|
// i just copied these from my ide tbh
|
||||||
|
return if (isDark) {
|
||||||
|
when (priority) {
|
||||||
|
LogPriority.ERROR -> Color(0xff_df_51_51)
|
||||||
|
LogPriority.WARN -> Color(0xff_bc_b5_00)
|
||||||
|
LogPriority.FATAL -> Color(0xff_b8_b6_fc)
|
||||||
|
LogPriority.DEBUG -> Color(0xff_aa_aa_aa)
|
||||||
|
LogPriority.VERBOSE -> Color(0xff_77_77_77)
|
||||||
|
else -> Color.Unspecified
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
when (priority) {
|
||||||
|
LogPriority.ERROR -> Color(0xff_df_51_51)
|
||||||
|
LogPriority.WARN -> Color(0xff_ea_b7_00)
|
||||||
|
LogPriority.FATAL -> Color(0xff_a1_42_f4)
|
||||||
|
LogPriority.DEBUG -> Color(0xff_77_77_77)
|
||||||
|
LogPriority.VERBOSE -> Color(0xff_aa_aa_aa)
|
||||||
|
else -> Color.Unspecified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SelectLogLevelDialog(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onSelect: (LogPriority) -> Unit,
|
||||||
|
initialValue: LogPriority
|
||||||
|
) {
|
||||||
|
SelectDialog(
|
||||||
|
title = stringResource(R.string.application_logs_level_dialog_name),
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
onSelect = { onSelect(it) },
|
||||||
|
initialValue = initialValue,
|
||||||
|
toLabel = {
|
||||||
|
// i'm lazy
|
||||||
|
it.name.lowercase().replaceFirstChar { c -> c.uppercaseChar() }
|
||||||
|
},
|
||||||
|
optionsCount = LogPriority.FATAL downTo LogPriority.VERBOSE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LogCard(logLine: LogLine, modifier: Modifier = Modifier) {
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
|
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 = FontFamily.Monospace
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"[${logLine.tag}]: ${logLine.messageTrimmed}",
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
style = Typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ApplicationLogsScreen(
|
||||||
|
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||||
|
logViewModel: LogViewModel = viewModel()
|
||||||
|
) {
|
||||||
|
val logLines = logViewModel.lineState
|
||||||
|
val isLoading by logViewModel.isLoading.collectAsState()
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
var showMoreOptions by remember { mutableStateOf(false) }
|
||||||
|
val saveLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
val logs = logViewModel.getLogData()
|
||||||
|
|
||||||
|
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||||
|
outputStream.bufferedWriter().use { writer ->
|
||||||
|
writer.write(logs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.makeText(context, R.string.application_logs_export_completed, Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(logLines.size) {
|
||||||
|
listState.scrollToItem(logLines.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
var showLogLevelSelect by remember { mutableStateOf(false) }
|
||||||
|
if (showLogLevelSelect) {
|
||||||
|
SelectLogLevelDialog(
|
||||||
|
onDismissRequest = { showLogLevelSelect = false },
|
||||||
|
onSelect = {
|
||||||
|
logViewModel.logLevel = it
|
||||||
|
showLogLevelSelect = false
|
||||||
|
},
|
||||||
|
initialValue = logViewModel.logLevel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
Column {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.back_icon_alt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
val data = logViewModel.getLogData()
|
||||||
|
|
||||||
|
val sendIntent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
putExtra(Intent.EXTRA_TEXT, data)
|
||||||
|
type = "text/plain"
|
||||||
|
}
|
||||||
|
|
||||||
|
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||||
|
|
||||||
|
context.startActivity(shareIntent)
|
||||||
|
}
|
||||||
|
}, enabled = !logLines.isEmpty()) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Share,
|
||||||
|
contentDescription = stringResource(R.string.application_logs_share)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { showMoreOptions = !showMoreOptions }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.MoreVert,
|
||||||
|
contentDescription = stringResource(R.string.application_logs_more)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showMoreOptions,
|
||||||
|
onDismissRequest = { showMoreOptions = false }
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_bug_report),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
if (logViewModel.filterCrashes) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Check,
|
||||||
|
contentDescription = stringResource(R.string.application_logs_crash_only_enabled_alt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.application_logs_crash_only))
|
||||||
|
}, onClick = {
|
||||||
|
logViewModel.toggleCrashBuffer()
|
||||||
|
showMoreOptions = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
DropdownMenuItem(
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_filter_list),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.application_logs_level))
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
showLogLevelSelect = true
|
||||||
|
showMoreOptions = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
DropdownMenuItem(
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_save),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.application_logs_export))
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
saveLauncher.launch(context.getString(R.string.application_logs_default_filename))
|
||||||
|
showMoreOptions = false
|
||||||
|
},
|
||||||
|
enabled = !logLines.isEmpty()
|
||||||
|
)
|
||||||
|
|
||||||
|
DropdownMenuItem(
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_delete),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.application_logs_delete))
|
||||||
|
}, onClick = {
|
||||||
|
logViewModel.clearLogs()
|
||||||
|
showMoreOptions = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.title_activity_application_logs),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
LazyColumn(
|
||||||
|
Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
state = listState
|
||||||
|
) {
|
||||||
|
if (!isLoading && logLines.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.application_logs_no_logs),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = Typography.bodyLarge,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(
|
||||||
|
logLines
|
||||||
|
) { line ->
|
||||||
|
LogCard(line)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showSystemUi = true)
|
||||||
|
@Composable
|
||||||
|
fun LogsPreview() {
|
||||||
|
GeodeLauncherTheme {
|
||||||
|
ApplicationLogsScreen(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package com.geode.launcher.preferences
|
||||||
|
|
||||||
|
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.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
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
|
||||||
|
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.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
|
import com.geode.launcher.ui.theme.Theme
|
||||||
|
import com.geode.launcher.utils.LabelledText
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
|
||||||
|
class DeveloperSettingsActivity : 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)
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
|
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
DeveloperSettingsScreen(onBackPressedDispatcher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun releaseChannelToKey(option: Int): String = when (option) {
|
||||||
|
1 -> stringResource(R.string.preference_release_channel_beta)
|
||||||
|
2 -> stringResource(R.string.preference_release_channel_nightly)
|
||||||
|
else -> stringResource(R.string.preference_release_channel_stable)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
Column {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(R.string.back_icon_alt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.title_activity_developer_settings),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
|
val developerMode by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
|
||||||
|
|
||||||
|
SettingsCard(
|
||||||
|
title = stringResource(R.string.preference_developer_mode),
|
||||||
|
preferenceKey = PreferenceUtils.Key.DEVELOPER_MODE,
|
||||||
|
)
|
||||||
|
|
||||||
|
InlineText(
|
||||||
|
stringResource(R.string.preference_developer_mode_about)
|
||||||
|
)
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
if (developerMode) {
|
||||||
|
OptionsGroup(title = stringResource(R.string.preference_category_gameplay)) {
|
||||||
|
SettingsStringCard(
|
||||||
|
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)
|
||||||
|
}}
|
||||||
|
)
|
||||||
|
SettingsCard(
|
||||||
|
title = stringResource(R.string.preference_override_exceptions_name),
|
||||||
|
description = stringResource(R.string.preference_override_exceptions_description),
|
||||||
|
preferenceKey = PreferenceUtils.Key.CUSTOM_SYMBOL_LIST
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionsGroup(title = stringResource(R.string.preference_category_updater)) {
|
||||||
|
SettingsSelectCard(
|
||||||
|
title = stringResource(R.string.preference_release_channel_tag_name),
|
||||||
|
dialogTitle = stringResource(R.string.preference_release_channel_select),
|
||||||
|
maxVal = 2,
|
||||||
|
preferenceKey = PreferenceUtils.Key.RELEASE_CHANNEL_TAG,
|
||||||
|
toLabel = { releaseChannelToKey(it) }
|
||||||
|
)
|
||||||
|
|
||||||
|
ElevatedCard(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) {
|
||||||
|
LabelledText(
|
||||||
|
label = stringResource(R.string.preference_developer_update_notice),
|
||||||
|
icon = {
|
||||||
|
Icon(Icons.Filled.Info, null)
|
||||||
|
},
|
||||||
|
modifier = Modifier.padding(8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionsGroup(title = stringResource(R.string.preference_category_testing)) {
|
||||||
|
SettingsCard(
|
||||||
|
title = stringResource(R.string.preference_enable_redesign),
|
||||||
|
preferenceKey = PreferenceUtils.Key.ENABLE_REDESIGN,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
-276
@@ -1,4 +1,4 @@
|
|||||||
package com.geode.launcher
|
package com.geode.launcher.preferences
|
||||||
|
|
||||||
import android.app.UiModeManager
|
import android.app.UiModeManager
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
@@ -13,6 +13,7 @@ import android.widget.Toast
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.OnBackPressedDispatcher
|
import androidx.activity.OnBackPressedDispatcher
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.compose.animation.animateColorAsState
|
import androidx.compose.animation.animateColorAsState
|
||||||
import androidx.compose.animation.core.Spring
|
import androidx.compose.animation.core.Spring
|
||||||
@@ -20,33 +21,32 @@ import androidx.compose.animation.core.spring
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.selection.toggleable
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import com.geode.launcher.api.ReleaseViewModel
|
import com.geode.launcher.BuildConfig
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.UserDirectoryProvider
|
||||||
|
import com.geode.launcher.updater.ReleaseViewModel
|
||||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
import com.geode.launcher.ui.theme.LocalTheme
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
import com.geode.launcher.ui.theme.Theme
|
import com.geode.launcher.ui.theme.Theme
|
||||||
@@ -58,7 +58,9 @@ import java.net.UnknownHostException
|
|||||||
|
|
||||||
class SettingsActivity : ComponentActivity() {
|
class SettingsActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||||
val theme = Theme.fromInt(themeOption)
|
val theme = Theme.fromInt(themeOption)
|
||||||
@@ -81,16 +83,6 @@ class SettingsActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
fun onExportSaveData(context: Context) {
|
|
||||||
Toast.makeText(
|
|
||||||
context,
|
|
||||||
"This function is not implemented yet!",
|
|
||||||
Toast.LENGTH_SHORT
|
|
||||||
).show()
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
fun onOpenFolder(context: Context) {
|
fun onOpenFolder(context: Context) {
|
||||||
val file = LaunchUtils.getBaseDirectory(context)
|
val file = LaunchUtils.getBaseDirectory(context)
|
||||||
val clipboardManager =
|
val clipboardManager =
|
||||||
@@ -104,7 +96,7 @@ fun onOpenFolder(context: Context) {
|
|||||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
||||||
Toast.makeText(
|
Toast.makeText(
|
||||||
context,
|
context,
|
||||||
context.getString(R.string.export_folder_copied),
|
context.getString(R.string.text_copied),
|
||||||
Toast.LENGTH_SHORT
|
Toast.LENGTH_SHORT
|
||||||
).show()
|
).show()
|
||||||
}
|
}
|
||||||
@@ -157,7 +149,9 @@ fun UpdateIndicator(
|
|||||||
enablePopup = true
|
enablePopup = true
|
||||||
val progress = updateStatus.downloaded / updateStatus.outOf.toDouble()
|
val progress = updateStatus.downloaded / updateStatus.outOf.toDouble()
|
||||||
|
|
||||||
CircularProgressIndicator(progress.toFloat())
|
CircularProgressIndicator(
|
||||||
|
progress = { progress.toFloat() },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
@@ -203,6 +197,15 @@ fun themeToKey(theme: Int): String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun displayOptionToKey(option: Int): String {
|
||||||
|
return when (option) {
|
||||||
|
1 -> stringResource(R.string.preference_display_mode_legacy)
|
||||||
|
2 -> stringResource(R.string.preference_display_mode_fullscreen)
|
||||||
|
else -> stringResource(R.string.preference_display_mode_default)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun updateTheme(context: Context, theme: Int) {
|
fun updateTheme(context: Context, theme: Int) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
|
val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
|
||||||
@@ -224,6 +227,16 @@ fun updateTheme(context: Context, theme: Int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onOpenLogs(context: Context) {
|
||||||
|
val launchIntent = Intent(context, ApplicationLogsActivity::class.java)
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onOpenDeveloperOptions(context: Context) {
|
||||||
|
val launchIntent = Intent(context, DeveloperSettingsActivity::class.java)
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
@@ -266,8 +279,8 @@ fun SettingsScreen(
|
|||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Filled.ArrowBack,
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
contentDescription = context.getString(R.string.settings_back_icon_alt)
|
contentDescription = context.getString(R.string.back_icon_alt)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -302,35 +315,39 @@ fun SettingsScreen(
|
|||||||
title = stringResource(R.string.preference_black_background_name),
|
title = stringResource(R.string.preference_black_background_name),
|
||||||
preferenceKey = PreferenceUtils.Key.BLACK_BACKGROUND
|
preferenceKey = PreferenceUtils.Key.BLACK_BACKGROUND
|
||||||
)
|
)
|
||||||
SettingsCard(
|
|
||||||
title = context.getString(R.string.preference_load_testing_name),
|
|
||||||
preferenceKey = PreferenceUtils.Key.LOAD_TESTING,
|
|
||||||
)
|
|
||||||
SettingsCard(
|
|
||||||
title = context.getString(R.string.preference_load_automatically_name),
|
|
||||||
description = context.getString(R.string.preference_load_automatically_description),
|
|
||||||
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY,
|
|
||||||
)
|
|
||||||
OptionsButton(
|
|
||||||
title = context.getString(R.string.preferences_copy_external_button),
|
|
||||||
description = LaunchUtils.getBaseDirectory(context).path,
|
|
||||||
onClick = { onOpenFolder(context) }
|
|
||||||
)
|
|
||||||
OptionsButton(
|
OptionsButton(
|
||||||
title = stringResource(R.string.preferences_open_file_manager),
|
title = stringResource(R.string.preferences_open_file_manager),
|
||||||
onClick = { onOpenFileManager(context) }
|
onClick = { onOpenFileManager(context) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OptionsGroup(stringResource(R.string.preference_category_gameplay)) {
|
||||||
|
SettingsCard(
|
||||||
|
title = context.getString(R.string.preference_load_automatically_name),
|
||||||
|
description = context.getString(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),
|
||||||
|
// necessary api doesn't exist on older versions of android
|
||||||
|
maxVal = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) 2 else 1,
|
||||||
|
preferenceKey = PreferenceUtils.Key.DISPLAY_MODE,
|
||||||
|
toLabel = { displayOptionToKey(it) }
|
||||||
|
)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
SettingsCard(
|
||||||
|
title = stringResource(R.string.preference_force_hrr),
|
||||||
|
preferenceKey = PreferenceUtils.Key.FORCE_HRR,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
OptionsGroup(context.getString(R.string.preference_category_updater)) {
|
OptionsGroup(context.getString(R.string.preference_category_updater)) {
|
||||||
SettingsCard(
|
SettingsCard(
|
||||||
title = context.getString(R.string.preference_update_automatically_name),
|
title = context.getString(R.string.preference_update_automatically_name),
|
||||||
preferenceKey = PreferenceUtils.Key.UPDATE_AUTOMATICALLY,
|
preferenceKey = PreferenceUtils.Key.UPDATE_AUTOMATICALLY,
|
||||||
)
|
)
|
||||||
SettingsCard(
|
|
||||||
title = context.getString(R.string.preference_release_channel_name),
|
|
||||||
preferenceKey = PreferenceUtils.Key.RELEASE_CHANNEL,
|
|
||||||
)
|
|
||||||
OptionsCard(
|
OptionsCard(
|
||||||
title = {
|
title = {
|
||||||
OptionsTitle(
|
OptionsTitle(
|
||||||
@@ -356,251 +373,37 @@ fun SettingsScreen(
|
|||||||
UpdateIndicator(snackbarHostState, updateStatus)
|
UpdateIndicator(snackbarHostState, updateStatus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/*
|
|
||||||
OptionsGroup("Data") {
|
OptionsGroup(stringResource(R.string.preference_category_developer)) {
|
||||||
OptionsButton(
|
OptionsButton(
|
||||||
title = "Export save data",
|
title = stringResource(R.string.preferences_view_logs),
|
||||||
onClick = { onExportSaveData(context) }
|
icon = {
|
||||||
|
Icon(painterResource(R.drawable.icon_description), contentDescription = null)
|
||||||
|
},
|
||||||
|
onClick = { onOpenLogs(context) }
|
||||||
|
)
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preference_open_developer_options),
|
||||||
|
icon = {
|
||||||
|
Icon(painterResource(R.drawable.icon_data_object), contentDescription = null)
|
||||||
|
},
|
||||||
|
onClick = { onOpenDeveloperOptions(context) }
|
||||||
|
)
|
||||||
|
OptionsButton(
|
||||||
|
title = context.getString(R.string.preferences_copy_external_button),
|
||||||
|
description = LaunchUtils.getBaseDirectory(context).path,
|
||||||
|
onClick = { onOpenFolder(context) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleSetting(context: Context, preferenceKey: PreferenceUtils.Key): Boolean {
|
|
||||||
val preferences = PreferenceUtils.get(context)
|
|
||||||
|
|
||||||
return preferences.toggleBoolean(preferenceKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getSetting(context: Context, preferenceKey: PreferenceUtils.Key): Boolean {
|
|
||||||
val preferences = PreferenceUtils.get(context)
|
|
||||||
|
|
||||||
return preferences.getBoolean(preferenceKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun OptionsGroup(title: String, content: @Composable () -> Unit) {
|
|
||||||
Column {
|
|
||||||
Text(
|
Text(
|
||||||
title,
|
stringResource(R.string.preference_launcher_version, BuildConfig.VERSION_NAME),
|
||||||
style = Typography.titleMedium,
|
style = Typography.labelSmall,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
)
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SettingsSelectCard(
|
|
||||||
title: String,
|
|
||||||
dialogTitle: String,
|
|
||||||
maxVal: Int,
|
|
||||||
preferenceKey: PreferenceUtils.Key,
|
|
||||||
toLabel: @Composable (Int) -> String,
|
|
||||||
extraSelectBehavior: ((Int) -> Unit)? = null
|
|
||||||
) {
|
|
||||||
val preferenceValue by PreferenceUtils.useIntPreference(preferenceKey)
|
|
||||||
|
|
||||||
var showDialog by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
OptionsCard(
|
|
||||||
title = { OptionsTitle(title = title) },
|
|
||||||
modifier = Modifier
|
|
||||||
.clickable(
|
|
||||||
onClick = {
|
|
||||||
showDialog = true
|
|
||||||
},
|
|
||||||
role = Role.Button
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Text(toLabel(preferenceValue))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showDialog) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
SelectDialog(
|
|
||||||
title = dialogTitle,
|
|
||||||
onDismissRequest = {
|
|
||||||
showDialog = false
|
|
||||||
},
|
|
||||||
onSelect = { selected ->
|
|
||||||
showDialog = false
|
|
||||||
PreferenceUtils.get(context)
|
|
||||||
.setInt(preferenceKey, selected)
|
|
||||||
extraSelectBehavior?.invoke(selected)
|
|
||||||
},
|
|
||||||
initialValue = preferenceValue,
|
|
||||||
toLabel = toLabel,
|
|
||||||
optionsCount = maxVal
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SelectDialog(
|
|
||||||
title: String,
|
|
||||||
onDismissRequest: () -> Unit,
|
|
||||||
onSelect: (Int) -> Unit,
|
|
||||||
initialValue: Int,
|
|
||||||
toLabel: @Composable (Int) -> String,
|
|
||||||
optionsCount: Int,
|
|
||||||
) {
|
|
||||||
var selectedValue by remember { mutableIntStateOf(initialValue) }
|
|
||||||
|
|
||||||
Dialog(onDismissRequest = onDismissRequest) {
|
|
||||||
Card(
|
|
||||||
shape = RoundedCornerShape(16.dp)
|
|
||||||
) {
|
|
||||||
// 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 {
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style = Typography.titleLarge,
|
|
||||||
modifier = Modifier.padding(
|
|
||||||
start = 28.dp,
|
|
||||||
top = 24.dp,
|
|
||||||
bottom = 12.dp
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// do not give the row or column padding!! it messes up the selection effect
|
|
||||||
(0..optionsCount).forEach { id ->
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(
|
|
||||||
onClick = { selectedValue = id },
|
|
||||||
role = Role.RadioButton
|
|
||||||
)
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
) {
|
|
||||||
RadioButton(
|
|
||||||
selected = selectedValue == id,
|
|
||||||
onClick = { selectedValue = id }
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
toLabel(id),
|
|
||||||
style = Typography.bodyMedium
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.End,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(
|
|
||||||
bottom = 16.dp,
|
|
||||||
end = 16.dp,
|
|
||||||
top = 4.dp
|
|
||||||
)
|
)
|
||||||
) {
|
|
||||||
TextButton(onClick = onDismissRequest) {
|
|
||||||
Text(stringResource(R.string.message_box_cancel))
|
|
||||||
}
|
|
||||||
|
|
||||||
TextButton(onClick = { onSelect(selectedValue) }) {
|
|
||||||
Text(stringResource(R.string.message_box_accept))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun OptionsButton(title: String, description: String? = null, onClick: () -> Unit) {
|
|
||||||
OptionsCard(
|
|
||||||
title = {
|
|
||||||
OptionsTitle(
|
|
||||||
title = title,
|
|
||||||
description = description
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.clickable(onClick = onClick, role = Role.Button)
|
|
||||||
) { }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SettingsCard(title: String, description: String? = null, preferenceKey: PreferenceUtils.Key) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
val settingEnabled = remember {
|
|
||||||
mutableStateOf(getSetting(context, preferenceKey))
|
|
||||||
}
|
|
||||||
|
|
||||||
OptionsCard(
|
|
||||||
title = {
|
|
||||||
OptionsTitle(
|
|
||||||
Modifier.fillMaxWidth(0.75f),
|
|
||||||
title = title,
|
|
||||||
description = description
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.toggleable(
|
|
||||||
value = settingEnabled.value,
|
|
||||||
onValueChange = { settingEnabled.value = toggleSetting(context, preferenceKey) },
|
|
||||||
role = Role.Switch,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Switch(checked = settingEnabled.value, onCheckedChange = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun OptionsTitle(modifier: Modifier = Modifier, title: String, description: String? = null) {
|
|
||||||
Column(
|
|
||||||
modifier,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
Text(title)
|
|
||||||
if (!description.isNullOrEmpty()) {
|
|
||||||
Text(
|
|
||||||
description,
|
|
||||||
style = Typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun OptionsCard(modifier: Modifier = Modifier, title: @Composable () -> Unit, content: @Composable () -> Unit) {
|
|
||||||
Row(
|
|
||||||
modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(64.dp)
|
|
||||||
.padding(horizontal = 16.dp),
|
|
||||||
Arrangement.SpaceBetween,
|
|
||||||
Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
title()
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
fun OptionsCardPreview() {
|
|
||||||
GeodeLauncherTheme {
|
|
||||||
OptionsGroup(title = "Preview Group") {
|
|
||||||
SettingsCard(
|
|
||||||
title = "Load files from /test",
|
|
||||||
description = "Very long testing description goes here. It is incredibly long, it should wrap onto a new line.",
|
|
||||||
preferenceKey = PreferenceUtils.Key.LOAD_TESTING
|
|
||||||
)
|
|
||||||
SettingsCard(
|
|
||||||
title = "Testing option 2",
|
|
||||||
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview(showSystemUi = true)
|
@Preview(showSystemUi = true)
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
package com.geode.launcher.preferences
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
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.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.selection.toggleable
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Clear
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
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.Switch
|
||||||
|
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.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
fun toggleSetting(context: Context, preferenceKey: PreferenceUtils.Key): Boolean {
|
||||||
|
val preferences = PreferenceUtils.get(context)
|
||||||
|
|
||||||
|
return preferences.toggleBoolean(preferenceKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSetting(context: Context, preferenceKey: PreferenceUtils.Key): Boolean {
|
||||||
|
val preferences = PreferenceUtils.get(context)
|
||||||
|
|
||||||
|
return preferences.getBoolean(preferenceKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OptionsGroup(title: String, content: @Composable () -> Unit) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = Typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsSelectCard(
|
||||||
|
title: String,
|
||||||
|
dialogTitle: String,
|
||||||
|
maxVal: Int,
|
||||||
|
preferenceKey: PreferenceUtils.Key,
|
||||||
|
toLabel: @Composable (Int) -> String,
|
||||||
|
extraSelectBehavior: ((Int) -> Unit)? = null
|
||||||
|
) {
|
||||||
|
val preferenceValue by PreferenceUtils.useIntPreference(preferenceKey)
|
||||||
|
|
||||||
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
OptionsCard(
|
||||||
|
title = { OptionsTitle(title = title) },
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(
|
||||||
|
onClick = {
|
||||||
|
showDialog = true
|
||||||
|
},
|
||||||
|
role = Role.Button
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(toLabel(preferenceValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDialog) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
SelectDialog(
|
||||||
|
title = dialogTitle,
|
||||||
|
onDismissRequest = {
|
||||||
|
showDialog = false
|
||||||
|
},
|
||||||
|
onSelect = { selected ->
|
||||||
|
showDialog = false
|
||||||
|
PreferenceUtils.get(context)
|
||||||
|
.setInt(preferenceKey, selected)
|
||||||
|
extraSelectBehavior?.invoke(selected)
|
||||||
|
},
|
||||||
|
initialValue = preferenceValue,
|
||||||
|
toLabel = toLabel,
|
||||||
|
optionsCount = 0..maxVal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsStringCard(
|
||||||
|
title: String,
|
||||||
|
dialogTitle: String,
|
||||||
|
preferenceKey: PreferenceUtils.Key,
|
||||||
|
filterInput: ((String) -> String)? = null
|
||||||
|
) {
|
||||||
|
var preferenceValue by PreferenceUtils.useStringPreference(preferenceKey)
|
||||||
|
|
||||||
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
OptionsCard(
|
||||||
|
title = {
|
||||||
|
OptionsTitle(title = title, description = preferenceValue)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(
|
||||||
|
onClick = {
|
||||||
|
showDialog = true
|
||||||
|
},
|
||||||
|
role = Role.Button
|
||||||
|
)
|
||||||
|
) { }
|
||||||
|
|
||||||
|
if (showDialog) {
|
||||||
|
StringDialog(
|
||||||
|
title = dialogTitle,
|
||||||
|
onDismissRequest = { showDialog = false },
|
||||||
|
onSelect = {
|
||||||
|
preferenceValue = it
|
||||||
|
showDialog = false
|
||||||
|
},
|
||||||
|
initialValue = preferenceValue ?: "",
|
||||||
|
filterInput = filterInput
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StringDialog(
|
||||||
|
title: String,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onSelect: (String) -> Unit,
|
||||||
|
initialValue: String,
|
||||||
|
filterInput: ((String) -> String)? = null
|
||||||
|
) {
|
||||||
|
var enteredValue by remember { mutableStateOf(initialValue) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { onDismissRequest() },
|
||||||
|
title = {
|
||||||
|
Text(title)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = enteredValue,
|
||||||
|
onValueChange = {
|
||||||
|
enteredValue = filterInput?.invoke(it) ?: it
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Ascii,
|
||||||
|
autoCorrect = false
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
if (enteredValue.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { enteredValue = "" }) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Clear,
|
||||||
|
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 <T> SelectDialog(
|
||||||
|
title: String,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onSelect: (T) -> Unit,
|
||||||
|
initialValue: T,
|
||||||
|
toLabel: @Composable (T) -> String,
|
||||||
|
optionsCount: Iterable<T>,
|
||||||
|
) {
|
||||||
|
var selectedValue by remember { mutableStateOf(initialValue) }
|
||||||
|
|
||||||
|
Dialog(onDismissRequest = onDismissRequest) {
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
// 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 {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = Typography.titleLarge,
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
start = 28.dp,
|
||||||
|
top = 24.dp,
|
||||||
|
bottom = 12.dp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// do not give the row or column padding!! it messes up the selection effect
|
||||||
|
optionsCount.forEach { id ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(
|
||||||
|
onClick = { selectedValue = id },
|
||||||
|
role = Role.RadioButton
|
||||||
|
)
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
) {
|
||||||
|
RadioButton(
|
||||||
|
selected = selectedValue == id,
|
||||||
|
onClick = { selectedValue = id }
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
toLabel(id),
|
||||||
|
style = Typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(
|
||||||
|
bottom = 16.dp,
|
||||||
|
end = 16.dp,
|
||||||
|
top = 4.dp
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(onClick = { onSelect(selectedValue) }) {
|
||||||
|
Text(stringResource(R.string.message_box_accept))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OptionsButton(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, onClick: () -> Unit) {
|
||||||
|
OptionsCard(
|
||||||
|
title = {
|
||||||
|
OptionsTitle(
|
||||||
|
title = title,
|
||||||
|
description = description,
|
||||||
|
icon = icon
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.clickable(onClick = onClick, role = Role.Button)
|
||||||
|
) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsCard(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, preferenceKey: PreferenceUtils.Key) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val settingEnabled = remember {
|
||||||
|
mutableStateOf(getSetting(context, preferenceKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
OptionsCard(
|
||||||
|
title = {
|
||||||
|
OptionsTitle(
|
||||||
|
Modifier.fillMaxWidth(0.75f),
|
||||||
|
title = title,
|
||||||
|
description = description,
|
||||||
|
icon = icon
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.toggleable(
|
||||||
|
value = settingEnabled.value,
|
||||||
|
onValueChange = { settingEnabled.value = toggleSetting(context, preferenceKey) },
|
||||||
|
role = Role.Switch,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Switch(checked = settingEnabled.value, onCheckedChange = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OptionsTitle(modifier: Modifier = Modifier, title: String, description: String? = null, icon: (@Composable () -> Unit)? = null) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
if (icon != null) {
|
||||||
|
icon()
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(title)
|
||||||
|
if (!description.isNullOrEmpty()) {
|
||||||
|
Text(
|
||||||
|
description,
|
||||||
|
style = Typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSecondaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OptionsCard(modifier: Modifier = Modifier, title: @Composable () -> Unit, content: @Composable () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(64.dp)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
Arrangement.SpaceBetween,
|
||||||
|
Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
title()
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
fun OptionsCardPreview() {
|
||||||
|
GeodeLauncherTheme {
|
||||||
|
OptionsGroup(title = "Preview Group") {
|
||||||
|
SettingsCard(
|
||||||
|
title = "Load files from /test",
|
||||||
|
description = "Very long testing description goes here. It is incredibly long, it should wrap onto a new line.",
|
||||||
|
preferenceKey = PreferenceUtils.Key.BLACK_BACKGROUND
|
||||||
|
)
|
||||||
|
SettingsCard(
|
||||||
|
title = "Testing option 2",
|
||||||
|
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,24 +79,6 @@ fun GeodeLauncherTheme(
|
|||||||
else -> LightColorScheme
|
else -> LightColorScheme
|
||||||
}
|
}
|
||||||
|
|
||||||
val view = LocalView.current
|
|
||||||
|
|
||||||
if (!view.isInEditMode) {
|
|
||||||
SideEffect {
|
|
||||||
with ((view.context as Activity).window) {
|
|
||||||
WindowCompat.setDecorFitsSystemWindows(this, false)
|
|
||||||
|
|
||||||
statusBarColor = Color.Transparent.toArgb()
|
|
||||||
navigationBarColor = Color.Transparent.toArgb()
|
|
||||||
|
|
||||||
WindowCompat.getInsetsController(this, view).apply {
|
|
||||||
isAppearanceLightStatusBars = theme == Theme.LIGHT
|
|
||||||
isAppearanceLightNavigationBars = theme == Theme.LIGHT
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
typography = Typography,
|
typography = Typography,
|
||||||
|
|||||||
+22
-3
@@ -1,4 +1,4 @@
|
|||||||
package com.geode.launcher.api
|
package com.geode.launcher.updater
|
||||||
|
|
||||||
import com.geode.launcher.utils.LaunchUtils
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
import kotlinx.datetime.Instant
|
import kotlinx.datetime.Instant
|
||||||
@@ -23,7 +23,8 @@ class Release(
|
|||||||
val tagName: String,
|
val tagName: String,
|
||||||
val createdAt: Instant,
|
val createdAt: Instant,
|
||||||
val publishedAt: Instant,
|
val publishedAt: Instant,
|
||||||
val assets: List<Asset>
|
val assets: List<Asset>,
|
||||||
|
val htmlUrl: String
|
||||||
) {
|
) {
|
||||||
fun getDescription(): String {
|
fun getDescription(): String {
|
||||||
if (tagName == "nightly") {
|
if (tagName == "nightly") {
|
||||||
@@ -42,7 +43,7 @@ class Release(
|
|||||||
return createdAt.epochSeconds
|
return createdAt.epochSeconds
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getAndroidDownload(): Asset? {
|
fun getGeodeDownload(): Asset? {
|
||||||
// try to find an asset that matches the architecture first
|
// try to find an asset that matches the architecture first
|
||||||
val platform = LaunchUtils.platformName
|
val platform = LaunchUtils.platformName
|
||||||
|
|
||||||
@@ -51,4 +52,22 @@ class Release(
|
|||||||
it.name.endsWith(releaseSuffix)
|
it.name.endsWith(releaseSuffix)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getLauncherDownload(): Asset? {
|
||||||
|
val platform = LaunchUtils.platformName
|
||||||
|
val use32BitPlatform = platform == "android32"
|
||||||
|
|
||||||
|
return assets.find { asset ->
|
||||||
|
/* you know it's good when you pull out the truth table
|
||||||
|
* u32bp | contains | found
|
||||||
|
* 1 | 1 | 1
|
||||||
|
* 0 | 1 | 0
|
||||||
|
* 1 | 0 | 0
|
||||||
|
* 0 | 0 | 1
|
||||||
|
* surprise! it's an xnor
|
||||||
|
*/
|
||||||
|
|
||||||
|
(asset.name.contains("android32")) == use32BitPlatform
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+123
-22
@@ -1,9 +1,12 @@
|
|||||||
package com.geode.launcher.utils
|
package com.geode.launcher.updater
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.geode.launcher.api.Release
|
import com.geode.launcher.BuildConfig
|
||||||
import com.geode.launcher.api.ReleaseRepository
|
import com.geode.launcher.utils.DownloadUtils
|
||||||
|
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.CancellationException
|
||||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
@@ -15,9 +18,14 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import okio.buffer
|
||||||
|
import okio.source
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.InterruptedIOException
|
import java.io.InterruptedIOException
|
||||||
|
|
||||||
|
private const val TAG_LATEST = "latest"
|
||||||
|
private const val TAG_BETA = "prerelease"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Singleton to manage Geode updates, from update checking to downloading.
|
* Singleton to manage Geode updates, from update checking to downloading.
|
||||||
*/
|
*/
|
||||||
@@ -30,7 +38,7 @@ class ReleaseManager private constructor(
|
|||||||
private lateinit var managerInstance: ReleaseManager
|
private lateinit var managerInstance: ReleaseManager
|
||||||
|
|
||||||
fun get(context: Context): ReleaseManager {
|
fun get(context: Context): ReleaseManager {
|
||||||
if (!::managerInstance.isInitialized) {
|
if (!Companion::managerInstance.isInitialized) {
|
||||||
val applicationContext = context.applicationContext
|
val applicationContext = context.applicationContext
|
||||||
|
|
||||||
val httpClient = OkHttpClient.Builder()
|
val httpClient = OkHttpClient.Builder()
|
||||||
@@ -74,6 +82,9 @@ class ReleaseManager private constructor(
|
|||||||
val isInUpdate: Boolean
|
val isInUpdate: Boolean
|
||||||
get() = _uiState.value !is ReleaseManagerState.Failure && _uiState.value !is ReleaseManagerState.Finished
|
get() = _uiState.value !is ReleaseManagerState.Failure && _uiState.value !is ReleaseManagerState.Finished
|
||||||
|
|
||||||
|
private val _availableLauncherUpdate = MutableStateFlow<Release?>(null)
|
||||||
|
val availableLauncherUpdate = _availableLauncherUpdate.asStateFlow()
|
||||||
|
|
||||||
private fun sendError(e: Exception) {
|
private fun sendError(e: Exception) {
|
||||||
_uiState.value = ReleaseManagerState.Failure(e)
|
_uiState.value = ReleaseManagerState.Failure(e)
|
||||||
|
|
||||||
@@ -84,21 +95,72 @@ class ReleaseManager private constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun getLatestRelease(): Release? {
|
private fun mapSelectedReleaseToTag(): String {
|
||||||
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
||||||
val useNightly = sharedPreferences.getBoolean(PreferenceUtils.Key.RELEASE_CHANNEL)
|
val releaseChannel = sharedPreferences.getInt(PreferenceUtils.Key.RELEASE_CHANNEL_TAG)
|
||||||
|
|
||||||
val latestRelease = if (useNightly) {
|
return when (releaseChannel) {
|
||||||
releaseRepository.getLatestNightlyRelease()
|
2 -> "nightly"
|
||||||
} else {
|
1 -> TAG_BETA
|
||||||
releaseRepository.getLatestRelease()
|
else -> TAG_LATEST
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return latestRelease
|
private fun getBestReleaseForGameVersion(): String? {
|
||||||
|
if (!GamePackageUtils.isGameInstalled(applicationContext.packageManager)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val gameVersion = GamePackageUtils.getGameVersionCode(applicationContext.packageManager)
|
||||||
|
|
||||||
|
return when {
|
||||||
|
gameVersion >= 39L -> mapSelectedReleaseToTag()
|
||||||
|
gameVersion == 38L -> "v2.0.0-beta.27"
|
||||||
|
gameVersion == 37L -> "v2.0.0-beta.5"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getLatestRelease(): Release? {
|
||||||
|
val targetTag = getBestReleaseForGameVersion() ?: return null
|
||||||
|
|
||||||
|
return when (targetTag) {
|
||||||
|
TAG_LATEST -> releaseRepository.getLatestGeodeRelease()
|
||||||
|
TAG_BETA -> releaseRepository.getLatestGeodePreRelease()
|
||||||
|
else -> releaseRepository.getReleaseByTag(targetTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun downloadLauncherUpdate(release: Release) {
|
||||||
|
val download = release.getLauncherDownload() ?: return
|
||||||
|
|
||||||
|
val outputDirectory = LaunchUtils.getBaseDirectory(applicationContext)
|
||||||
|
val outputFile = File(outputDirectory, download.name)
|
||||||
|
|
||||||
|
if (outputFile.exists()) {
|
||||||
|
// only download the apk once
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.value = ReleaseManagerState.InDownload(0, download.size.toLong())
|
||||||
|
|
||||||
|
try {
|
||||||
|
val fileStream = DownloadUtils.downloadStream(
|
||||||
|
httpClient,
|
||||||
|
download.browserDownloadUrl
|
||||||
|
) { progress, outOf ->
|
||||||
|
_uiState.value = ReleaseManagerState.InDownload(progress, outOf)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileStream.copyTo(outputFile.outputStream())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
sendError(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun performUpdate(release: Release) {
|
private suspend fun performUpdate(release: Release) {
|
||||||
val releaseAsset = release.getAndroidDownload()
|
val releaseAsset = release.getGeodeDownload()
|
||||||
if (releaseAsset == null) {
|
if (releaseAsset == null) {
|
||||||
val noAssetException = Exception("missing Android download")
|
val noAssetException = Exception("missing Android download")
|
||||||
_uiState.value = ReleaseManagerState.Failure(noAssetException)
|
_uiState.value = ReleaseManagerState.Failure(noAssetException)
|
||||||
@@ -110,7 +172,10 @@ class ReleaseManager private constructor(
|
|||||||
_uiState.value = ReleaseManagerState.InDownload(0, releaseAsset.size.toLong())
|
_uiState.value = ReleaseManagerState.InDownload(0, releaseAsset.size.toLong())
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val fileStream = DownloadUtils.downloadStream(httpClient, releaseAsset.browserDownloadUrl) { progress, outOf ->
|
val fileStream = DownloadUtils.downloadStream(
|
||||||
|
httpClient,
|
||||||
|
releaseAsset.browserDownloadUrl
|
||||||
|
) { progress, outOf ->
|
||||||
_uiState.value = ReleaseManagerState.InDownload(progress, outOf)
|
_uiState.value = ReleaseManagerState.InDownload(progress, outOf)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +198,8 @@ class ReleaseManager private constructor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
downloadLauncherUpdateIfNecessary()
|
||||||
|
|
||||||
// extraction performed
|
// extraction performed
|
||||||
updatePreferences(release)
|
updatePreferences(release)
|
||||||
_uiState.value = ReleaseManagerState.Finished(true)
|
_uiState.value = ReleaseManagerState.Finished(true)
|
||||||
@@ -145,13 +212,27 @@ class ReleaseManager private constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
||||||
|
if (!sharedPreferences.getBoolean(PreferenceUtils.Key.DEVELOPER_MODE)) {
|
||||||
val fileLastModified = sharedPreferences.getLong(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED)
|
|
||||||
if (fileLastModified == 0L) {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return fileLastModified != geodeFile.lastModified()
|
val originalFileHash = sharedPreferences.getString(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED)
|
||||||
|
?: return false
|
||||||
|
|
||||||
|
val currentFileHash = computeFileHash(geodeFile)
|
||||||
|
|
||||||
|
return originalFileHash != currentFileHash
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkLauncherUpdate(launcherUpdate: Release) {
|
||||||
|
if (launcherUpdate.tagName != BuildConfig.VERSION_NAME) {
|
||||||
|
_availableLauncherUpdate.value = launcherUpdate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun downloadLauncherUpdateIfNecessary() {
|
||||||
|
val update = _availableLauncherUpdate.value ?: return
|
||||||
|
downloadLauncherUpdate(update)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun checkForNewRelease(allowOverwriting: Boolean = false) {
|
private suspend fun checkForNewRelease(allowOverwriting: Boolean = false) {
|
||||||
@@ -162,7 +243,20 @@ class ReleaseManager private constructor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val launcherRelease = try {
|
||||||
|
releaseRepository.getLatestLauncherRelease()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
sendError(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (launcherRelease != null) {
|
||||||
|
checkLauncherUpdate(launcherRelease)
|
||||||
|
}
|
||||||
|
|
||||||
if (release == null) {
|
if (release == null) {
|
||||||
|
downloadLauncherUpdateIfNecessary()
|
||||||
|
|
||||||
_uiState.value = ReleaseManagerState.Finished()
|
_uiState.value = ReleaseManagerState.Finished()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -176,13 +270,17 @@ class ReleaseManager private constructor(
|
|||||||
val geodeFile = getGeodeOutputPath()
|
val geodeFile = getGeodeOutputPath()
|
||||||
|
|
||||||
// check if an update is needed
|
// check if an update is needed
|
||||||
if (latestVersion <= currentVersion && geodeFile.exists()) {
|
if (latestVersion == currentVersion && geodeFile.exists()) {
|
||||||
|
downloadLauncherUpdateIfNecessary()
|
||||||
|
|
||||||
_uiState.value = ReleaseManagerState.Finished()
|
_uiState.value = ReleaseManagerState.Finished()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if the file was externally modified
|
// check if the file was externally modified
|
||||||
if (!allowOverwriting && fileWasExternallyModified()) {
|
if (!allowOverwriting && fileWasExternallyModified()) {
|
||||||
|
downloadLauncherUpdateIfNecessary()
|
||||||
|
|
||||||
sendError(UpdateException(UpdateException.Reason.EXTERNAL_FILE_IN_USE))
|
sendError(UpdateException(UpdateException.Reason.EXTERNAL_FILE_IN_USE))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -203,13 +301,16 @@ class ReleaseManager private constructor(
|
|||||||
release.getDescriptor()
|
release.getDescriptor()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// store hash as modified time is unreliable (don't know why)
|
||||||
val outputFile = getGeodeOutputPath()
|
val outputFile = getGeodeOutputPath()
|
||||||
sharedPreferences.setLong(
|
val fileHash = computeFileHash(outputFile)
|
||||||
PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED,
|
|
||||||
outputFile.lastModified()
|
sharedPreferences.setString(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED, fileHash)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun computeFileHash(file: File): String =
|
||||||
|
file.source().buffer().readByteString().md5().hex()
|
||||||
|
|
||||||
private fun getGeodeOutputPath(): File {
|
private fun getGeodeOutputPath(): File {
|
||||||
val geodeName = LaunchUtils.geodeFilename
|
val geodeName = LaunchUtils.geodeFilename
|
||||||
val geodeDirectory = LaunchUtils.getBaseDirectory(applicationContext)
|
val geodeDirectory = LaunchUtils.getBaseDirectory(applicationContext)
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package com.geode.launcher.updater
|
||||||
|
|
||||||
|
import com.geode.launcher.utils.DownloadUtils.executeCoroutine
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlinx.datetime.DateTimeUnit
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import kotlinx.datetime.until
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonNamingStrategy
|
||||||
|
import kotlinx.serialization.json.okio.decodeFromBufferedSource
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
class ReleaseRepository(private val httpClient: OkHttpClient) {
|
||||||
|
companion object {
|
||||||
|
private const val GITHUB_API_BASE = "https://api.github.com"
|
||||||
|
private const val GITHUB_API_HEADER = "X-GitHub-Api-Version"
|
||||||
|
private const val GITHUB_API_VERSION = "2022-11-28"
|
||||||
|
|
||||||
|
private const val GITHUB_RATELIMIT_REMAINING = "x-ratelimit-remaining"
|
||||||
|
private const val GITHUB_RATELIMIT_RESET = "x-ratelimit-reset"
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getLatestLauncherRelease(): Release? {
|
||||||
|
val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/android-launcher/releases/latest"
|
||||||
|
|
||||||
|
val url = URL(releasePath)
|
||||||
|
|
||||||
|
return getReleaseByUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getLatestGeodeRelease(): Release? {
|
||||||
|
val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases/latest"
|
||||||
|
val url = URL(releasePath)
|
||||||
|
|
||||||
|
return getReleaseByUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getReleaseByTag(tag: String): Release? {
|
||||||
|
val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases/tags/$tag"
|
||||||
|
val url = URL(releasePath)
|
||||||
|
|
||||||
|
return getReleaseByUrl(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
suspend fun getLatestGeodePreRelease(): Release? {
|
||||||
|
val releasesUrl = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases?per_page=2"
|
||||||
|
val url = URL(releasesUrl)
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.addHeader("Accept", "application/json")
|
||||||
|
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val call = httpClient.newCall(request)
|
||||||
|
val response = call.executeCoroutine()
|
||||||
|
|
||||||
|
return 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" }
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
|
||||||
|
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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateRateLimitMessage(resetTime: Instant): String {
|
||||||
|
val currentTime = Clock.System.now()
|
||||||
|
val resetDelay = currentTime.until(resetTime, DateTimeUnit.SECOND)
|
||||||
|
|
||||||
|
val formattedWait = "${resetDelay / 60}m"
|
||||||
|
|
||||||
|
return "api ratelimit reached, try again in ${formattedWait}"
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
private suspend fun getReleaseByUrl(url: URL): Release? {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.addHeader("Accept", "application/json")
|
||||||
|
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val call = httpClient.newCall(request)
|
||||||
|
val response = call.executeCoroutine()
|
||||||
|
|
||||||
|
return when (response.code) {
|
||||||
|
200 -> {
|
||||||
|
val format = Json {
|
||||||
|
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
}
|
||||||
|
|
||||||
|
val release = format.decodeFromBufferedSource<Release>(
|
||||||
|
response.body!!.source()
|
||||||
|
)
|
||||||
|
|
||||||
|
release
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
|
||||||
|
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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-4
@@ -1,4 +1,4 @@
|
|||||||
package com.geode.launcher.api
|
package com.geode.launcher.updater
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
@@ -7,7 +7,7 @@ import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.AP
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
import com.geode.launcher.utils.ReleaseManager
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -53,6 +53,8 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
|
|||||||
val isInUpdate
|
val isInUpdate
|
||||||
get() = ReleaseManager.get(application).isInUpdate
|
get() = ReleaseManager.get(application).isInUpdate
|
||||||
|
|
||||||
|
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdate
|
||||||
|
|
||||||
var hasPerformedCheck = false
|
var hasPerformedCheck = false
|
||||||
private set
|
private set
|
||||||
|
|
||||||
@@ -78,7 +80,7 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
|
|||||||
fun useGlobalCheckState() {
|
fun useGlobalCheckState() {
|
||||||
hasPerformedCheck = true
|
hasPerformedCheck = true
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val releaseFlow = ReleaseManager.get(application)
|
val releaseFlow = ReleaseManager.get(application)
|
||||||
.uiState
|
.uiState
|
||||||
|
|
||||||
@@ -89,7 +91,7 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
|
|||||||
fun runReleaseCheck(isManual: Boolean = false) {
|
fun runReleaseCheck(isManual: Boolean = false) {
|
||||||
hasPerformedCheck = true
|
hasPerformedCheck = true
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val releaseFlow = ReleaseManager.get(application)
|
val releaseFlow = ReleaseManager.get(application)
|
||||||
.checkForUpdates(isManual)
|
.checkForUpdates(isManual)
|
||||||
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.geode.launcher.utils
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.text.InlineTextContent
|
||||||
|
import androidx.compose.foundation.text.appendInlineContent
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.Placeholder
|
||||||
|
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun LabelledText(label: String, icon: @Composable (() -> Unit)? = null, modifier: Modifier = Modifier) {
|
||||||
|
if (icon != null) {
|
||||||
|
val iconId = "inlineIcon"
|
||||||
|
val paddingId = "paddingIcon"
|
||||||
|
|
||||||
|
val labelText = buildAnnotatedString {
|
||||||
|
appendInlineContent(iconId, "[x]")
|
||||||
|
appendInlineContent(paddingId, " ")
|
||||||
|
append(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
val inlineContent = mapOf(
|
||||||
|
Pair(iconId, InlineTextContent(
|
||||||
|
Placeholder(
|
||||||
|
width = 24.sp,
|
||||||
|
height = 24.sp,
|
||||||
|
placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
icon()
|
||||||
|
}),
|
||||||
|
Pair(paddingId, InlineTextContent(Placeholder(width = 6.sp, height = 24.sp, placeholderVerticalAlign = PlaceholderVerticalAlign.Center)) {
|
||||||
|
Spacer(modifier = Modifier.fillMaxWidth())
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(text = labelText, inlineContent = inlineContent, modifier = modifier)
|
||||||
|
} else {
|
||||||
|
Text(label, modifier = modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,10 +13,8 @@ object Constants {
|
|||||||
@SuppressLint("SdCardPath")
|
@SuppressLint("SdCardPath")
|
||||||
const val GJ_DATA_DIR = "/data/data/${PACKAGE_NAME}"
|
const val GJ_DATA_DIR = "/data/data/${PACKAGE_NAME}"
|
||||||
|
|
||||||
const val SUPPORTED_VERSION_CODE = 37L
|
const val SUPPORTED_VERSION_CODE_MIN = 37L
|
||||||
const val SUPPORTED_VERSION_STRING = "2.2.13"
|
const val SUPPORTED_VERSION_CODE = 39L
|
||||||
|
|
||||||
const val LAUNCHER_KEY_RETURN_TITLE = "return_title"
|
|
||||||
const val LAUNCHER_KEY_RETURN_MESSAGE = "return_message"
|
|
||||||
|
|
||||||
|
const val SUPPORTED_VERSION_STRING = "2.2.142"
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.geode.launcher.utils
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
|
||||||
|
class ConstrainedFrameLayout(context: Context) : FrameLayout(context) {
|
||||||
|
// avoid running the aspect ratio fix when we don't need it
|
||||||
|
var aspectRatio: Float? = null;
|
||||||
|
|
||||||
|
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||||
|
if (aspectRatio != null) {
|
||||||
|
val width = MeasureSpec.getSize(widthMeasureSpec)
|
||||||
|
val height = MeasureSpec.getSize(heightMeasureSpec)
|
||||||
|
if (width == 0 || height == 0) {
|
||||||
|
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||||
|
}
|
||||||
|
|
||||||
|
val calculatedWidth = height * (aspectRatio ?: 1.0f);
|
||||||
|
val padding = ((width - calculatedWidth) / 2).toInt()
|
||||||
|
|
||||||
|
// ignore paddings where the screen ends up looking odd
|
||||||
|
if (padding > 0) {
|
||||||
|
setPadding(padding, 0, padding, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
super.onMeasure(
|
||||||
|
widthMeasureSpec,
|
||||||
|
heightMeasureSpec
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,19 +116,13 @@ object DownloadUtils {
|
|||||||
fun copyFile(inputStream: InputStream, outputStream: OutputStream) {
|
fun copyFile(inputStream: InputStream, outputStream: OutputStream) {
|
||||||
// gotta love copying
|
// gotta love copying
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
FileUtils.copy(inputStream, outputStream)
|
inputStream.use { input -> outputStream.use { output ->
|
||||||
|
FileUtils.copy(input, output)
|
||||||
|
}}
|
||||||
} else {
|
} else {
|
||||||
inputStream.use { input ->
|
inputStream.use { input -> outputStream.use { output ->
|
||||||
outputStream.use { output ->
|
input.copyTo(output)
|
||||||
val buffer = ByteArray(4 * 1024)
|
}}
|
||||||
while (true) {
|
|
||||||
val byteCount = input.read(buffer)
|
|
||||||
if (byteCount < 0) break
|
|
||||||
output.write(buffer, 0, byteCount)
|
|
||||||
}
|
|
||||||
output.flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package com.geode.launcher.utils
|
||||||
|
|
||||||
|
import android.content.ContentUris
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class FileUtils {
|
||||||
|
// copied from https://stackoverflow.com/questions/17546101/get-real-path-for-uri-android
|
||||||
|
// i am actually very lazy to move this to a separate class
|
||||||
|
// hello zmx i moved it
|
||||||
|
companion object {
|
||||||
|
fun getRealPathFromURI(context: Context, uri: Uri): String? {
|
||||||
|
when {
|
||||||
|
// DocumentProvider
|
||||||
|
DocumentsContract.isDocumentUri(context, uri) -> {
|
||||||
|
when {
|
||||||
|
// ExternalStorageProvider
|
||||||
|
isExternalStorageDocument(uri) -> {
|
||||||
|
return handleExternalStorageDocument(context, uri)
|
||||||
|
}
|
||||||
|
isDownloadsDocument(uri) -> {
|
||||||
|
return handleDownloadsDocument(context, uri)
|
||||||
|
}
|
||||||
|
isMediaDocument(uri) -> {
|
||||||
|
return handleMediaDocument(context, uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: add the geode provider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"content".equals(uri.scheme, ignoreCase = true) -> {
|
||||||
|
// Return the remote address
|
||||||
|
if (isGooglePhotosUri(uri)) return uri.lastPathSegment
|
||||||
|
// This happened with a directory
|
||||||
|
if (isExternalStorageDocument(uri)) {
|
||||||
|
return handleExternalStorageDocument(context, uri)
|
||||||
|
}
|
||||||
|
return getDataColumn(context, uri, null, null)
|
||||||
|
}
|
||||||
|
"file".equals(uri.scheme, ignoreCase = true) -> {
|
||||||
|
return uri.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleMediaDocument(context: Context, uri: Uri): String? {
|
||||||
|
val docId = DocumentsContract.getDocumentId(uri)
|
||||||
|
val split = docId.split(":").toTypedArray()
|
||||||
|
val type = split[0]
|
||||||
|
var contentUri: Uri? = null
|
||||||
|
when (type) {
|
||||||
|
"image" -> {
|
||||||
|
contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||||
|
}
|
||||||
|
"video" -> {
|
||||||
|
contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||||
|
}
|
||||||
|
"audio" -> {
|
||||||
|
contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val selection = "_id=?"
|
||||||
|
val selectionArgs = arrayOf(split[1])
|
||||||
|
return getDataColumn(
|
||||||
|
context,
|
||||||
|
contentUri,
|
||||||
|
selection,
|
||||||
|
selectionArgs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDownloadsDocument(context: Context, uri: Uri): String? {
|
||||||
|
val fileName = getFilePath(context, uri)
|
||||||
|
if (fileName != null) {
|
||||||
|
return Environment.getExternalStorageDirectory().toString() + "/Download/" + fileName
|
||||||
|
}
|
||||||
|
var id = DocumentsContract.getDocumentId(uri)
|
||||||
|
if (id.startsWith("raw:")) {
|
||||||
|
id = id.replaceFirst("raw:".toRegex(), "")
|
||||||
|
val file = File(id)
|
||||||
|
if (file.exists()) return id
|
||||||
|
}
|
||||||
|
val contentUri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), java.lang.Long.valueOf(id))
|
||||||
|
return getDataColumn(context, contentUri, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleExternalStorageDocument(context: Context, uri: Uri): String {
|
||||||
|
val docId: String = if (DocumentsContract.isDocumentUri(context, uri)) {
|
||||||
|
DocumentsContract.getDocumentId(uri)
|
||||||
|
} else {
|
||||||
|
// please please please PLEASE let this be the only other case
|
||||||
|
DocumentsContract.getTreeDocumentId(uri)
|
||||||
|
}
|
||||||
|
val split = docId.split(":").toTypedArray()
|
||||||
|
val type = split[0]
|
||||||
|
// This is for checking Main Memory
|
||||||
|
return if ("primary".equals(type, ignoreCase = true)) {
|
||||||
|
if (split.size > 1) {
|
||||||
|
Environment.getExternalStorageDirectory().toString() + "/" + split[1]
|
||||||
|
} else {
|
||||||
|
Environment.getExternalStorageDirectory().toString() + "/"
|
||||||
|
}
|
||||||
|
// This is for checking SD Card
|
||||||
|
} else {
|
||||||
|
"storage" + "/" + docId.replace(":", "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private fun getFilePath(context: Context, uri: Uri?): String? {
|
||||||
|
var cursor: Cursor? = null
|
||||||
|
val projection = arrayOf(
|
||||||
|
MediaStore.MediaColumns.DISPLAY_NAME
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
if (uri == null) return null
|
||||||
|
cursor = context.contentResolver.query(
|
||||||
|
uri, projection, null, null,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
if (cursor != null && cursor.moveToFirst()) {
|
||||||
|
val index = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)
|
||||||
|
val data = cursor.getString(index)
|
||||||
|
cursor.close()
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
cursor?.close()
|
||||||
|
} finally {
|
||||||
|
cursor?.close()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDataColumn(context: Context, uri: Uri?, selection: String?,
|
||||||
|
selectionArgs: Array<String>?): String? {
|
||||||
|
var cursor: Cursor? = null
|
||||||
|
val column = "_data"
|
||||||
|
val projection = arrayOf(
|
||||||
|
column
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
if (uri == null) return null
|
||||||
|
cursor = context.contentResolver.query(
|
||||||
|
uri, projection, selection, selectionArgs,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
if (cursor != null && cursor.moveToFirst()) {
|
||||||
|
val index = cursor.getColumnIndexOrThrow(column)
|
||||||
|
val data = cursor.getString(index)
|
||||||
|
cursor.close()
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
cursor?.close()
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param uri The Uri to check.
|
||||||
|
* @return Whether the Uri authority is ExternalStorageProvider.
|
||||||
|
*/
|
||||||
|
private fun isExternalStorageDocument(uri: Uri): Boolean {
|
||||||
|
return "com.android.externalstorage.documents" == uri.authority
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param uri The Uri to check.
|
||||||
|
* @return Whether the Uri authority is DownloadsProvider.
|
||||||
|
*/
|
||||||
|
private fun isDownloadsDocument(uri: Uri): Boolean {
|
||||||
|
return "com.android.providers.downloads.documents" == uri.authority
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param uri The Uri to check.
|
||||||
|
* @return Whether the Uri authority is MediaProvider.
|
||||||
|
*/
|
||||||
|
private fun isMediaDocument(uri: Uri): Boolean {
|
||||||
|
return "com.android.providers.media.documents" == uri.authority
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param uri The Uri to check.
|
||||||
|
* @return Whether the Uri authority is Google Photos.
|
||||||
|
*/
|
||||||
|
private fun isGooglePhotosUri(uri: Uri): Boolean {
|
||||||
|
return "com.google.android.apps.photos.content" == uri.authority
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package com.geode.launcher.utils
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
|
import android.content.pm.PackageInfo
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.res.AssetManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import okio.ByteString.Companion.toByteString
|
||||||
|
|
||||||
|
object GamePackageUtils {
|
||||||
|
fun isGameInstalled(packageManager: PackageManager): Boolean {
|
||||||
|
return try {
|
||||||
|
packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
||||||
|
true
|
||||||
|
} catch (e: PackageManager.NameNotFoundException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getGameVersionCode(packageManager: PackageManager): Long {
|
||||||
|
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
||||||
|
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
game.longVersionCode
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
game.versionCode.toLong()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getGameVersionString(packageManager: PackageManager): String {
|
||||||
|
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
||||||
|
return game.versionName
|
||||||
|
}
|
||||||
|
|
||||||
|
private val gameVersionMap = mapOf(
|
||||||
|
37L to "2.200",
|
||||||
|
38L to "2.205",
|
||||||
|
39L to "2.206"
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the version name that more closely aligns to the Windows/macOS releases
|
||||||
|
*/
|
||||||
|
fun getUnifiedVersionName(packageManager: PackageManager): String {
|
||||||
|
val versionCode = getGameVersionCode(packageManager)
|
||||||
|
return gameVersionMap[versionCode] ?: getGameVersionString(packageManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detectAbiMismatch(context: Context, packageInfo: PackageInfo, loadException: Error): Boolean {
|
||||||
|
val abi = LaunchUtils.applicationArchitecture
|
||||||
|
val isExtracted =
|
||||||
|
packageInfo.applicationInfo.flags and ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS == ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS
|
||||||
|
val isSplit = (packageInfo.applicationInfo.splitSourceDirs?.size ?: 0) > 1
|
||||||
|
|
||||||
|
val metadata =
|
||||||
|
"Geometry Dash metadata:\nSplit sources: $isSplit\nExtracted libraries: $isExtracted\nLauncher architecture: $abi"
|
||||||
|
Log.i("GeodeLauncher", metadata)
|
||||||
|
|
||||||
|
// easiest check! these messages are hardcoded in bionic/linker/linker_phdr.cpp
|
||||||
|
if (loadException.message?.contains("32-bit instead of 64-bit") == true) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadException.message?.contains("64-bit instead of 32-bit") == true) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// determine if it can find gd libraries for the opposite architecture
|
||||||
|
val gdBinaryName = "lib${Constants.COCOS_LIB_NAME}.so"
|
||||||
|
val oppositeArchitecture = if (abi == "arm64-v8a") "armeabi-v7a" else "arm64-v8a"
|
||||||
|
|
||||||
|
// detect issues using native library directory (only works on extracted libraries)
|
||||||
|
val nativeLibraryDir = packageInfo.applicationInfo.nativeLibraryDir
|
||||||
|
|
||||||
|
if (nativeLibraryDir.contains(oppositeArchitecture)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// android has some odd behavior where 32bit libraries get installed to the arm directory instead of armeabi-v7a
|
||||||
|
// detect it, ig
|
||||||
|
if (oppositeArchitecture == "armeabi-v7a" && nativeLibraryDir.contains("/lib/arm/")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isExtracted) {
|
||||||
|
try {
|
||||||
|
context.assets.openNonAssetFd("lib/$oppositeArchitecture/$gdBinaryName")
|
||||||
|
return true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// this is good, actually!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("DiscouragedPrivateApi")
|
||||||
|
fun addAssetsFromPackage(assetManager: AssetManager, packageInfo: PackageInfo) {
|
||||||
|
// this method is officially marked as deprecated but it is the only method we are allowed to reflect
|
||||||
|
// (the source recommends replacing with AssetManager.setApkAssets(ApkAssets[], boolean) lol)
|
||||||
|
val clazz = assetManager.javaClass
|
||||||
|
val aspMethod = clazz.getDeclaredMethod("addAssetPath", String::class.java)
|
||||||
|
|
||||||
|
aspMethod.invoke(assetManager, packageInfo.applicationInfo.sourceDir)
|
||||||
|
packageInfo.applicationInfo.splitSourceDirs?.forEach {
|
||||||
|
aspMethod.invoke(assetManager, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val GAME_CERTIFICATE_HASH = "f5e7d8284d72c461a5f022d0cf755df101c3bb1e69cbe241bc1aef2cc5610a43"
|
||||||
|
|
||||||
|
private fun validateCertificate(certificate: ByteArray): Boolean =
|
||||||
|
certificate.toByteString().sha256().hex() == GAME_CERTIFICATE_HASH
|
||||||
|
|
||||||
|
fun identifyGameLegitimacy(packageManager: PackageManager): Boolean {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val certificates = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, PackageManager.GET_SIGNING_CERTIFICATES)
|
||||||
|
val signingInfo = game.signingInfo
|
||||||
|
|
||||||
|
signingInfo.signingCertificateHistory
|
||||||
|
} else {
|
||||||
|
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, PackageManager.GET_SIGNATURES)
|
||||||
|
game.signatures
|
||||||
|
}
|
||||||
|
|
||||||
|
return certificates.any {
|
||||||
|
validateCertificate(it.toByteArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,62 +1,96 @@
|
|||||||
package com.geode.launcher.utils
|
package com.geode.launcher.utils
|
||||||
|
|
||||||
import android.app.Activity
|
import android.Manifest
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.ContentUris
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.database.Cursor
|
import android.content.pm.PackageManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
import android.provider.MediaStore
|
import android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION
|
||||||
import android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
|
||||||
import android.provider.OpenableColumns
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.annotation.Keep
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import com.geode.launcher.BuildConfig
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.UserDirectoryProvider
|
||||||
|
import com.geode.launcher.activityresult.GeodeOpenFileActivityResult
|
||||||
|
import com.geode.launcher.activityresult.GeodeOpenFilesActivityResult
|
||||||
|
import com.geode.launcher.activityresult.GeodeSaveFileActivityResult
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.lang.ref.WeakReference
|
import java.lang.ref.WeakReference
|
||||||
import java.net.URI
|
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
@Suppress("unused")
|
@Keep
|
||||||
|
@Suppress("unused", "KotlinJniMissingFunction")
|
||||||
object GeodeUtils {
|
object GeodeUtils {
|
||||||
private lateinit var activity: WeakReference<AppCompatActivity>
|
private lateinit var activity: WeakReference<AppCompatActivity>
|
||||||
private lateinit var fileResultLauncher: ActivityResultLauncher<Intent>
|
private lateinit var openFileResultLauncher: ActivityResultLauncher<GeodeOpenFileActivityResult.OpenFileParams>
|
||||||
private lateinit var filesResultLauncher: ActivityResultLauncher<Intent>
|
private lateinit var openDirectoryResultLauncher: ActivityResultLauncher<Uri?>
|
||||||
|
private lateinit var openFilesResultLauncher: ActivityResultLauncher<GeodeOpenFilesActivityResult.OpenFileParams>
|
||||||
|
private lateinit var saveFileResultLauncher: ActivityResultLauncher<GeodeSaveFileActivityResult.SaveFileParams>
|
||||||
|
private lateinit var requestPermissionLauncher: ActivityResultLauncher<String>
|
||||||
|
private lateinit var internalRequestPermissionsLauncher: ActivityResultLauncher<Array<String>>
|
||||||
|
private lateinit var internalRequestAllFilesLauncher: ActivityResultLauncher<Intent>
|
||||||
|
|
||||||
|
private var afterRequestPermissions: (() -> Unit)? = null
|
||||||
|
private var afterRequestPermissionsFailure: (() -> Unit)? = null
|
||||||
|
|
||||||
fun setContext(activity: AppCompatActivity) {
|
fun setContext(activity: AppCompatActivity) {
|
||||||
this.activity = WeakReference(activity)
|
this.activity = WeakReference(activity)
|
||||||
fileResultLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
openFileResultLauncher = activity.registerForActivityResult(GeodeOpenFileActivityResult()) { uri ->
|
||||||
if (result.resultCode == Activity.RESULT_OK) {
|
|
||||||
val data = result.data
|
|
||||||
if (data != null) {
|
|
||||||
val uri = data.data
|
|
||||||
if (uri != null) {
|
if (uri != null) {
|
||||||
val path = getRealPathFromURI(activity, uri)
|
val path = FileUtils.getRealPathFromURI(activity, uri)
|
||||||
if (path != null) {
|
if (path != null) {
|
||||||
selectFileCallback(path)
|
selectFileCallback(path)
|
||||||
return@registerForActivityResult
|
return@registerForActivityResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Toast.makeText(activity, R.string.file_select_error, Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(activity, R.string.no_file_selected, Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
failedCallback()
|
||||||
}
|
}
|
||||||
|
openDirectoryResultLauncher = activity.registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) {
|
||||||
|
if (it != null) {
|
||||||
|
val path = FileUtils.getRealPathFromURI(activity, it)
|
||||||
|
if (path != null) {
|
||||||
|
selectFileCallback(path)
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.makeText(activity, R.string.file_select_error, Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(activity, R.string.no_file_selected, Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
}
|
}
|
||||||
failedCallback()
|
failedCallback()
|
||||||
}
|
}
|
||||||
filesResultLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
openFilesResultLauncher = activity.registerForActivityResult(GeodeOpenFilesActivityResult()) { result ->
|
||||||
if (result.resultCode == Activity.RESULT_OK) {
|
if (result.isEmpty()) {
|
||||||
val data = result.data
|
Toast.makeText(activity, R.string.no_file_selected, Toast.LENGTH_SHORT)
|
||||||
if (data != null) {
|
.show()
|
||||||
val clipData = data.clipData
|
|
||||||
if (clipData != null) {
|
failedCallback()
|
||||||
val paths = Array(clipData.itemCount) { "" }
|
return@registerForActivityResult
|
||||||
for (i in 0 until clipData.itemCount) {
|
}
|
||||||
val path = getRealPathFromURI(activity, clipData.getItemAt(i).uri)
|
val paths: Array<String> = Array(result.size) {"n = $it"}
|
||||||
|
for (i in result.indices) {
|
||||||
|
val path = FileUtils.getRealPathFromURI(activity, result[i])
|
||||||
if (path != null) {
|
if (path != null) {
|
||||||
paths[i] = path
|
paths[i] = path
|
||||||
}
|
}
|
||||||
@@ -64,24 +98,62 @@ object GeodeUtils {
|
|||||||
selectFilesCallback(paths)
|
selectFilesCallback(paths)
|
||||||
return@registerForActivityResult
|
return@registerForActivityResult
|
||||||
}
|
}
|
||||||
|
saveFileResultLauncher = activity.registerForActivityResult(GeodeSaveFileActivityResult()) { uri ->
|
||||||
|
if (uri != null) {
|
||||||
|
val path = FileUtils.getRealPathFromURI(activity, uri)
|
||||||
|
if (path != null) {
|
||||||
|
selectFileCallback(path)
|
||||||
|
return@registerForActivityResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Toast.makeText(activity, R.string.file_select_error, Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(activity, R.string.no_file_selected, Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
}
|
}
|
||||||
failedCallback()
|
failedCallback()
|
||||||
}
|
}
|
||||||
|
requestPermissionLauncher = activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
|
||||||
|
permissionCallback(isGranted)
|
||||||
|
}
|
||||||
|
|
||||||
|
internalRequestPermissionsLauncher = activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
|
||||||
|
val isGranted = permissions.values.all { it }
|
||||||
|
if (isGranted) {
|
||||||
|
afterRequestPermissions?.invoke()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(activity, R.string.missing_permissions, Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
|
|
||||||
|
afterRequestPermissionsFailure?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// only necessary on newer android versions
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
internalRequestAllFilesLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { _ ->
|
||||||
|
if (Environment.isExternalStorageManager()) {
|
||||||
|
afterRequestPermissions?.invoke()
|
||||||
|
} else {
|
||||||
|
Toast.makeText(activity, R.string.missing_permissions, Toast.LENGTH_SHORT)
|
||||||
|
.show()
|
||||||
|
|
||||||
|
afterRequestPermissionsFailure?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun getLogcatCrashBuffer(): String {
|
fun getLogcatCrashBuffer(): String {
|
||||||
try {
|
return try {
|
||||||
val logcatProcess = Runtime.getRuntime().exec("logcat -v brief -b crash -d")
|
val logcatProcess = Runtime.getRuntime().exec("logcat -v brief -b crash -d")
|
||||||
|
|
||||||
val logcatOutput = logcatProcess.inputStream.bufferedReader().readText()
|
logcatProcess.inputStream.bufferedReader().readText()
|
||||||
|
} catch (e: Exception) {
|
||||||
return logcatOutput
|
|
||||||
}
|
|
||||||
catch (e: Exception) {
|
|
||||||
Log.e("Geode", "Failed to get logcat crash buffer", e)
|
Log.e("Geode", "Failed to get logcat crash buffer", e)
|
||||||
return ""
|
""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,287 +183,318 @@ object GeodeUtils {
|
|||||||
activity.get()?.run {
|
activity.get()?.run {
|
||||||
packageManager.getLaunchIntentForPackage(packageName)?.also {
|
packageManager.getLaunchIntentForPackage(packageName)?.also {
|
||||||
val mainIntent = Intent.makeRestartActivityTask(it.component)
|
val mainIntent = Intent.makeRestartActivityTask(it.component)
|
||||||
mainIntent.putExtra("restarted", true);
|
mainIntent.putExtra("restarted", true)
|
||||||
startActivity(mainIntent)
|
startActivity(mainIntent)
|
||||||
exitProcess(0)
|
exitProcess(0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO As of now this is unused
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun openFolder(path: String): Boolean {
|
fun openFolder(path: String): Boolean {
|
||||||
activity.get()?.run {
|
val context = activity.get()!!
|
||||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
|
||||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
|
||||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
|
||||||
DocumentFile.fromFile(File(path)).also {
|
|
||||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, it.uri)
|
|
||||||
}
|
|
||||||
intent.setType("*/*")
|
|
||||||
|
|
||||||
if (intent.resolveActivity(packageManager) != null) {
|
val pathFile = File(path)
|
||||||
startActivity(intent)
|
val baseDirectory = LaunchUtils.getBaseDirectory(context)
|
||||||
return true
|
val isInternalPath = pathFile.startsWith(baseDirectory)
|
||||||
|
|
||||||
|
val intent = if (isInternalPath) {
|
||||||
|
// TODO: figure out how to get this to point to the path it should be pointing at
|
||||||
|
// (the best i got was pointing at a file)
|
||||||
|
// val relativePath = pathFile.relativeTo(baseDirectory)
|
||||||
|
|
||||||
|
Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
data = DocumentsContract.buildRootUri(
|
||||||
|
"${context.packageName}.user", UserDirectoryProvider.ROOT
|
||||||
|
)
|
||||||
|
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||||
|
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
|
||||||
|
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION or
|
||||||
|
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
return false
|
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||||
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
addCategory(Intent.CATEGORY_OPENABLE)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val documentFile = DocumentFile.fromFile(File(path))
|
||||||
|
putExtra(DocumentsContract.EXTRA_INITIAL_URI, documentFile.uri)
|
||||||
}
|
}
|
||||||
|
|
||||||
external fun selectFileCallback(path: String)
|
type = "*/*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
external fun selectFilesCallback(paths: Array<String>)
|
return try {
|
||||||
|
context.startActivity(intent)
|
||||||
|
true
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
external fun failedCallback()
|
private external fun selectFileCallback(path: String)
|
||||||
|
|
||||||
|
private external fun selectFilesCallback(paths: Array<String>)
|
||||||
|
|
||||||
|
private external fun failedCallback()
|
||||||
|
|
||||||
|
private fun checkForFilePermissions(onSuccess: () -> Unit, onFailure: () -> Unit) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
if (Environment.isExternalStorageManager()) {
|
||||||
|
onSuccess()
|
||||||
|
} else {
|
||||||
|
val intent = Intent(
|
||||||
|
ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
|
||||||
|
Uri.fromParts("package", BuildConfig.APPLICATION_ID, null)
|
||||||
|
)
|
||||||
|
|
||||||
|
internalRequestAllFilesLauncher.launch(intent)
|
||||||
|
afterRequestPermissions = onSuccess
|
||||||
|
afterRequestPermissionsFailure = onFailure
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val permissions = listOf(
|
||||||
|
Manifest.permission.READ_EXTERNAL_STORAGE,
|
||||||
|
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||||
|
)
|
||||||
|
|
||||||
|
val context = activity.get()!!
|
||||||
|
|
||||||
|
val needsPermissions = permissions.filter {
|
||||||
|
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsPermissions.isNotEmpty()) {
|
||||||
|
internalRequestPermissionsLauncher.launch(needsPermissions.toTypedArray())
|
||||||
|
afterRequestPermissions = onSuccess
|
||||||
|
afterRequestPermissionsFailure = onFailure
|
||||||
|
} else {
|
||||||
|
onSuccess()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun selectFile(path: String): Boolean {
|
fun selectFile(path: String): Boolean {
|
||||||
activity.get()?.run {
|
var uri: Uri?
|
||||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
|
||||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
|
||||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
|
||||||
DocumentFile.fromFile(File(path)).also {
|
DocumentFile.fromFile(File(path)).also {
|
||||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, it.uri)
|
uri = it.uri
|
||||||
}
|
}
|
||||||
intent.setType("*/*")
|
|
||||||
|
|
||||||
if (intent.resolveActivity(packageManager) != null) {
|
return try {
|
||||||
print("Geode Selectfile")
|
checkForFilePermissions(
|
||||||
fileResultLauncher.launch(intent)
|
onSuccess = {
|
||||||
print("Geode launched ")
|
openFileResultLauncher.launch(GeodeOpenFileActivityResult.OpenFileParams(arrayOf("*/*"), uri))
|
||||||
return true
|
},
|
||||||
|
onFailure = { failedCallback() }
|
||||||
|
)
|
||||||
|
|
||||||
|
true
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun selectFiles(path: String): Boolean {
|
fun selectFiles(path: String): Boolean {
|
||||||
activity.get()?.run {
|
var uri: Uri?
|
||||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
|
||||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
|
||||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
|
||||||
DocumentFile.fromFile(File(path)).also {
|
DocumentFile.fromFile(File(path)).also {
|
||||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, it.uri)
|
uri = it.uri
|
||||||
}
|
}
|
||||||
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
|
||||||
intent.setType("*/*")
|
|
||||||
|
|
||||||
if (intent.resolveActivity(packageManager) != null) {
|
return try {
|
||||||
filesResultLauncher.launch(intent)
|
checkForFilePermissions(
|
||||||
return true
|
onSuccess = {
|
||||||
|
openFilesResultLauncher.launch(GeodeOpenFilesActivityResult.OpenFileParams(arrayOf("*/*"), uri))
|
||||||
|
},
|
||||||
|
onFailure = { failedCallback() }
|
||||||
|
)
|
||||||
|
|
||||||
|
true
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun selectFolder(path: String): Boolean {
|
fun selectFolder(path: String): Boolean {
|
||||||
activity.get()?.run {
|
var uri: Uri?
|
||||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
|
||||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
|
||||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
|
||||||
DocumentFile.fromFile(File(path)).also {
|
DocumentFile.fromFile(File(path)).also {
|
||||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, it.uri)
|
uri = it.uri
|
||||||
}
|
}
|
||||||
intent.setType("*/*")
|
|
||||||
|
|
||||||
if (intent.resolveActivity(packageManager) != null) {
|
return try {
|
||||||
fileResultLauncher.launch(intent)
|
checkForFilePermissions(
|
||||||
return true
|
onSuccess = {
|
||||||
|
openDirectoryResultLauncher.launch(uri)
|
||||||
|
},
|
||||||
|
onFailure = { failedCallback() }
|
||||||
|
)
|
||||||
|
|
||||||
|
true
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun createFile(path: String): Boolean {
|
fun createFile(path: String): Boolean {
|
||||||
activity.get()?.run {
|
val initialPath = File(path)
|
||||||
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
|
|
||||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
|
||||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
|
||||||
DocumentFile.fromFile(File(path)).also {
|
|
||||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, it.uri)
|
|
||||||
}
|
|
||||||
intent.setType("*/*")
|
|
||||||
|
|
||||||
if (intent.resolveActivity(packageManager) != null) {
|
return try {
|
||||||
fileResultLauncher.launch(intent)
|
checkForFilePermissions(
|
||||||
return true
|
onSuccess = {
|
||||||
|
saveFileResultLauncher.launch(GeodeSaveFileActivityResult.SaveFileParams(null, initialPath))
|
||||||
|
},
|
||||||
|
onFailure = { failedCallback() }
|
||||||
|
)
|
||||||
|
|
||||||
|
true
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun getBaseDirectory(): String {
|
fun getBaseDirectory(): String {
|
||||||
val activity = activity.get()!!
|
val activity = activity.get()!!
|
||||||
return LaunchUtils.getBaseDirectory(activity).path
|
return LaunchUtils.getBaseDirectory(activity).canonicalPath
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getInternalDirectory(): String {
|
||||||
|
val activity = activity.get()!!
|
||||||
|
return activity.filesDir.canonicalPath
|
||||||
}
|
}
|
||||||
|
|
||||||
private val gameVersionMap = mapOf(
|
private val gameVersionMap = mapOf(
|
||||||
37L to "2.200"
|
37L to "2.200",
|
||||||
|
38L to "2.205"
|
||||||
)
|
)
|
||||||
|
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun getGameVersion(): String {
|
fun getGameVersion(): String {
|
||||||
// these versions should be aligned to windows releases, not what android says
|
// these versions should be aligned to windows releases, not what android says
|
||||||
activity.get()?.run {
|
activity.get()?.run {
|
||||||
val versionCode = LaunchUtils.getGeometryDashVersionCode(packageManager)
|
return GamePackageUtils.getUnifiedVersionName(packageManager)
|
||||||
return gameVersionMap[versionCode] ?: LaunchUtils.getGeometryDashVersionString(packageManager)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// copied from https://stackoverflow.com/questions/17546101/get-real-path-for-uri-android
|
|
||||||
// i am actually very lazy to move this to a separate class
|
|
||||||
|
|
||||||
fun getRealPathFromURI(context: Context, uri: Uri): String? {
|
|
||||||
when {
|
|
||||||
// DocumentProvider
|
|
||||||
DocumentsContract.isDocumentUri(context, uri) -> {
|
|
||||||
when {
|
|
||||||
// ExternalStorageProvider
|
|
||||||
isExternalStorageDocument(uri) -> {
|
|
||||||
val docId = DocumentsContract.getDocumentId(uri)
|
|
||||||
val split = docId.split(":").toTypedArray()
|
|
||||||
val type = split[0]
|
|
||||||
// This is for checking Main Memory
|
|
||||||
return if ("primary".equals(type, ignoreCase = true)) {
|
|
||||||
if (split.size > 1) {
|
|
||||||
Environment.getExternalStorageDirectory().toString() + "/" + split[1]
|
|
||||||
} else {
|
|
||||||
Environment.getExternalStorageDirectory().toString() + "/"
|
|
||||||
}
|
|
||||||
// This is for checking SD Card
|
|
||||||
} else {
|
|
||||||
"storage" + "/" + docId.replace(":", "/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isDownloadsDocument(uri) -> {
|
|
||||||
val fileName = getFilePath(context, uri)
|
|
||||||
if (fileName != null) {
|
|
||||||
return Environment.getExternalStorageDirectory().toString() + "/Download/" + fileName
|
|
||||||
}
|
|
||||||
var id = DocumentsContract.getDocumentId(uri)
|
|
||||||
if (id.startsWith("raw:")) {
|
|
||||||
id = id.replaceFirst("raw:".toRegex(), "")
|
|
||||||
val file = File(id)
|
|
||||||
if (file.exists()) return id
|
|
||||||
}
|
|
||||||
val contentUri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), java.lang.Long.valueOf(id))
|
|
||||||
return getDataColumn(context, contentUri, null, null)
|
|
||||||
}
|
|
||||||
isMediaDocument(uri) -> {
|
|
||||||
val docId = DocumentsContract.getDocumentId(uri)
|
|
||||||
val split = docId.split(":").toTypedArray()
|
|
||||||
val type = split[0]
|
|
||||||
var contentUri: Uri? = null
|
|
||||||
when (type) {
|
|
||||||
"image" -> {
|
|
||||||
contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
|
||||||
}
|
|
||||||
"video" -> {
|
|
||||||
contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
|
||||||
}
|
|
||||||
"audio" -> {
|
|
||||||
contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val selection = "_id=?"
|
|
||||||
val selectionArgs = arrayOf(split[1])
|
|
||||||
return getDataColumn(context, contentUri, selection, selectionArgs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: add the geode provider
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"content".equals(uri.scheme, ignoreCase = true) -> {
|
|
||||||
// Return the remote address
|
|
||||||
if (isGooglePhotosUri(uri)) return uri.lastPathSegment
|
|
||||||
return getDataColumn(context, uri, null, null)
|
|
||||||
}
|
|
||||||
"file".equals(uri.scheme, ignoreCase = true) -> {
|
|
||||||
return uri.path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getDataColumn(context: Context, uri: Uri?, selection: String?,
|
|
||||||
selectionArgs: Array<String>?): String? {
|
|
||||||
var cursor: Cursor? = null
|
|
||||||
val column = "_data"
|
|
||||||
val projection = arrayOf(
|
|
||||||
column
|
|
||||||
)
|
|
||||||
try {
|
|
||||||
if (uri == null) return null
|
|
||||||
cursor = context.contentResolver.query(uri, projection, selection, selectionArgs,
|
|
||||||
null)
|
|
||||||
if (cursor != null && cursor.moveToFirst()) {
|
|
||||||
val index = cursor.getColumnIndexOrThrow(column)
|
|
||||||
return cursor.getString(index)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
cursor?.close()
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fun getFilePath(context: Context, uri: Uri?): String? {
|
|
||||||
var cursor: Cursor? = null
|
|
||||||
val projection = arrayOf(
|
|
||||||
MediaStore.MediaColumns.DISPLAY_NAME
|
|
||||||
)
|
|
||||||
try {
|
|
||||||
if (uri == null) return null
|
|
||||||
cursor = context.contentResolver.query(uri, projection, null, null,
|
|
||||||
null)
|
|
||||||
if (cursor != null && cursor.moveToFirst()) {
|
|
||||||
val index = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)
|
|
||||||
return cursor.getString(index)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
cursor?.close()
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param uri The Uri to check.
|
|
||||||
* @return Whether the Uri authority is ExternalStorageProvider.
|
|
||||||
*/
|
|
||||||
fun isExternalStorageDocument(uri: Uri): Boolean {
|
|
||||||
return "com.android.externalstorage.documents" == uri.authority
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param uri The Uri to check.
|
|
||||||
* @return Whether the Uri authority is DownloadsProvider.
|
|
||||||
*/
|
|
||||||
fun isDownloadsDocument(uri: Uri): Boolean {
|
|
||||||
return "com.android.providers.downloads.documents" == uri.authority
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param uri The Uri to check.
|
|
||||||
* @return Whether the Uri authority is MediaProvider.
|
|
||||||
*/
|
|
||||||
fun isMediaDocument(uri: Uri): Boolean {
|
|
||||||
return "com.android.providers.media.documents" == uri.authority
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param uri The Uri to check.
|
|
||||||
* @return Whether the Uri authority is Google Photos.
|
|
||||||
*/
|
|
||||||
fun isGooglePhotosUri(uri: Uri): Boolean {
|
|
||||||
return "com.google.android.apps.photos.content" == uri.authority
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isGeodeUri(uri: Uri): Boolean {
|
fun isGeodeUri(uri: Uri): Boolean {
|
||||||
return "com.geode.launcher.user" == uri.authority
|
return "com.geode.launcher.user" == uri.authority
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val INTERNAL_PERMISSION_PREFIX = "geode.permission_internal"
|
||||||
|
private const val MANAGE_ALL_FILES = "${INTERNAL_PERMISSION_PREFIX}.MANAGE_ALL_FILES"
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getPermissionStatus(permission: String): Boolean {
|
||||||
|
val context = activity.get() ?: return false
|
||||||
|
|
||||||
|
return when (permission) {
|
||||||
|
MANAGE_ALL_FILES -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
Environment.isExternalStorageManager()
|
||||||
|
} else {
|
||||||
|
val permissions = listOf(
|
||||||
|
Manifest.permission.READ_EXTERNAL_STORAGE,
|
||||||
|
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||||
|
)
|
||||||
|
|
||||||
|
return permissions.all {
|
||||||
|
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun requestPermission(permission: String) {
|
||||||
|
if (permission == MANAGE_ALL_FILES) {
|
||||||
|
// this function handles already having perms for us
|
||||||
|
checkForFilePermissions(onSuccess = {
|
||||||
|
permissionCallback(true)
|
||||||
|
}, onFailure = {
|
||||||
|
permissionCallback(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getPermissionStatus(permission)) {
|
||||||
|
permissionCallback(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
requestPermissionLauncher.launch(permission)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
permissionCallback(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private external fun permissionCallback(granted: Boolean)
|
||||||
|
|
||||||
|
const val ARGUMENT_SAFE_MODE = "--geode:safe-mode"
|
||||||
|
|
||||||
|
private var additionalLaunchArguments = arrayListOf<String>()
|
||||||
|
fun setAdditionalLaunchArguments(vararg args: String) {
|
||||||
|
additionalLaunchArguments.addAll(args)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearLaunchArguments() = setAdditionalLaunchArguments()
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getLaunchArguments(): String? {
|
||||||
|
activity.get()?.apply {
|
||||||
|
val preferences = PreferenceUtils.get(this)
|
||||||
|
|
||||||
|
val userArgs = preferences.getString(PreferenceUtils.Key.LAUNCH_ARGUMENTS)
|
||||||
|
val args = if (!userArgs.isNullOrEmpty()) {
|
||||||
|
listOf(userArgs) + additionalLaunchArguments
|
||||||
|
} else additionalLaunchArguments
|
||||||
|
|
||||||
|
return args.joinToString(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CapabilityListener {
|
||||||
|
fun onCapabilityAdded(capability: String): Boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const val CAPABILITY_EXTENDED_INPUT = "extended_input"
|
||||||
|
const val CAPABILITY_TIMESTAMP_INPUT = "timestamp_inputs"
|
||||||
|
|
||||||
|
private var capabilityListener: WeakReference<CapabilityListener?> = WeakReference(null)
|
||||||
|
|
||||||
|
fun setCapabilityListener(listener: CapabilityListener) {
|
||||||
|
capabilityListener = WeakReference(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun reportPlatformCapability(capability: String?): Boolean {
|
||||||
|
if (capability.isNullOrEmpty()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return capabilityListener.get()?.onCapabilityAdded(capability) ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
external fun nativeKeyUp(keyCode: Int, modifiers: Int)
|
||||||
|
external fun nativeKeyDown(keyCode: Int, modifiers: Int, isRepeating: Boolean)
|
||||||
|
external fun nativeActionScroll(scrollX: Float, scrollY: Float)
|
||||||
|
external fun resizeSurface(width: Int, height: Int)
|
||||||
|
|
||||||
|
// represents the timestamp of the next input callback, in nanoseconds (most events don't send it, but it's there)
|
||||||
|
external fun setNextInputTimestamp(timestamp: Long)
|
||||||
}
|
}
|
||||||
@@ -1,91 +1,18 @@
|
|||||||
package com.geode.launcher.utils
|
package com.geode.launcher.utils
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.pm.PackageInfo
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.content.res.AssetManager
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.ApplicationInfo
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
object LaunchUtils {
|
object LaunchUtils {
|
||||||
enum class FailureReason {
|
|
||||||
NOT_FOUND, ABI_MISMATCH, UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isGeometryDashInstalled(packageManager: PackageManager): Boolean {
|
|
||||||
return try {
|
|
||||||
packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
|
||||||
true
|
|
||||||
} catch (e: PackageManager.NameNotFoundException) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getGeometryDashVersionCode(packageManager: PackageManager): Long {
|
|
||||||
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
|
||||||
|
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
||||||
game.longVersionCode
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
game.versionCode.toLong()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getGeometryDashVersionString(packageManager: PackageManager): String {
|
|
||||||
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
|
||||||
return game.versionName
|
|
||||||
}
|
|
||||||
|
|
||||||
fun diagnoseLoadErrors(context: Context, packageInfo: PackageInfo): FailureReason {
|
|
||||||
val abi = applicationArchitecture
|
|
||||||
val isExtracted =
|
|
||||||
packageInfo.applicationInfo.flags and ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS == ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS
|
|
||||||
val isSplit = (packageInfo.applicationInfo.splitSourceDirs?.size ?: 0) > 1
|
|
||||||
|
|
||||||
val metadata =
|
|
||||||
"Geometry Dash metadata:\nSplit sources: $isSplit\nExtracted libraries: $isExtracted\nLauncher architecture: $abi"
|
|
||||||
Log.i("GeodeLauncher", metadata)
|
|
||||||
|
|
||||||
// determine if it can find gd libraries for the opposite architecture
|
|
||||||
val gdBinaryName = "lib${Constants.COCOS_LIB_NAME}.so"
|
|
||||||
val oppositeArchitecture = if (abi == "arm64-v8a") "armeabi-v7a" else "arm64-v8a"
|
|
||||||
|
|
||||||
if (packageInfo.applicationInfo.nativeLibraryDir.contains(oppositeArchitecture)) {
|
|
||||||
return FailureReason.ABI_MISMATCH
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isExtracted) {
|
|
||||||
try {
|
|
||||||
context.assets.openNonAssetFd("lib/$oppositeArchitecture/$gdBinaryName")
|
|
||||||
return FailureReason.ABI_MISMATCH
|
|
||||||
} catch (_: Exception) {
|
|
||||||
// this is good, actually!
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// try fetching for its own libraries (only for uncompressed libraries)
|
|
||||||
if (!isExtracted) {
|
|
||||||
try {
|
|
||||||
context.assets.openNonAssetFd("lib/$abi/$gdBinaryName")
|
|
||||||
} catch (_: Exception) {
|
|
||||||
return FailureReason.NOT_FOUND
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return FailureReason.UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
// supposedly CPU_ABI returns the current arch for the running application
|
// supposedly CPU_ABI returns the current arch for the running application
|
||||||
// despite being deprecated, this is also one of the few ways to get this information
|
// despite being deprecated, this is also one of the few ways to get this information
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
val applicationArchitecture: String = Build.CPU_ABI
|
val applicationArchitecture: String = Build.CPU_ABI
|
||||||
|
|
||||||
val platformName: String = if (applicationArchitecture == "arm64-v8a")
|
val is64bit = applicationArchitecture == "arm64-v8a"
|
||||||
"android64" else "android32"
|
|
||||||
|
val platformName: String = if (is64bit) "android64" else "android32"
|
||||||
|
|
||||||
val geodeFilename: String = "Geode.$platformName.so"
|
val geodeFilename: String = "Geode.$platformName.so"
|
||||||
|
|
||||||
@@ -137,16 +64,47 @@ object LaunchUtils {
|
|||||||
return File(getBaseDirectory(context), "save")
|
return File(getBaseDirectory(context), "save")
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("DiscouragedPrivateApi")
|
private const val CRASH_INDICATOR_NAME = "lastSessionDidCrash"
|
||||||
fun addAssetsFromPackage(assetManager: AssetManager, packageInfo: PackageInfo) {
|
|
||||||
// this method is officially marked as deprecated but it is the only method we are allowed to reflect
|
|
||||||
// (the source recommends replacing with AssetManager.setApkAssets(ApkAssets[], boolean) lol)
|
|
||||||
val clazz = assetManager.javaClass
|
|
||||||
val aspMethod = clazz.getDeclaredMethod("addAssetPath", String::class.java)
|
|
||||||
|
|
||||||
aspMethod.invoke(assetManager, packageInfo.applicationInfo.sourceDir)
|
private fun getCrashDirectory(context: Context): File {
|
||||||
packageInfo.applicationInfo.splitSourceDirs?.forEach {
|
val base = getBaseDirectory(context)
|
||||||
aspMethod.invoke(assetManager, it)
|
return File(base, "game/geode/crashlogs/")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLastCrash(context: Context): File? {
|
||||||
|
val crashDirectory = getCrashDirectory(context)
|
||||||
|
if (!crashDirectory.exists()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val children = crashDirectory.listFiles {
|
||||||
|
// ignore indicator files (including old file)
|
||||||
|
_, name -> name != CRASH_INDICATOR_NAME && name != "last-pid"
|
||||||
|
}
|
||||||
|
|
||||||
|
return children?.maxByOrNull { it.lastModified() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun lastSessionCrashed(context: Context): Boolean {
|
||||||
|
val base = getCrashDirectory(context)
|
||||||
|
val crashIndicatorFile = File(base, CRASH_INDICATOR_NAME)
|
||||||
|
|
||||||
|
return crashIndicatorFile.exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class LauncherError {
|
||||||
|
LINKER_NEEDS_64BIT,
|
||||||
|
LINKER_NEEDS_32BIT,
|
||||||
|
LINKER_FAILED,
|
||||||
|
GENERIC,
|
||||||
|
CRASHED;
|
||||||
|
|
||||||
|
fun isAbiFailure(): Boolean {
|
||||||
|
return this == LINKER_NEEDS_32BIT || this == LINKER_NEEDS_64BIT
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const val LAUNCHER_KEY_RETURN_ERROR = "return_error"
|
||||||
|
const val LAUNCHER_KEY_RETURN_MESSAGE = "return_message"
|
||||||
|
const val LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE = "return_extended_message"
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@ import androidx.core.content.edit
|
|||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import com.geode.launcher.BuildConfig
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extension object for SharedPreferences to add better key safety and default values.
|
* Extension object for SharedPreferences to add better key safety and default values.
|
||||||
@@ -23,36 +24,69 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun useBooleanPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<Boolean> {
|
fun useBooleanPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<Boolean> {
|
||||||
return usePreference(preferenceKey, lifecycleOwner) { p, k ->
|
return usePreference(
|
||||||
p.getBoolean(k)
|
preferenceKey,
|
||||||
}
|
lifecycleOwner,
|
||||||
|
preferenceGet = { p, k -> p.getBoolean(k) },
|
||||||
|
preferenceSet = { p, k, v -> p.setBoolean(k, v) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun useStringPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<String?> {
|
fun useStringPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<String?> {
|
||||||
return usePreference(preferenceKey, lifecycleOwner) { p, k ->
|
return usePreference(
|
||||||
p.getString(k)
|
preferenceKey,
|
||||||
}
|
lifecycleOwner,
|
||||||
|
preferenceGet = { p, k -> p.getString(k) },
|
||||||
|
preferenceSet = { p, k, v -> p.setString(k, v) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun useIntPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<Int> {
|
fun useIntPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<Int> {
|
||||||
return usePreference(preferenceKey, lifecycleOwner) { p, k ->
|
return usePreference(
|
||||||
p.getInt(k)
|
preferenceKey,
|
||||||
}
|
lifecycleOwner,
|
||||||
|
preferenceGet = { p, k -> p.getInt(k) },
|
||||||
|
preferenceSet = { p, k, v -> p.setInt(k, v) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun <T> usePreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, preferenceGet: (PreferenceUtils, Key) -> T): MutableState<T> {
|
fun useLongPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<Long> {
|
||||||
|
return usePreference(
|
||||||
|
preferenceKey,
|
||||||
|
lifecycleOwner,
|
||||||
|
preferenceGet = { p, k -> p.getLong(k) },
|
||||||
|
preferenceSet = { p, k, v -> p.setLong(k, v) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun <T> usePreference(
|
||||||
|
preferenceKey: Key,
|
||||||
|
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
|
||||||
|
preferenceGet: (PreferenceUtils, Key) -> T,
|
||||||
|
preferenceSet: (PreferenceUtils, Key, T) -> Unit
|
||||||
|
): MutableState<T> {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val sharedPreferences = context.getSharedPreferences(FILE_KEY, Context.MODE_PRIVATE)
|
val sharedPreferences = context.getSharedPreferences(FILE_KEY, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
val preferences = get(sharedPreferences)
|
val preferences = get(sharedPreferences)
|
||||||
|
|
||||||
val preferenceValue = remember {
|
val preferenceValue = remember {
|
||||||
mutableStateOf(
|
val state = mutableStateOf(preferenceGet(preferences, preferenceKey))
|
||||||
preferenceGet(preferences, preferenceKey)
|
object : MutableState<T> by state {
|
||||||
)
|
override var value: T
|
||||||
|
get() = state.value
|
||||||
|
set(value) {
|
||||||
|
// ignore setting the same thing (messes up default preferences)
|
||||||
|
if (state.value == value) { return }
|
||||||
|
state.value = value
|
||||||
|
|
||||||
|
preferenceSet(preferences, preferenceKey, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener {
|
val listener = SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
@@ -89,7 +123,6 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum class Key {
|
enum class Key {
|
||||||
LOAD_TESTING,
|
|
||||||
LOAD_AUTOMATICALLY,
|
LOAD_AUTOMATICALLY,
|
||||||
UPDATE_AUTOMATICALLY,
|
UPDATE_AUTOMATICALLY,
|
||||||
RELEASE_CHANNEL,
|
RELEASE_CHANNEL,
|
||||||
@@ -98,18 +131,35 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
|||||||
THEME,
|
THEME,
|
||||||
BLACK_BACKGROUND,
|
BLACK_BACKGROUND,
|
||||||
CURRENT_RELEASE_MODIFIED,
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun defaultValueForBooleanKey(key: Key): Boolean {
|
private fun defaultValueForBooleanKey(key: Key): Boolean {
|
||||||
return when (key) {
|
return when (key) {
|
||||||
Key.UPDATE_AUTOMATICALLY -> true
|
Key.UPDATE_AUTOMATICALLY, Key.FORCE_HRR -> true
|
||||||
|
Key.DEVELOPER_MODE -> BuildConfig.DEBUG
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun defaultValueForIntKey(key: Key) = when (key) {
|
||||||
|
Key.DISPLAY_MODE -> if (this.getBoolean(Key.LIMIT_ASPECT_RATIO)) 1 else 0
|
||||||
|
// people wanted a reset on nightly anyways, so this is a good excuse to do so
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
|
||||||
private fun keyToName(key: Key): String {
|
private fun keyToName(key: Key): String {
|
||||||
return when (key) {
|
return when (key) {
|
||||||
Key.LOAD_TESTING -> "PreferenceLoadTesting"
|
|
||||||
Key.LOAD_AUTOMATICALLY -> "PreferenceLoadAutomatically"
|
Key.LOAD_AUTOMATICALLY -> "PreferenceLoadAutomatically"
|
||||||
Key.UPDATE_AUTOMATICALLY -> "PreferenceUpdateAutomatically"
|
Key.UPDATE_AUTOMATICALLY -> "PreferenceUpdateAutomatically"
|
||||||
Key.RELEASE_CHANNEL -> "PreferenceReleaseChannel"
|
Key.RELEASE_CHANNEL -> "PreferenceReleaseChannel"
|
||||||
@@ -117,7 +167,18 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
|||||||
Key.CURRENT_VERSION_TIMESTAMP -> "PreferenceCurrentVersionDescriptor"
|
Key.CURRENT_VERSION_TIMESTAMP -> "PreferenceCurrentVersionDescriptor"
|
||||||
Key.THEME -> "PreferenceTheme"
|
Key.THEME -> "PreferenceTheme"
|
||||||
Key.BLACK_BACKGROUND -> "PreferenceBlackBackground"
|
Key.BLACK_BACKGROUND -> "PreferenceBlackBackground"
|
||||||
Key.CURRENT_RELEASE_MODIFIED -> "PreferenceReleaseModified"
|
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +189,13 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
|||||||
return sharedPreferences.getBoolean(keyName, defaultValue)
|
return sharedPreferences.getBoolean(keyName, defaultValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setBoolean(key: Key, value: Boolean) {
|
||||||
|
val keyName = keyToName(key)
|
||||||
|
sharedPreferences.edit {
|
||||||
|
putBoolean(keyName, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun toggleBoolean(key: Key): Boolean {
|
fun toggleBoolean(key: Key): Boolean {
|
||||||
val currentValue = getBoolean(key)
|
val currentValue = getBoolean(key)
|
||||||
val keyName = keyToName(key)
|
val keyName = keyToName(key)
|
||||||
@@ -144,7 +212,7 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
|||||||
return sharedPreferences.getString(keyName, null)
|
return sharedPreferences.getString(keyName, null)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setString(key: Key, value: String) {
|
fun setString(key: Key, value: String?) {
|
||||||
val keyName = keyToName(key)
|
val keyName = keyToName(key)
|
||||||
sharedPreferences.edit {
|
sharedPreferences.edit {
|
||||||
putString(keyName, value)
|
putString(keyName, value)
|
||||||
@@ -165,7 +233,7 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
|||||||
|
|
||||||
fun getInt(key: Key): Int {
|
fun getInt(key: Key): Int {
|
||||||
val keyName = keyToName(key)
|
val keyName = keyToName(key)
|
||||||
return sharedPreferences.getInt(keyName, 0)
|
return sharedPreferences.getInt(keyName, defaultValueForIntKey(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setInt(key: Key, value: Int) {
|
fun setInt(key: Key, value: Int) {
|
||||||
|
|||||||
@@ -2,17 +2,22 @@ package org.cocos2dx.lib
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.opengl.GLSurfaceView
|
import android.opengl.GLSurfaceView
|
||||||
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.os.Message
|
import android.os.Message
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
|
import android.view.Surface
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
import com.customRobTop.BaseRobTopActivity
|
import com.customRobTop.BaseRobTopActivity
|
||||||
|
import com.geode.launcher.utils.GeodeUtils
|
||||||
|
|
||||||
private const val HANDLER_OPEN_IME_KEYBOARD = 2
|
private const val HANDLER_OPEN_IME_KEYBOARD = 2
|
||||||
private const val HANDLER_CLOSE_IME_KEYBOARD = 3
|
private const val HANDLER_CLOSE_IME_KEYBOARD = 3
|
||||||
|
private const val MS_TO_NS = 1_000_000
|
||||||
|
|
||||||
class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||||
companion object {
|
companion object {
|
||||||
@@ -37,6 +42,13 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private lateinit var cocos2dxRenderer: Cocos2dxRenderer
|
private lateinit var cocos2dxRenderer: Cocos2dxRenderer
|
||||||
|
var useKeyboardEvents = false
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
cocos2dxRenderer.sendResizeEvents = value
|
||||||
|
}
|
||||||
|
|
||||||
|
var sendTimestampEvents = false
|
||||||
|
|
||||||
var cocos2dxEditText: Cocos2dxEditText? = null
|
var cocos2dxEditText: Cocos2dxEditText? = null
|
||||||
set(value) {
|
set(value) {
|
||||||
@@ -103,6 +115,15 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
|
fun updateRefreshRate() {
|
||||||
|
val maxRefreshRate = display.supportedModes.maxBy { it.refreshRate }
|
||||||
|
holder.surface.setFrameRate(
|
||||||
|
maxRefreshRate.refreshRate,
|
||||||
|
Surface.FRAME_RATE_COMPATIBILITY_DEFAULT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
queueEvent { cocos2dxRenderer.handleOnPause() }
|
queueEvent { cocos2dxRenderer.handleOnPause() }
|
||||||
super.onPause()
|
super.onPause()
|
||||||
@@ -118,6 +139,9 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
val ids = IntArray(pointerNumber)
|
val ids = IntArray(pointerNumber)
|
||||||
val xs = FloatArray(pointerNumber)
|
val xs = FloatArray(pointerNumber)
|
||||||
val ys = FloatArray(pointerNumber)
|
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
|
||||||
|
|
||||||
for (i in 0 until pointerNumber) {
|
for (i in 0 until pointerNumber) {
|
||||||
ids[i] = motionEvent.getPointerId(i)
|
ids[i] = motionEvent.getPointerId(i)
|
||||||
xs[i] = motionEvent.getX(i)
|
xs[i] = motionEvent.getX(i)
|
||||||
@@ -129,11 +153,10 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
val xDown = xs[0]
|
val xDown = xs[0]
|
||||||
val f = ys[0]
|
val f = ys[0]
|
||||||
queueEvent {
|
queueEvent {
|
||||||
cocos2dxRenderer.handleActionDown(
|
if (sendTimestampEvents)
|
||||||
idDown,
|
GeodeUtils.setNextInputTimestamp(timestamp)
|
||||||
xDown,
|
|
||||||
f
|
cocos2dxRenderer.handleActionDown(idDown, xDown, f)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -141,26 +164,29 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
val idUp = motionEvent.getPointerId(0)
|
val idUp = motionEvent.getPointerId(0)
|
||||||
val f2 = xs[0]
|
val f2 = xs[0]
|
||||||
val f3 = ys[0]
|
val f3 = ys[0]
|
||||||
queueEvent { cocos2dxRenderer.handleActionUp(idUp, f2, f3) }
|
queueEvent {
|
||||||
|
if (sendTimestampEvents)
|
||||||
|
GeodeUtils.setNextInputTimestamp(timestamp)
|
||||||
|
|
||||||
|
cocos2dxRenderer.handleActionUp(idUp, f2, f3)
|
||||||
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
MotionEvent.ACTION_MOVE -> {
|
MotionEvent.ACTION_MOVE -> {
|
||||||
queueEvent {
|
queueEvent {
|
||||||
cocos2dxRenderer.handleActionMove(
|
if (sendTimestampEvents)
|
||||||
ids,
|
GeodeUtils.setNextInputTimestamp(timestamp)
|
||||||
xs,
|
|
||||||
ys
|
cocos2dxRenderer.handleActionMove(ids, xs, ys)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
MotionEvent.ACTION_CANCEL -> {
|
MotionEvent.ACTION_CANCEL -> {
|
||||||
queueEvent {
|
queueEvent {
|
||||||
cocos2dxRenderer.handleActionCancel(
|
if (sendTimestampEvents)
|
||||||
ids,
|
GeodeUtils.setNextInputTimestamp(timestamp)
|
||||||
xs,
|
|
||||||
ys
|
cocos2dxRenderer.handleActionCancel(ids, xs, ys)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -170,11 +196,10 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
val xPointerDown = motionEvent.getX(indexPointerDown)
|
val xPointerDown = motionEvent.getX(indexPointerDown)
|
||||||
val y = motionEvent.getY(indexPointerDown)
|
val y = motionEvent.getY(indexPointerDown)
|
||||||
queueEvent {
|
queueEvent {
|
||||||
cocos2dxRenderer.handleActionDown(
|
if (sendTimestampEvents)
|
||||||
idPointerDown,
|
GeodeUtils.setNextInputTimestamp(timestamp)
|
||||||
xPointerDown,
|
|
||||||
y
|
cocos2dxRenderer.handleActionDown(idPointerDown, xPointerDown, y)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -184,11 +209,10 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
val xPointerUp = motionEvent.getX(indexPointUp)
|
val xPointerUp = motionEvent.getX(indexPointUp)
|
||||||
val y2 = motionEvent.getY(indexPointUp)
|
val y2 = motionEvent.getY(indexPointUp)
|
||||||
queueEvent {
|
queueEvent {
|
||||||
cocos2dxRenderer.handleActionUp(
|
if (sendTimestampEvents)
|
||||||
idPointerUp,
|
GeodeUtils.setNextInputTimestamp(timestamp)
|
||||||
xPointerUp,
|
|
||||||
y2
|
cocos2dxRenderer.handleActionUp(idPointerUp, xPointerUp, y2)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -207,7 +231,7 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onKeyDown(keyCode: Int, keyEvent: KeyEvent): Boolean {
|
private fun legacyKeyDown(keyCode: Int, keyEvent: KeyEvent): Boolean {
|
||||||
return when (keyCode) {
|
return when (keyCode) {
|
||||||
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_MENU -> {
|
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_MENU -> {
|
||||||
if (keyEvent.repeatCount != 0 || BaseRobTopActivity.blockBackButton) {
|
if (keyEvent.repeatCount != 0 || BaseRobTopActivity.blockBackButton) {
|
||||||
@@ -220,6 +244,82 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||||
|
if (!useKeyboardEvents) {
|
||||||
|
return legacyKeyDown(keyCode, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (keyCode) {
|
||||||
|
// ignore system keys
|
||||||
|
KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_MUTE -> {
|
||||||
|
super.onKeyDown(keyCode, event)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
if (BaseRobTopActivity.blockBackButton) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
queueEvent {
|
||||||
|
if (sendTimestampEvents)
|
||||||
|
GeodeUtils.setNextInputTimestamp(event.eventTime * MS_TO_NS)
|
||||||
|
|
||||||
|
GeodeUtils.nativeKeyDown(keyCode, event.modifiers, event.repeatCount != 0)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
|
||||||
|
if (!useKeyboardEvents) {
|
||||||
|
return super.onKeyUp(keyCode, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (keyCode) {
|
||||||
|
KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_MUTE -> {
|
||||||
|
super.onKeyUp(keyCode, event)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
if (event.repeatCount != 0 || BaseRobTopActivity.blockBackButton) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
queueEvent {
|
||||||
|
if (sendTimestampEvents)
|
||||||
|
GeodeUtils.setNextInputTimestamp(event.eventTime * MS_TO_NS)
|
||||||
|
|
||||||
|
GeodeUtils.nativeKeyUp(keyCode, event.modifiers)
|
||||||
|
}
|
||||||
|
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onGenericMotionEvent(event: MotionEvent?): Boolean {
|
||||||
|
if (!useKeyboardEvents) {
|
||||||
|
return super.onGenericMotionEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event?.action == MotionEvent.ACTION_SCROLL) {
|
||||||
|
val scrollX = event.getAxisValue(MotionEvent.AXIS_HSCROLL)
|
||||||
|
val scrollY = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
|
||||||
|
val timestamp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||||
|
event.eventTimeNanos else event.eventTime * MS_TO_NS
|
||||||
|
|
||||||
|
queueEvent {
|
||||||
|
if (sendTimestampEvents)
|
||||||
|
GeodeUtils.setNextInputTimestamp(timestamp)
|
||||||
|
|
||||||
|
GeodeUtils.nativeActionScroll(scrollX, scrollY)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.onGenericMotionEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
fun setCocos2dxRenderer(renderer: Cocos2dxRenderer) {
|
fun setCocos2dxRenderer(renderer: Cocos2dxRenderer) {
|
||||||
this.cocos2dxRenderer = renderer
|
this.cocos2dxRenderer = renderer
|
||||||
setRenderer(this.cocos2dxRenderer)
|
setRenderer(this.cocos2dxRenderer)
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
package org.cocos2dx.lib
|
package org.cocos2dx.lib
|
||||||
|
|
||||||
import android.opengl.GLSurfaceView
|
import android.opengl.GLSurfaceView
|
||||||
|
import android.os.Build
|
||||||
import javax.microedition.khronos.egl.EGLConfig
|
import javax.microedition.khronos.egl.EGLConfig
|
||||||
import javax.microedition.khronos.opengles.GL10
|
import javax.microedition.khronos.opengles.GL10
|
||||||
|
|
||||||
@Suppress("unused", "KotlinJniMissingFunction")
|
@Suppress("unused", "KotlinJniMissingFunction")
|
||||||
class Cocos2dxRenderer : GLSurfaceView.Renderer {
|
class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceView.Renderer {
|
||||||
companion object {
|
companion object {
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun setAnimationInterval(@Suppress("UNUSED_PARAMETER") animationInterval: Double) {
|
fun setAnimationInterval(@Suppress("UNUSED_PARAMETER") animationInterval: Double) {
|
||||||
@@ -59,6 +60,8 @@ class Cocos2dxRenderer : GLSurfaceView.Renderer {
|
|||||||
private var lastTickInNanoSeconds: Long = 0
|
private var lastTickInNanoSeconds: Long = 0
|
||||||
private var screenWidth = 0
|
private var screenWidth = 0
|
||||||
private var screenHeight = 0
|
private var screenHeight = 0
|
||||||
|
var sendResizeEvents = false
|
||||||
|
var setFrameRate = false
|
||||||
|
|
||||||
fun setScreenWidthAndHeight(surfaceWidth: Int, surfaceHeight: Int) {
|
fun setScreenWidthAndHeight(surfaceWidth: Int, surfaceHeight: Int) {
|
||||||
screenWidth = surfaceWidth
|
screenWidth = surfaceWidth
|
||||||
@@ -70,7 +73,11 @@ class Cocos2dxRenderer : GLSurfaceView.Renderer {
|
|||||||
lastTickInNanoSeconds = System.nanoTime()
|
lastTickInNanoSeconds = System.nanoTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSurfaceChanged(gl10: GL10?, width: Int, height: Int) {}
|
override fun onSurfaceChanged(gl10: GL10?, width: Int, height: Int) {
|
||||||
|
if (setFrameRate && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
handler.updateRefreshRate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onDrawFrame(gl: GL10?) {
|
override fun onDrawFrame(gl: GL10?) {
|
||||||
nativeRender()
|
nativeRender()
|
||||||
|
|||||||
@@ -11,18 +11,24 @@ import java.io.IOException
|
|||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
class MediaCodec {
|
class MediaCodec {
|
||||||
private var channelCount = 0
|
// these variables require getX functions for ndk access
|
||||||
|
var channelCount = 0
|
||||||
|
private set
|
||||||
|
|
||||||
var mCodecPtr = 0L
|
var length = 0L
|
||||||
|
private set
|
||||||
|
|
||||||
|
var sampleRate = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var mCodecPtr = 0L
|
||||||
private var mCurrentOutputBufferIndex = -1
|
private var mCurrentOutputBufferIndex = -1
|
||||||
private var mDecoder: MediaCodec? = null
|
private var mDecoder: MediaCodec? = null
|
||||||
private var mExtractor: MediaExtractor? = null
|
private var mExtractor: MediaExtractor? = null
|
||||||
private var mInputBuffers: Array<ByteBuffer>? = null
|
private var mInputBuffers: Array<ByteBuffer>? = null
|
||||||
private var mInputFinished = false
|
private var mInputFinished = false
|
||||||
private var length = 0L
|
|
||||||
private var mOutputBuffers: Array<ByteBuffer>? = null
|
private var mOutputBuffers: Array<ByteBuffer>? = null
|
||||||
private var mOutputFinished = false
|
private var mOutputFinished = false
|
||||||
private var sampleRate = 0
|
|
||||||
|
|
||||||
fun init(codecPtr: Long): Boolean {
|
fun init(codecPtr: Long): Boolean {
|
||||||
mCodecPtr = codecPtr
|
mCodecPtr = codecPtr
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<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>
|
||||||
|
<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>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M480,760Q546,760 593,713Q640,666 640,600L640,440Q640,374 593,327Q546,280 480,280Q414,280 367,327Q320,374 320,440L320,600Q320,666 367,713Q414,760 480,760ZM400,640L560,640L560,560L400,560L400,640ZM400,480L560,480L560,400L400,400L400,480ZM480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520ZM480,840Q415,840 359.5,808Q304,776 272,720L160,720L160,640L244,640Q241,620 240.5,600Q240,580 240,560L160,560L160,480L240,480Q240,460 240.5,440Q241,420 244,400L160,400L160,320L272,320Q286,297 303.5,277Q321,257 344,242L280,176L336,120L422,206Q450,197 479,197Q508,197 536,206L624,120L680,176L614,242Q637,257 655.5,276.5Q674,296 688,320L800,320L800,400L716,400Q719,420 719.5,440Q720,460 720,480L800,480L800,560L720,560Q720,580 719.5,600Q719,620 716,640L800,640L800,720L688,720Q656,776 600.5,808Q545,840 480,840Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:autoMirrored="true">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M360,720Q327,720 303.5,696.5Q280,673 280,640L280,160Q280,127 303.5,103.5Q327,80 360,80L720,80Q753,80 776.5,103.5Q800,127 800,160L800,640Q800,673 776.5,696.5Q753,720 720,720L360,720ZM360,640L720,640Q720,640 720,640Q720,640 720,640L720,160Q720,160 720,160Q720,160 720,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640ZM200,880Q167,880 143.5,856.5Q120,833 120,800L120,240L200,240L200,800Q200,800 200,800Q200,800 200,800L640,800L640,880L200,880ZM360,640Q360,640 360,640Q360,640 360,640L360,160Q360,160 360,160Q360,160 360,160L360,160Q360,160 360,160Q360,160 360,160L360,640Q360,640 360,640Q360,640 360,640L360,640Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M560,800L560,720L680,720Q697,720 708.5,708.5Q720,697 720,680L720,600Q720,562 742,531Q764,500 800,487L800,473Q764,460 742,429Q720,398 720,360L720,280Q720,263 708.5,251.5Q697,240 680,240L560,240L560,160L680,160Q730,160 765,195Q800,230 800,280L800,360Q800,377 811.5,388.5Q823,400 840,400L880,400L880,560L840,560Q823,560 811.5,571.5Q800,583 800,600L800,680Q800,730 765,765Q730,800 680,800L560,800ZM280,800Q230,800 195,765Q160,730 160,680L160,600Q160,583 148.5,571.5Q137,560 120,560L80,560L80,400L120,400Q137,400 148.5,388.5Q160,377 160,360L160,280Q160,230 195,195Q230,160 280,160L400,160L400,240L280,240Q263,240 251.5,251.5Q240,263 240,280L240,360Q240,398 218,429Q196,460 160,473L160,487Q196,500 218,531Q240,562 240,600L240,680Q240,697 251.5,708.5Q263,720 280,720L400,720L400,800L280,800Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M320,720L640,720L640,640L320,640L320,720ZM320,560L640,560L640,480L320,480L320,560ZM240,880Q207,880 183.5,856.5Q160,833 160,800L160,160Q160,127 183.5,103.5Q207,80 240,80L560,80L800,320L800,800Q800,833 776.5,856.5Q753,880 720,880L240,880ZM520,360L520,160L240,160Q240,160 240,160Q240,160 240,160L240,800Q240,800 240,800Q240,800 240,800L720,800Q720,800 720,800Q720,800 720,800L720,360L520,360ZM240,160L240,160L240,360L240,360L240,160L240,360L240,360L240,800Q240,800 240,800Q240,800 240,800L240,800Q240,800 240,800Q240,800 240,800L240,160Q240,160 240,160Q240,160 240,160Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M400,720L400,640L560,640L560,720L400,720ZM240,520L240,440L720,440L720,520L240,520ZM120,320L120,240L840,240L840,320L120,320Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M424,640Q424,559 438.5,523.5Q453,488 500,446Q541,410 562.5,383.5Q584,357 584,323Q584,282 556.5,255Q529,228 480,228Q429,228 402.5,259Q376,290 365,322L262,278Q283,214 339,167Q395,120 480,120Q585,120 641.5,178.5Q698,237 698,319Q698,369 676.5,404.5Q655,440 609,485Q560,532 549.5,556.5Q539,581 539,640L424,640ZM480,880Q447,880 423.5,856.5Q400,833 400,800Q400,767 423.5,743.5Q447,720 480,720Q513,720 536.5,743.5Q560,767 560,800Q560,833 536.5,856.5Q513,880 480,880Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M240,720L240,240L320,240L320,720L240,720ZM400,720L800,480L400,240L400,720ZM480,579L480,381L645,480L480,579ZM480,480L480,480L480,480Z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M840,280L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L680,120L840,280ZM760,314L646,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L760,760Q760,760 760,760Q760,760 760,760L760,314ZM480,720Q530,720 565,685Q600,650 600,600Q600,550 565,515Q530,480 480,480Q430,480 395,515Q360,550 360,600Q360,650 395,685Q430,720 480,720ZM240,400L600,400L600,240L240,240L240,400ZM200,314L200,760Q200,760 200,760Q200,760 200,760L200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200L200,200L200,314Z"/>
|
||||||
|
</vector>
|
||||||
@@ -7,12 +7,18 @@
|
|||||||
<string name="launcher_launch">Launch</string>
|
<string name="launcher_launch">Launch</string>
|
||||||
<string name="launcher_settings">Settings</string>
|
<string name="launcher_settings">Settings</string>
|
||||||
<string name="launcher_download">Download</string>
|
<string name="launcher_download">Download</string>
|
||||||
|
<string name="launcher_install">Install</string>
|
||||||
<string name="launcher_title">Geode</string>
|
<string name="launcher_title">Geode</string>
|
||||||
<string name="launcher_logo_alt">geode logo</string>
|
<string name="launcher_logo_alt">geode logo</string>
|
||||||
<string name="launcher_launch_icon_alt">play icon</string>
|
<string name="launcher_launch_icon_alt">play icon</string>
|
||||||
<string name="launcher_download_icon_alt">download icon</string>
|
<string name="launcher_download_icon_alt">download icon</string>
|
||||||
|
<string name="launcher_download_game">Download Game</string>
|
||||||
|
<string name="launcher_download_32bit">Download the 32-bit version of launcher.</string>
|
||||||
|
<string name="launcher_download_universal">Download the non 32-bit version of launcher.</string>
|
||||||
<string name="launcher_settings_icon_alt">settings icon</string>
|
<string name="launcher_settings_icon_alt">settings icon</string>
|
||||||
|
<string name="launcher_retry_icon_alt">retry icon</string>
|
||||||
<string name="geode_download_title">Geode is not installed.</string>
|
<string name="geode_download_title">Geode is not installed.</string>
|
||||||
|
<string name="game_outdated">A Geometry Dash update is required.\nYou currently have update %1$s.</string>
|
||||||
<string name="geode_download_description">Geode is downloading. Please wait.</string>
|
<string name="geode_download_description">Geode is downloading. Please wait.</string>
|
||||||
<plurals name="automatically_load_countdown">
|
<plurals name="automatically_load_countdown">
|
||||||
<item quantity="one">Automatically launching game in %1$d second.</item>
|
<item quantity="one">Automatically launching game in %1$d second.</item>
|
||||||
@@ -21,9 +27,45 @@
|
|||||||
<string name="launcher_warning_icon_alt">warning</string>
|
<string name="launcher_warning_icon_alt">warning</string>
|
||||||
<string name="launcher_error_icon_alt">error</string>
|
<string name="launcher_error_icon_alt">error</string>
|
||||||
<string name="launcher_unsupported_version_title">Unsupported version</string>
|
<string name="launcher_unsupported_version_title">Unsupported version</string>
|
||||||
<string name="launcher_unsupported_version_description">
|
<string name="launcher_outdated_version_description">
|
||||||
The current version of Geometry Dash is not compatible with the version supported by the launcher (%2$s).\nLaunch will continue, but crashes may occur!
|
The current version of Geometry Dash is not compatible with the version supported by the launcher (%2$s). You must update Geometry Dash to fix this.\nLaunch will continue, but crashes may occur!
|
||||||
</string>
|
</string>
|
||||||
|
<string name="launcher_unsupported_version_description">
|
||||||
|
The current version of Geometry Dash is not compatible with the version supported by the launcher. A launcher update is required to fix this.\nLaunch will continue, but crashes may occur!
|
||||||
|
</string>
|
||||||
|
<string name="launcher_update_available">A new launcher update is available!</string>
|
||||||
|
<string name="launcher_update_dismiss">Dismiss</string>
|
||||||
|
<string name="safe_mode_enable_title">Enable safe mode?</string>
|
||||||
|
<string name="safe_mode_enable_description">Safe mode disables loading of all mods, allowing one to remove problematic mods or diagnose crashes. If you\'re not experiencing issues with Geode, you should probably not be using safe mode.</string>
|
||||||
|
<string name="launcher_failed_to_load">Geode failed to load.</string>
|
||||||
|
<string name="launcher_crashed">Geode crashed.</string>
|
||||||
|
<string name="launcher_error_more">Help</string>
|
||||||
|
<string name="launcher_error_details">Error details</string>
|
||||||
|
<string name="launcher_error_view_logs">View logs</string>
|
||||||
|
<string name="launcher_error_copy_error_alt">Copy</string>
|
||||||
|
<string name="launcher_error_copy">Copy details</string>
|
||||||
|
<string name="launcher_error_export_crash">Share crash</string>
|
||||||
|
<string name="launcher_error_export_missing">No crash dump was found!</string>
|
||||||
|
<string name="launcher_error_device_info">Android %2$s, %1$s\nGeometry Dash %4$s with Geode %3$s</string>
|
||||||
|
|
||||||
|
<string name="launcher_notification_settings">Launcher Settings</string>
|
||||||
|
<string name="launcher_notification_update_failed">Failed to check for updates.</string>
|
||||||
|
<string name="launcher_notification_update_success">Geode updated!</string>
|
||||||
|
<string name="launcher_notification_update_cta">Tap to install.</string>
|
||||||
|
|
||||||
|
<string name="launcher_starting_game">Starting Geometry Dash…</string>
|
||||||
|
<string name="launcher_downloading_update">Downloading update…</string>
|
||||||
|
<string name="launcher_downloading_update_details">(%1$s/%2$s)</string>
|
||||||
|
<string name="launcher_cancelled_manual">Launch cancelled.</string>
|
||||||
|
<string name="launcher_cancelled_navigated_away">Launch paused.</string>
|
||||||
|
<string name="launcher_cancelled_crash">Launch paused due to a crash.</string>
|
||||||
|
<string name="launcher_cancelled_error">Could not start Geode.</string>
|
||||||
|
<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>
|
||||||
|
<string name="launcher_cancelled_retry">Retry</string>
|
||||||
|
<string name="launcher_cancelled_help_alt">Help</string>
|
||||||
|
<string name="launcher_in_safe_mode">Starting in safe mode…</string>
|
||||||
|
|
||||||
<!-- Updater strings -->
|
<!-- Updater strings -->
|
||||||
<string name="release_fetch_failed">Update failed.\n%1$s</string>
|
<string name="release_fetch_failed">Update failed.\n%1$s</string>
|
||||||
@@ -36,29 +78,44 @@
|
|||||||
<string name="release_fetch_cancelled">Update cancelled.</string>
|
<string name="release_fetch_cancelled">Update cancelled.</string>
|
||||||
<string name="release_fetch_button_retry">Retry</string>
|
<string name="release_fetch_button_retry">Retry</string>
|
||||||
<string name="release_fetch_button_cancel">Cancel</string>
|
<string name="release_fetch_button_cancel">Cancel</string>
|
||||||
|
<string name="release_fetch_try_later">Please try again later.</string>
|
||||||
<string name="release_fetch_manual_check_required">Externally managed library in use, please manually check for updates if you want to overwrite it.</string>
|
<string name="release_fetch_manual_check_required">Externally managed library in use, please manually check for updates if you want to overwrite it.</string>
|
||||||
|
|
||||||
<!-- GeometryDashActivity strings -->
|
<!-- GeometryDashActivity strings -->
|
||||||
<string name="load_failed_link_error">Failed to link libraries</string>
|
<string name="load_failed_link_error">Failed to link libraries</string>
|
||||||
<string name="load_failed_link_error_description">Game libraries could not be found. This may occur if the architecture of the launcher and Geometry Dash do not match, or from a system bug.\nReported issue: %1$s.\nCheck application logs for more details.</string>
|
<string name="load_failed_link_error_description">Game libraries could not be found. Try to:</string>
|
||||||
<string name="load_failed_generic_error">Load failed</string>
|
<string name="load_failed_abi_error">The architecture of the launcher and Geometry Dash do not match. Try to:</string>
|
||||||
<string name="load_failed_generic_error_description">Uncaught error during game load: `%1$s`.\nCheck application logs for more details. Please report this issue!</string>
|
<string name="load_failed_generic_error">Failed to load Geode</string>
|
||||||
|
<string name="load_failed_generic_error_description">Uncaught error during game load. Try to:</string>
|
||||||
|
<string name="load_failed_crashed">Geode crashed</string>
|
||||||
|
<string name="load_failed_crashed_description">A crash was reported during the last session. Try to:</string>
|
||||||
|
<string name="load_failed_recommendation_reinstall">Reinstall Geometry Dash.</string>
|
||||||
|
<string name="load_failed_recommendation_switch_32bit_abi">Install the 32-bit version of the launcher.</string>
|
||||||
|
<string name="load_failed_recommendation_switch_64bit_abi">Install the non 32-bit version of the launcher.</string>
|
||||||
|
<string name="load_failed_recommendation_update">Verify that the latest versions of the launcher and Geometry Dash are installed.</string>
|
||||||
|
<string name="load_failed_recommendation_report">Report this issue to the developers.</string>
|
||||||
|
<string name="load_failed_recommendation_safe_mode">Hold the Launch button to launch in safe mode.</string>
|
||||||
|
|
||||||
<string name="message_box_cancel">Cancel</string>
|
<string name="message_box_cancel">Cancel</string>
|
||||||
<string name="message_box_accept">OK</string>
|
<string name="message_box_accept">OK</string>
|
||||||
|
<string name="message_box_continue">Continue</string>
|
||||||
|
<string name="missing_permissions">Missing required permissions for this action!</string>
|
||||||
|
<string name="no_file_selected">File selection cancelled.</string>
|
||||||
|
<string name="file_select_error">Failed to get file.</string>
|
||||||
|
|
||||||
<!-- SettingsActivity strings -->
|
<!-- SettingsActivity strings -->
|
||||||
<string name="title_activity_settings">Settings</string>
|
<string name="title_activity_settings">Settings</string>
|
||||||
<string name="settings_back_icon_alt">back arrow icon</string>
|
<string name="back_icon_alt">back</string>
|
||||||
|
|
||||||
<string name="export_folder_tag">Launcher External Folder</string>
|
<string name="export_folder_tag">Launcher External Folder</string>
|
||||||
<string name="export_folder_copied">Copied!</string>
|
<string name="text_copied">Copied to clipboard.</string>
|
||||||
|
|
||||||
<string name="preference_category_testing">General</string>
|
<string name="preference_category_testing">General</string>
|
||||||
<string name="preference_load_testing_name">Load files from /test</string>
|
|
||||||
<string name="preference_load_automatically_name">Automatically launch</string>
|
<string name="preference_load_automatically_name">Automatically launch</string>
|
||||||
<string name="preference_load_automatically_description">Launches the game after a short delay.</string>
|
<string name="preference_load_automatically_description">Launches the game after a short delay.</string>
|
||||||
<string name="preferences_copy_external_button">Copy external files directory</string>
|
<string name="preferences_copy_external_button">Copy external files directory</string>
|
||||||
<string name="preferences_open_file_manager">Open file manager</string>
|
<string name="preferences_open_file_manager">Open file manager</string>
|
||||||
|
<string name="preferences_view_logs">View application logs</string>
|
||||||
|
|
||||||
<string name="preference_theme_name">Theme</string>
|
<string name="preference_theme_name">Theme</string>
|
||||||
<string name="preference_theme_select">Select theme</string>
|
<string name="preference_theme_select">Select theme</string>
|
||||||
@@ -66,17 +123,64 @@
|
|||||||
<string name="preference_theme_light">Light</string>
|
<string name="preference_theme_light">Light</string>
|
||||||
<string name="preference_theme_dark">Dark</string>
|
<string name="preference_theme_dark">Dark</string>
|
||||||
<string name="preference_black_background_name">Black background in dark theme</string>
|
<string name="preference_black_background_name">Black background in dark theme</string>
|
||||||
|
<string name="preference_force_hrr">Force maximum refresh rate</string>
|
||||||
|
<string name="preference_enable_redesign">✨ Enable UI refresh</string>
|
||||||
|
|
||||||
|
<string name="preference_display_mode_name">Screen scale</string>
|
||||||
|
<string name="preference_display_mode_select">Select screen scale</string>
|
||||||
|
<string name="preference_display_mode_legacy">Limited (16:9)</string>
|
||||||
|
<string name="preference_display_mode_fullscreen">Edge-to-edge</string>
|
||||||
|
<string name="preference_display_mode_default">Default</string>
|
||||||
|
|
||||||
<string name="preference_category_updater">Updates</string>
|
<string name="preference_category_updater">Updates</string>
|
||||||
<string name="preference_update_automatically_name">Enable automatic updates</string>
|
<string name="preference_update_automatically_name">Enable automatic updates</string>
|
||||||
<string name="preference_release_channel_name">Use nightly release channel</string>
|
<string name="preference_release_channel_name">Use nightly release channel</string>
|
||||||
|
|
||||||
|
<string name="preference_category_gameplay">Gameplay</string>
|
||||||
|
|
||||||
|
<string name="preference_release_channel_tag_name">Release channel</string>
|
||||||
|
<string name="preference_release_channel_select">Select release channel</string>
|
||||||
|
<string name="preference_release_channel_stable">Stable</string>
|
||||||
|
<string name="preference_release_channel_beta">Pre-release</string>
|
||||||
|
<string name="preference_release_channel_nightly">Nightly</string>
|
||||||
|
|
||||||
<string name="preference_check_for_updates_button">Check for updates</string>
|
<string name="preference_check_for_updates_button">Check for updates</string>
|
||||||
<string name="preference_check_for_updates_description">Current version: %1$s</string>
|
<string name="preference_check_for_updates_description">Current loader version: %1$s</string>
|
||||||
|
<string name="preference_launcher_version">Geode Launcher v%1$s</string>
|
||||||
<string name="preference_check_for_updates_failed">Update failed. See logs for more details.</string>
|
<string name="preference_check_for_updates_failed">Update failed. See logs for more details.</string>
|
||||||
<string name="preference_check_for_updates_none_found">No updates found.</string>
|
<string name="preference_check_for_updates_none_found">No updates found.</string>
|
||||||
<string name="preference_check_for_updates_success">Geode has been updated!</string>
|
<string name="preference_check_for_updates_success">Geode has been updated!</string>
|
||||||
<string name="preference_check_for_updates_downloading">Downloading update…</string>
|
<string name="preference_check_for_updates_downloading">Downloading update…</string>
|
||||||
<string name="preference_check_for_updates_no_internet">No internet found.</string>
|
<string name="preference_check_for_updates_no_internet">No internet found.</string>
|
||||||
<string name="preference_check_for_updates_already_updating">An update is already in progress!</string>
|
<string name="preference_check_for_updates_already_updating">An update is already in progress!</string>
|
||||||
|
|
||||||
|
<string name="preference_category_developer">Advanced</string>
|
||||||
|
<string name="preference_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_open_developer_options">Open developer options</string>
|
||||||
|
|
||||||
|
<!-- ApplicationLogsActivity strings -->
|
||||||
|
<string name="title_activity_application_logs">Application Logs</string>
|
||||||
|
<string name="application_logs_no_logs">No logs available.</string>
|
||||||
|
<string name="application_logs_delete">Clear All</string>
|
||||||
|
<string name="application_logs_share">Share</string>
|
||||||
|
<string name="application_log_copy_label">Log Message</string>
|
||||||
|
<string name="application_logs_more">More</string>
|
||||||
|
<string name="application_logs_export">Save To</string>
|
||||||
|
<string name="application_logs_level">Set Log Level</string>
|
||||||
|
<string name="application_logs_level_dialog_name">Set minimum log level</string>
|
||||||
|
<string name="application_logs_export_completed">Log export complete.</string>
|
||||||
|
<string name="application_logs_default_filename" translatable="false">launcher_logcat.txt</string>
|
||||||
|
<string name="application_logs_crash_only">Show Crash Buffer</string>
|
||||||
|
<string name="application_logs_crash_only_enabled_alt">Enabled</string>
|
||||||
|
|
||||||
|
<string name="title_activity_developer_settings">Developer Settings</string>
|
||||||
|
<string name="preference_developer_mode">Enable developer options</string>
|
||||||
|
<string name="preference_developer_mode_about">Developer mode enables some options that may make the game unstable. Please do not modify them unless you know what you\'re doing!\nThese options may change behavior, or be removed entirely in future updates.</string>
|
||||||
|
<string name="preference_developer_update_notice">Enabling developer mode automatically disallows the updater from overwriting custom loader builds.</string>
|
||||||
|
|
||||||
|
<string name="preference_override_exceptions_name">Custom exceptions fix symbols</string>
|
||||||
|
<string name="preference_override_exceptions_description">Uses exception_symbols.txt to patch</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
|
|
||||||
<style name="Theme.NoActionBar.Fullscreen" parent="@style/Theme.AppCompat.Light.NoActionBar">
|
<style name="Theme.NoActionBar.Fullscreen" parent="@style/Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<item name="android:windowNoTitle">true</item>
|
<item name="android:windowNoTitle">true</item>
|
||||||
<item name="android:windowActionBar">false</item>
|
<item name="android:windowActionBar">false</item>
|
||||||
<item name="android:windowFullscreen">true</item>
|
<item name="android:windowFullscreen">true</item>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<game-mode-config
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
tools:targetApi="33"
|
||||||
|
android:supportsBatteryGameMode="false"
|
||||||
|
android:supportsPerformanceGameMode="false"
|
||||||
|
android:allowGameFpsOverride="false" />
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
buildscript {
|
|
||||||
ext {
|
|
||||||
compose_version = '1.5.4'
|
|
||||||
compose_compiler = '1.5.7'
|
|
||||||
}
|
|
||||||
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
|
||||||
plugins {
|
|
||||||
id 'com.android.application' version '8.2.1' apply false
|
|
||||||
id 'com.android.library' version '8.2.1' apply false
|
|
||||||
id 'org.jetbrains.kotlin.android' version '1.9.21' apply false
|
|
||||||
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.21' apply false
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.register('clean', Delete) {
|
|
||||||
delete rootProject.buildDir
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
buildscript {
|
||||||
|
val composeBOM by extra("2024.06.00")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.5.0" apply false
|
||||||
|
id("com.android.library") version "8.5.0" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.0.0" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.0" apply false
|
||||||
|
id("org.jetbrains.kotlin.plugin.compose") version "2.0.0" apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register<Delete>("clean") {
|
||||||
|
delete(rootProject.layout.buildDirectory)
|
||||||
|
}
|
||||||
@@ -21,3 +21,5 @@ kotlin.code.style=official
|
|||||||
# resources declared in the library itself and none from the library's dependencies,
|
# resources declared in the library itself and none from the library's dependencies,
|
||||||
# thereby reducing the size of the R class for that library
|
# thereby reducing the size of the R class for that library
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
|
# nice optimization
|
||||||
|
org.gradle.configuration-cache=true
|
||||||
Vendored
BIN
Binary file not shown.
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
|||||||
case $MAX_FD in #(
|
case $MAX_FD in #(
|
||||||
max*)
|
max*)
|
||||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
# shellcheck disable=SC3045
|
# shellcheck disable=SC2039,SC3045
|
||||||
MAX_FD=$( ulimit -H -n ) ||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
warn "Could not query maximum file descriptor limit"
|
warn "Could not query maximum file descriptor limit"
|
||||||
esac
|
esac
|
||||||
@@ -153,7 +153,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
|||||||
'' | soft) :;; #(
|
'' | soft) :;; #(
|
||||||
*)
|
*)
|
||||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
# shellcheck disable=SC3045
|
# shellcheck disable=SC2039,SC3045
|
||||||
ulimit -n "$MAX_FD" ||
|
ulimit -n "$MAX_FD" ||
|
||||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
esac
|
esac
|
||||||
@@ -202,11 +202,11 @@ fi
|
|||||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
# Collect all arguments for the java command;
|
# Collect all arguments for the java command:
|
||||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
# shell script including quotes and variable substitutions, so put them in
|
# and any embedded shellness will be escaped.
|
||||||
# double quotes to make sure that they get re-expanded; and
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
# * put everything else in single quotes, so that it's not re-expanded.
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
set -- \
|
set -- \
|
||||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
|||||||
Vendored
+10
-10
@@ -43,11 +43,11 @@ set JAVA_EXE=java.exe
|
|||||||
%JAVA_EXE% -version >NUL 2>&1
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
if %ERRORLEVEL% equ 0 goto execute
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
echo.
|
echo. 1>&2
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
echo.
|
echo. 1>&2
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
echo location of your Java installation.
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
goto fail
|
goto fail
|
||||||
|
|
||||||
@@ -57,11 +57,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
|||||||
|
|
||||||
if exist "%JAVA_EXE%" goto execute
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
echo.
|
echo. 1>&2
|
||||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
echo.
|
echo. 1>&2
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
echo location of your Java installation.
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
goto fail
|
goto fail
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ pluginManagement {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("UnstableApiUsage")
|
||||||
dependencyResolutionManagement {
|
dependencyResolutionManagement {
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
repositories {
|
repositories {
|
||||||
@@ -12,5 +14,6 @@ dependencyResolutionManagement {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "launcher"
|
rootProject.name = "launcher"
|
||||||
include ':app'
|
include(":app")
|
||||||
Reference in new issue
Block a user