mirror of
https://github.com/geode-sdk/android-launcher.git
synced 2026-10-22 08:42:38 +00:00
Compare commits
266
Commits
1.0.0
...
web-portal
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
208819436a | ||
|
|
d7c9e82fc2 | ||
|
|
8448b3f2a9 | ||
|
|
3a6c731710 | ||
|
|
2dde48c8fc | ||
|
|
f52b90b259 | ||
|
|
fa69a649dd | ||
|
|
a16c76cd06 | ||
|
|
9b5d614d1a | ||
|
|
d6a6de8361 | ||
|
|
2797e52683 | ||
|
|
69b00fecf5 | ||
|
|
f0eddd1652 | ||
|
|
d7190fa928 | ||
|
|
8e8a56da2e | ||
|
|
b418e154da | ||
|
|
cd306a5b5e | ||
|
|
fb9a6d1912 | ||
|
|
e276e302ab | ||
|
|
d7c779fce3 | ||
|
|
1e0dcc91ec | ||
|
|
266055e6d5 | ||
|
|
ab8e3f83d2 | ||
|
|
a75d68e5e0 | ||
|
|
13191dc66f | ||
|
|
b7f378b947 | ||
|
|
25367d5650 | ||
|
|
aa51c201e5 | ||
|
|
0b92f4a318 | ||
|
|
7d75825d01 | ||
|
|
c77271f45c | ||
|
|
09bb28d66f | ||
|
|
157952b6f7 | ||
|
|
352ff63ab0 | ||
|
|
91fc804dee | ||
|
|
87808b56b3 | ||
|
|
2c2884c3b8 | ||
|
|
74e91e7dc1 | ||
|
|
e3ffbf311e | ||
|
|
5ad77bca57 | ||
|
|
3be0127e04 | ||
|
|
458c837aba | ||
|
|
67ca7707d8 | ||
|
|
776a4fa468 | ||
|
|
9528eef313 | ||
|
|
9827fe36c1 | ||
|
|
ce44270b7a | ||
|
|
34e324c54d | ||
|
|
986e33ce03 | ||
|
|
91c06bde8c | ||
|
|
a9d40a7594 | ||
|
|
bb6146be37 | ||
|
|
071cf40d42 | ||
|
|
19f47f138a | ||
|
|
c070b5cfe0 | ||
|
|
4782431bae | ||
|
|
b63073c135 | ||
|
|
c029fed37d | ||
|
|
dacabda792 | ||
|
|
824d4395c7 | ||
|
|
bd502b56c2 | ||
|
|
6991598fcb | ||
|
|
2cc8e7215a | ||
|
|
5e81d1c70e | ||
|
|
b03ff6b735 | ||
|
|
220c1b0dd6 | ||
|
|
0a4fb091f4 | ||
|
|
110f74400c | ||
|
|
eed8020229 | ||
|
|
ceb3d5b461 | ||
|
|
53cd339213 | ||
|
|
327e9b4d39 | ||
|
|
10a0670a47 | ||
|
|
500d3ba015 | ||
|
|
1236180425 | ||
|
|
129ee40c0d | ||
|
|
a687f8464e | ||
|
|
b409a200bb | ||
|
|
acb07db6f2 | ||
|
|
0ae65971fc | ||
|
|
c4bd4333ca | ||
|
|
f9e30e549a | ||
|
|
1583f259b9 | ||
|
|
c090754129 | ||
|
|
31227860f4 | ||
|
|
edc2428f48 | ||
|
|
6f3473b4ca | ||
|
|
f0af12f466 | ||
|
|
5bea87f98c | ||
|
|
8617d900ee | ||
|
|
1d91357cad | ||
|
|
1d495cb26e | ||
|
|
31b5a8f16e | ||
|
|
b1c8690872 | ||
|
|
c01470ac5a | ||
|
|
98d3b76e4b | ||
|
|
4a5e4ce85c | ||
|
|
d3a3ab57a1 | ||
|
|
86a79f8cda | ||
|
|
5fdf3127ae | ||
|
|
e9635160d1 | ||
|
|
4e6f99c745 | ||
|
|
1fd72ba8bc | ||
|
|
03e12bf701 | ||
|
|
4ae10dba7b | ||
|
|
cad559f1d9 | ||
|
|
b60c564173 | ||
|
|
7c21431efe | ||
|
|
d345f4ccf8 | ||
|
|
6b70d8c6d0 | ||
|
|
5f8e2b0ac3 | ||
|
|
fa7a8d96ea | ||
|
|
291237d4f5 | ||
|
|
d4e85e3055 | ||
|
|
78992316e5 | ||
|
|
70333e3b5f | ||
|
|
492e1ac11c | ||
|
|
3daf5c48f0 | ||
|
|
e9e250191c | ||
|
|
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 |
No files matched your search
@@ -7,6 +7,15 @@ on:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
config:
|
||||
- name: Release
|
||||
lower: release
|
||||
|
||||
- name: Debug
|
||||
lower: debug
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -27,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Build project
|
||||
run: |
|
||||
./gradlew app:assembleDebug \
|
||||
./gradlew app:assemble${{ matrix.config.name }} \
|
||||
-Pandroid.injected.signing.store.file="$HOME/release.keystore" \
|
||||
-Pandroid.injected.signing.store.password="${{secrets.KEYSTORE_PASSWORD}}" \
|
||||
-Pandroid.injected.signing.key.alias="${{secrets.KEY_ALIAS}}" \
|
||||
@@ -37,8 +46,8 @@ jobs:
|
||||
id: rename_apk_universal
|
||||
run: |
|
||||
SHORT_SHA=${GITHUB_SHA::7}
|
||||
APK_PATH=${{github.workspace}}/app/build/outputs/apk/debug/app-universal-debug.apk
|
||||
OUT_PATH=${{github.workspace}}/geode-launcher-debug-$SHORT_SHA.apk
|
||||
APK_PATH=${{github.workspace}}/app/build/outputs/apk/${{ matrix.config.lower }}/app-universal-${{ matrix.config.lower }}.apk
|
||||
OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-$SHORT_SHA.apk
|
||||
mv $APK_PATH $OUT_PATH
|
||||
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -46,15 +55,15 @@ jobs:
|
||||
id: rename_apk
|
||||
run: |
|
||||
SHORT_SHA=${GITHUB_SHA::7}
|
||||
APK_PATH=${{github.workspace}}/app/build/outputs/apk/debug/app-armeabi-v7a-debug.apk
|
||||
OUT_PATH=${{github.workspace}}/geode-launcher-debug-android32-$SHORT_SHA.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-${{ matrix.config.lower }}-android32-$SHORT_SHA.apk
|
||||
mv $APK_PATH $OUT_PATH
|
||||
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload development build
|
||||
uses: actions/upload-artifact@v3
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: geode-launcher-debug
|
||||
name: geode-launcher-${{ matrix.config.lower }}
|
||||
path: |
|
||||
${{ steps.rename_apk_universal.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
|
||||
app/release/
|
||||
.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
|
||||
|
||||
Launches a vanilla copy of Geometry Dash with the Geode loader added.
|
||||
|
||||
## TODO:
|
||||
|
||||
* Figure out better way of packaging libgeode
|
||||
@@ -1,98 +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 7
|
||||
versionName "1.0.0"
|
||||
|
||||
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
|
||||
buildConfig 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 platform('androidx.compose:compose-bom:2023.10.01')
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
implementation "androidx.compose.ui:ui"
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation "androidx.compose.ui:ui-tooling-preview"
|
||||
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.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"
|
||||
debugImplementation "androidx.compose.ui:ui-test-manifest"
|
||||
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4"
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
val composeBOM: String by rootProject.extra
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
extraWarnings.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.geode.launcher"
|
||||
minSdk = 23
|
||||
targetSdk = 35
|
||||
versionCode = 22
|
||||
versionName = "1.6.1"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
|
||||
@Suppress("UnstableApiUsage")
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
arguments("-DUSE_TULIPHOOK:BOOL=OFF", "-DANDROID_STL=c++_shared", "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES:BOOL=OFF")
|
||||
}
|
||||
}
|
||||
|
||||
//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
|
||||
}
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.JVM_1_8
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
version = "3.25.0+"
|
||||
path = file("src/main/cpp/CMakeLists.txt")
|
||||
}
|
||||
}
|
||||
namespace = "com.geode.launcher"
|
||||
ndkVersion = "29.0.14206865"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation (platform("androidx.compose:compose-bom:$composeBOM"))
|
||||
implementation ("androidx.core:core-ktx:1.17.0")
|
||||
implementation ("androidx.compose.ui:ui")
|
||||
implementation ("androidx.compose.material3:material3")
|
||||
implementation ("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
|
||||
implementation ("androidx.activity:activity-compose:1.12.1")
|
||||
implementation ("androidx.activity:activity-ktx:1.12.1")
|
||||
implementation ("androidx.appcompat:appcompat:1.7.1")
|
||||
implementation ("androidx.documentfile:documentfile:1.1.0")
|
||||
implementation ("com.squareup.okio:okio:3.16.4")
|
||||
implementation ("com.squareup.okhttp3:okhttp:5.3.2")
|
||||
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0")
|
||||
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.9.0")
|
||||
implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1")
|
||||
implementation ("com.mikepenz:multiplatform-markdown-renderer-android:0.38.1")
|
||||
implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.38.1")
|
||||
implementation ("androidx.browser:browser:1.9.0")
|
||||
|
||||
implementation ("io.ktor:ktor-server-core-jvm:3.3.3")
|
||||
implementation ("io.ktor:ktor-server-cio:3.3.3")
|
||||
implementation ("io.ktor:ktor-utils-jvm:3.3.3")
|
||||
implementation ("io.ktor:ktor-server-status-pages:3.3.3")
|
||||
implementation ("io.ktor:ktor-server-sse:3.3.3")
|
||||
|
||||
debugImplementation ("androidx.compose.ui:ui-tooling")
|
||||
coreLibraryDesugaring ("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
}
|
||||
Vendored
+7
-20
@@ -1,21 +1,8 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
# Add wildcard keep rules to all of the library code in use
|
||||
-keep class org.fmod.** { *; }
|
||||
-keep class org.cocos2dx.lib.** { *; }
|
||||
-keep class com.customRobTop.** { *; }
|
||||
|
||||
# If your project uses WebView with JS, uncomment the following
|
||||
# and specify the fully qualified class name to the JavaScript interface
|
||||
# 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
|
||||
# note: if you're going to add more rules, consider the @Keep annotation
|
||||
# this should really only be kept to non-custom code
|
||||
-dontwarn java.lang.management.**
|
||||
@@ -9,8 +9,21 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
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-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.wifi"
|
||||
@@ -21,25 +34,45 @@
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:appCategory="game"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:hasFragileUserData="true"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:theme="@style/Theme.GeodeLauncher"
|
||||
tools:targetApi="31">
|
||||
tools:targetApi="35">
|
||||
<activity
|
||||
android:name=".ApplicationLogsActivity"
|
||||
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=".SettingsActivity"
|
||||
android:name=".preferences.CrashLogsActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/title_activity_crash_logs"
|
||||
android:theme="@style/Theme.GeodeLauncher" />
|
||||
<activity
|
||||
android:name=".preferences.ApplicationIconActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/title_activity_application_icon"
|
||||
android:theme="@style/Theme.GeodeLauncher" />
|
||||
<activity
|
||||
android:name=".preferences.SettingsActivity"
|
||||
android:exported="false"
|
||||
android:label="@string/title_activity_settings"
|
||||
android:theme="@style/Theme.GeodeLauncher">
|
||||
<meta-data
|
||||
android:name="android.app.lib_name"
|
||||
android:value="" />
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".GeometryDashActivity"
|
||||
@@ -47,6 +80,7 @@
|
||||
android:hardwareAccelerated="true"
|
||||
android:screenOrientation="landscape"
|
||||
android:theme="@style/Theme.NoActionBar.Fullscreen"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize"
|
||||
android:windowSoftInputMode="stateAlwaysHidden">
|
||||
<meta-data
|
||||
android:name="android.app.lib_name"
|
||||
@@ -58,11 +92,49 @@
|
||||
android:theme="@style/Theme.GeodeLauncher">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity-alias
|
||||
android:name=".MainActivityGeode"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher_geode"
|
||||
android:targetActivity=".MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".MainActivityPride"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher_pride"
|
||||
android:targetActivity=".MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".MainActivityTrans"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher_trans"
|
||||
android:targetActivity=".MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<provider
|
||||
android:name=".UserDirectoryProvider"
|
||||
android:authorities="com.geode.launcher.user"
|
||||
@@ -82,6 +154,16 @@
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/provider_paths" />
|
||||
</provider>
|
||||
|
||||
<meta-data
|
||||
android:name="android.game_mode_config"
|
||||
android:resource="@xml/game_mode_config" />
|
||||
|
||||
<service
|
||||
android:name=".DeveloperServerService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:exported="false">
|
||||
</service>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,376 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Geode Android Web Interface</title>
|
||||
<meta name="color-scheme" content="light dark">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Quicksand:wght@300..700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
.logo {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: "Quicksand", sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3em;
|
||||
}
|
||||
|
||||
.listing-main {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Poppins", sans-serif;
|
||||
}
|
||||
|
||||
#file-name {
|
||||
font-family: "Quicksand", sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#logs-area {
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
height: 15rem;
|
||||
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1 class="title">
|
||||
<svg
|
||||
width="698"
|
||||
height="698"
|
||||
viewBox="0 0 696 698"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="logo"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M393.076 24.4223L372.884 9.00248C358.192 -2.21718 337.808 -2.2172 323.116 9.00246L16.9433 242.812C3.28395 253.243 -2.41995 271.086 2.656 287.506L120.413 668.435C125.723 685.612 141.605 697.326 159.584 697.326H536.416C554.395 697.326 570.277 685.612 575.587 668.435L693.344 287.506C698.42 271.086 692.716 253.243 679.057 242.812L650.835 221.26L618.374 323.385L529.198 613.394C525.713 624.725 515.245 632.458 503.39 632.458H190.61C178.755 632.458 168.287 624.725 164.802 613.394L67.1963 295.971C63.8815 285.191 67.6162 273.487 76.562 266.619L330.558 71.6226C331.579 70.8386 332.643 70.1371 333.739 69.5183L393.076 24.4223Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M336.614 143.264C344.511 137.209 355.489 137.209 363.386 143.264L552.797 288.49C560.096 294.086 563.143 303.632 560.437 312.422L487.62 548.924C484.778 558.153 476.251 564.451 466.594 564.451H233.406C223.749 564.451 215.222 558.153 212.38 548.924L199.934 508.5H261.676C262.393 508.647 263.134 508.724 263.888 508.724H432.112C436.963 508.724 441.241 505.546 442.643 500.903L495.141 326.982C496.452 322.638 494.961 317.934 491.386 315.139L354.777 208.3C350.795 205.186 345.205 205.186 341.223 208.3L215.925 306.292L152.151 353.307L139.563 312.422C136.857 303.632 139.904 294.086 147.203 288.49L336.614 143.264Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
Geode Android Manager
|
||||
</h1>
|
||||
|
||||
<div class="listing-main">
|
||||
<section>
|
||||
<label for="path-listing">
|
||||
Directory Listing:
|
||||
</label> <br />
|
||||
<select id="path-listing" size=25>
|
||||
</select>
|
||||
|
||||
<br />
|
||||
|
||||
<label for="path-name">Path:</label>
|
||||
<input type="text" id="path-name" />
|
||||
<button id="traverse-btn">Go</button>
|
||||
|
||||
<!--br />
|
||||
|
||||
<input type="file" id="upload-file">
|
||||
<button id="upload-btn">Upload File</button-->
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2><span id="file-name"></span></h2>
|
||||
|
||||
<div class="hidden" id="file-metadata">
|
||||
<p>
|
||||
Size: <span id="file-size"></span> <br />
|
||||
Modified At: <span id="file-modified"></span> <br />
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<!-- button id="file-replace">Replace</button>
|
||||
<button id="file-delete">Delete</button>
|
||||
|
||||
<br /-->
|
||||
|
||||
<a href="" download id="file-download">Download</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Logs</h2>
|
||||
|
||||
<pre id="logs-area"></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const basePath = "/";
|
||||
|
||||
/** @type {HTMLSelectElement | null} */
|
||||
const pathListing = document.querySelector("#path-listing");
|
||||
|
||||
/** @type {HTMLInputElement | null} */
|
||||
const pathName = document.querySelector("#path-name");
|
||||
|
||||
/** @type {HTMLButtonElement | null} */
|
||||
const goBtn = document.querySelector("#traverse-btn");
|
||||
|
||||
/** @type {HTMLSpanElement | null} */
|
||||
const fileName = document.querySelector("#file-name");
|
||||
|
||||
/** @type {HTMLSpanElement | null} */
|
||||
const fileSize = document.querySelector("#file-size");
|
||||
|
||||
/** @type {HTMLSpanElement | null} */
|
||||
const fileModified = document.querySelector("#file-modified");
|
||||
|
||||
/** @type {HTMLAnchorElement | null} */
|
||||
const fileDownload = document.querySelector("#file-download");
|
||||
|
||||
/** @type {HTMLDivElement | null} */
|
||||
const fileMetadata = document.querySelector("#file-metadata");
|
||||
|
||||
/** @type {HTMLPreElement | null} */
|
||||
const logsArea = document.querySelector("#logs-area");
|
||||
|
||||
const currentListing = {};
|
||||
|
||||
function formatBytes(bytes, decimals = 2) {
|
||||
if (bytes == 0) return "0 B";
|
||||
|
||||
const base = 1024;
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
|
||||
const unitIndex = Math.floor(Math.log(bytes) / Math.log(base));
|
||||
|
||||
const formattedValue = (bytes / Math.pow(base, unitIndex)).toFixed(decimals);
|
||||
|
||||
return `${formattedValue} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
async function fetchDirectoryListing(path) {
|
||||
goBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const data = await fetch(`${basePath}files/${path}`);
|
||||
const json = await data.json();
|
||||
|
||||
const selectElements = json.sort((a, b) => {
|
||||
const aIsDir = a.type == "directory";
|
||||
const bIsDir = b.type == "directory";
|
||||
|
||||
if (aIsDir && !bIsDir) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (!aIsDir && bIsDir) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return a.filename.localeCompare(b.filename);
|
||||
}).map((f) => {
|
||||
currentListing[f.filename] = f;
|
||||
|
||||
const optionElement = document.createElement("option");
|
||||
optionElement.value = f.filename;
|
||||
|
||||
if (f.type == "directory") {
|
||||
optionElement.innerText = "📁 " + f.filename;
|
||||
} else {
|
||||
optionElement.innerText = f.filename;
|
||||
}
|
||||
|
||||
return optionElement;
|
||||
});
|
||||
|
||||
const parentElement = document.createElement("option");
|
||||
parentElement.value = "..";
|
||||
parentElement.innerText = "⤴️ ..";
|
||||
|
||||
selectElements.unshift(parentElement);
|
||||
|
||||
pathListing.replaceChildren(...selectElements);
|
||||
|
||||
pathName.value = path;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
goBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateFileData() {
|
||||
if (pathListing.value == "..") {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentFile = currentListing[pathListing.value];
|
||||
|
||||
fileName.innerText = currentFile.filename;
|
||||
|
||||
if (currentFile.type == "directory") {
|
||||
fileMetadata.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
fileMetadata.classList.remove("hidden");
|
||||
|
||||
fileSize.innerText = formatBytes(currentFile.size);
|
||||
const modifiedDate = new Date(currentFile.lastModified);
|
||||
fileModified.innerText = new Intl.DateTimeFormat("default", {
|
||||
timeStyle: "long",
|
||||
dateStyle: "long",
|
||||
}).format(modifiedDate);
|
||||
fileDownload.href = `${basePath}files${updatePath(currentFile.filename)}`;
|
||||
}
|
||||
|
||||
function updatePath(append) {
|
||||
const currentPath = pathName.value;
|
||||
|
||||
if (append == "..") {
|
||||
if (currentPath == "/") {
|
||||
return "/"
|
||||
} else {
|
||||
const path = currentPath.split("/");
|
||||
path.pop();
|
||||
|
||||
return path.join("/");
|
||||
}
|
||||
}
|
||||
|
||||
if (currentPath.endsWith("/")) {
|
||||
return currentPath + append;
|
||||
} else {
|
||||
return `${currentPath}/${append}`;
|
||||
}
|
||||
}
|
||||
|
||||
pathListing.addEventListener("dblclick", async (event) => {
|
||||
const clickedOption = event.target.value;
|
||||
|
||||
if (clickedOption == "..") {
|
||||
const newPath = updatePath("..");
|
||||
await fetchDirectoryListing(newPath);
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = currentListing[clickedOption];
|
||||
|
||||
if (selected.type == "directory") {
|
||||
const newPath = updatePath(selected.filename);
|
||||
await fetchDirectoryListing(newPath);
|
||||
} else {
|
||||
fileDownload.click();
|
||||
}
|
||||
});
|
||||
|
||||
pathListing.addEventListener("change", (event) => {
|
||||
const clickedOption = event.target.value;
|
||||
|
||||
updateFileData();
|
||||
});
|
||||
|
||||
goBtn.addEventListener("click", async () => {
|
||||
const goto = pathName.value;
|
||||
await fetchDirectoryListing(goto);
|
||||
});
|
||||
|
||||
function formatLog(line) {
|
||||
/*
|
||||
{
|
||||
"process": {
|
||||
"processId": 31123,
|
||||
"threadId": 31123,
|
||||
"processUid": 10486
|
||||
},
|
||||
"time": "2025-12-17T10:38:19.488696104Z",
|
||||
"logId": 4,
|
||||
"priority": "error",
|
||||
"tag": "AndroidRuntime",
|
||||
"message": "..."
|
||||
},
|
||||
*/
|
||||
|
||||
return `[${line.priority.charAt(0)}] ${line.time} - [${line.tag}] ${line.message}`
|
||||
}
|
||||
|
||||
async function updateLogs() {
|
||||
const data = await fetch(`${basePath}logs`);
|
||||
const json = await data.json();
|
||||
|
||||
const lines = json.map((l) => formatLog(l));
|
||||
const message = lines.join("\n");
|
||||
|
||||
logsArea.innerText = message;
|
||||
logsArea.scrollTop = logsArea.scrollHeight - logsArea.clientHeight
|
||||
}
|
||||
|
||||
const unprocessedLogs = [];
|
||||
|
||||
function onTick() {
|
||||
const batch = unprocessedLogs.splice(0, 100);
|
||||
|
||||
if (batch.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formatted = batch.map((l) => formatLog(JSON.parse(l))).join("\n");
|
||||
|
||||
const isScrolledToBottom = (logsArea.scrollHeight - logsArea.clientHeight) <= logsArea.scrollTop + 1;
|
||||
|
||||
logsArea.innerText += `\n${formatted}`;
|
||||
|
||||
if (isScrolledToBottom) {
|
||||
logsArea.scrollTop = logsArea.scrollHeight - logsArea.clientHeight
|
||||
}
|
||||
}
|
||||
|
||||
function animationFrame() {
|
||||
onTick();
|
||||
|
||||
requestAnimationFrame(animationFrame);
|
||||
}
|
||||
|
||||
function openLogsStream() {
|
||||
const evtSource = new EventSource(`${basePath}logs_live`);
|
||||
|
||||
// we store new logs to process on the next frame, to avoid slowdowns when lots of logs come in at once
|
||||
evtSource.addEventListener("log", (event) => {
|
||||
unprocessedLogs.push(event.data);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async (event) => {
|
||||
await fetchDirectoryListing("/");
|
||||
|
||||
await updateLogs();
|
||||
|
||||
openLogsStream();
|
||||
|
||||
requestAnimationFrame(animationFrame);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -13,7 +13,7 @@ option(USE_TULIPHOOK "enables choice of hooking library" OFF)
|
||||
if (USE_TULIPHOOK)
|
||||
FetchContent_Declare(TulipHook
|
||||
GIT_REPOSITORY https://github.com/geode-sdk/TulipHook.git
|
||||
GIT_TAG 70122be72da7787dc42813ba0a992f3612e7871e
|
||||
GIT_TAG v3.1.4
|
||||
)
|
||||
|
||||
set(TULIP_LINK_SOURCE ON CACHE INTERNAL "")
|
||||
@@ -24,8 +24,9 @@ if (USE_TULIPHOOK)
|
||||
target_link_libraries(launcherfix PRIVATE TulipHook TulipHookInclude)
|
||||
else()
|
||||
FetchContent_Declare(dobby
|
||||
# this is the last version that builds on android at all
|
||||
GIT_REPOSITORY https://github.com/jmpews/Dobby
|
||||
GIT_TAG 6a80a61ea062424d1595613058a174c64454fa57
|
||||
GIT_TAG 0932d69c320e786672361ab53825ba8f4245e9d3
|
||||
)
|
||||
|
||||
FetchContent_GetProperties(dobby)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
#include <dlfcn.h>
|
||||
#include <jni.h>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
#include <android/log.h>
|
||||
#include <link.h>
|
||||
|
||||
#ifndef DISABLE_LAUNCHER_FIX
|
||||
|
||||
@@ -15,8 +19,9 @@
|
||||
|
||||
class DataPaths {
|
||||
public:
|
||||
std::string original_data_path;
|
||||
std::string data_path;
|
||||
std::string original_data_path{};
|
||||
std::string data_path{};
|
||||
std::string load_symbols_from{};
|
||||
|
||||
static DataPaths& get_instance() {
|
||||
static auto paths_instance = DataPaths();
|
||||
@@ -24,7 +29,7 @@ public:
|
||||
}
|
||||
|
||||
private:
|
||||
DataPaths() : original_data_path(), data_path() {}
|
||||
DataPaths() {}
|
||||
};
|
||||
|
||||
extern "C"
|
||||
@@ -41,6 +46,20 @@ JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setDataPath(
|
||||
env->ReleaseStringUTFChars(data_path, data_path_str);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_enableCustomSymbolList(
|
||||
JNIEnv* env,
|
||||
jobject,
|
||||
jstring symbol_path
|
||||
) {
|
||||
auto is_copy = jboolean();
|
||||
auto symbol_path_str = env->GetStringUTFChars(symbol_path, &is_copy);
|
||||
|
||||
DataPaths::get_instance().load_symbols_from = std::string(symbol_path_str);
|
||||
|
||||
env->ReleaseStringUTFChars(symbol_path, symbol_path_str);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setOriginalDataPath(
|
||||
JNIEnv *env,
|
||||
@@ -55,9 +74,200 @@ JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setOriginalDataPath(
|
||||
env->ReleaseStringUTFChars(data_path, data_path_str);
|
||||
}
|
||||
|
||||
std::string redirect_path(const char* pathname) {
|
||||
auto data_path = DataPaths::get_instance().data_path;
|
||||
auto original_data_path = DataPaths::get_instance().original_data_path;
|
||||
// i copied this function from the android linker
|
||||
std::uint32_t elfhash(const char *_name) {
|
||||
const unsigned char *name = reinterpret_cast<const unsigned char*>(_name);
|
||||
std::uint32_t h = 0, g;
|
||||
|
||||
while (*name) {
|
||||
h = (h << 4) + *name++;
|
||||
g = h & 0xf0000000;
|
||||
h ^= g;
|
||||
h ^= g >> 24;
|
||||
}
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
std::array<std::uint8_t, 3> SYMBOL_PATCH_VALUE{":3"};
|
||||
|
||||
bool patch_symbol(std::uint32_t* hash_table, char* str_table, ElfW(Sym)* sym_table, const char* orig_name) {
|
||||
auto hash = elfhash(orig_name);
|
||||
|
||||
auto nbucket = hash_table[0];
|
||||
auto nchain = hash_table[1];
|
||||
auto bucket = &hash_table[2];
|
||||
auto chain = &bucket[nbucket];
|
||||
|
||||
for (auto i = bucket[hash % nbucket]; i != 0; i = chain[i]) {
|
||||
auto sym = sym_table + i;
|
||||
auto sym_name = str_table + sym->st_name;
|
||||
|
||||
if (strcmp(sym_name, orig_name) == 0) {
|
||||
// there's probably no point to checking this, honestly
|
||||
if (ELF_ST_BIND(sym->st_info) == STB_GLOBAL && ELF_ST_TYPE(sym->st_info) == STT_FUNC) {
|
||||
// we found it! now go rename the symbol
|
||||
|
||||
#ifdef USE_TULIPHOOK
|
||||
auto res = tulip::hook::writeMemory(sym_name, SYMBOL_PATCH_VALUE.data(), SYMBOL_PATCH_VALUE.size());
|
||||
if (!res) {
|
||||
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "failed to patch symbol: %s", res.unwrapErr().c_str());
|
||||
return false;
|
||||
}
|
||||
#else
|
||||
DobbyCodePatch(reinterpret_cast<void*>(sym_name), SYMBOL_PATCH_VALUE.data(), SYMBOL_PATCH_VALUE.size());
|
||||
#endif
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "could not find symbol %s to patch (hash: %u)", orig_name, hash);
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<std::string> get_symbols_listing() {
|
||||
auto symbol_path = DataPaths::get_instance().load_symbols_from;
|
||||
if (symbol_path.empty()) {
|
||||
// this is every function that i thought would be relevant
|
||||
return {
|
||||
"__gxx_personality_v0",
|
||||
"__cxa_throw",
|
||||
"__cxa_rethrow",
|
||||
"__cxa_allocate_exception",
|
||||
"__cxa_end_catch",
|
||||
"__cxa_begin_catch",
|
||||
"__cxa_guard_abort",
|
||||
"__cxa_guard_acquire",
|
||||
"__cxa_guard_release",
|
||||
"__cxa_free_exception"
|
||||
|
||||
// android 10's libc doesn't have these symbols
|
||||
// and the ndk is forbidden from exporting them!! so keep it for old mods...
|
||||
// "_Unwind_RaiseException",
|
||||
// "_Unwind_Resume"
|
||||
};
|
||||
}
|
||||
|
||||
std::ifstream symbol_file{symbol_path};
|
||||
if (!symbol_file) {
|
||||
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "failed to read symbol file at %s", symbol_path.c_str());
|
||||
return {};
|
||||
}
|
||||
|
||||
std::vector<std::string> symbols_list{};
|
||||
std::string current_line{};
|
||||
while (std::getline(symbol_file, current_line)) {
|
||||
if (!current_line.empty()) {
|
||||
symbols_list.push_back(current_line);
|
||||
}
|
||||
}
|
||||
|
||||
return symbols_list;
|
||||
}
|
||||
|
||||
int on_dl_iterate(dl_phdr_info* info, size_t size, void* data) {
|
||||
// this is probably going to be gd
|
||||
if (info->dlpi_name != nullptr && strstr(info->dlpi_name, "libcocos2dcpp.so") != nullptr) {
|
||||
// step 1: get the dynamic table
|
||||
std::uintptr_t dyn_addr = 0u;
|
||||
|
||||
for (auto i = 0u; i < info->dlpi_phnum; i++) {
|
||||
auto phdr = info->dlpi_phdr + i;
|
||||
|
||||
if (phdr->p_type == PT_DYNAMIC) {
|
||||
dyn_addr = info->dlpi_addr + phdr->p_vaddr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (dyn_addr == 0u) {
|
||||
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "failed to find libcocos dynamic section");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// step 2: get the symbol table
|
||||
auto dyn_entry = reinterpret_cast<ElfW(Dyn)*>(dyn_addr);
|
||||
auto dyn_end_reached = false;
|
||||
|
||||
std::uintptr_t sym_table_addr = 0u;
|
||||
std::uintptr_t str_table_addr = 0u;
|
||||
std::uintptr_t hash_table_addr = 0u;
|
||||
std::uintptr_t soname_idx = 0u;
|
||||
|
||||
while (!dyn_end_reached) {
|
||||
auto tag = dyn_entry->d_tag;
|
||||
|
||||
switch (dyn_entry->d_tag) {
|
||||
case DT_SYMTAB:
|
||||
sym_table_addr = info->dlpi_addr + dyn_entry->d_un.d_val;
|
||||
break;
|
||||
case DT_STRTAB:
|
||||
str_table_addr = info->dlpi_addr + dyn_entry->d_un.d_val;
|
||||
break;
|
||||
case DT_HASH:
|
||||
hash_table_addr = info->dlpi_addr + dyn_entry->d_un.d_val;
|
||||
break;
|
||||
case DT_SONAME:
|
||||
soname_idx = dyn_entry->d_un.d_val;
|
||||
break;
|
||||
case DT_NULL:
|
||||
dyn_end_reached = true;
|
||||
break;
|
||||
}
|
||||
|
||||
dyn_entry++;
|
||||
}
|
||||
|
||||
if (hash_table_addr == 0u || str_table_addr == 0u || sym_table_addr == 0u) {
|
||||
__android_log_print(ANDROID_LOG_ERROR, "GeodeLauncher-fix", "failed to parse dynamic section (at least one table is null)");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// patch symbol names
|
||||
auto hash_table = reinterpret_cast<std::uint32_t*>(hash_table_addr);
|
||||
auto str_table = reinterpret_cast<char*>(str_table_addr);
|
||||
auto sym_table = reinterpret_cast<ElfW(Sym)*>(sym_table_addr);
|
||||
|
||||
auto so_name = str_table + soname_idx;
|
||||
if (strcmp(so_name, "libcocos2dcpp.so") != 0) {
|
||||
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "DT_SONAME gives us %s! that's not good", so_name);
|
||||
}
|
||||
|
||||
auto has_error = false;
|
||||
auto symbols_listing = get_symbols_listing();
|
||||
for (const auto& symbol : symbols_listing) {
|
||||
if (!patch_symbol(hash_table, str_table, sym_table, symbol.c_str())) {
|
||||
has_error = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (has_error) {
|
||||
__android_log_print(
|
||||
ANDROID_LOG_INFO,
|
||||
"GeodeLauncher-fix",
|
||||
"symbol patch diagnostics\nlibrary path: %s\naddrs: base=%p sym=%p str=%p hash=%p",
|
||||
info->dlpi_name, reinterpret_cast<void*>(info->dlpi_addr), sym_table, str_table, hash_table
|
||||
);
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
}
|
||||
|
||||
// this should be called after gd is loaded but before geode
|
||||
extern "C"
|
||||
JNIEXPORT int JNICALL Java_com_geode_launcher_LauncherFix_performExceptionsRenaming(JNIEnv*, jobject) {
|
||||
return dl_iterate_phdr(on_dl_iterate, nullptr);
|
||||
}
|
||||
|
||||
std::optional<std::string> redirect_path(const char* pathname) {
|
||||
auto& data_path = DataPaths::get_instance().data_path;
|
||||
auto& original_data_path = DataPaths::get_instance().original_data_path;
|
||||
|
||||
if (!data_path.empty() && !original_data_path.empty()) {
|
||||
// call this a c string optimization
|
||||
@@ -68,22 +278,26 @@ std::string redirect_path(const char* pathname) {
|
||||
}
|
||||
}
|
||||
|
||||
return pathname;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
FILE* (*fopen_original)(const char *pathname, const char *mode);
|
||||
FILE* fopen_hook(const char* pathname, const char* mode) {
|
||||
auto path_str = redirect_path(pathname);
|
||||
auto redirect = path_str.has_value() ? path_str->c_str() : pathname;
|
||||
|
||||
return fopen_original(path_str.c_str(), mode);
|
||||
return fopen_original(redirect, mode);
|
||||
}
|
||||
|
||||
int (*rename_original)(const char* old_path, const char* new_path);
|
||||
int rename_hook(const char* old_path, const char* new_path) {
|
||||
auto old_path_str = redirect_path(old_path);
|
||||
auto new_path_str = redirect_path(new_path);
|
||||
auto old_redirect = old_path_str ? old_path_str->c_str() : old_path;
|
||||
|
||||
return rename_original(old_path_str.c_str(), new_path_str.c_str());
|
||||
auto new_path_str = redirect_path(new_path);
|
||||
auto new_redirect = new_path_str ? new_path_str->c_str() : new_path;
|
||||
|
||||
return rename_original(old_redirect, new_redirect);
|
||||
}
|
||||
|
||||
bool hook_function(void* addr, auto* hook, auto** orig) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.customRobTop
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.app.KeyguardManager
|
||||
import android.content.*
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
@@ -11,7 +10,6 @@ import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import com.customRobTop.JniToCpp.resumeSound
|
||||
import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.closeIMEKeyboard
|
||||
import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.openIMEKeyboard
|
||||
import java.lang.ref.WeakReference
|
||||
@@ -26,7 +24,7 @@ object BaseRobTopActivity {
|
||||
|
||||
var isPaused = false
|
||||
|
||||
lateinit var me: WeakReference<Activity>
|
||||
var me: WeakReference<Activity> = WeakReference(null)
|
||||
private var shouldResumeSound = true
|
||||
|
||||
fun setCurrentActivity(currentActivity: Activity) {
|
||||
@@ -259,29 +257,4 @@ object BaseRobTopActivity {
|
||||
level = DeprecationLevel.HIDDEN
|
||||
)
|
||||
fun logEvent(event: String) {}
|
||||
|
||||
class ReceiverScreen : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_SCREEN_ON -> {
|
||||
Log.d("TAG", "ACTION_SCREEN_ON")
|
||||
if (!(me.get()?.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager).isKeyguardLocked) {
|
||||
shouldResumeSound = true
|
||||
}
|
||||
if (!isPaused && shouldResumeSound) {
|
||||
resumeSound()
|
||||
}
|
||||
}
|
||||
Intent.ACTION_SCREEN_OFF -> {
|
||||
shouldResumeSound = false
|
||||
}
|
||||
Intent.ACTION_USER_PRESENT -> {
|
||||
shouldResumeSound = true
|
||||
if (!isPaused) {
|
||||
resumeSound()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
package com.geode.launcher
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.format.Formatter
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.geode.launcher.main.*
|
||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||
import com.geode.launcher.ui.theme.LocalTheme
|
||||
import com.geode.launcher.ui.theme.Theme
|
||||
import com.geode.launcher.ui.theme.launcherTitleStyle
|
||||
import com.geode.launcher.utils.Constants
|
||||
import com.geode.launcher.utils.GamePackageUtils
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AltMainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
enableEdgeToEdge()
|
||||
clearDownloadedApks(this)
|
||||
|
||||
val gdInstalled = GamePackageUtils.isGameInstalled(packageManager)
|
||||
val geodeInstalled = LaunchUtils.isGeodeInstalled(this)
|
||||
|
||||
val returnMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_MESSAGE)
|
||||
val returnExtendedMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE)
|
||||
|
||||
@Suppress("DEPRECATION") // the new api is android 13 only (why)
|
||||
val returnError = intent.extras?.getSerializable(LaunchUtils.LAUNCHER_KEY_RETURN_ERROR)
|
||||
as? LaunchUtils.LauncherError
|
||||
|
||||
val loadFailureInfo = if (returnError != null && returnMessage != null) {
|
||||
LoadFailureInfo(returnError, returnMessage, returnExtendedMessage)
|
||||
} else if (LaunchUtils.lastSessionCrashed(this)) {
|
||||
LoadFailureInfo(LaunchUtils.LauncherError.CRASHED)
|
||||
} else { null }
|
||||
|
||||
|
||||
setContent {
|
||||
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||
val theme = Theme.fromInt(themeOption)
|
||||
|
||||
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||
|
||||
val launchViewModel = viewModel<LaunchViewModel>(factory = LaunchViewModel.Factory)
|
||||
|
||||
if (loadFailureInfo != null) {
|
||||
launchViewModel.loadFailure = loadFailureInfo
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalTheme provides theme) {
|
||||
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
AltMainScreen(launchViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (gdInstalled && geodeInstalled) {
|
||||
intent.getBooleanExtra("restarted", false).let {
|
||||
if (it) {
|
||||
onLaunch(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class LaunchStatusInfo(
|
||||
val title: String?,
|
||||
val details: String? = null,
|
||||
val progress: (() -> Float)? = null
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun LaunchCancelledIcon(cancelReason: LaunchViewModel.LaunchCancelReason, modifier: Modifier = Modifier) {
|
||||
when (cancelReason) {
|
||||
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED,
|
||||
LaunchViewModel.LaunchCancelReason.GAME_MISSING -> Icon(painterResource(R.drawable.icon_error), contentDescription = null, modifier)
|
||||
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED,
|
||||
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> Icon(painterResource(R.drawable.icon_warning), contentDescription = null, modifier)
|
||||
else -> Icon(painterResource(R.drawable.icon_info), contentDescription = null, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun mapCancelReasonToInfo(cancelReason: LaunchViewModel.LaunchCancelReason): LaunchStatusInfo {
|
||||
return when (cancelReason) {
|
||||
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED -> LaunchStatusInfo(
|
||||
title = stringResource(R.string.launcher_cancelled_crash)
|
||||
)
|
||||
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> LaunchStatusInfo(
|
||||
title = stringResource(R.string.geode_download_title)
|
||||
)
|
||||
LaunchViewModel.LaunchCancelReason.GAME_MISSING -> LaunchStatusInfo(
|
||||
title = stringResource(R.string.launcher_cancelled_error),
|
||||
details = stringResource(R.string.game_not_found)
|
||||
)
|
||||
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED -> LaunchStatusInfo(
|
||||
title = stringResource(R.string.launcher_cancelled_error),
|
||||
details = stringResource(R.string.launcher_cancelled_outdated)
|
||||
)
|
||||
LaunchViewModel.LaunchCancelReason.AUTOMATIC,
|
||||
LaunchViewModel.LaunchCancelReason.MANUAL-> LaunchStatusInfo(
|
||||
title = null
|
||||
)
|
||||
else -> LaunchStatusInfo(
|
||||
title = stringResource(R.string.launcher_cancelled_manual)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun mapLaunchStatusToInfo(state: LaunchViewModel.LaunchUIState, inSafeMode: Boolean = false): LaunchStatusInfo {
|
||||
return when (state) {
|
||||
is LaunchViewModel.LaunchUIState.Initial,
|
||||
is LaunchViewModel.LaunchUIState.Working,
|
||||
is LaunchViewModel.LaunchUIState.Ready -> LaunchStatusInfo(
|
||||
title = stringResource(R.string.launcher_starting_game),
|
||||
details = if (inSafeMode) stringResource(R.string.launcher_in_safe_mode) else null
|
||||
)
|
||||
is LaunchViewModel.LaunchUIState.UpdateCheck -> LaunchStatusInfo(
|
||||
title = stringResource(R.string.release_fetch_in_progress)
|
||||
)
|
||||
is LaunchViewModel.LaunchUIState.Updating -> {
|
||||
val context = LocalContext.current
|
||||
|
||||
val downloaded = remember(state.downloaded) {
|
||||
Formatter.formatShortFileSize(context, state.downloaded)
|
||||
}
|
||||
|
||||
val outOf = remember(state.outOf) {
|
||||
state.outOf?.run {
|
||||
Formatter.formatShortFileSize(context, this)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchStatusInfo(
|
||||
title = stringResource(R.string.launcher_downloading_update),
|
||||
details = stringResource(R.string.launcher_downloading_update_details, downloaded, outOf ?: "…"),
|
||||
progress = {
|
||||
val progress = state.downloaded / state.outOf!!.toDouble()
|
||||
progress.toFloat()
|
||||
}.takeIf { state.outOf != null }
|
||||
)
|
||||
}
|
||||
is LaunchViewModel.LaunchUIState.Cancelled -> {
|
||||
return mapCancelReasonToInfo(state.reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LaunchCancelledBody(statusInfo: LaunchStatusInfo, icon: @Composable () -> Unit, inProgress: Boolean, modifier: Modifier = Modifier) {
|
||||
if (statusInfo.title == null && statusInfo.details == null && !inProgress) {
|
||||
return
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier.width(300.dp)
|
||||
) {
|
||||
if (statusInfo.title != null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
|
||||
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyLarge) {
|
||||
icon()
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(statusInfo.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (inProgress) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
if (statusInfo.details != null) {
|
||||
Text(
|
||||
statusInfo.details,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LaunchProgressBody(statusInfo: LaunchStatusInfo, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
modifier = modifier.width(300.dp)
|
||||
) {
|
||||
if (statusInfo.title != null) {
|
||||
Text(statusInfo.title, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
|
||||
if (statusInfo.progress != null) {
|
||||
LinearProgressIndicator(
|
||||
statusInfo.progress,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
|
||||
if (statusInfo.details != null) {
|
||||
Text(statusInfo.details, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RetryButtonContents(reason: LaunchViewModel.LaunchCancelReason) {
|
||||
when (reason) {
|
||||
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED -> {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_resume),
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(stringResource(R.string.launcher_cancelled_resume))
|
||||
}
|
||||
LaunchViewModel.LaunchCancelReason.MANUAL,
|
||||
LaunchViewModel.LaunchCancelReason.AUTOMATIC -> {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_play_arrow),
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(stringResource(R.string.launcher_launch))
|
||||
}
|
||||
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_refresh),
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(stringResource(R.string.launcher_cancelled_retry))
|
||||
}
|
||||
// these shouldn't be allowing retries anyways
|
||||
LaunchViewModel.LaunchCancelReason.GAME_MISSING,
|
||||
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun LaunchProgressCard(
|
||||
uiState: LaunchViewModel.LaunchUIState,
|
||||
crashInfo: LoadFailureInfo?,
|
||||
onCancel: () -> Unit,
|
||||
onResume: (safeMode: Boolean) -> Unit,
|
||||
onMore: () -> Unit,
|
||||
extraOptions: @Composable () -> Unit,
|
||||
safeModeEnabled: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var showSafeModeDialog by remember { mutableStateOf(false) }
|
||||
val status = mapLaunchStatusToInfo(uiState, safeModeEnabled)
|
||||
|
||||
Box(modifier = modifier) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
if (uiState is LaunchViewModel.LaunchUIState.Cancelled) {
|
||||
LaunchCancelledBody(
|
||||
statusInfo = status,
|
||||
icon = { LaunchCancelledIcon(uiState.reason, Modifier.size(32.dp)) },
|
||||
inProgress = uiState.inProgress
|
||||
)
|
||||
|
||||
if (uiState.reason.isGameInstallIssue()) {
|
||||
val context = LocalContext.current
|
||||
val showDownload = remember { GamePackageUtils.showDownloadBadge(context.packageManager) }
|
||||
|
||||
if (showDownload) {
|
||||
GooglePlayBadge(modifier = Modifier.align(Alignment.CenterHorizontally))
|
||||
}
|
||||
}
|
||||
|
||||
if (uiState.reason.allowsRetry()) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally),
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
) {
|
||||
LongPressButton(onClick = { onResume(false) }, onLongPress = {
|
||||
showSafeModeDialog = true
|
||||
}) {
|
||||
RetryButtonContents(uiState.reason)
|
||||
}
|
||||
|
||||
if (crashInfo != null) {
|
||||
FilledTonalButton(onClick = onMore) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_question_mark),
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(stringResource(R.string.launcher_cancelled_help_alt))
|
||||
}
|
||||
}
|
||||
|
||||
extraOptions()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LaunchProgressBody(statusInfo = status, modifier = Modifier.padding(top = 16.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.width(300.dp)) {
|
||||
TextButton(onClick = onCancel) {
|
||||
Text(stringResource(R.string.release_fetch_button_cancel))
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1.0f))
|
||||
|
||||
extraOptions()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showSafeModeDialog) {
|
||||
SafeModeDialog(
|
||||
onDismiss = {
|
||||
showSafeModeDialog = false
|
||||
},
|
||||
onLaunch = {
|
||||
onResume(true)
|
||||
showSafeModeDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExtraOptions(onSettings: () -> Unit) {
|
||||
IconButton(
|
||||
onClick = onSettings,
|
||||
) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_settings),
|
||||
contentDescription = stringResource(R.string.launcher_settings_icon_alt)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GeodeLogo(modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.geode_monochrome),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(64.dp, 64.dp),
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.launcher_title),
|
||||
style = launcherTitleStyle,
|
||||
fontSize = 64.sp,
|
||||
modifier = Modifier
|
||||
.padding(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AltMainScreen(
|
||||
launchViewModel: LaunchViewModel = viewModel(factory = LaunchViewModel.Factory)
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
launchViewModel.beginLaunchFlow()
|
||||
}
|
||||
|
||||
val launchUIState by launchViewModel.uiState.collectAsState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var launchInSafeMode by remember { mutableStateOf(false) }
|
||||
|
||||
var showErrorInfo by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.safeDrawingPadding(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
GeodeLogo()
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
LaunchProgressCard(
|
||||
launchUIState,
|
||||
crashInfo = launchViewModel.currentCrashInfo(),
|
||||
onCancel = {
|
||||
launchInSafeMode = false
|
||||
coroutineScope.launch {
|
||||
launchViewModel.cancelLaunch()
|
||||
}
|
||||
},
|
||||
onResume = { safeMode ->
|
||||
launchInSafeMode = safeMode
|
||||
|
||||
launchViewModel.clearCrashInfo()
|
||||
coroutineScope.launch {
|
||||
launchViewModel.beginLaunchFlow(true)
|
||||
}
|
||||
},
|
||||
onMore = {
|
||||
showErrorInfo = true
|
||||
},
|
||||
extraOptions = {
|
||||
ExtraOptions(
|
||||
onSettings = {
|
||||
coroutineScope.launch {
|
||||
launchViewModel.cancelLaunch(true)
|
||||
}
|
||||
|
||||
onSettings(context)
|
||||
}
|
||||
)
|
||||
},
|
||||
safeModeEnabled = launchInSafeMode
|
||||
)
|
||||
|
||||
// only show launcher update in a case where a user won't see it ingame
|
||||
if (launchUIState is LaunchViewModel.LaunchUIState.Cancelled) {
|
||||
val gameVersion = remember { GamePackageUtils.getGameVersionCodeOrNull(context.packageManager) }
|
||||
|
||||
if (gameVersion != null && gameVersion < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
|
||||
UnsupportedVersionWarning()
|
||||
}
|
||||
|
||||
val nextLauncherUpdate by launchViewModel.nextLauncherUpdate.collectAsState()
|
||||
if (nextLauncherUpdate != null) {
|
||||
LauncherUpdateIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (launchUIState is LaunchViewModel.LaunchUIState.Ready) {
|
||||
UpdateWarning(launchInSafeMode) {
|
||||
launchInSafeMode = false
|
||||
coroutineScope.launch {
|
||||
launchViewModel.cancelLaunch()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val loadFailure = launchViewModel.currentCrashInfo()
|
||||
if (showErrorInfo && loadFailure != null) {
|
||||
ErrorInfoSheet(loadFailure, onDismiss = { showErrorInfo = false })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package com.geode.launcher
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Binder
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import com.geode.launcher.log.LogLine
|
||||
import com.geode.launcher.utils.DownloadUtils
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.yield
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okio.Buffer
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import java.io.EOFException
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.file.DirectoryNotEmptyException
|
||||
import kotlin.collections.plusAssign
|
||||
import kotlin.io.path.deleteExisting
|
||||
import kotlin.io.path.exists
|
||||
import kotlin.io.path.isDirectory
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Instant
|
||||
import io.ktor.http.*
|
||||
import io.ktor.http.content.PartData
|
||||
import io.ktor.http.content.forEachPart
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.engine.*
|
||||
import io.ktor.server.cio.*
|
||||
import io.ktor.server.http.content.staticFiles
|
||||
import io.ktor.server.plugins.statuspages.StatusPages
|
||||
import io.ktor.server.request.receiveChannel
|
||||
import io.ktor.server.request.receiveMultipart
|
||||
import io.ktor.server.sse.*
|
||||
import io.ktor.sse.ServerSentEvent
|
||||
import io.ktor.util.cio.writeChannel
|
||||
import io.ktor.utils.io.copyAndClose
|
||||
|
||||
enum class FileType {
|
||||
@SerialName("file")
|
||||
FILE,
|
||||
@SerialName("directory")
|
||||
DIRECTORY
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class FileMeta(val filename: String, val type: FileType, val size: Long, val lastModified: Instant)
|
||||
|
||||
class DeveloperServerService : Service() {
|
||||
private val binder = LocalBinder()
|
||||
|
||||
private var server: EmbeddedServer<CIOApplicationEngine, CIOApplicationEngine.Configuration>? = null
|
||||
|
||||
inner class LocalBinder : Binder() {
|
||||
fun getService(): DeveloperServerService = this@DeveloperServerService
|
||||
}
|
||||
|
||||
private fun startForeground() {
|
||||
try {
|
||||
val notification = NotificationCompat.Builder(this, "geode_developer_server")
|
||||
.setSmallIcon(R.drawable.geode_monochrome)
|
||||
.build()
|
||||
ServiceCompat.startForeground(
|
||||
this,
|
||||
100,
|
||||
notification,
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
||||
} else {
|
||||
0
|
||||
},
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
/*
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
&& e is ForegroundServiceStartNotAllowedException
|
||||
) {
|
||||
println("idk")
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent): IBinder {
|
||||
return binder
|
||||
}
|
||||
|
||||
override fun onTimeout(startId: Int, fgsType: Int) {
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
server?.stop()
|
||||
server = null
|
||||
}
|
||||
|
||||
// not recursive copy
|
||||
private fun copyIndexToCache() {
|
||||
val folderName = "web_interface"
|
||||
val base = File(application.cacheDir, folderName)
|
||||
|
||||
base.deleteRecursively()
|
||||
base.mkdirs()
|
||||
|
||||
assets.list(folderName)?.forEach {
|
||||
val assetName = "$folderName/$it"
|
||||
val outputPath = File(base, it)
|
||||
assets.open(assetName).use { inputStream ->
|
||||
outputPath.outputStream().use { outputStream ->
|
||||
DownloadUtils.copyFile(inputStream, outputStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createLogsFlow(): Flow<LogLine> = flow {
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val seconds = timestamp / 1000
|
||||
val ms = timestamp % 1000
|
||||
|
||||
// Time format is 'MM-DD hh:mm:ss.mmm...', 'YYYY-MM-DD hh:mm:ss.mmm...', or 'sssss.mmm...'.
|
||||
val timeString = "$seconds.$ms"
|
||||
|
||||
val logCommand = "logcat -B -T $timeString"
|
||||
|
||||
val logProcess = try {
|
||||
Runtime.getRuntime().exec(logCommand)
|
||||
} catch (ioe: IOException) {
|
||||
ioe.printStackTrace()
|
||||
emit(LogLine.showException(ioe))
|
||||
|
||||
return@flow
|
||||
}
|
||||
|
||||
val logBuffer = logProcess.inputStream.source().buffer()
|
||||
|
||||
try {
|
||||
// this runs until the stream is exhausted
|
||||
while (currentCoroutineContext().isActive) {
|
||||
val line = LogLine.fromBufferedSource(logBuffer)
|
||||
emit(line)
|
||||
|
||||
yield()
|
||||
}
|
||||
} catch (_: EOFException) {
|
||||
// ignore, end of file reached
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
emit(LogLine.showException(e))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readLogs(): List<LogLine> {
|
||||
val logLines = mutableListOf<LogLine>()
|
||||
|
||||
val logCommand = "logcat -B -d"
|
||||
val logProcess = try {
|
||||
withContext(Dispatchers.IO) {
|
||||
Runtime.getRuntime().exec(logCommand)
|
||||
}
|
||||
} catch (ioe: IOException) {
|
||||
ioe.printStackTrace()
|
||||
logLines += LogLine.showException(ioe)
|
||||
|
||||
return logLines
|
||||
}
|
||||
|
||||
// read entire log into a buffer so no logs are added to the buffer during processing
|
||||
val logBuffer = Buffer()
|
||||
logProcess.inputStream.source().buffer().readAll(logBuffer)
|
||||
|
||||
try {
|
||||
coroutineScope {
|
||||
// this runs until the stream is exhausted
|
||||
while (true) {
|
||||
val line = LogLine.fromBufferedSource(logBuffer)
|
||||
logLines += line
|
||||
|
||||
yield()
|
||||
}
|
||||
}
|
||||
} catch (_: EOFException) {
|
||||
// ignore, end of file reached
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
logLines += LogLine.showException(e)
|
||||
}
|
||||
|
||||
return logLines
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
val staticDir = File(application.cacheDir, "web_interface")
|
||||
val baseDir = LaunchUtils.getBaseDirectory(application, true)
|
||||
|
||||
val logsFlow = createLogsFlow()
|
||||
|
||||
server = embeddedServer(CIO, port = 9043) {
|
||||
routing {
|
||||
install(SSE)
|
||||
|
||||
install(StatusPages) {
|
||||
exception<Throwable> { call, cause ->
|
||||
call.respondText(text = "500: $cause" , status = HttpStatusCode.InternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
get("/logs") {
|
||||
val logs = Json.encodeToString(readLogs())
|
||||
call.respondText(logs, contentType = ContentType.Application.Json)
|
||||
}
|
||||
|
||||
post("/stop") {
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
sse("/logs_live") {
|
||||
heartbeat {
|
||||
period = 5000.milliseconds
|
||||
event = ServerSentEvent(comments = "heartbeat")
|
||||
}
|
||||
logsFlow.collect {
|
||||
val log = Json.encodeToString(it)
|
||||
send(log, event = "log", id = it.logId.toString())
|
||||
}
|
||||
}
|
||||
|
||||
staticFiles("/", staticDir)
|
||||
|
||||
staticFiles("/files", baseDir, index = null) {
|
||||
fallback { requestedPath, call ->
|
||||
val basePath = baseDir.toPath()
|
||||
val builtPath = basePath.resolve(requestedPath).normalize()
|
||||
if (!builtPath.startsWith(basePath) || !builtPath.exists()) {
|
||||
// block path traversal issues
|
||||
call.response.status(HttpStatusCode.NotFound)
|
||||
} else {
|
||||
val filenames = builtPath.toFile().listFiles()?.map {
|
||||
FileMeta(
|
||||
filename = it.name,
|
||||
type = if (it.isDirectory) FileType.DIRECTORY else FileType.FILE,
|
||||
size = it.length(),
|
||||
lastModified = Instant.fromEpochMilliseconds(it.lastModified())
|
||||
)
|
||||
} ?: emptyList()
|
||||
|
||||
val data = Json.encodeToString(filenames)
|
||||
|
||||
call.respondText(data, contentType = ContentType.Application.Json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delete("/files/{path...}") {
|
||||
val pathSegments = call.parameters.getAll("path")
|
||||
val requestedPath = pathSegments?.joinToString("/") ?: ""
|
||||
|
||||
val basePath = baseDir.toPath()
|
||||
val builtPath = basePath.resolve(requestedPath).normalize()
|
||||
if (!builtPath.startsWith(basePath) || !builtPath.exists()) {
|
||||
call.response.status(HttpStatusCode.NotFound)
|
||||
} else {
|
||||
try {
|
||||
builtPath.deleteExisting()
|
||||
call.response.status(HttpStatusCode.NoContent)
|
||||
} catch (e: DirectoryNotEmptyException) {
|
||||
val message = Json.encodeToString(buildJsonObject {
|
||||
put("message", "Provided directory $requestedPath was not empty")
|
||||
})
|
||||
|
||||
call.respondText(message, status = HttpStatusCode.Conflict, contentType = ContentType.Application.Json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
put("/files/{path...}") {
|
||||
val pathSegments = call.parameters.getAll("path")
|
||||
val requestedPath = pathSegments?.joinToString("/") ?: ""
|
||||
|
||||
val basePath = baseDir.toPath()
|
||||
val builtPath = basePath.resolve(requestedPath).normalize()
|
||||
if (!builtPath.startsWith(basePath)) {
|
||||
call.response.status(HttpStatusCode.NotFound)
|
||||
} else {
|
||||
if (builtPath.isDirectory()) {
|
||||
val multipart = call.receiveMultipart(formFieldLimit = 1024 * 1024 * 100)
|
||||
multipart.forEachPart { part ->
|
||||
if (part is PartData.FileItem) {
|
||||
val fileName = part.originalFileName ?: "uploaded_file"
|
||||
|
||||
val outputPath = builtPath.resolve(fileName).normalize()
|
||||
if (!outputPath.startsWith(basePath)) {
|
||||
val outputFile = outputPath.toFile()
|
||||
part.provider().copyAndClose(outputFile.writeChannel())
|
||||
}
|
||||
}
|
||||
part.dispose()
|
||||
}
|
||||
} else {
|
||||
val outputFile = builtPath.toFile()
|
||||
outputFile.parentFile?.mkdirs()
|
||||
|
||||
call.receiveChannel().copyAndClose(outputFile.writeChannel())
|
||||
}
|
||||
|
||||
call.response.status(HttpStatusCode.NoContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
this.startForeground()
|
||||
|
||||
copyIndexToCache()
|
||||
server?.start(wait = false)
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,38 @@
|
||||
package com.geode.launcher
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.opengl.GLSurfaceView
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.text.InputType
|
||||
import android.util.Log
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.AppCompatEditText
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import com.customRobTop.BaseRobTopActivity
|
||||
import com.customRobTop.JniToCpp
|
||||
import com.geode.launcher.main.LaunchNotification
|
||||
import com.geode.launcher.main.determineDisplayedCards
|
||||
import com.geode.launcher.utils.Constants
|
||||
import com.geode.launcher.utils.ConstrainedFrameLayout
|
||||
import com.geode.launcher.utils.DownloadUtils
|
||||
import com.geode.launcher.utils.GamePackageUtils
|
||||
import com.geode.launcher.utils.GeodeUtils
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import org.cocos2dx.lib.Cocos2dxEditText
|
||||
import org.cocos2dx.lib.Cocos2dxGLSurfaceView
|
||||
import org.cocos2dx.lib.Cocos2dxHelper
|
||||
import org.cocos2dx.lib.Cocos2dxRenderer
|
||||
@@ -35,64 +40,116 @@ import org.fmod.FMOD
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import javax.microedition.khronos.egl.EGL10
|
||||
import javax.microedition.khronos.egl.EGLConfig
|
||||
import javax.microedition.khronos.egl.EGLDisplay
|
||||
|
||||
enum class DisplayMode {
|
||||
DEFAULT, LIMITED, FULLSCREEN;
|
||||
|
||||
class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperListener {
|
||||
companion object {
|
||||
fun fromInt(i: Int) = when (i) {
|
||||
1 -> LIMITED
|
||||
2 -> FULLSCREEN
|
||||
else -> DEFAULT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ratioForPreference(value: String) = when (value) {
|
||||
"4_3" -> 4/3.0f
|
||||
"1_1" -> 1.0f
|
||||
"16_10" -> 16/10.0f
|
||||
else -> 1.77f
|
||||
}
|
||||
|
||||
class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperListener, GeodeUtils.CapabilityListener {
|
||||
private var mGLSurfaceView: Cocos2dxGLSurfaceView? = null
|
||||
private val sTag = GeometryDashActivity::class.simpleName
|
||||
private var mIsRunning = false
|
||||
private var mIsOnPause = false
|
||||
private var mHasWindowFocus = false
|
||||
private var mReceiver: BroadcastReceiver? = null
|
||||
|
||||
private var displayMode = DisplayMode.DEFAULT
|
||||
private var mForceRefreshRate = false
|
||||
private var mLimitedRefreshRate: Int? = null
|
||||
private var mAspectRatio = 0.0f
|
||||
private var mScreenZoom = 1.0f
|
||||
private var mScreenZoomFit = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
setupUIState()
|
||||
FMOD.init(this)
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// return back to main if Geometry Dash isn't found
|
||||
if (!LaunchUtils.isGeometryDashInstalled(packageManager)) {
|
||||
if (!GamePackageUtils.isGameInstalled(packageManager)) {
|
||||
returnToMain()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
createVersionFile()
|
||||
tryLoadGame()
|
||||
} catch (e: UnsatisfiedLinkError) {
|
||||
e.printStackTrace()
|
||||
Log.e("GeodeLauncher", "Library linkage failure", e)
|
||||
|
||||
// generates helpful information for use in debugging library load failures
|
||||
val gdPackageInfo = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
||||
val abiMismatch = LaunchUtils.detectAbiMismatch(this, gdPackageInfo, e)
|
||||
val abiMismatch = GamePackageUtils.detectAbiMismatch(this, gdPackageInfo, e)
|
||||
|
||||
val errorMessage = if (abiMismatch) getString(R.string.load_failed_abi_error_description) else
|
||||
getString(R.string.load_failed_link_error_description)
|
||||
val is64bit = LaunchUtils.is64bit
|
||||
val errorMessage = when {
|
||||
abiMismatch && is64bit -> LaunchUtils.LauncherError.LINKER_NEEDS_32BIT
|
||||
abiMismatch -> LaunchUtils.LauncherError.LINKER_NEEDS_64BIT
|
||||
e.message?.contains("__gxx_personality_v0") == true ->
|
||||
LaunchUtils.LauncherError.LINKER_FAILED_STL
|
||||
else -> LaunchUtils.LauncherError.LINKER_FAILED
|
||||
}
|
||||
|
||||
returnToMain(
|
||||
getString(R.string.load_failed_link_error),
|
||||
errorMessage
|
||||
)
|
||||
returnToMain(errorMessage, e.message, e.stackTraceToString())
|
||||
|
||||
return
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
Log.e("GeodeLauncher", "Uncaught error during game load", e)
|
||||
|
||||
returnToMain(
|
||||
getString(R.string.load_failed_generic_error),
|
||||
getString(R.string.load_failed_generic_error_description, e.message ?: "UnknownException")
|
||||
LaunchUtils.LauncherError.GENERIC,
|
||||
e.message ?: "Unknown Exception",
|
||||
e.stackTraceToString()
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
mGLSurfaceView?.sendKeyBack()
|
||||
}
|
||||
})
|
||||
mGLSurfaceView?.manualBackEvents = true
|
||||
}
|
||||
|
||||
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 {
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
|
||||
if (!returnTitle.isNullOrEmpty() && !returnMessage.isNullOrEmpty()) {
|
||||
putExtra(Constants.LAUNCHER_KEY_RETURN_TITLE, returnTitle)
|
||||
putExtra(Constants.LAUNCHER_KEY_RETURN_MESSAGE, returnMessage)
|
||||
if (error != null && !returnMessage.isNullOrEmpty()) {
|
||||
putExtra(LaunchUtils.LAUNCHER_KEY_RETURN_ERROR, error)
|
||||
putExtra(LaunchUtils.LAUNCHER_KEY_RETURN_MESSAGE, returnMessage)
|
||||
putExtra(LaunchUtils.LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE, returnExtendedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,44 +161,58 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
||||
|
||||
setupRedirection(gdPackageInfo)
|
||||
|
||||
// request read and write permissions
|
||||
val hasStoragePerms = (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) and
|
||||
(checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED)
|
||||
|
||||
if (!hasStoragePerms) {
|
||||
requestPermissions(
|
||||
arrayOf(
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE,
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
),
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
Cocos2dxHelper.init(this, this)
|
||||
|
||||
GeodeUtils.setContext(this)
|
||||
GeodeUtils.setCapabilityListener(this)
|
||||
|
||||
tryLoadLibrary(gdPackageInfo, Constants.FMOD_LIB_NAME)
|
||||
tryLoadLibrary(gdPackageInfo, Constants.COCOS_LIB_NAME)
|
||||
|
||||
if (getLoadTesting()) {
|
||||
loadTestingLibraries()
|
||||
if (GamePackageUtils.getGameVersionCode(packageManager) >= 39L) {
|
||||
/*
|
||||
val customSymbols = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.CUSTOM_SYMBOL_LIST)
|
||||
if (customSymbols) {
|
||||
val symbolFile = File(LaunchUtils.getBaseDirectory(this), "exception_symbols.txt")
|
||||
LauncherFix.enableCustomSymbolList(symbolFile.path)
|
||||
}
|
||||
*/
|
||||
|
||||
// this fix requires geode v3, which is 2.206+
|
||||
// there is a short period in which 2.206 users will still have geode v2, but whatever. ig
|
||||
LauncherFix.performExceptionsRenaming()
|
||||
}
|
||||
|
||||
loadInternalMods()
|
||||
|
||||
setContentView(createView())
|
||||
|
||||
setupPostLibraryLoad(gdPackageInfo)
|
||||
|
||||
if (!loadGeodeLibrary()) {
|
||||
Log.w("GeodeLauncher", "could not load Geode object!")
|
||||
try {
|
||||
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) {
|
||||
try {
|
||||
LaunchUtils.addAssetsFromPackage(assets, packageInfo)
|
||||
GamePackageUtils.addAssetsFromPackage(assets, packageInfo)
|
||||
} catch (e: NoSuchMethodException) {
|
||||
e.printStackTrace()
|
||||
Log.e("GeodeLauncher", "Failed to add asset redirection", e)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -149,34 +220,94 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
||||
val saveDir = LaunchUtils.getSaveDirectory(this)
|
||||
saveDir.mkdir()
|
||||
|
||||
// make sure it's loaded, just in case
|
||||
System.loadLibrary("c++_shared")
|
||||
|
||||
LauncherFix.loadLibrary()
|
||||
LauncherFix.setOriginalDataPath(Constants.GJ_DATA_DIR)
|
||||
LauncherFix.setDataPath(saveDir.path)
|
||||
} catch (e: UnsatisfiedLinkError) {
|
||||
e.printStackTrace()
|
||||
Log.e("GeodeLauncher", "Failed to load LauncherFix", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupPostLibraryLoad(packageInfo: PackageInfo) {
|
||||
FMOD.init(this)
|
||||
|
||||
// call native functions after native libraries init
|
||||
JniToCpp.setupHSSAssets(
|
||||
packageInfo.applicationInfo.sourceDir,
|
||||
packageInfo.applicationInfo!!.sourceDir,
|
||||
Environment.getExternalStorageDirectory().absolutePath
|
||||
)
|
||||
Cocos2dxHelper.nativeSetApkPath(packageInfo.applicationInfo.sourceDir)
|
||||
Cocos2dxHelper.nativeSetApkPath(packageInfo.applicationInfo!!.sourceDir)
|
||||
|
||||
BaseRobTopActivity.setCurrentActivity(this)
|
||||
registerReceiver()
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||
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"
|
||||
|
||||
try {
|
||||
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 {
|
||||
@@ -205,26 +336,26 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||
private fun loadGeodeLibrary(): Boolean {
|
||||
private fun loadGeodeLibrary() {
|
||||
// Load Geode if exists
|
||||
// bundling the object with the application allows for nicer backtraces
|
||||
try {
|
||||
// put libgeode.so in jniLibs/armeabi-v7a to get this
|
||||
System.loadLibrary("geode")
|
||||
return true
|
||||
return
|
||||
} catch (e: UnsatisfiedLinkError) {
|
||||
// but users may prefer it stored with data
|
||||
val geodeFilename = LaunchUtils.geodeFilename
|
||||
val geodePath = File(filesDir.path, "launcher/$geodeFilename")
|
||||
if (geodePath.exists()) {
|
||||
System.load(geodePath.path)
|
||||
return true
|
||||
return
|
||||
}
|
||||
|
||||
// you know zmx i have 0 clue what this does so im
|
||||
// just gonna like copy the binary from external
|
||||
// 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")
|
||||
if (copiedPath.exists()) {
|
||||
@@ -241,25 +372,41 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
||||
)
|
||||
|
||||
if (copiedGeodePath.exists()) {
|
||||
try {
|
||||
println("Loading Geode from ${externalGeodePath.name}")
|
||||
System.load(copiedGeodePath.path)
|
||||
} catch (e: UnsatisfiedLinkError) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun createView(): FrameLayout {
|
||||
val frameLayoutParams = ViewGroup.LayoutParams(-1, -1)
|
||||
val frameLayout = FrameLayout(this)
|
||||
val frameLayoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
val frameLayout = ConstrainedFrameLayout(this)
|
||||
frameLayout.layoutParams = frameLayoutParams
|
||||
val editTextLayoutParams = ViewGroup.LayoutParams(-1, -2)
|
||||
val editText = Cocos2dxEditText(this)
|
||||
|
||||
if (displayMode == DisplayMode.LIMITED) {
|
||||
frameLayout.aspectRatio = mAspectRatio
|
||||
}
|
||||
|
||||
if (mScreenZoomFit && mScreenZoom != 1.0f) {
|
||||
frameLayout.fitZoom = true
|
||||
}
|
||||
|
||||
if (mScreenZoom != 1.0f) {
|
||||
frameLayout.zoom = mScreenZoom
|
||||
}
|
||||
|
||||
val editTextLayoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
val editText = AppCompatEditText(this)
|
||||
editText.layoutParams = editTextLayoutParams
|
||||
frameLayout.addView(editText)
|
||||
|
||||
@@ -268,16 +415,35 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
||||
this.mGLSurfaceView = glSurfaceView
|
||||
frameLayout.addView(this.mGLSurfaceView)
|
||||
|
||||
glSurfaceView.setEGLConfigChooser(5, 6, 5, 0, 16, 8)
|
||||
val showNotification = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.ENABLE_REDESIGN)
|
||||
if (showNotification) {
|
||||
val hasCards = determineDisplayedCards(this)
|
||||
if (hasCards.isNotEmpty()) {
|
||||
val notificationView = ComposeView(this)
|
||||
frameLayout.addView(notificationView)
|
||||
|
||||
if (isAndroidEmulator()) {
|
||||
glSurfaceView.setEGLConfigChooser(8, 8, 8, 8, 16, 0)
|
||||
notificationView.setContent {
|
||||
LaunchNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
glSurfaceView.initView()
|
||||
glSurfaceView.setCocos2dxRenderer(Cocos2dxRenderer())
|
||||
glSurfaceView.setEGLContextClientVersion(2)
|
||||
glSurfaceView.setEGLConfigChooser(EGLConfigChooser())
|
||||
|
||||
editText.inputType = 145
|
||||
glSurfaceView.initView()
|
||||
|
||||
val renderer = Cocos2dxRenderer(glSurfaceView)
|
||||
glSurfaceView.setCocos2dxRenderer(renderer)
|
||||
|
||||
renderer.setFrameRate = mForceRefreshRate
|
||||
|
||||
val frameRate = mLimitedRefreshRate
|
||||
if (frameRate != null) {
|
||||
renderer.limitFrameRate(frameRate)
|
||||
}
|
||||
|
||||
editText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
||||
glSurfaceView.cocos2dxEditText = editText
|
||||
|
||||
return frameLayout
|
||||
@@ -286,6 +452,39 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
||||
private fun setupUIState() {
|
||||
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE
|
||||
|
||||
val preferenceUtils = PreferenceUtils.get(this)
|
||||
|
||||
displayMode = DisplayMode.fromInt(
|
||||
preferenceUtils.getInt(PreferenceUtils.Key.DISPLAY_MODE)
|
||||
)
|
||||
|
||||
mAspectRatio = ratioForPreference(preferenceUtils.getString(PreferenceUtils.Key.CUSTOM_ASPECT_RATIO) ?: "16_9")
|
||||
mScreenZoom = preferenceUtils.getInt(PreferenceUtils.Key.SCREEN_ZOOM) / 100.0f
|
||||
mScreenZoomFit = preferenceUtils.getBoolean(PreferenceUtils.Key.SCREEN_ZOOM_FIT)
|
||||
|
||||
val limitedRefreshRate = preferenceUtils.getInt(PreferenceUtils.Key.LIMIT_FRAME_RATE).takeIf { it != 0 }
|
||||
mLimitedRefreshRate = limitedRefreshRate
|
||||
|
||||
mForceRefreshRate = true
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val chosenDisplay = display.supportedModes?.maxByOrNull { it.refreshRate }
|
||||
if (chosenDisplay != null) {
|
||||
var chosenRefreshRate = chosenDisplay.refreshRate
|
||||
println("setupUIState: Forcing a refresh rate of $chosenRefreshRate (display ${chosenDisplay.modeId})")
|
||||
|
||||
window.attributes.preferredRefreshRate = chosenRefreshRate
|
||||
} else {
|
||||
println("setupUIState could not find a display to select refresh rate of...")
|
||||
}
|
||||
}
|
||||
|
||||
if (displayMode == DisplayMode.FULLSCREEN && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
window.attributes.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
|
||||
GeodeUtils.handleSafeArea = true
|
||||
} else {
|
||||
GeodeUtils.handleSafeArea = false
|
||||
}
|
||||
|
||||
hideSystemUi()
|
||||
}
|
||||
|
||||
@@ -311,19 +510,18 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
unregisterReceivers()
|
||||
FMOD.close()
|
||||
}
|
||||
|
||||
private fun resumeGame() {
|
||||
mIsRunning = true
|
||||
// Cocos2dxHelper.onResume()
|
||||
Cocos2dxHelper.onResume()
|
||||
mGLSurfaceView?.onResume()
|
||||
}
|
||||
|
||||
private fun pauseGame() {
|
||||
mIsRunning = false
|
||||
// Cocos2dxHelper.onPause()
|
||||
Cocos2dxHelper.onPause()
|
||||
mGLSurfaceView?.onPause()
|
||||
}
|
||||
|
||||
@@ -336,38 +534,24 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRestart() {
|
||||
super.onRestart()
|
||||
mIsOnPause = false
|
||||
BaseRobTopActivity.isPaused = false
|
||||
if (!this.mIsRunning) {
|
||||
resumeGame()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
mIsOnPause = true
|
||||
BaseRobTopActivity.isPaused = true
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
if (mIsRunning) {
|
||||
pauseGame()
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerReceiver() {
|
||||
unregisterReceivers()
|
||||
try {
|
||||
val filter = IntentFilter(Intent.ACTION_SCREEN_ON)
|
||||
filter.addAction(Intent.ACTION_SCREEN_OFF)
|
||||
filter.addAction(Intent.ACTION_USER_PRESENT)
|
||||
mReceiver = BaseRobTopActivity.ReceiverScreen()
|
||||
registerReceiver(mReceiver, filter)
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterReceivers() {
|
||||
if (mReceiver != null) {
|
||||
unregisterReceiver(mReceiver)
|
||||
mReceiver = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun runOnGLThread(runnable: Runnable) {
|
||||
mGLSurfaceView?.queueEvent(runnable)
|
||||
}
|
||||
@@ -394,50 +578,126 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
private fun isAndroidEmulator(): Boolean {
|
||||
Log.d(sTag, "model=" + Build.MODEL)
|
||||
val product = Build.PRODUCT
|
||||
Log.d(sTag, "product=$product")
|
||||
var isEmulator = false
|
||||
if (product != null) {
|
||||
isEmulator = product == "sdk" || product.contains("_sdk") || product.contains("sdk_")
|
||||
override fun onCapabilityAdded(capability: String): Boolean {
|
||||
return when (capability) {
|
||||
GeodeUtils.CAPABILITY_EXTENDED_INPUT -> {
|
||||
mGLSurfaceView?.useKeyboardEvents = true
|
||||
true
|
||||
}
|
||||
GeodeUtils.CAPABILITY_TIMESTAMP_INPUT -> {
|
||||
mGLSurfaceView?.sendTimestampEvents = true
|
||||
true
|
||||
}
|
||||
GeodeUtils.CAPABILITY_INTERNAL_CALLBACKS -> {
|
||||
mGLSurfaceView?.sendInternalTimestampEvents = true
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
Log.d(sTag, "isEmulator=$isEmulator")
|
||||
return isEmulator
|
||||
}
|
||||
|
||||
private fun getLoadTesting(): Boolean {
|
||||
val preferences = PreferenceUtils.get(this)
|
||||
return preferences.getBoolean(PreferenceUtils.Key.LOAD_TESTING)
|
||||
/**
|
||||
* Copies a mod from the launcher's assets to the Geode mods directory.
|
||||
* This method is not recommended for casual use, the new mod will not be automatically removed.
|
||||
*/
|
||||
private fun loadInternalMods() {
|
||||
val internalModBase = "mods"
|
||||
|
||||
val modListing = try {
|
||||
assets.list(internalModBase)
|
||||
} catch (_: IOException) {
|
||||
emptyArray<String>()
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||
private fun loadTestingLibraries() {
|
||||
// clear data dir
|
||||
val testDirPath = File(filesDir.path + File.separator + "testlib" + File.separator)
|
||||
if (testDirPath.exists()) {
|
||||
testDirPath.deleteRecursively()
|
||||
}
|
||||
testDirPath.mkdir()
|
||||
|
||||
val dir = LaunchUtils.getBaseDirectory(this)
|
||||
val testingPath = File(dir, "test")
|
||||
|
||||
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)
|
||||
val modDirectory = File(
|
||||
LaunchUtils.getBaseDirectory(this),
|
||||
"game/geode/mods"
|
||||
)
|
||||
|
||||
try {
|
||||
println("Loading test library ${outputFile.name}")
|
||||
System.load(outputFile.path)
|
||||
} catch (e: UnsatisfiedLinkError) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
modDirectory.mkdirs()
|
||||
|
||||
modListing?.forEach { fileName ->
|
||||
if (fileName.endsWith(".geode")) {
|
||||
val modOutput = File(modDirectory, fileName)
|
||||
|
||||
val mod = assets.open("$internalModBase/$fileName")
|
||||
DownloadUtils.copyFile(mod, modOutput.outputStream())
|
||||
|
||||
println("Copied internal mod $fileName")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EGLConfigChooser : GLSurfaceView.EGLConfigChooser {
|
||||
// this comes from EGL14, but is unavailable on EGL10
|
||||
// also EGL14 is incompatible with EGL10. so whatever
|
||||
private var EGL_OPENGL_ES2_BIT: Int = 0x04
|
||||
|
||||
private data class ConfigValues(
|
||||
val redSize: Int,
|
||||
val greenSize: Int,
|
||||
val blueSize: Int,
|
||||
val alphaSize: Int,
|
||||
val depthSize: Int,
|
||||
val stencilSize: Int,
|
||||
)
|
||||
|
||||
private fun getAttribValue(egl: EGL10, display: EGLDisplay, config: EGLConfig, attrib: Int): Int {
|
||||
val value = IntArray(1)
|
||||
egl.eglGetConfigAttrib(display, config, attrib, value)
|
||||
|
||||
return value[0]
|
||||
}
|
||||
|
||||
private fun testConfig(egl: EGL10, display: EGLDisplay, attrib: ConfigValues): EGLConfig? {
|
||||
val attribList = intArrayOf(
|
||||
EGL10.EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,
|
||||
EGL10.EGL_RED_SIZE, attrib.redSize,
|
||||
EGL10.EGL_GREEN_SIZE, attrib.greenSize,
|
||||
EGL10.EGL_BLUE_SIZE, attrib.blueSize,
|
||||
EGL10.EGL_ALPHA_SIZE, attrib.alphaSize,
|
||||
EGL10.EGL_DEPTH_SIZE, attrib.depthSize,
|
||||
EGL10.EGL_STENCIL_SIZE, attrib.stencilSize,
|
||||
EGL10.EGL_NONE
|
||||
)
|
||||
|
||||
val configs = arrayOfNulls<EGLConfig>(1)
|
||||
val numConfigs = IntArray(1)
|
||||
|
||||
val res = egl.eglChooseConfig(display, attribList, configs, configs.size, numConfigs)
|
||||
if (res && numConfigs[0] > 0 && configs[0] != null) {
|
||||
return configs[0]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun printConfig(egl: EGL10, display: EGLDisplay, config: EGLConfig) {
|
||||
val id = getAttribValue(egl, display, config, EGL10.EGL_CONFIG_ID)
|
||||
val red = getAttribValue(egl, display, config, EGL10.EGL_RED_SIZE)
|
||||
val green = getAttribValue(egl, display, config, EGL10.EGL_GREEN_SIZE)
|
||||
val blue = getAttribValue(egl, display, config, EGL10.EGL_BLUE_SIZE)
|
||||
val alpha = getAttribValue(egl, display, config, EGL10.EGL_ALPHA_SIZE)
|
||||
val depth = getAttribValue(egl, display, config, EGL10.EGL_DEPTH_SIZE)
|
||||
val stencil = getAttribValue(egl, display, config, EGL10.EGL_STENCIL_SIZE)
|
||||
println("EGLConfig $id: (red = $red, green = $green, blue = $blue, alpha = $alpha, depth = $depth, stencil = $stencil)")
|
||||
}
|
||||
|
||||
override fun chooseConfig(egl: EGL10, display: EGLDisplay): EGLConfig {
|
||||
val configs = listOf(
|
||||
ConfigValues(8, 8, 8, 8, 16, 8),
|
||||
ConfigValues(8, 8, 8, 0, 16, 8),
|
||||
ConfigValues(5, 6, 5, 0, 16, 8),
|
||||
ConfigValues(0, 0, 0, 0, 0, 0),
|
||||
)
|
||||
|
||||
return configs.firstNotNullOf {
|
||||
val config = testConfig(egl, display, it)
|
||||
if (config != null) {
|
||||
printConfig(egl, display, config)
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.geode.launcher
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import com.geode.launcher.utils.Constants
|
||||
|
||||
@Keep
|
||||
object LauncherFix {
|
||||
fun loadLibrary() {
|
||||
System.loadLibrary(Constants.LAUNCHER_FIX_LIB_NAME)
|
||||
@@ -10,4 +12,8 @@ object LauncherFix {
|
||||
external fun setDataPath(dataPath: String)
|
||||
|
||||
external fun setOriginalDataPath(dataPath: String)
|
||||
|
||||
external fun performExceptionsRenaming()
|
||||
|
||||
external fun enableCustomSymbolList(symbolsPath: String)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
package com.geode.launcher
|
||||
|
||||
import android.content.Context
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.text.format.Formatter.formatShortFileSize
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
@@ -11,11 +12,6 @@ import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
@@ -28,59 +24,86 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.geode.launcher.updater.ReleaseViewModel
|
||||
import com.geode.launcher.main.*
|
||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||
import com.geode.launcher.ui.theme.LocalTheme
|
||||
import com.geode.launcher.ui.theme.Theme
|
||||
import com.geode.launcher.ui.theme.Typography
|
||||
import com.geode.launcher.ui.theme.launcherTitleStyle
|
||||
import com.geode.launcher.updater.ReleaseViewModel
|
||||
import com.geode.launcher.utils.Constants
|
||||
import com.geode.launcher.utils.GamePackageUtils
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import com.geode.launcher.updater.ReleaseManager
|
||||
import com.geode.launcher.utils.useCountdownTimer
|
||||
import java.net.ConnectException
|
||||
import java.net.UnknownHostException
|
||||
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
clearDownloadedApks(this)
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val gdInstalled = LaunchUtils.isGeometryDashInstalled(packageManager)
|
||||
val gdInstalled = GamePackageUtils.isGameInstalled(packageManager)
|
||||
val geodeInstalled = LaunchUtils.isGeodeInstalled(this)
|
||||
|
||||
val returnMessage = intent.extras?.getString(Constants.LAUNCHER_KEY_RETURN_MESSAGE)
|
||||
val returnTitle = intent.extras?.getString(Constants.LAUNCHER_KEY_RETURN_TITLE)
|
||||
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 }
|
||||
|
||||
if (PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.DEVELOPER_SERVER)) {
|
||||
val intent = Intent(this, DeveloperServerService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
|
||||
val name = getString(R.string.notification_channel_development_server)
|
||||
val descriptionText = getString(R.string.notification_channel_development_server_description)
|
||||
val importance = NotificationManager.IMPORTANCE_DEFAULT
|
||||
val mChannel = NotificationChannel("geode_developer_server", name, importance)
|
||||
mChannel.description = descriptionText
|
||||
|
||||
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(mChannel)
|
||||
|
||||
this.startForegroundService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
val redirectToAlt = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.ENABLE_REDESIGN)
|
||||
if (redirectToAlt) {
|
||||
val launchIntent = Intent(this, AltMainActivity::class.java)
|
||||
launchIntent.putExtras(intent)
|
||||
startActivity(launchIntent)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setContent {
|
||||
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||
val theme = Theme.fromInt(themeOption)
|
||||
|
||||
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||
|
||||
CompositionLocalProvider(LocalTheme provides theme) {
|
||||
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
|
||||
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
val loadFailed = !returnMessage.isNullOrEmpty() && !returnTitle.isNullOrEmpty()
|
||||
MainScreen(gdInstalled, geodeInstalled, loadFailed)
|
||||
|
||||
if (!returnMessage.isNullOrEmpty() && !returnTitle.isNullOrEmpty()) {
|
||||
LoadFailedDialog(returnTitle, returnMessage)
|
||||
}
|
||||
MainScreen(gdInstalled, geodeInstalled, loadFailureInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,340 +118,59 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LauncherUpdateIndicator(modifier: Modifier = Modifier, openTo: String, onDismiss: () -> Unit) {
|
||||
val uriHandler = LocalUriHandler.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)
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.align(Alignment.End)) {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.launcher_update_dismiss))
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(4.dp))
|
||||
|
||||
TextButton(onClick = { uriHandler.openUri(openTo) },) {
|
||||
Text(stringResource(R.string.launcher_download))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
},
|
||||
) {
|
||||
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 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) }
|
||||
|
||||
var lastDismissedVersion by PreferenceUtils.useLongPreference(
|
||||
preferenceKey = PreferenceUtils.Key.DISMISSED_GJ_UPDATE
|
||||
)
|
||||
|
||||
if (gdVersionCode != Constants.SUPPORTED_VERSION_CODE && gdVersionCode != lastDismissedVersion) {
|
||||
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
|
||||
lastDismissedVersion = gdVersionCode
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val nextUpdate by releaseViewModel.nextLauncherUpdate.collectAsState()
|
||||
val nextUpdateValue = nextUpdate
|
||||
|
||||
if (!releaseViewModel.isInUpdate && nextUpdateValue != null) {
|
||||
val updateUrl = nextUpdateValue.getLauncherDownload()?.browserDownloadUrl
|
||||
?: nextUpdateValue.htmlUrl
|
||||
|
||||
LauncherUpdateIndicator(
|
||||
modifier = modifier,
|
||||
openTo = updateUrl,
|
||||
onDismiss = {
|
||||
releaseViewModel.dismissLauncherUpdate()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
fun MainScreen(
|
||||
gdInstalled: Boolean = true,
|
||||
geodePreinstalled: Boolean = true,
|
||||
disableAutomaticLaunch: Boolean = false,
|
||||
loadFailureInfo: LoadFailureInfo? = null,
|
||||
releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val shouldAutomaticallyLaunch = PreferenceUtils.useBooleanPreference(
|
||||
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY
|
||||
)
|
||||
|
||||
val shouldUpdate by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
|
||||
|
||||
val autoUpdateState by releaseViewModel.uiState.collectAsState()
|
||||
|
||||
val geodeJustInstalled = (autoUpdateState as? ReleaseViewModel.ReleaseUIState.Finished)
|
||||
?.hasUpdated ?: false
|
||||
?.hasUpdated == true
|
||||
val geodeInstalled = geodePreinstalled || geodeJustInstalled
|
||||
|
||||
var beginLaunch by remember { mutableStateOf(false) }
|
||||
var launchInSafeMode by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(shouldUpdate) {
|
||||
if (shouldUpdate && !releaseViewModel.hasPerformedCheck) {
|
||||
if (shouldUpdate && !releaseViewModel.hasPerformedCheck && gdInstalled) {
|
||||
releaseViewModel.runReleaseCheck()
|
||||
} else {
|
||||
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(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
@@ -436,64 +178,46 @@ fun MainScreen(
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.geode_logo),
|
||||
contentDescription = context.getString(R.string.launcher_logo_alt),
|
||||
modifier = Modifier.size(136.dp, 136.dp)
|
||||
contentDescription = stringResource(R.string.launcher_logo_alt),
|
||||
modifier = Modifier.size(100.dp, 100.dp)
|
||||
)
|
||||
Text(
|
||||
context.getString(R.string.launcher_title),
|
||||
fontSize = 32.sp,
|
||||
stringResource(R.string.launcher_title),
|
||||
style = launcherTitleStyle,
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
|
||||
if (gdInstalled && geodeInstalled) {
|
||||
if (shouldAutomaticallyLaunch.value && !releaseViewModel.isInUpdate && !disableAutomaticLaunch) {
|
||||
val countdownTimer = useCountdownTimer(
|
||||
time = 3000,
|
||||
onCountdownFinish = {
|
||||
// just in case this changed async
|
||||
if (shouldAutomaticallyLaunch.value) {
|
||||
when {
|
||||
gdInstalled && geodeInstalled -> {
|
||||
val gdVersion = remember {
|
||||
GamePackageUtils.getGameVersionCode(context.packageManager)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (countdownTimer != 0L) {
|
||||
if (gdVersion < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
|
||||
UnsupportedVersionWarning()
|
||||
}
|
||||
}
|
||||
}
|
||||
gdInstalled -> {
|
||||
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) {
|
||||
Text(
|
||||
context.getString(R.string.geode_download_title),
|
||||
stringResource(R.string.geode_download_title),
|
||||
modifier = Modifier.padding(12.dp)
|
||||
)
|
||||
|
||||
@@ -504,32 +228,21 @@ fun MainScreen(
|
||||
) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_download),
|
||||
contentDescription = context.getString(R.string.launcher_download_icon_alt)
|
||||
contentDescription = stringResource(R.string.launcher_download_icon_alt)
|
||||
)
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(context.getString(R.string.launcher_download))
|
||||
Text(stringResource(R.string.launcher_download))
|
||||
}
|
||||
Spacer(Modifier.size(2.dp))
|
||||
IconButton(onClick = { onSettings(context) }) {
|
||||
Icon(
|
||||
Icons.Filled.Settings,
|
||||
contentDescription = context.getString(R.string.launcher_settings_icon_alt)
|
||||
painterResource(R.drawable.icon_settings),
|
||||
contentDescription = stringResource(R.string.launcher_settings_icon_alt)
|
||||
)
|
||||
}
|
||||
}
|
||||
} 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(
|
||||
@@ -538,9 +251,13 @@ fun MainScreen(
|
||||
.padding(12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (beginLaunch) {
|
||||
UpdateWarning()
|
||||
UpdateWarning(launchInSafeMode) {
|
||||
beginLaunch = false
|
||||
launchInSafeMode = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,618 +0,0 @@
|
||||
package com.geode.launcher
|
||||
|
||||
import android.app.UiModeManager
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.DocumentsContract
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedDispatcher
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material3.*
|
||||
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.mutableIntStateOf
|
||||
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.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.geode.launcher.updater.ReleaseViewModel
|
||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||
import com.geode.launcher.ui.theme.LocalTheme
|
||||
import com.geode.launcher.ui.theme.Theme
|
||||
import com.geode.launcher.ui.theme.Typography
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import java.net.ConnectException
|
||||
import java.net.UnknownHostException
|
||||
|
||||
class SettingsActivity : 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
|
||||
) {
|
||||
SettingsScreen(
|
||||
onBackPressedDispatcher = onBackPressedDispatcher
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
fun onExportSaveData(context: Context) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"This function is not implemented yet!",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
*/
|
||||
|
||||
fun onOpenFolder(context: Context) {
|
||||
val file = LaunchUtils.getBaseDirectory(context)
|
||||
val clipboardManager =
|
||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboardManager.setPrimaryClip(
|
||||
ClipData.newPlainText(
|
||||
context.getString(R.string.export_folder_tag),
|
||||
file.path
|
||||
)
|
||||
)
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.text_copied),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun onOpenFileManager(context: Context) {
|
||||
// future thoughts: Intent.ACTION_VIEW may not work on some devices
|
||||
// the "correct" solution is to go through a list of possible intents/applications
|
||||
// don't feel like writing that behavior. this is good enough
|
||||
|
||||
val intent = 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
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
context.startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getText(R.string.no_activity_found), Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UpdateIndicator(
|
||||
snackbarHostState: SnackbarHostState,
|
||||
updateStatus: ReleaseViewModel.ReleaseUIState
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
var enablePopup by remember { mutableStateOf(false) }
|
||||
|
||||
when (updateStatus) {
|
||||
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
|
||||
enablePopup = true
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
is ReleaseViewModel.ReleaseUIState.InDownload -> {
|
||||
// is this the ideal design? idk
|
||||
enablePopup = true
|
||||
val progress = updateStatus.downloaded / updateStatus.outOf.toDouble()
|
||||
|
||||
CircularProgressIndicator(progress.toFloat())
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
LaunchedEffect(updateStatus) {
|
||||
// only show popup if some progress was shown
|
||||
if (!enablePopup) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
when (updateStatus) {
|
||||
is ReleaseViewModel.ReleaseUIState.Failure -> {
|
||||
val message = when (updateStatus.exception) {
|
||||
is UnknownHostException, is ConnectException ->
|
||||
context.getString(R.string.release_fetch_no_internet)
|
||||
else -> context.getString(R.string.preference_check_for_updates_failed)
|
||||
}
|
||||
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
is ReleaseViewModel.ReleaseUIState.Finished -> {
|
||||
if (updateStatus.hasUpdated) {
|
||||
snackbarHostState.showSnackbar(
|
||||
context.getString(R.string.preference_check_for_updates_success)
|
||||
)
|
||||
} else {
|
||||
snackbarHostState.showSnackbar(
|
||||
context.getString(R.string.preference_check_for_updates_none_found)
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun themeToKey(theme: Int): String {
|
||||
return when (theme) {
|
||||
1 -> stringResource(R.string.preference_theme_light)
|
||||
2 -> stringResource(R.string.preference_theme_dark)
|
||||
else -> stringResource(R.string.preference_theme_default)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateTheme(context: Context, theme: Int) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
|
||||
uiModeManager.setApplicationNightMode(
|
||||
when (theme) {
|
||||
1 -> UiModeManager.MODE_NIGHT_NO
|
||||
2 -> UiModeManager.MODE_NIGHT_YES
|
||||
else -> UiModeManager.MODE_NIGHT_CUSTOM
|
||||
}
|
||||
)
|
||||
} else {
|
||||
AppCompatDelegate.setDefaultNightMode(
|
||||
when (theme) {
|
||||
1 -> AppCompatDelegate.MODE_NIGHT_NO
|
||||
2 -> AppCompatDelegate.MODE_NIGHT_YES
|
||||
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onOpenLogs(context: Context) {
|
||||
val launchIntent = Intent(context, ApplicationLogsActivity::class.java)
|
||||
context.startActivity(launchIntent)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||
releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val currentRelease by PreferenceUtils.useStringPreference(PreferenceUtils.Key.CURRENT_VERSION_TAG)
|
||||
val updateStatus by releaseViewModel.uiState.collectAsState()
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var showUpdateInProgress by remember { mutableStateOf(false) }
|
||||
|
||||
if (showUpdateInProgress) {
|
||||
LaunchedEffect(snackbarHostState) {
|
||||
snackbarHostState.showSnackbar(
|
||||
context.getString(R.string.preference_check_for_updates_already_updating)
|
||||
)
|
||||
showUpdateInProgress = false
|
||||
}
|
||||
}
|
||||
|
||||
// fix theme transition by giving it the exact same animation as the top bar
|
||||
val containerColor by animateColorAsState(
|
||||
targetValue = MaterialTheme.colorScheme.background,
|
||||
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
|
||||
label = "background color"
|
||||
)
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
containerColor = containerColor,
|
||||
snackbarHost = {
|
||||
SnackbarHost(hostState = snackbarHostState)
|
||||
},
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowBack,
|
||||
contentDescription = context.getString(R.string.back_icon_alt)
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
context.getString(R.string.title_activity_settings),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Column(
|
||||
Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OptionsGroup(context.getString(R.string.preference_category_testing)) {
|
||||
SettingsSelectCard(
|
||||
title = stringResource(R.string.preference_theme_name),
|
||||
dialogTitle = stringResource(R.string.preference_theme_select),
|
||||
maxVal = 2,
|
||||
preferenceKey = PreferenceUtils.Key.THEME,
|
||||
toLabel = { themeToKey(it) },
|
||||
extraSelectBehavior = { updateTheme(context, it) }
|
||||
)
|
||||
SettingsCard(
|
||||
title = stringResource(R.string.preference_black_background_name),
|
||||
preferenceKey = PreferenceUtils.Key.BLACK_BACKGROUND
|
||||
)
|
||||
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(
|
||||
title = stringResource(R.string.preferences_open_file_manager),
|
||||
onClick = { onOpenFileManager(context) }
|
||||
)
|
||||
OptionsButton(
|
||||
title = stringResource(R.string.preferences_view_logs),
|
||||
onClick = { onOpenLogs(context) }
|
||||
)
|
||||
}
|
||||
|
||||
OptionsGroup(context.getString(R.string.preference_category_updater)) {
|
||||
SettingsCard(
|
||||
title = context.getString(R.string.preference_update_automatically_name),
|
||||
preferenceKey = PreferenceUtils.Key.UPDATE_AUTOMATICALLY,
|
||||
)
|
||||
SettingsCard(
|
||||
title = context.getString(R.string.preference_release_channel_name),
|
||||
preferenceKey = PreferenceUtils.Key.RELEASE_CHANNEL,
|
||||
)
|
||||
OptionsCard(
|
||||
title = {
|
||||
OptionsTitle(
|
||||
title = stringResource(R.string.preference_check_for_updates_button),
|
||||
description = stringResource(
|
||||
R.string.preference_check_for_updates_description,
|
||||
currentRelease ?: "unknown"
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
onClick = {
|
||||
if (releaseViewModel.isInUpdate) {
|
||||
showUpdateInProgress = true
|
||||
} else {
|
||||
releaseViewModel.runReleaseCheck(true)
|
||||
}
|
||||
},
|
||||
role = Role.Button
|
||||
)
|
||||
) {
|
||||
UpdateIndicator(snackbarHostState, updateStatus)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
stringResource(R.string.preference_launcher_version, BuildConfig.VERSION_NAME),
|
||||
style = Typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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 = 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)
|
||||
@Composable
|
||||
fun DefaultPreview() {
|
||||
GeodeLauncherTheme {
|
||||
SettingsScreen(null)
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,13 @@ package com.geode.launcher
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.DocumentsProvider
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import java.io.File
|
||||
|
||||
@@ -38,7 +40,7 @@ class UserDirectoryProvider : DocumentsProvider() {
|
||||
|
||||
override fun onCreate(): Boolean {
|
||||
val context = context ?: return false
|
||||
rootDir = LaunchUtils.getBaseDirectory(context)
|
||||
rootDir = LaunchUtils.getBaseDirectory(context, true)
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -150,7 +152,7 @@ class UserDirectoryProvider : DocumentsProvider() {
|
||||
notifyFileChange(file)
|
||||
}
|
||||
|
||||
override fun renameDocument(documentId: String, displayName: String): String? {
|
||||
override fun renameDocument(documentId: String, displayName: String): String {
|
||||
val file = getFileForDocumentId(documentId)
|
||||
val dest = findFileNameForNewFile(File(file.parentFile, displayName))
|
||||
file.renameTo(dest)
|
||||
@@ -203,4 +205,19 @@ class UserDirectoryProvider : DocumentsProvider() {
|
||||
val uri = getDocumentUri(parentDocumentId)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.geode.launcher.log
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
data class CrashDump @OptIn(ExperimentalTime::class) constructor(
|
||||
val filename: String,
|
||||
val lastModified: Instant
|
||||
)
|
||||
|
||||
class CrashViewModel(private val application: Application) : ViewModel() {
|
||||
companion object {
|
||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as Application
|
||||
|
||||
CrashViewModel(
|
||||
application = application
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val _lineState = mutableStateListOf<CrashDump>()
|
||||
val lineState = _lineState
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading = _isLoading.asStateFlow()
|
||||
|
||||
private var failedToLoad = false
|
||||
|
||||
private var logJob: Job? = null
|
||||
|
||||
private fun getCrashList(): List<CrashDump> {
|
||||
val crashDirectory = LaunchUtils.getCrashDirectory(application)
|
||||
if (!crashDirectory.exists()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val children = crashDirectory.listFiles {
|
||||
// ignore indicator files (including old file)
|
||||
_, name -> name != LaunchUtils.CRASH_INDICATOR_NAME && name != "last-pid"
|
||||
} ?: return emptyList()
|
||||
|
||||
return children
|
||||
.map {
|
||||
CrashDump(
|
||||
filename = it.name,
|
||||
lastModified = Instant.fromEpochMilliseconds(it.lastModified())
|
||||
)
|
||||
}
|
||||
.sortedByDescending { it.lastModified }
|
||||
}
|
||||
|
||||
fun clearCrashes() {
|
||||
logJob?.cancel()
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val crashDirectory = LaunchUtils.getCrashDirectory(application)
|
||||
if (crashDirectory.exists()) {
|
||||
val files = crashDirectory.listFiles()
|
||||
files?.forEach {
|
||||
it.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_lineState.clear()
|
||||
}
|
||||
|
||||
private fun loadLogs() {
|
||||
_isLoading.value = true
|
||||
|
||||
logJob?.cancel()
|
||||
logJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
val lines = getCrashList()
|
||||
|
||||
_lineState.clear()
|
||||
_lineState.addAll(lines)
|
||||
|
||||
_isLoading.value = false
|
||||
|
||||
logJob = null
|
||||
}
|
||||
}
|
||||
|
||||
fun removeCrash(filename: String) {
|
||||
logJob?.cancel()
|
||||
|
||||
val crashPath = File(LaunchUtils.getCrashDirectory(application), filename)
|
||||
if (crashPath.exists()) {
|
||||
crashPath.delete()
|
||||
loadLogs()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
loadLogs()
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
package com.geode.launcher.log
|
||||
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toJavaInstant
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import okio.Buffer
|
||||
import okio.BufferedSource
|
||||
import java.io.IOException
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.toJavaInstant
|
||||
|
||||
fun BufferedSource.readCChar(): Char {
|
||||
return this.readUtf8CodePoint().toChar()
|
||||
@@ -24,14 +29,74 @@ fun BufferedSource.readCString(): String {
|
||||
return buffer.toString()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ProcessInformation(val processId: Int, val threadId: Int, val processUid: Int)
|
||||
|
||||
@Serializable
|
||||
enum class LogPriority {
|
||||
UNKNOWN, DEFAULT, VERBOSE, DEBUG, INFO, WARN, ERROR, FATAL, SILENT;
|
||||
@SerialName("unknown")
|
||||
UNKNOWN,
|
||||
@SerialName("default")
|
||||
DEFAULT,
|
||||
@SerialName("verbose")
|
||||
VERBOSE,
|
||||
@SerialName("debug")
|
||||
DEBUG,
|
||||
@SerialName("info")
|
||||
INFO,
|
||||
@SerialName("warn")
|
||||
WARN,
|
||||
@SerialName("error")
|
||||
ERROR,
|
||||
@SerialName("fatal")
|
||||
FATAL,
|
||||
@SerialName("silent")
|
||||
SILENT;
|
||||
|
||||
class LogPriorityIterator(
|
||||
start: LogPriority,
|
||||
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 {
|
||||
return when (byte.toInt()) {
|
||||
fun fromByte(byte: Byte): LogPriority = fromInt(byte.toInt())
|
||||
|
||||
fun fromInt(int: Int) = when (int) {
|
||||
0x1 -> DEFAULT
|
||||
0x2 -> VERBOSE
|
||||
0x3 -> DEBUG
|
||||
@@ -43,6 +108,17 @@ enum class LogPriority {
|
||||
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 {
|
||||
@@ -62,6 +138,7 @@ enum class LogPriority {
|
||||
/**
|
||||
* Represents a log entry from logcat.
|
||||
*/
|
||||
@Serializable
|
||||
data class LogLine(
|
||||
val process: ProcessInformation,
|
||||
val time: Instant,
|
||||
@@ -81,6 +158,7 @@ data class LogLine(
|
||||
else -> throw IOException("LogLine::fromInputStream: unknown format for (headerSize = $size)")
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
fun fromBufferedSource(source: BufferedSource): LogLine {
|
||||
/*
|
||||
// from android <liblog/include/log/log_read.h>
|
||||
@@ -109,7 +187,7 @@ data class LogLine(
|
||||
};
|
||||
*/
|
||||
|
||||
/* val payloadLength = */ source.readShortLe()
|
||||
val payloadLength = source.readShortLe().toLong()
|
||||
val headerSize = source.readShortLe().toUShort()
|
||||
|
||||
val entryVersion = headerSizeToVersion(headerSize)
|
||||
@@ -125,11 +203,17 @@ data class LogLine(
|
||||
val processInformation = ProcessInformation(pid, tid, uid)
|
||||
val time = Instant.fromEpochSeconds(sec, nSec)
|
||||
|
||||
val priorityByte = source.readByte()
|
||||
// the payload is split into three parts
|
||||
// initial priority byte -> null terminated tag -> non null terminated message
|
||||
|
||||
val packetBuffer = Buffer()
|
||||
source.readFully(packetBuffer, payloadLength)
|
||||
|
||||
val priorityByte = packetBuffer.readByte()
|
||||
val priority = LogPriority.fromByte(priorityByte)
|
||||
|
||||
val tag = source.readCString()
|
||||
val message = source.readCString()
|
||||
val tag = packetBuffer.readCString()
|
||||
val message = packetBuffer.readUtf8()
|
||||
|
||||
return LogLine(
|
||||
process = processInformation,
|
||||
@@ -137,7 +221,7 @@ data class LogLine(
|
||||
time = time,
|
||||
logId = lid,
|
||||
tag = tag,
|
||||
message = message
|
||||
message = message.trimEnd('\u0000')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -151,11 +235,15 @@ data class LogLine(
|
||||
)
|
||||
}
|
||||
|
||||
@Transient
|
||||
val identifier = time.toJavaInstant()
|
||||
|
||||
val messageTrimmed by lazy { this.message.trim() }
|
||||
val messageTrimmed by lazy {
|
||||
this.message.trim { it <= ' ' }
|
||||
}
|
||||
|
||||
val formattedTime by lazy { this.time.toLocalDateTime(TimeZone.currentSystemDefault()) }
|
||||
|
||||
val asSimpleString by lazy {
|
||||
"$formattedTime [${this.priority.toChar()}/${this.tag}]: ${this.messageTrimmed}"
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@ import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.yield
|
||||
import okio.Buffer
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import java.io.EOFException
|
||||
import java.util.LinkedList
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* ViewModel to manage log output from logcat.
|
||||
@@ -30,11 +32,22 @@ class LogViewModel: ViewModel() {
|
||||
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
|
||||
|
||||
@@ -43,23 +56,36 @@ class LogViewModel: ViewModel() {
|
||||
}
|
||||
|
||||
private suspend fun logOutput(): List<LogLine> {
|
||||
val logLines = LinkedList<LogLine>()
|
||||
val logLines = ArrayList<LogLine>()
|
||||
|
||||
// -B = binary format, -d = dump logs
|
||||
val logCommand = if (filterCrashes) "logcat -b crash -B -d"
|
||||
else "logcat -B -d"
|
||||
|
||||
// -B = binary format, -d = dump logs
|
||||
val logSource = Runtime.getRuntime().exec(logCommand)
|
||||
.inputStream.source().buffer()
|
||||
val logProcess = try {
|
||||
withContext(Dispatchers.IO) {
|
||||
Runtime.getRuntime().exec(logCommand)
|
||||
}
|
||||
} catch (ioe: IOException) {
|
||||
ioe.printStackTrace()
|
||||
logLines += LogLine.showException(ioe)
|
||||
|
||||
failedToLoad = true
|
||||
|
||||
return logLines
|
||||
}
|
||||
|
||||
// read entire log into a buffer so no logs are added to the buffer during processing
|
||||
val logBuffer = Buffer()
|
||||
logProcess.inputStream.source().buffer().readAll(logBuffer)
|
||||
|
||||
try {
|
||||
coroutineScope {
|
||||
// this runs until the stream is exhausted
|
||||
while (true) {
|
||||
val line = LogLine.fromBufferedSource(logSource)
|
||||
val line = LogLine.fromBufferedSource(logBuffer)
|
||||
|
||||
// skip debug/verbose lines
|
||||
if (line.priority >= LogPriority.INFO) {
|
||||
if (line.priority >= logLevel) {
|
||||
logLines += line
|
||||
}
|
||||
|
||||
@@ -71,6 +97,9 @@ class LogViewModel: ViewModel() {
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
logLines += LogLine.showException(e)
|
||||
|
||||
// technically it maybe didn't completely fail...
|
||||
failedToLoad = true
|
||||
}
|
||||
|
||||
return logLines
|
||||
@@ -88,8 +117,24 @@ class LogViewModel: ViewModel() {
|
||||
_lineState.clear()
|
||||
}
|
||||
|
||||
fun getLogData(): String {
|
||||
return _lineState.joinToString("\n") { it.asSimpleString }
|
||||
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() {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.geode.launcher.main
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.AbsoluteRoundedCornerShape
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun InlineDialog(headline: @Composable () -> Unit, body: @Composable () -> Unit, modifier: Modifier = Modifier) {
|
||||
ElevatedCard(modifier = modifier) {
|
||||
Column(modifier = Modifier.padding(18.dp).sizeIn(maxWidth = 512.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
) {
|
||||
headline()
|
||||
}
|
||||
|
||||
body()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
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.verticalScroll
|
||||
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.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withLink
|
||||
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 context = LocalContext.current
|
||||
val recommendations = when (failureReason) {
|
||||
LaunchUtils.LauncherError.LINKER_FAILED_STL -> {
|
||||
val isDeveloper = PreferenceUtils.get(context).getBoolean(PreferenceUtils.Key.DEVELOPER_MODE)
|
||||
|
||||
buildList {
|
||||
add(stringResource(R.string.load_failed_recommendation_update))
|
||||
add(stringResource(R.string.load_failed_recommendation_report))
|
||||
|
||||
if (isDeveloper)
|
||||
add(stringResource(R.string.load_failed_recommendation_dev_stl))
|
||||
}
|
||||
}
|
||||
LaunchUtils.LauncherError.LINKER_FAILED -> listOfNotNull(
|
||||
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_STL,
|
||||
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,
|
||||
LaunchUtils.applicationArchitecture,
|
||||
BuildConfig.VERSION_NAME
|
||||
),
|
||||
style = Typography.labelMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
fun ErrorInfoActions(extraDetails: String?, modifier: Modifier = Modifier) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
|
||||
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(
|
||||
painterResource(R.drawable.icon_share),
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(stringResource(R.string.launcher_error_export_crash))
|
||||
}
|
||||
|
||||
val developerSettingsEnabled = remember {
|
||||
PreferenceUtils.get(context).getBoolean(PreferenceUtils.Key.DEVELOPER_MODE)
|
||||
}
|
||||
if (developerSettingsEnabled) {
|
||||
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 context = LocalContext.current
|
||||
val version = BuildConfig.VERSION_NAME
|
||||
|
||||
val showDownload = remember { GamePackageUtils.showDownloadBadge(context.packageManager) }
|
||||
|
||||
val downloadBase = "https://github.com/geode-sdk/android-launcher/releases/download/$version"
|
||||
val legacyDownloadUrl = "$downloadBase/geode-launcher-v$version-android32.apk"
|
||||
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")
|
||||
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withLink(LinkAnnotation.Url(
|
||||
downloadUrl,
|
||||
TextLinkStyles(
|
||||
style = SpanStyle(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textDecoration = TextDecoration.Underline
|
||||
),
|
||||
)
|
||||
)) {
|
||||
append(downloadText)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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,312 @@
|
||||
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.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.painterResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geode.launcher.GeometryDashActivity
|
||||
import com.geode.launcher.R
|
||||
import com.geode.launcher.preferences.SettingsActivity
|
||||
import com.geode.launcher.ui.theme.Typography
|
||||
import com.geode.launcher.utils.Constants
|
||||
import com.geode.launcher.utils.GamePackageUtils
|
||||
import com.geode.launcher.utils.GeodeUtils
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import com.geode.launcher.utils.useCountdownTimer
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
||||
|
||||
@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(
|
||||
painterResource(R.drawable.icon_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(
|
||||
painterResource(R.drawable.icon_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(
|
||||
painterResource(R.drawable.icon_play_arrow),
|
||||
contentDescription = stringResource(R.string.launcher_launch_icon_alt)
|
||||
)
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(stringResource(R.string.launcher_launch))
|
||||
}
|
||||
Spacer(Modifier.size(2.dp))
|
||||
IconButton(onClick = { onSettings(context) }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_settings),
|
||||
contentDescription = stringResource(R.string.launcher_settings_icon_alt)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showSafeModeDialog) {
|
||||
SafeModeDialog(
|
||||
onDismiss = {
|
||||
launchInSafeMode = false
|
||||
showSafeModeDialog = false
|
||||
},
|
||||
onLaunch = {
|
||||
launchInSafeMode = true
|
||||
|
||||
onPlayGame(true)
|
||||
|
||||
showSafeModeDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LaunchBlockedLabel(text: String) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val showDownload = remember { GamePackageUtils.showDownloadBadge(context.packageManager) }
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(text, textAlign = TextAlign.Center, modifier = Modifier.padding(12.dp))
|
||||
|
||||
if (showDownload) {
|
||||
GooglePlayBadge()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UnsupportedVersionWarning(modifier: Modifier = Modifier) {
|
||||
InlineDialog(
|
||||
headline = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_warning),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
stringResource(R.string.launcher_unsupported_version_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
},
|
||||
body = {
|
||||
Text(
|
||||
stringResource(R.string.launcher_game_update_required),
|
||||
)
|
||||
},
|
||||
modifier = modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
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.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.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.Constants
|
||||
import com.geode.launcher.utils.GamePackageUtils
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
enum class LaunchNotificationType {
|
||||
GEODE_UPDATED, LAUNCHER_UPDATE_AVAILABLE, UPDATE_FAILED, UNSUPPORTED_VERSION;
|
||||
}
|
||||
|
||||
fun determineDisplayedCards(context: Context): List<LaunchNotificationType> {
|
||||
val cards = mutableListOf<LaunchNotificationType>()
|
||||
|
||||
if (GamePackageUtils.getGameVersionCode(context.packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
|
||||
cards.add(LaunchNotificationType.UNSUPPORTED_VERSION)
|
||||
}
|
||||
|
||||
val releaseManager = ReleaseManager.get(context)
|
||||
val loadAutomatically = PreferenceUtils.get(context).getBoolean(PreferenceUtils.Key.LOAD_AUTOMATICALLY)
|
||||
|
||||
val availableUpdate = releaseManager.availableLauncherUpdate.value
|
||||
if (loadAutomatically && 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) {
|
||||
when (type) {
|
||||
LaunchNotificationType.LAUNCHER_UPDATE_AVAILABLE -> {
|
||||
var showInfoDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showInfoDialog) {
|
||||
LauncherUpdateInformation {
|
||||
showInfoDialog = false
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedNotificationCard(
|
||||
displayLength = 5000L,
|
||||
onClick = {
|
||||
showInfoDialog = true
|
||||
}
|
||||
) {
|
||||
LauncherUpdateContent()
|
||||
}
|
||||
}
|
||||
LaunchNotificationType.UPDATE_FAILED -> {
|
||||
AnimatedNotificationCard {
|
||||
UpdateFailedContent()
|
||||
}
|
||||
}
|
||||
LaunchNotificationType.UNSUPPORTED_VERSION -> {
|
||||
AnimatedNotificationCard {
|
||||
OutdatedVersionContent()
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
painterResource(R.drawable.icon_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 OutdatedVersionContent(modifier: Modifier = Modifier) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(text = stringResource(id = R.string.launcher_notification_compatibility))
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_warning),
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
supportingContent = {
|
||||
Text(text = stringResource(id = R.string.launcher_notification_compatibility_description))
|
||||
},
|
||||
modifier = modifier.width(IntrinsicSize.Max)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UpdateFailedContent(modifier: Modifier = Modifier) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(text = stringResource(id = R.string.launcher_notification_update_failed))
|
||||
},
|
||||
leadingContent = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_warning),
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
modifier = modifier.width(IntrinsicSize.Max)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
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 = 3000L
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
fun isGameInstallIssue() = when (this) {
|
||||
GAME_OUTDATED,
|
||||
GAME_MISSING -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
sealed class LaunchUIState {
|
||||
data object Initial : LaunchUIState()
|
||||
data object UpdateCheck : LaunchUIState()
|
||||
data class Updating(val downloaded: Long, val outOf: Long?) : LaunchUIState()
|
||||
data class 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 && !hasManuallyStarted) {
|
||||
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,432 @@
|
||||
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.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geode.launcher.R
|
||||
import com.geode.launcher.UserDirectoryProvider
|
||||
import com.geode.launcher.ui.theme.Typography
|
||||
import com.geode.launcher.updater.ReleaseManager
|
||||
import com.geode.launcher.updater.ReleaseViewModel
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import com.mikepenz.markdown.compose.LocalBulletListHandler
|
||||
import com.mikepenz.markdown.m3.Markdown
|
||||
import com.mikepenz.markdown.m3.markdownTypography
|
||||
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
|
||||
import java.io.File
|
||||
import java.net.ConnectException
|
||||
import java.net.UnknownHostException
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.toJavaInstant
|
||||
|
||||
fun clearDownloadedApks(context: Context) {
|
||||
// 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?.getDownload()
|
||||
|
||||
if (launcherDownload != null) {
|
||||
val outputFile = launcherDownload.filename
|
||||
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 (_: ActivityNotFoundException) {
|
||||
// if it fails to install, just open it in the browser
|
||||
try {
|
||||
val downloadUrl = launcherDownload.url
|
||||
val downloadIntent = Intent(Intent.ACTION_VIEW).apply {
|
||||
data = Uri.parse(downloadUrl)
|
||||
}
|
||||
|
||||
context.startActivity(downloadIntent)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
Toast.makeText(context, context.getString(R.string.no_activity_found), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(context, context.getString(R.string.release_fetch_no_releases), Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalTime::class)
|
||||
@Composable
|
||||
fun LauncherUpdateInformation(onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val nextRelease = remember { ReleaseManager.get(context).availableLauncherUpdate.value }
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
|
||||
if (nextRelease != null) {
|
||||
ModalBottomSheet(onDismissRequest = { onDismiss() }, sheetState = sheetState) {
|
||||
Box(Modifier.verticalScroll(rememberScrollState())) {
|
||||
Column(modifier = Modifier.padding(horizontal = 24.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.launcher_update_name, nextRelease.release.tagName),
|
||||
style = Typography.headlineSmall,
|
||||
)
|
||||
|
||||
val releasedTime = remember {
|
||||
DateFormat.getDateInstance().format(
|
||||
Date.from(nextRelease.release.publishedAt.toJavaInstant())
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
stringResource(R.string.launcher_update_released, "$releasedTime"),
|
||||
style = Typography.labelLarge
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.padding(vertical = 12.dp)) {
|
||||
Button(
|
||||
onClick = { installLauncherUpdate(context) },
|
||||
) {
|
||||
Icon(painterResource(R.drawable.icon_download), contentDescription = null)
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(stringResource(R.string.launcher_install))
|
||||
}
|
||||
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
IconButton(onClick = { uriHandler.openUri(nextRelease.release.htmlUrl) }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_link),
|
||||
stringResource(R.string.launcher_update_external_link)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (nextRelease.release.body != null) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
Text(
|
||||
stringResource(R.string.launcher_update_changelog),
|
||||
style = Typography.titleLarge
|
||||
)
|
||||
|
||||
// every new release of the markdown library adds another arg to it
|
||||
CompositionLocalProvider(LocalBulletListHandler provides { _, _, _, _, _ -> "• " }) {
|
||||
Markdown(
|
||||
content = nextRelease.release.body.replace("\r", ""),
|
||||
typography = markdownTypography(
|
||||
textLink = TextLinkStyles(
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
).toSpanStyle()
|
||||
),
|
||||
),
|
||||
flavour = GFMFlavourDescriptor(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
|
||||
var showInfoDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if (showInfoDialog) {
|
||||
LauncherUpdateInformation {
|
||||
showInfoDialog = false
|
||||
}
|
||||
}
|
||||
|
||||
ElevatedCard(modifier) {
|
||||
Column(
|
||||
// buttons add enough padding already, lower to compensate
|
||||
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)
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.align(Alignment.End)) {
|
||||
TextButton(
|
||||
onClick = { showInfoDialog = true },
|
||||
) {
|
||||
Text(stringResource(R.string.launcher_update_view))
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = { installLauncherUpdate(context) },
|
||||
) {
|
||||
Text(stringResource(R.string.launcher_install))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UpdateProgressIndicator(
|
||||
message: String,
|
||||
releaseViewModel: ReleaseViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: (() -> Float)? = null
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.Start,
|
||||
modifier = modifier
|
||||
) {
|
||||
Text(message, style = MaterialTheme.typography.labelMedium)
|
||||
|
||||
Spacer(modifier = Modifier.padding(4.dp))
|
||||
|
||||
if (progress == null) {
|
||||
LinearProgressIndicator()
|
||||
} else {
|
||||
LinearProgressIndicator(progress = progress)
|
||||
}
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
releaseViewModel.cancelUpdate()
|
||||
},
|
||||
modifier = Modifier.offset((-12).dp)
|
||||
) {
|
||||
Text(stringResource(R.string.release_fetch_button_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UpdateMessageIndicator(
|
||||
message: String,
|
||||
releaseViewModel: ReleaseViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
allowRetry: Boolean = false
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = modifier
|
||||
) {
|
||||
Text(
|
||||
message,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.padding(4.dp))
|
||||
|
||||
if (allowRetry) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
releaseViewModel.runReleaseCheck(true)
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_refresh),
|
||||
contentDescription = stringResource(R.string.launcher_retry_icon_alt)
|
||||
)
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(stringResource(R.string.release_fetch_button_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UpdateCard(releaseViewModel: ReleaseViewModel, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val releaseState by releaseViewModel.uiState.collectAsState()
|
||||
|
||||
when (val state = releaseState) {
|
||||
is ReleaseViewModel.ReleaseUIState.Failure -> {
|
||||
val message = when (state.exception) {
|
||||
is UnknownHostException, is ConnectException ->
|
||||
stringResource(R.string.release_fetch_no_internet)
|
||||
is ReleaseManager.UpdateException -> {
|
||||
when (state.exception.reason) {
|
||||
ReleaseManager.UpdateException.Reason.EXTERNAL_FILE_IN_USE -> stringResource(
|
||||
R.string.release_fetch_manual_check_required
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
UpdateMessageIndicator(
|
||||
stringResource(
|
||||
R.string.release_fetch_failed,
|
||||
message ?: stringResource(R.string.release_fetch_try_later)
|
||||
),
|
||||
modifier = modifier,
|
||||
allowRetry = true,
|
||||
releaseViewModel = releaseViewModel
|
||||
)
|
||||
}
|
||||
is ReleaseViewModel.ReleaseUIState.InDownload -> {
|
||||
val downloaded = remember(state.downloaded) {
|
||||
Formatter.formatShortFileSize(context, state.downloaded)
|
||||
}
|
||||
|
||||
val outOf = remember(state.outOf) {
|
||||
state.outOf?.run {
|
||||
Formatter.formatShortFileSize(context, this)
|
||||
}
|
||||
}
|
||||
|
||||
UpdateProgressIndicator(
|
||||
if (outOf != null) stringResource(
|
||||
R.string.release_fetch_downloading,
|
||||
downloaded,
|
||||
outOf
|
||||
) else stringResource(
|
||||
R.string.release_fetch_downloading_indeterminate,
|
||||
downloaded,
|
||||
),
|
||||
modifier = modifier,
|
||||
releaseViewModel = releaseViewModel,
|
||||
progress = {
|
||||
val progress = state.downloaded / state.outOf!!.toDouble()
|
||||
progress.toFloat()
|
||||
}.takeIf { state.outOf != null }
|
||||
)
|
||||
}
|
||||
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
|
||||
UpdateProgressIndicator(
|
||||
stringResource(R.string.release_fetch_in_progress),
|
||||
modifier = modifier,
|
||||
releaseViewModel = releaseViewModel
|
||||
)
|
||||
}
|
||||
is ReleaseViewModel.ReleaseUIState.Finished -> {
|
||||
if (state.hasUpdated) {
|
||||
UpdateMessageIndicator(
|
||||
stringResource(R.string.release_fetch_success),
|
||||
modifier = modifier,
|
||||
releaseViewModel = releaseViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
is ReleaseViewModel.ReleaseUIState.Cancelled -> {
|
||||
if (state.isCancelling) {
|
||||
UpdateProgressIndicator(
|
||||
stringResource(R.string.release_fetch_cancelling),
|
||||
modifier = modifier,
|
||||
releaseViewModel = releaseViewModel
|
||||
)
|
||||
} else {
|
||||
UpdateMessageIndicator(
|
||||
stringResource(R.string.release_fetch_cancelled),
|
||||
modifier = modifier,
|
||||
allowRetry = true,
|
||||
releaseViewModel = releaseViewModel
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val nextUpdate by releaseViewModel.nextLauncherUpdate.collectAsState()
|
||||
|
||||
if (!releaseViewModel.isInUpdate && nextUpdate != null) {
|
||||
LauncherUpdateIndicator(
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package com.geode.launcher.preferences
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedDispatcher
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
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.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.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.PreferenceUtils
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import com.geode.launcher.utils.ApplicationIcon
|
||||
import com.geode.launcher.utils.ApplicationIconDetails
|
||||
import com.geode.launcher.utils.IconUtils
|
||||
import com.geode.launcher.utils.adaptiveIconPainterResource
|
||||
|
||||
class ApplicationIconActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||
val theme = Theme.fromInt(themeOption)
|
||||
|
||||
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||
|
||||
CompositionLocalProvider(LocalTheme provides theme) {
|
||||
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
ApplicationIconScreen(onBackPressedDispatcher)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onSelectIcon(context: Context, selectedIcon: ApplicationIconDetails) {
|
||||
val packageManager = context.packageManager
|
||||
|
||||
try {
|
||||
ApplicationIcon.entries.forEach {
|
||||
val iconData = IconUtils.getIconDetails(it)
|
||||
|
||||
packageManager.setComponentEnabledSetting(
|
||||
ComponentName(context, "${context.packageName}.${iconData.component}"),
|
||||
if (iconData.id == selectedIcon.id)
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
else PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
|
||||
PackageManager.DONT_KILL_APP
|
||||
)
|
||||
}
|
||||
} catch (_: IllegalArgumentException) {
|
||||
Toast.makeText(context, R.string.application_icon_set_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
PreferenceUtils.get(context).setString(PreferenceUtils.Key.SELECTED_ICON, selectedIcon.id)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IconCard(iconData: ApplicationIconDetails, selected: Boolean, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 1.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.then(modifier)
|
||||
.selectable(
|
||||
selected = selected,
|
||||
onClick = {
|
||||
onSelectIcon(context, iconData)
|
||||
},
|
||||
role = Role.RadioButton
|
||||
)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
Arrangement.SpaceBetween,
|
||||
Alignment.CenterVertically,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(
|
||||
adaptiveIconPainterResource(iconData.iconId),
|
||||
contentDescription = null
|
||||
)
|
||||
Text(stringResource(iconData.nameId))
|
||||
}
|
||||
|
||||
RadioButton(
|
||||
selected = selected,
|
||||
onClick = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ApplicationIconScreen(
|
||||
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||
) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
|
||||
var showMoreOptions by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
Column {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_arrow_back),
|
||||
contentDescription = stringResource(R.string.back_icon_alt)
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.title_activity_application_icon),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
val selectedIcon by PreferenceUtils.useStringPreference(PreferenceUtils.Key.SELECTED_ICON)
|
||||
|
||||
Column(Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
) {
|
||||
ApplicationIcon.entries.forEach {
|
||||
val iconData = IconUtils.getIconDetails(it)
|
||||
|
||||
IconCard(iconData, selectedIcon == iconData.id)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
+70
-60
@@ -1,10 +1,6 @@
|
||||
package com.geode.launcher
|
||||
package com.geode.launcher.preferences
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -22,15 +18,10 @@ 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.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.Divider
|
||||
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
|
||||
@@ -51,17 +42,23 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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
|
||||
@@ -69,6 +66,7 @@ import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||
import com.geode.launcher.ui.theme.LocalTheme
|
||||
import com.geode.launcher.ui.theme.Theme
|
||||
import com.geode.launcher.ui.theme.Typography
|
||||
import com.geode.launcher.ui.theme.robotoMonoFamily
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -82,9 +80,10 @@ class ApplicationLogsActivity : ComponentActivity() {
|
||||
val theme = Theme.fromInt(themeOption)
|
||||
|
||||
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||
|
||||
CompositionLocalProvider(LocalTheme provides theme) {
|
||||
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
|
||||
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
@@ -104,43 +103,50 @@ fun getLogColor(priority: LogPriority): Color {
|
||||
// i just copied these from my ide tbh
|
||||
return if (isDark) {
|
||||
when (priority) {
|
||||
LogPriority.ERROR -> Color(0xffdf5151)
|
||||
LogPriority.WARN -> Color(0xffbcb500)
|
||||
LogPriority.FATAL -> Color(0xffb8b6fc)
|
||||
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(0xffdf5151)
|
||||
LogPriority.WARN -> Color(0xffeab700)
|
||||
LogPriority.FATAL -> Color(0xffa142f4)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun copyLogText(context: Context, logMessage: String) {
|
||||
val clipboardManager =
|
||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboardManager.setPrimaryClip(
|
||||
ClipData.newPlainText(
|
||||
context.getString(R.string.application_log_copy_label),
|
||||
logMessage
|
||||
@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,
|
||||
) {
|
||||
(LogPriority.VERBOSE..LogPriority.FATAL).reversed().forEach {
|
||||
SelectOption(
|
||||
name = it.name.lowercase().replaceFirstChar { c -> c.uppercaseChar() },
|
||||
value = it
|
||||
)
|
||||
)
|
||||
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.text_copied),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LogCard(logLine: LogLine, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
@@ -148,7 +154,8 @@ fun LogCard(logLine: LogLine, modifier: Modifier = Modifier) {
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onLongPress = {
|
||||
copyLogText(context, logLine.asSimpleString)
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
clipboardManager.setText(AnnotatedString(logLine.asSimpleString))
|
||||
}
|
||||
)
|
||||
},
|
||||
@@ -157,12 +164,12 @@ fun LogCard(logLine: LogLine, modifier: Modifier = Modifier) {
|
||||
"[${logLine.priority.toChar()}] ${logLine.formattedTime}",
|
||||
style = Typography.labelSmall,
|
||||
color = getLogColor(logLine.priority),
|
||||
fontFamily = FontFamily.Monospace
|
||||
fontFamily = robotoMonoFamily
|
||||
)
|
||||
|
||||
Text(
|
||||
"[${logLine.tag}]: ${logLine.messageTrimmed}",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontFamily = robotoMonoFamily,
|
||||
style = Typography.bodyMedium
|
||||
)
|
||||
}
|
||||
@@ -178,6 +185,7 @@ fun ApplicationLogsScreen(
|
||||
val isLoading by logViewModel.isLoading.collectAsState()
|
||||
|
||||
val context = LocalContext.current
|
||||
val resources = LocalResources.current
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
@@ -205,6 +213,18 @@ fun ApplicationLogsScreen(
|
||||
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 = {
|
||||
@@ -213,7 +233,7 @@ fun ApplicationLogsScreen(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.ArrowBack,
|
||||
painterResource(R.drawable.icon_arrow_back),
|
||||
contentDescription = stringResource(R.string.back_icon_alt)
|
||||
)
|
||||
}
|
||||
@@ -221,26 +241,17 @@ fun ApplicationLogsScreen(
|
||||
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)
|
||||
saveLauncher.launch(resources.getString(R.string.application_logs_default_filename))
|
||||
}
|
||||
}, enabled = !logLines.isEmpty()) {
|
||||
Icon(
|
||||
Icons.Filled.Share,
|
||||
contentDescription = stringResource(R.string.application_logs_share)
|
||||
painterResource(R.drawable.icon_save),
|
||||
contentDescription = stringResource(R.string.application_logs_export),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { showMoreOptions = !showMoreOptions },) {
|
||||
IconButton(onClick = { showMoreOptions = !showMoreOptions }) {
|
||||
Icon(
|
||||
Icons.Filled.MoreVert,
|
||||
painterResource(R.drawable.icon_more_vert),
|
||||
contentDescription = stringResource(R.string.application_logs_more)
|
||||
)
|
||||
}
|
||||
@@ -252,14 +263,14 @@ fun ApplicationLogsScreen(
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_filter_list),
|
||||
painterResource(R.drawable.icon_bug_report),
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (logViewModel.filterCrashes) {
|
||||
Icon(
|
||||
Icons.Filled.Check,
|
||||
painterResource(R.drawable.icon_check),
|
||||
contentDescription = stringResource(R.string.application_logs_crash_only_enabled_alt)
|
||||
)
|
||||
}
|
||||
@@ -275,18 +286,17 @@ fun ApplicationLogsScreen(
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_save),
|
||||
painterResource(R.drawable.icon_filter_list),
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(stringResource(R.string.application_logs_export))
|
||||
Text(stringResource(R.string.application_logs_level))
|
||||
},
|
||||
onClick = {
|
||||
saveLauncher.launch(context.getString(R.string.application_logs_default_filename))
|
||||
showLogLevelSelect = true
|
||||
showMoreOptions = false
|
||||
},
|
||||
enabled = !logLines.isEmpty()
|
||||
}
|
||||
)
|
||||
|
||||
DropdownMenuItem(
|
||||
@@ -345,7 +355,7 @@ fun ApplicationLogsScreen(
|
||||
logLines
|
||||
) { line ->
|
||||
LogCard(line)
|
||||
Divider()
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package com.geode.launcher.preferences
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.provider.DocumentsContract
|
||||
import android.widget.Toast
|
||||
import java.text.DateFormat
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedDispatcher
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.geode.launcher.R
|
||||
import com.geode.launcher.UserDirectoryProvider
|
||||
import com.geode.launcher.log.CrashDump
|
||||
import com.geode.launcher.log.CrashViewModel
|
||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||
import com.geode.launcher.ui.theme.LocalTheme
|
||||
import com.geode.launcher.ui.theme.Theme
|
||||
import com.geode.launcher.ui.theme.Typography
|
||||
import com.geode.launcher.ui.theme.robotoMonoFamily
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.toJavaInstant
|
||||
|
||||
class CrashLogsActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||
val theme = Theme.fromInt(themeOption)
|
||||
|
||||
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||
|
||||
CompositionLocalProvider(LocalTheme provides theme) {
|
||||
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
CrashLogsScreen(onBackPressedDispatcher)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun shareCrash(context: Context, filename: String) {
|
||||
val baseDirectory = LaunchUtils.getBaseDirectory(context)
|
||||
val crashPath = File(LaunchUtils.getCrashDirectory(context), filename)
|
||||
|
||||
if (!crashPath.exists()) {
|
||||
Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
val providerLocation = crashPath.toRelativeString(baseDirectory)
|
||||
val documentsPath = "${UserDirectoryProvider.ROOT}${providerLocation}"
|
||||
|
||||
val uri = DocumentsContract.buildDocumentUri("${context.packageName}.user", documentsPath)
|
||||
|
||||
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||
putExtra(Intent.EXTRA_STREAM, uri)
|
||||
type = "application/octet-stream"
|
||||
}
|
||||
|
||||
try {
|
||||
context.startActivity(shareIntent)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
Toast.makeText(context, R.string.no_activity_found, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Composable
|
||||
fun CrashCard(crashDump: CrashDump, crashViewModel: CrashViewModel, modifier: Modifier = Modifier) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 1.dp, horizontal = 8.dp)
|
||||
.height(64.dp)
|
||||
.then(modifier)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||
.padding(vertical = 8.dp, horizontal = 8.dp)
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.SpaceAround,
|
||||
modifier = Modifier.fillMaxHeight().offset(x = 12.dp).weight(0.7f)
|
||||
) {
|
||||
Text(
|
||||
crashDump.filename,
|
||||
fontFamily = robotoMonoFamily,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1
|
||||
)
|
||||
|
||||
val formattedLastModified = remember {
|
||||
DateFormat.getDateTimeInstance()
|
||||
.format(
|
||||
Date.from(crashDump.lastModified.toJavaInstant())
|
||||
)
|
||||
|
||||
}
|
||||
Text(formattedLastModified, style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.weight(0.3f), horizontalArrangement = Arrangement.End) {
|
||||
val context = LocalContext.current
|
||||
IconButton(onClick = {
|
||||
shareCrash(context, crashDump.filename)
|
||||
}) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_share),
|
||||
contentDescription = stringResource(R.string.application_logs_share)
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
crashViewModel.removeCrash(crashDump.filename)
|
||||
}) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_delete),
|
||||
contentDescription = stringResource(R.string.application_crashes_delete_one)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CrashLogsScreen(
|
||||
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||
crashViewModel: CrashViewModel = viewModel(factory = CrashViewModel.Factory)
|
||||
) {
|
||||
val logLines = crashViewModel.lineState
|
||||
val isLoading by crashViewModel.isLoading.collectAsState()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
|
||||
var showMoreOptions by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
Column {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_arrow_back),
|
||||
contentDescription = stringResource(R.string.back_icon_alt)
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { showMoreOptions = !showMoreOptions }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_more_vert),
|
||||
contentDescription = stringResource(R.string.application_logs_more)
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showMoreOptions,
|
||||
onDismissRequest = { showMoreOptions = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_delete),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(stringResource(R.string.application_logs_delete))
|
||||
}, onClick = {
|
||||
crashViewModel.clearCrashes()
|
||||
showMoreOptions = false
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.title_activity_crash_logs),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
if (!isLoading && logLines.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.application_crashes_no_dumps),
|
||||
textAlign = TextAlign.Center,
|
||||
style = Typography.bodyLarge,
|
||||
modifier = Modifier.fillMaxSize().padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val itemsSize = logLines.size
|
||||
|
||||
itemsIndexed(
|
||||
logLines,
|
||||
key = { index, line -> line.filename }
|
||||
) { index, line ->
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (index == 0) 16.dp else 4.dp,
|
||||
topEnd = if (index == 0) 16.dp else 4.dp,
|
||||
bottomStart = if (index + 1 == itemsSize) 16.dp else 4.dp,
|
||||
bottomEnd = if (index + 1 == itemsSize) 16.dp else 4.dp
|
||||
)
|
||||
|
||||
CrashCard(
|
||||
line,
|
||||
crashViewModel,
|
||||
modifier = Modifier.clip(shape)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showSystemUi = true)
|
||||
@Composable
|
||||
fun CrashLogsPreview() {
|
||||
GeodeLauncherTheme {
|
||||
CrashLogsScreen(null)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package com.geode.launcher.preferences
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedDispatcher
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.ContextCompat
|
||||
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.LaunchUtils
|
||||
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)
|
||||
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||
|
||||
CompositionLocalProvider(LocalTheme provides theme) {
|
||||
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
DeveloperSettingsScreen(onBackPressedDispatcher)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onOpenFolder(context: Context) {
|
||||
val file = LaunchUtils.getBaseDirectory(context)
|
||||
val clipboardManager =
|
||||
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboardManager.setPrimaryClip(
|
||||
ClipData.newPlainText(
|
||||
context.getString(R.string.export_folder_tag),
|
||||
file.path
|
||||
)
|
||||
)
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getString(R.string.text_copied),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
fun onOpenLogs(context: Context) {
|
||||
val launchIntent = Intent(context, ApplicationLogsActivity::class.java)
|
||||
context.startActivity(launchIntent)
|
||||
}
|
||||
|
||||
@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(
|
||||
painterResource(R.drawable.icon_arrow_back),
|
||||
contentDescription = stringResource(R.string.back_icon_alt)
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.title_activity_developer_settings),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.background),
|
||||
)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
|
||||
val developerMode by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
|
||||
|
||||
OptionsGroup {
|
||||
SettingsCard(
|
||||
title = stringResource(R.string.preference_developer_mode),
|
||||
preferenceKey = PreferenceUtils.Key.DEVELOPER_MODE,
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}}
|
||||
)
|
||||
|
||||
/*
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
SettingsCard(
|
||||
title = stringResource(R.string.preference_force_hrr),
|
||||
preferenceKey = PreferenceUtils.Key.FORCE_HRR,
|
||||
)
|
||||
}
|
||||
|
||||
SettingsCard(
|
||||
title = stringResource(R.string.preference_override_exceptions_name),
|
||||
description = stringResource(R.string.preference_override_exceptions_description),
|
||||
preferenceKey = PreferenceUtils.Key.CUSTOM_SYMBOL_LIST
|
||||
)
|
||||
*/
|
||||
}
|
||||
|
||||
OptionsGroup(stringResource(R.string.preference_category_developer)) {
|
||||
val context = LocalContext.current
|
||||
OptionsButton(
|
||||
title = stringResource(R.string.preferences_view_logs),
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_description),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
onClick = { onOpenLogs(context) }
|
||||
)
|
||||
|
||||
OptionsButton(
|
||||
title = stringResource(R.string.preferences_copy_external_button),
|
||||
description = LaunchUtils.getBaseDirectory(context).path,
|
||||
onClick = { onOpenFolder(context) }
|
||||
)
|
||||
|
||||
SettingsCard(
|
||||
title = stringResource(R.string.preference_development_server_name),
|
||||
description = stringResource(R.string.preference_development_server_description),
|
||||
preferenceKey = PreferenceUtils.Key.DEVELOPER_SERVER,
|
||||
) { enabled ->
|
||||
if (enabled) {
|
||||
|
||||
} else {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OptionsGroup(title = stringResource(R.string.preference_category_updater)) {
|
||||
SettingsSelectCard(
|
||||
title = stringResource(R.string.preference_release_channel_tag_name),
|
||||
dialogTitle = stringResource(R.string.preference_release_channel_select),
|
||||
preferenceKey = PreferenceUtils.Key.RELEASE_CHANNEL_TAG,
|
||||
options = linkedMapOf(
|
||||
0 to stringResource(R.string.preference_release_channel_stable),
|
||||
1 to stringResource(R.string.preference_release_channel_beta),
|
||||
2 to stringResource(R.string.preference_release_channel_nightly),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
OptionsGroup(title = stringResource(R.string.preference_category_testing)) {
|
||||
SettingsCard(
|
||||
title = stringResource(R.string.preference_enable_redesign),
|
||||
preferenceKey = PreferenceUtils.Key.ENABLE_REDESIGN,
|
||||
)
|
||||
}
|
||||
|
||||
OptionsGroup(title = stringResource(R.string.preference_profiles)) {
|
||||
ProfileCreateCard()
|
||||
ProfileSelectCard()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
package com.geode.launcher.preferences
|
||||
|
||||
import android.app.UiModeManager
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.DocumentsContract
|
||||
import android.view.WindowManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedDispatcher
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
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.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
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.LocalTheme
|
||||
import com.geode.launcher.ui.theme.Theme
|
||||
import com.geode.launcher.utils.ApplicationIcon
|
||||
import com.geode.launcher.utils.Constants
|
||||
import com.geode.launcher.utils.GamePackageUtils
|
||||
import com.geode.launcher.utils.IconUtils
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import java.net.ConnectException
|
||||
import java.net.UnknownHostException
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class SettingsActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setContent {
|
||||
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||
val theme = Theme.fromInt(themeOption)
|
||||
|
||||
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||
|
||||
CompositionLocalProvider(LocalTheme provides theme) {
|
||||
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
SettingsScreen(
|
||||
onBackPressedDispatcher = onBackPressedDispatcher
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onOpenFileManager(context: Context) {
|
||||
// future thoughts: Intent.ACTION_VIEW may not work on some devices
|
||||
// the "correct" solution is to go through a list of possible intents/applications
|
||||
// don't feel like writing that behavior. this is good enough
|
||||
|
||||
val intent = 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
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
context.startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getText(R.string.no_activity_found), Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UpdateIndicator(
|
||||
snackbarHostState: SnackbarHostState,
|
||||
updateStatus: ReleaseViewModel.ReleaseUIState
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val resources = LocalResources.current
|
||||
|
||||
var enablePopup by remember { mutableStateOf(false) }
|
||||
|
||||
when (updateStatus) {
|
||||
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
|
||||
enablePopup = true
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
is ReleaseViewModel.ReleaseUIState.InDownload -> {
|
||||
// is this the ideal design? idk
|
||||
enablePopup = true
|
||||
if (updateStatus.outOf != null) {
|
||||
CircularProgressIndicator(
|
||||
progress = {
|
||||
val progress = updateStatus.downloaded / updateStatus.outOf.toDouble()
|
||||
progress.toFloat()
|
||||
},
|
||||
)
|
||||
} else {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
LaunchedEffect(updateStatus) {
|
||||
// only show popup if some progress was shown
|
||||
if (!enablePopup) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
when (updateStatus) {
|
||||
is ReleaseViewModel.ReleaseUIState.Failure -> {
|
||||
val message = when (updateStatus.exception) {
|
||||
is UnknownHostException, is ConnectException ->
|
||||
resources.getString(R.string.release_fetch_no_internet)
|
||||
else -> resources.getString(R.string.preference_check_for_updates_failed)
|
||||
}
|
||||
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
is ReleaseViewModel.ReleaseUIState.Finished -> {
|
||||
if (updateStatus.hasUpdated) {
|
||||
snackbarHostState.showSnackbar(
|
||||
resources.getString(R.string.preference_check_for_updates_success)
|
||||
)
|
||||
} else {
|
||||
val gameVersion = GamePackageUtils.getGameVersionCodeOrNull(context.packageManager)
|
||||
if (gameVersion != null && gameVersion < Constants.SUPPORTED_VERSION_CODE) {
|
||||
snackbarHostState.showSnackbar(
|
||||
resources.getString(R.string.launcher_game_update_required_short)
|
||||
)
|
||||
} else {
|
||||
snackbarHostState.showSnackbar(
|
||||
resources.getString(R.string.preference_check_for_updates_none_found)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateTheme(context: Context, theme: Int) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val uiModeManager = context.getSystemService(Context.UI_MODE_SERVICE) as UiModeManager
|
||||
uiModeManager.setApplicationNightMode(
|
||||
when (theme) {
|
||||
1 -> UiModeManager.MODE_NIGHT_NO
|
||||
2 -> UiModeManager.MODE_NIGHT_YES
|
||||
else -> UiModeManager.MODE_NIGHT_CUSTOM
|
||||
}
|
||||
)
|
||||
} else {
|
||||
AppCompatDelegate.setDefaultNightMode(
|
||||
when (theme) {
|
||||
1 -> AppCompatDelegate.MODE_NIGHT_NO
|
||||
2 -> AppCompatDelegate.MODE_NIGHT_YES
|
||||
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onOpenDeveloperOptions(context: Context) {
|
||||
val launchIntent = Intent(context, DeveloperSettingsActivity::class.java)
|
||||
context.startActivity(launchIntent)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeveloperModeDialog(onDismiss: () -> Unit, onEnable: () -> Unit) {
|
||||
AlertDialog(
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_warning),
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
title = { Text(stringResource(R.string.preference_developer_options_dialog_title)) },
|
||||
text = { Text(stringResource(R.string.preference_developer_mode_about)) },
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.message_box_cancel))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onEnable) {
|
||||
Text(stringResource(R.string.message_box_accept))
|
||||
}
|
||||
},
|
||||
onDismissRequest = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||
releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val resources = LocalResources.current
|
||||
|
||||
val currentRelease by PreferenceUtils.useStringPreference(PreferenceUtils.Key.CURRENT_VERSION_TAG)
|
||||
val updateStatus by releaseViewModel.uiState.collectAsState()
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var showUpdateInProgress by remember { mutableStateOf(false) }
|
||||
|
||||
if (showUpdateInProgress) {
|
||||
LaunchedEffect(snackbarHostState) {
|
||||
snackbarHostState.showSnackbar(
|
||||
resources.getString(R.string.preference_check_for_updates_already_updating)
|
||||
)
|
||||
showUpdateInProgress = false
|
||||
}
|
||||
}
|
||||
|
||||
// fix theme transition by giving it the exact same animation as the top bar
|
||||
val containerColor by animateColorAsState(
|
||||
targetValue = MaterialTheme.colorScheme.background,
|
||||
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
|
||||
label = "background color"
|
||||
)
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
containerColor = containerColor,
|
||||
snackbarHost = {
|
||||
SnackbarHost(hostState = snackbarHostState)
|
||||
},
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_arrow_back),
|
||||
contentDescription = stringResource(R.string.back_icon_alt)
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.title_activity_settings),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.background),
|
||||
)
|
||||
},
|
||||
content = { innerPadding ->
|
||||
Column(
|
||||
Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OptionsGroup(stringResource(R.string.preference_category_testing)) {
|
||||
SettingsSelectCard(
|
||||
title = stringResource(R.string.preference_theme_name),
|
||||
dialogTitle = stringResource(R.string.preference_theme_select),
|
||||
preferenceKey = PreferenceUtils.Key.THEME,
|
||||
options = linkedMapOf(
|
||||
0 to stringResource(R.string.preference_theme_default),
|
||||
1 to stringResource(R.string.preference_theme_light),
|
||||
2 to stringResource(R.string.preference_theme_dark),
|
||||
),
|
||||
extraSelectBehavior = { updateTheme(context, it) }
|
||||
)
|
||||
SettingsCard(
|
||||
title = stringResource(R.string.preference_black_background_name),
|
||||
preferenceKey = PreferenceUtils.Key.BLACK_BACKGROUND
|
||||
)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
SettingsCard(
|
||||
title = stringResource(R.string.preference_disable_styles),
|
||||
preferenceKey = PreferenceUtils.Key.DISABLE_USER_THEME
|
||||
)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val currentIcon by PreferenceUtils.useStringPreference(PreferenceUtils.Key.SELECTED_ICON)
|
||||
val iconName = remember(currentIcon) {
|
||||
IconUtils.getIconDetails(
|
||||
ApplicationIcon.fromId(currentIcon ?: "default")
|
||||
).nameId
|
||||
}
|
||||
|
||||
OptionsButton(
|
||||
title = stringResource(R.string.preference_set_application_icon),
|
||||
onClick = {
|
||||
val launchIntent = Intent(context, ApplicationIconActivity::class.java)
|
||||
context.startActivity(launchIntent)
|
||||
},
|
||||
description = stringResource(iconName),
|
||||
displayInline = true,
|
||||
)
|
||||
}
|
||||
|
||||
OptionsButton(
|
||||
title = stringResource(R.string.preferences_open_file_manager),
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_folder),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
onClick = { onOpenFileManager(context) }
|
||||
)
|
||||
}
|
||||
|
||||
OptionsGroup(stringResource(R.string.preference_category_gameplay)) {
|
||||
SettingsCard(
|
||||
title = stringResource(R.string.preference_load_automatically_name),
|
||||
description = stringResource(R.string.preference_load_automatically_description),
|
||||
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY,
|
||||
)
|
||||
SettingsSelectCard(
|
||||
title = stringResource(R.string.preference_display_mode_name),
|
||||
dialogTitle = stringResource(R.string.preference_display_mode_select),
|
||||
preferenceKey = PreferenceUtils.Key.DISPLAY_MODE,
|
||||
options = buildMap {
|
||||
put(0, stringResource(R.string.preference_display_mode_default))
|
||||
put(1, stringResource(R.string.preference_display_mode_legacy))
|
||||
|
||||
// necessary api doesn't exist on older versions of android
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
put(2, stringResource(R.string.preference_display_mode_fullscreen))
|
||||
}
|
||||
}
|
||||
)
|
||||
val currentDisplayMode by PreferenceUtils.useIntPreference(PreferenceUtils.Key.DISPLAY_MODE)
|
||||
|
||||
if (currentDisplayMode == 1) {
|
||||
SettingsStringSelectCard(
|
||||
title = stringResource(R.string.preference_custom_aspect_ratio_name),
|
||||
description = stringResource(R.string.preference_custom_aspect_ratio_description),
|
||||
dialogTitle = stringResource(R.string.preference_custom_aspect_ratio_select),
|
||||
preferenceKey = PreferenceUtils.Key.CUSTOM_ASPECT_RATIO,
|
||||
options = linkedMapOf(
|
||||
"16_9" to stringResource(R.string.preference_custom_aspect_ratio_16x9),
|
||||
"16_10" to stringResource(R.string.preference_custom_aspect_ratio_16x10),
|
||||
"4_3" to stringResource(R.string.preference_custom_aspect_ratio_4x3),
|
||||
"1_1" to stringResource(R.string.preference_custom_aspect_ratio_1x1),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
SettingsRangeCard(
|
||||
title = stringResource(R.string.preference_screen_zoom_name),
|
||||
description = stringResource(R.string.preference_screen_zoom_description),
|
||||
dialogTitle = stringResource(R.string.preference_screen_zoom_select),
|
||||
preferenceKey = PreferenceUtils.Key.SCREEN_ZOOM,
|
||||
labelSuffix = "x",
|
||||
range = 25..100,
|
||||
scale = 100,
|
||||
step = 5,
|
||||
) {
|
||||
SettingsCard(
|
||||
title = stringResource(R.string.preference_screen_zoom_fit_name),
|
||||
preferenceKey = PreferenceUtils.Key.SCREEN_ZOOM_FIT,
|
||||
asCard = false
|
||||
)
|
||||
}
|
||||
|
||||
val maxFrameRate = remember {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
context.display.supportedModes.maxOf { it.refreshRate }
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay.refreshRate
|
||||
}.roundToInt()
|
||||
}
|
||||
|
||||
SettingsFPSCard(
|
||||
title = stringResource(R.string.preference_limit_framerate_name),
|
||||
dialogTitle = stringResource(R.string.preference_limit_framerate_select),
|
||||
preferenceKey = PreferenceUtils.Key.LIMIT_FRAME_RATE,
|
||||
maxFrameRate = maxFrameRate
|
||||
)
|
||||
}
|
||||
|
||||
OptionsGroup(stringResource(R.string.preference_category_updater)) {
|
||||
SettingsCard(
|
||||
title = stringResource(R.string.preference_update_automatically_name),
|
||||
preferenceKey = PreferenceUtils.Key.UPDATE_AUTOMATICALLY,
|
||||
)
|
||||
OptionsCard(
|
||||
title = {
|
||||
OptionsTitle(
|
||||
title = stringResource(R.string.preference_check_for_updates_button),
|
||||
description = stringResource(
|
||||
R.string.preference_check_for_updates_description,
|
||||
currentRelease ?: "unknown"
|
||||
)
|
||||
)
|
||||
},
|
||||
modifier = Modifier
|
||||
.clickable(
|
||||
onClick = {
|
||||
if (releaseViewModel.isInUpdate) {
|
||||
showUpdateInProgress = true
|
||||
} else {
|
||||
releaseViewModel.runReleaseCheck(true)
|
||||
}
|
||||
},
|
||||
role = Role.Button
|
||||
)
|
||||
) {
|
||||
UpdateIndicator(snackbarHostState, updateStatus)
|
||||
}
|
||||
}
|
||||
|
||||
val developerModeEnabled by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
|
||||
|
||||
OptionsGroup(stringResource(R.string.preference_category_developer)) {
|
||||
if (developerModeEnabled) {
|
||||
OptionsButton(
|
||||
title = stringResource(R.string.preference_open_developer_options),
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_data_object),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
onClick = { onOpenDeveloperOptions(context) }
|
||||
)
|
||||
}
|
||||
|
||||
OptionsButton(
|
||||
title = stringResource(R.string.preferences_view_crashes),
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_bug_report),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
val launchIntent = Intent(context, CrashLogsActivity::class.java)
|
||||
context.startActivity(launchIntent)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
OptionsGroup(stringResource(R.string.preference_category_about)) {
|
||||
var launcherButtonTapped by remember { mutableIntStateOf(0) }
|
||||
var showDeveloperDialog by remember { mutableStateOf(false) }
|
||||
|
||||
OptionsButton(
|
||||
stringResource(R.string.preference_launcher_version_name),
|
||||
stringResource(R.string.preference_launcher_version_description, BuildConfig.VERSION_NAME),
|
||||
displayInline = true
|
||||
) {
|
||||
launcherButtonTapped += 1
|
||||
|
||||
if (launcherButtonTapped >= 7 && !developerModeEnabled) {
|
||||
showDeveloperDialog = true
|
||||
launcherButtonTapped = 0
|
||||
}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
if (showDeveloperDialog) {
|
||||
DeveloperModeDialog(
|
||||
onDismiss = {
|
||||
showDeveloperDialog = false
|
||||
},
|
||||
onEnable = {
|
||||
PreferenceUtils.get(context).setBoolean(PreferenceUtils.Key.DEVELOPER_MODE, true)
|
||||
showDeveloperDialog = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
OptionsLabel(
|
||||
stringResource(R.string.preference_loader_version_name),
|
||||
stringResource(R.string.preference_loader_version_description, currentRelease ?: "unknown"),
|
||||
displayInline = true
|
||||
)
|
||||
|
||||
val gameInstalled = remember {
|
||||
GamePackageUtils.isGameInstalled(context.packageManager)
|
||||
}
|
||||
|
||||
if (gameInstalled) {
|
||||
val gameVersion = remember {
|
||||
GamePackageUtils.getUnifiedVersionName(context.packageManager)
|
||||
}
|
||||
|
||||
val gameSource = remember {
|
||||
resources.getString(when (GamePackageUtils.identifyGameSource(context.packageManager)) {
|
||||
GamePackageUtils.GameSource.GOOGLE -> R.string.preference_game_source_google
|
||||
GamePackageUtils.GameSource.AMAZON -> R.string.preference_game_source_amazon
|
||||
else -> R.string.preference_game_source_unknown
|
||||
})
|
||||
}
|
||||
|
||||
OptionsLabel(
|
||||
stringResource(R.string.preference_game_version_name),
|
||||
stringResource(R.string.preference_game_version_description, gameVersion, gameSource),
|
||||
displayInline = true
|
||||
)
|
||||
|
||||
OptionsLabel(
|
||||
stringResource(R.string.preference_architecture_name),
|
||||
stringResource(R.string.preference_architecture_description, Build.MODEL, LaunchUtils.applicationArchitecture),
|
||||
displayInline = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(showSystemUi = true)
|
||||
@Composable
|
||||
fun DefaultPreview() {
|
||||
GeodeLauncherTheme {
|
||||
SettingsScreen(null)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,24 @@
|
||||
package com.geode.launcher.ui.theme
|
||||
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.geode.launcher.R
|
||||
|
||||
val robotoMonoFamily = FontFamily(
|
||||
Font(R.font.robotomono_medium, FontWeight.Medium),
|
||||
Font(R.font.robotomono_regular, FontWeight.Normal)
|
||||
)
|
||||
|
||||
val quicksandFamily = FontFamily(
|
||||
Font(R.font.quicksand_semibold, FontWeight.SemiBold)
|
||||
)
|
||||
|
||||
val launcherTitleStyle = TextStyle(
|
||||
fontFamily = quicksandFamily,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 32.sp,
|
||||
letterSpacing = 0.sp
|
||||
)
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.geode.launcher.ui.theme
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -9,14 +8,9 @@ import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
private val DarkColorScheme = darkColorScheme(
|
||||
primary = Purple80,
|
||||
@@ -28,16 +22,6 @@ private val LightColorScheme = lightColorScheme(
|
||||
primary = Purple40,
|
||||
secondary = PurpleGrey40,
|
||||
tertiary = Pink40
|
||||
|
||||
/* Other default colors to override
|
||||
background = Color(0xFFFFFBFE),
|
||||
surface = Color(0xFFFFFBFE),
|
||||
onPrimary = Color.White,
|
||||
onSecondary = Color.White,
|
||||
onTertiary = Color.White,
|
||||
onBackground = Color(0xFF1C1B1F),
|
||||
onSurface = Color(0xFF1C1B1F),
|
||||
*/
|
||||
)
|
||||
|
||||
enum class Theme {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package com.geode.launcher.updater
|
||||
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
@Serializable
|
||||
data class Asset(
|
||||
data class Asset @OptIn(ExperimentalTime::class) constructor(
|
||||
val url: String,
|
||||
val id: Int,
|
||||
val name: String,
|
||||
@@ -16,7 +17,7 @@ data class Asset(
|
||||
)
|
||||
|
||||
@Serializable
|
||||
class Release(
|
||||
data class Release @OptIn(ExperimentalTime::class) constructor(
|
||||
val url: String,
|
||||
val id: Int,
|
||||
val targetCommitish: String,
|
||||
@@ -24,40 +25,88 @@ class Release(
|
||||
val createdAt: Instant,
|
||||
val publishedAt: Instant,
|
||||
val assets: List<Asset>,
|
||||
val htmlUrl: String
|
||||
) {
|
||||
fun getDescription(): String {
|
||||
if (tagName == "nightly") {
|
||||
val htmlUrl: String,
|
||||
val body: String?,
|
||||
val name: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoaderVersion @OptIn(ExperimentalTime::class) constructor(
|
||||
val tag: String,
|
||||
val version: String,
|
||||
val createdAt: Instant,
|
||||
val commitHash: String,
|
||||
val prerelease: Boolean
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoaderPayload<T>(
|
||||
val payload: T?,
|
||||
val error: String
|
||||
)
|
||||
|
||||
data class DownloadableAsset(val url: String, val filename: String, val size: Long? = null)
|
||||
|
||||
abstract class Downloadable {
|
||||
abstract fun getDescription(): String
|
||||
abstract fun getDescriptor(): Long
|
||||
abstract fun getDownload(): DownloadableAsset?
|
||||
}
|
||||
|
||||
class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadable() {
|
||||
override fun getDescription(): String {
|
||||
if (release.tagName == "nightly") {
|
||||
// get the commit from the assets
|
||||
// otherwise, a separate request is needed to get the hash (ew)
|
||||
val asset = assets.first()
|
||||
val asset = release.assets.first()
|
||||
val commit = asset.name.substring(6..12)
|
||||
|
||||
return "nightly-$commit"
|
||||
}
|
||||
|
||||
return tagName
|
||||
return release.tagName
|
||||
}
|
||||
|
||||
fun getDescriptor(): Long {
|
||||
return createdAt.epochSeconds
|
||||
@OptIn(ExperimentalTime::class)
|
||||
override fun getDescriptor(): Long {
|
||||
return release.createdAt.epochSeconds
|
||||
}
|
||||
|
||||
fun getGeodeDownload(): Asset? {
|
||||
private fun getGitHubDownload(): Asset? {
|
||||
// try to find an asset that matches the architecture first
|
||||
val platform = LaunchUtils.platformName
|
||||
|
||||
val releaseSuffix = "$platform.zip"
|
||||
return assets.find {
|
||||
return release.assets.find {
|
||||
it.name.endsWith(releaseSuffix)
|
||||
}
|
||||
}
|
||||
|
||||
fun getLauncherDownload(): Asset? {
|
||||
override fun getDownload(): DownloadableAsset? {
|
||||
val download = getGitHubDownload() ?: return null
|
||||
return DownloadableAsset(
|
||||
url = download.browserDownloadUrl,
|
||||
filename = download.name,
|
||||
size = download.size.toLong()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadableLauncherRelease(val release: Release) : Downloadable() {
|
||||
override fun getDescription(): String {
|
||||
return release.tagName
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
override fun getDescriptor(): Long {
|
||||
return release.createdAt.epochSeconds
|
||||
}
|
||||
|
||||
private fun getGitHubDownload(): Asset? {
|
||||
val platform = LaunchUtils.platformName
|
||||
val use32BitPlatform = platform == "android32"
|
||||
|
||||
return assets.find { asset ->
|
||||
return release.assets.find { asset ->
|
||||
/* you know it's good when you pull out the truth table
|
||||
* u32bp | contains | found
|
||||
* 1 | 1 | 1
|
||||
@@ -70,4 +119,32 @@ class Release(
|
||||
(asset.name.contains("android32")) == use32BitPlatform
|
||||
}
|
||||
}
|
||||
|
||||
override fun getDownload(): DownloadableAsset? {
|
||||
val download = getGitHubDownload() ?: return null
|
||||
return DownloadableAsset(
|
||||
url = download.browserDownloadUrl,
|
||||
filename = download.name,
|
||||
size = download.size.toLong()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadableLoaderRelease(private val version: LoaderVersion) : Downloadable() {
|
||||
override fun getDescription(): String {
|
||||
return version.tag
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
override fun getDescriptor(): Long {
|
||||
return version.createdAt.epochSeconds
|
||||
}
|
||||
|
||||
override fun getDownload(): DownloadableAsset? {
|
||||
val filename = "geode-${version.tag}-${LaunchUtils.platformName}.zip"
|
||||
return DownloadableAsset(
|
||||
url = "https://github.com/geode-sdk/geode/releases/download/${version.tag}/$filename",
|
||||
filename = filename
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import com.geode.launcher.BuildConfig
|
||||
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
|
||||
@@ -22,6 +23,9 @@ import okio.source
|
||||
import java.io.File
|
||||
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.
|
||||
*/
|
||||
@@ -57,7 +61,7 @@ class ReleaseManager private constructor(
|
||||
sealed class ReleaseManagerState {
|
||||
data object InUpdateCheck : ReleaseManagerState()
|
||||
data class Failure(val exception: Exception) : ReleaseManagerState()
|
||||
data class InDownload(val downloaded: Long, val outOf: Long) : ReleaseManagerState()
|
||||
data class InDownload(val downloaded: Long, val outOf: Long?) : ReleaseManagerState()
|
||||
data class Finished(val hasUpdated: Boolean = false) : ReleaseManagerState()
|
||||
}
|
||||
|
||||
@@ -78,7 +82,7 @@ class ReleaseManager private constructor(
|
||||
val isInUpdate: Boolean
|
||||
get() = _uiState.value !is ReleaseManagerState.Failure && _uiState.value !is ReleaseManagerState.Finished
|
||||
|
||||
private val _availableLauncherUpdate = MutableStateFlow<Release?>(null)
|
||||
private val _availableLauncherUpdate = MutableStateFlow<DownloadableLauncherRelease?>(null)
|
||||
val availableLauncherUpdate = _availableLauncherUpdate.asStateFlow()
|
||||
|
||||
private fun sendError(e: Exception) {
|
||||
@@ -91,24 +95,72 @@ class ReleaseManager private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissLauncherUpdate() {
|
||||
val update = availableLauncherUpdate.value ?: return
|
||||
|
||||
private fun mapSelectedReleaseToTag(): String {
|
||||
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
||||
sharedPreferences.setString(PreferenceUtils.Key.LAST_DISMISSED_UPDATE, update.tagName)
|
||||
val releaseChannel = sharedPreferences.getInt(PreferenceUtils.Key.RELEASE_CHANNEL_TAG)
|
||||
|
||||
_availableLauncherUpdate.value = null
|
||||
return when (releaseChannel) {
|
||||
2 -> "nightly"
|
||||
1 -> TAG_BETA
|
||||
else -> TAG_LATEST
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getLatestRelease(): Release? {
|
||||
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
||||
val useNightly = sharedPreferences.getBoolean(PreferenceUtils.Key.RELEASE_CHANNEL)
|
||||
|
||||
return releaseRepository.getLatestGeodeRelease(useNightly)
|
||||
private fun getBestReleaseForGameVersion(gameVersion: Long): String? = when {
|
||||
gameVersion >= 40L -> mapSelectedReleaseToTag()
|
||||
gameVersion == 39L -> "v3.9.3"
|
||||
gameVersion == 38L -> "v2.0.0-beta.27"
|
||||
gameVersion == 37L -> "v2.0.0-beta.4"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private suspend fun performUpdate(release: Release) {
|
||||
val releaseAsset = release.getGeodeDownload()
|
||||
private suspend fun getLatestRelease(): Downloadable? {
|
||||
if (!GamePackageUtils.isGameInstalled(applicationContext.packageManager)) {
|
||||
return null
|
||||
}
|
||||
|
||||
val gameVersion = GamePackageUtils.getGameVersionCode(applicationContext.packageManager)
|
||||
val preferences = PreferenceUtils.get(applicationContext)
|
||||
|
||||
val targetTag = preferences.getInt(PreferenceUtils.Key.RELEASE_CHANNEL_TAG)
|
||||
|
||||
return when (targetTag) {
|
||||
2 -> releaseRepository.getReleaseByTag("nightly")
|
||||
1 -> releaseRepository.getLatestIndexRelease(gameVersion, true)
|
||||
else -> releaseRepository.getLatestIndexRelease(gameVersion, false)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadLauncherUpdate(release: Downloadable) {
|
||||
val download = release.getDownload() ?: return
|
||||
|
||||
val outputDirectory = LaunchUtils.getBaseDirectory(applicationContext)
|
||||
val outputFile = File(outputDirectory, download.filename)
|
||||
|
||||
if (outputFile.exists()) {
|
||||
// only download the apk once
|
||||
return
|
||||
}
|
||||
|
||||
_uiState.value = ReleaseManagerState.InDownload(0, download.size)
|
||||
|
||||
try {
|
||||
val fileStream = DownloadUtils.downloadStream(
|
||||
httpClient,
|
||||
download.url
|
||||
) { progress, outOf ->
|
||||
_uiState.value = ReleaseManagerState.InDownload(progress, outOf)
|
||||
}
|
||||
|
||||
fileStream.copyTo(outputFile.outputStream())
|
||||
} catch (e: Exception) {
|
||||
sendError(e)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun performUpdate(release: Downloadable) {
|
||||
val releaseAsset = release.getDownload()
|
||||
if (releaseAsset == null) {
|
||||
val noAssetException = Exception("missing Android download")
|
||||
_uiState.value = ReleaseManagerState.Failure(noAssetException)
|
||||
@@ -117,47 +169,62 @@ class ReleaseManager private constructor(
|
||||
}
|
||||
|
||||
// set an initial download size
|
||||
_uiState.value = ReleaseManagerState.InDownload(0, releaseAsset.size.toLong())
|
||||
_uiState.value = ReleaseManagerState.InDownload(0, releaseAsset.size)
|
||||
|
||||
val outputFile = getTempFile()
|
||||
// clone the file instance as renameTo may move the original file
|
||||
val tempFilePath = outputFile.path
|
||||
|
||||
try {
|
||||
val fileStream = DownloadUtils.downloadStream(
|
||||
httpClient,
|
||||
releaseAsset.browserDownloadUrl
|
||||
releaseAsset.url
|
||||
) { progress, outOf ->
|
||||
_uiState.value = ReleaseManagerState.InDownload(progress, outOf)
|
||||
}
|
||||
|
||||
outputFile.parentFile?.mkdirs()
|
||||
|
||||
val geodeFile = getGeodeOutputPath()
|
||||
|
||||
DownloadUtils.extractFileFromZipStream(
|
||||
fileStream,
|
||||
outputFile.outputStream(),
|
||||
geodeFile.name
|
||||
)
|
||||
|
||||
// work around a permission issue from adb push
|
||||
if (geodeFile.exists()) {
|
||||
geodeFile.delete()
|
||||
}
|
||||
|
||||
geodeFile.parentFile?.mkdirs()
|
||||
|
||||
DownloadUtils.extractFileFromZipStream(
|
||||
fileStream,
|
||||
geodeFile.outputStream(),
|
||||
geodeFile.name
|
||||
)
|
||||
outputFile.renameTo(geodeFile)
|
||||
} catch (e: Exception) {
|
||||
sendError(e)
|
||||
return
|
||||
} finally {
|
||||
val tempFileClone = File(tempFilePath)
|
||||
try {
|
||||
if (tempFileClone.exists()) tempFileClone.delete()
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
downloadLauncherUpdateIfNecessary()
|
||||
|
||||
// extraction performed
|
||||
updatePreferences(release)
|
||||
_uiState.value = ReleaseManagerState.Finished(true)
|
||||
}
|
||||
|
||||
private fun fileWasExternallyModified(): Boolean {
|
||||
private fun fileWasExternallyModified(modifiedOnly: Boolean = false): Boolean {
|
||||
// make sure geode is still here. just in case
|
||||
val geodeFile = getGeodeOutputPath()
|
||||
if (!geodeFile.exists()) {
|
||||
return false
|
||||
return !modifiedOnly
|
||||
}
|
||||
|
||||
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
||||
|
||||
val originalFileHash = sharedPreferences.getString(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED)
|
||||
?: return false
|
||||
|
||||
@@ -166,25 +233,18 @@ class ReleaseManager private constructor(
|
||||
return originalFileHash != currentFileHash
|
||||
}
|
||||
|
||||
private fun checkLauncherUpdate(launcherUpdate: Release) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
println("ignoring launcher update check - in debug mode!")
|
||||
return
|
||||
}
|
||||
|
||||
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
||||
val lastDismissedVersion = sharedPreferences.getString(PreferenceUtils.Key.LAST_DISMISSED_UPDATE)
|
||||
|
||||
if (!lastDismissedVersion.isNullOrEmpty() && launcherUpdate.tagName == lastDismissedVersion) {
|
||||
return
|
||||
}
|
||||
|
||||
if (launcherUpdate.tagName != BuildConfig.VERSION_NAME) {
|
||||
private fun checkLauncherUpdate(launcherUpdate: DownloadableLauncherRelease) {
|
||||
if (launcherUpdate.release.tagName != BuildConfig.VERSION_NAME) {
|
||||
_availableLauncherUpdate.value = launcherUpdate
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkForNewRelease(allowOverwriting: Boolean = false) {
|
||||
private suspend fun downloadLauncherUpdateIfNecessary() {
|
||||
val update = _availableLauncherUpdate.value ?: return
|
||||
downloadLauncherUpdate(update)
|
||||
}
|
||||
|
||||
private suspend fun checkForNewRelease(isManual: Boolean = false) {
|
||||
val release = try {
|
||||
getLatestRelease()
|
||||
} catch (e: Exception) {
|
||||
@@ -204,6 +264,8 @@ class ReleaseManager private constructor(
|
||||
}
|
||||
|
||||
if (release == null) {
|
||||
downloadLauncherUpdateIfNecessary()
|
||||
|
||||
_uiState.value = ReleaseManagerState.Finished()
|
||||
return
|
||||
}
|
||||
@@ -213,17 +275,20 @@ class ReleaseManager private constructor(
|
||||
val currentVersion = sharedPreferences.getLong(PreferenceUtils.Key.CURRENT_VERSION_TIMESTAMP)
|
||||
val latestVersion = release.getDescriptor()
|
||||
|
||||
// make sure geode is still here. just in case
|
||||
val geodeFile = getGeodeOutputPath()
|
||||
|
||||
// check if an update is needed
|
||||
if (latestVersion <= currentVersion && geodeFile.exists()) {
|
||||
if (latestVersion == currentVersion && !fileWasExternallyModified()) {
|
||||
downloadLauncherUpdateIfNecessary()
|
||||
|
||||
_uiState.value = ReleaseManagerState.Finished()
|
||||
return
|
||||
}
|
||||
|
||||
val allowOverwriting = !sharedPreferences.getBoolean(PreferenceUtils.Key.DEVELOPER_MODE) || isManual
|
||||
|
||||
// check if the file was externally modified
|
||||
if (!allowOverwriting && fileWasExternallyModified()) {
|
||||
if (!allowOverwriting && fileWasExternallyModified(true)) {
|
||||
downloadLauncherUpdateIfNecessary()
|
||||
|
||||
sendError(UpdateException(UpdateException.Reason.EXTERNAL_FILE_IN_USE))
|
||||
return
|
||||
}
|
||||
@@ -231,7 +296,7 @@ class ReleaseManager private constructor(
|
||||
performUpdate(release)
|
||||
}
|
||||
|
||||
private fun updatePreferences(release: Release) {
|
||||
private fun updatePreferences(release: Downloadable) {
|
||||
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
||||
|
||||
sharedPreferences.setString(
|
||||
@@ -261,6 +326,15 @@ class ReleaseManager private constructor(
|
||||
return File(geodeDirectory, geodeName)
|
||||
}
|
||||
|
||||
private fun getTempFile(): File {
|
||||
val geodeName = LaunchUtils.geodeFilename
|
||||
val geodeDirectory = LaunchUtils.getBaseDirectory(applicationContext)
|
||||
|
||||
val tempFile = File.createTempFile(geodeName, null, geodeDirectory)
|
||||
|
||||
return tempFile
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the current update job.
|
||||
*/
|
||||
|
||||
@@ -1,41 +1,121 @@
|
||||
package com.geode.launcher.updater
|
||||
|
||||
import com.geode.launcher.utils.DownloadUtils.executeCoroutine
|
||||
import kotlin.time.Clock
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlin.time.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.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.IOException
|
||||
import java.net.URL
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
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"
|
||||
|
||||
private const val GEODE_API_BASE = "https://api.geode-sdk.org/v1"
|
||||
}
|
||||
|
||||
suspend fun getLatestLauncherRelease(): Release? {
|
||||
suspend fun getLatestLauncherRelease(): DownloadableLauncherRelease? {
|
||||
val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/android-launcher/releases/latest"
|
||||
|
||||
val url = URL(releasePath)
|
||||
|
||||
return getReleaseByUrl(url)
|
||||
return getReleaseByUrl(url)?.let(::DownloadableLauncherRelease)
|
||||
}
|
||||
|
||||
suspend fun getLatestGeodeRelease(isNightly: Boolean = false): Release? {
|
||||
val geodeBaseUrl = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases"
|
||||
val releasePath = if (isNightly) "$geodeBaseUrl/tags/nightly"
|
||||
else "$geodeBaseUrl/latest"
|
||||
|
||||
suspend fun getLatestGeodeRelease(): DownloadableGitHubLoaderRelease? {
|
||||
val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases/latest"
|
||||
val url = URL(releasePath)
|
||||
|
||||
return getReleaseByUrl(url)
|
||||
return getReleaseByUrl(url)?.let(::DownloadableGitHubLoaderRelease)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
suspend fun getReleaseByTag(tag: String): DownloadableGitHubLoaderRelease? {
|
||||
val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases/tags/$tag"
|
||||
val url = URL(releasePath)
|
||||
|
||||
return getReleaseByUrl(url)?.let(::DownloadableGitHubLoaderRelease)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class, ExperimentalTime::class)
|
||||
suspend fun getLatestGeodePreRelease(): DownloadableGitHubLoaderRelease? {
|
||||
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" })
|
||||
?.let(::DownloadableGitHubLoaderRelease)
|
||||
}
|
||||
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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
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, ExperimentalTime::class)
|
||||
private suspend fun getReleaseByUrl(url: URL): Release? {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
@@ -59,6 +139,65 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
|
||||
|
||||
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[GITHUB_RATELIMIT_REMAINING]?.toInt()
|
||||
val limitReset = response.headers[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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
suspend fun getLatestIndexRelease(gd: Long, prerelease: Boolean = false): DownloadableLoaderRelease? {
|
||||
val url = "$GEODE_API_BASE/loader/versions/latest".toHttpUrlOrNull()!!
|
||||
.newBuilder()
|
||||
.addQueryParameter("gd", gd.toString())
|
||||
.addQueryParameter("platform", "android")
|
||||
.addQueryParameter("prerelease", prerelease.toString())
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
val call = httpClient.newCall(request)
|
||||
val response = call.executeCoroutine()
|
||||
|
||||
return when (response.code) {
|
||||
200 -> {
|
||||
val format = Json {
|
||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
val body = format.decodeFromBufferedSource<LoaderPayload<LoaderVersion>>(
|
||||
response.body!!.source()
|
||||
)
|
||||
|
||||
val release = body.payload ?: throw IOException("index error: ${body.error}")
|
||||
|
||||
DownloadableLoaderRelease(release)
|
||||
}
|
||||
404 -> {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
|
||||
sealed class ReleaseUIState {
|
||||
data object InUpdateCheck : ReleaseUIState()
|
||||
data class Failure(val exception: Exception) : ReleaseUIState()
|
||||
data class InDownload(val downloaded: Long, val outOf: Long) : ReleaseUIState()
|
||||
data class InDownload(val downloaded: Long, val outOf: Long?) : ReleaseUIState()
|
||||
data class Finished(val hasUpdated: Boolean = false) : ReleaseUIState()
|
||||
data class Cancelled(val isCancelling: Boolean = false) : ReleaseUIState()
|
||||
|
||||
@@ -98,10 +98,4 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
|
||||
syncUiState(releaseFlow)
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissLauncherUpdate() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
ReleaseManager.get(application).dismissLauncherUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,14 @@ object Constants {
|
||||
@SuppressLint("SdCardPath")
|
||||
const val GJ_DATA_DIR = "/data/data/${PACKAGE_NAME}"
|
||||
|
||||
const val SUPPORTED_VERSION_CODE = 38L
|
||||
const val SUPPORTED_VERSION_STRING = "2.205"
|
||||
// anything below this version code is blocked from opening the game
|
||||
const val SUPPORTED_VERSION_CODE_MIN = 37L
|
||||
|
||||
const val LAUNCHER_KEY_RETURN_TITLE = "return_title"
|
||||
const val LAUNCHER_KEY_RETURN_MESSAGE = "return_message"
|
||||
// anything below this version code shows a warning on the main menu
|
||||
const val SUPPORTED_VERSION_CODE_MIN_WARNING = 40L
|
||||
|
||||
// anything above this version code will show a warning when starting the game
|
||||
const val SUPPORTED_VERSION_CODE = 40L
|
||||
|
||||
const val SUPPORTED_VERSION_STRING = "2.2.143"
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.geode.launcher.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.widget.FrameLayout
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.min
|
||||
|
||||
class ConstrainedFrameLayout(context: Context) : FrameLayout(context) {
|
||||
// avoid running the aspect ratio fix when we don't need it
|
||||
var aspectRatio: Float? = null
|
||||
var zoom: Float? = null
|
||||
var fitZoom: Boolean = false
|
||||
|
||||
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
|
||||
var paddingX = 0.0f
|
||||
var paddingY = 0.0f
|
||||
|
||||
val currentZoom = zoom
|
||||
if (currentZoom != null) {
|
||||
val width = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val height = MeasureSpec.getSize(heightMeasureSpec)
|
||||
if (width == 0 || height == 0) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
return
|
||||
}
|
||||
|
||||
val calculatedWidth = width * currentZoom
|
||||
val calculatedHeight = height * currentZoom
|
||||
|
||||
paddingX = (width - calculatedWidth)/2
|
||||
paddingY = (height - calculatedHeight)/2
|
||||
}
|
||||
|
||||
val currentAspectRatio = aspectRatio
|
||||
if (currentAspectRatio != null) {
|
||||
val width = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val height = MeasureSpec.getSize(heightMeasureSpec)
|
||||
if (width == 0 || height == 0) {
|
||||
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
|
||||
return
|
||||
}
|
||||
|
||||
val calculatedWidth = (height - paddingY * 2) * currentAspectRatio
|
||||
val padding = (width - calculatedWidth) / 2
|
||||
|
||||
paddingX = padding
|
||||
}
|
||||
|
||||
if (paddingX > 0.0 || paddingY > 0.0) {
|
||||
if (fitZoom) {
|
||||
val width = MeasureSpec.getSize(widthMeasureSpec)
|
||||
val height = MeasureSpec.getSize(heightMeasureSpec)
|
||||
|
||||
val calculatedHeight = (height - paddingY * 2)
|
||||
val calculatedWidth = (width - paddingX * 2)
|
||||
|
||||
val zoomFactor = min(height / calculatedHeight, width / calculatedWidth)
|
||||
|
||||
scaleX = zoomFactor
|
||||
scaleY = zoomFactor
|
||||
}
|
||||
|
||||
setPadding(
|
||||
ceil(paddingX).toInt(),
|
||||
ceil(paddingY).toInt(),
|
||||
floor(paddingX).toInt(),
|
||||
floor(paddingY).toInt()
|
||||
)
|
||||
}
|
||||
|
||||
super.onMeasure(
|
||||
widthMeasureSpec,
|
||||
heightMeasureSpec
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,163 @@
|
||||
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 showDownloadBadge(packageManager: PackageManager) =
|
||||
!isGameInstalled(packageManager) || !identifyGameLegitimacy(packageManager)
|
||||
|
||||
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 getGameVersionCodeOrNull(packageManager: PackageManager): Long? {
|
||||
return try {
|
||||
getGameVersionCode(packageManager)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getGameVersionString(packageManager: PackageManager): String {
|
||||
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
||||
return game.versionName ?: "0.000"
|
||||
}
|
||||
|
||||
private val gameVersionMap = mapOf(
|
||||
37L to "2.200",
|
||||
38L to "2.205",
|
||||
39L to "2.206",
|
||||
40L to "2.2074" // ok
|
||||
)
|
||||
|
||||
/**
|
||||
* 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 const val AMAZON_CERTIFICATE_HASH = "1f228081e4d66e006887d4ba0f3d40e96a80c758b84231f11cd5c1c9aef6048f"
|
||||
|
||||
private fun validateCertificate(certificate: ByteArray): GameSource = when (val hash = certificate.toByteString().sha256().hex()) {
|
||||
GAME_CERTIFICATE_HASH -> GameSource.GOOGLE
|
||||
AMAZON_CERTIFICATE_HASH -> GameSource.AMAZON
|
||||
else -> {
|
||||
println("Encountered certificate hash: $hash")
|
||||
GameSource.UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
enum class GameSource {
|
||||
UNKNOWN, GOOGLE, AMAZON
|
||||
}
|
||||
|
||||
fun identifyGameSource(packageManager: PackageManager): GameSource {
|
||||
@Suppress("DEPRECATION")
|
||||
val certificates = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, PackageManager.GET_SIGNING_CERTIFICATES)
|
||||
val signingInfo = game.signingInfo
|
||||
|
||||
signingInfo?.signingCertificateHistory ?: return GameSource.UNKNOWN
|
||||
} else {
|
||||
val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, PackageManager.GET_SIGNATURES)
|
||||
game.signatures
|
||||
}
|
||||
|
||||
return certificates?.map {
|
||||
validateCertificate(it.toByteArray())
|
||||
}?.firstOrNull {
|
||||
it != GameSource.UNKNOWN
|
||||
} ?: GameSource.UNKNOWN
|
||||
}
|
||||
|
||||
fun identifyGameLegitimacy(packageManager: PackageManager) =
|
||||
identifyGameSource(packageManager) != GameSource.UNKNOWN
|
||||
}
|
||||
@@ -1,62 +1,103 @@
|
||||
package com.geode.launcher.utils
|
||||
|
||||
import android.app.Activity
|
||||
import android.Manifest
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.database.Cursor
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.MediaStore
|
||||
import android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||
import android.provider.OpenableColumns
|
||||
import android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.Keep
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.geode.launcher.BuildConfig
|
||||
import com.geode.launcher.R
|
||||
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.lang.ref.WeakReference
|
||||
import java.net.URI
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
@Suppress("unused")
|
||||
@Keep
|
||||
@Suppress("unused", "KotlinJniMissingFunction")
|
||||
object GeodeUtils {
|
||||
private lateinit var activity: WeakReference<AppCompatActivity>
|
||||
private lateinit var fileResultLauncher: ActivityResultLauncher<Intent>
|
||||
private lateinit var filesResultLauncher: ActivityResultLauncher<Intent>
|
||||
var handleSafeArea: Boolean = false
|
||||
|
||||
private lateinit var openFileResultLauncher: ActivityResultLauncher<GeodeOpenFileActivityResult.OpenFileParams>
|
||||
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) {
|
||||
this.activity = WeakReference(activity)
|
||||
fileResultLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val data = result.data
|
||||
if (data != null) {
|
||||
val uri = data.data
|
||||
openFileResultLauncher = activity.registerForActivityResult(GeodeOpenFileActivityResult()) { uri ->
|
||||
if (uri != null) {
|
||||
val path = getRealPathFromURI(activity, uri)
|
||||
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()
|
||||
}
|
||||
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()
|
||||
}
|
||||
filesResultLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val data = result.data
|
||||
if (data != null) {
|
||||
val clipData = data.clipData
|
||||
if (clipData != null) {
|
||||
val paths = Array(clipData.itemCount) { "" }
|
||||
for (i in 0 until clipData.itemCount) {
|
||||
val path = getRealPathFromURI(activity, clipData.getItemAt(i).uri)
|
||||
openFilesResultLauncher = activity.registerForActivityResult(GeodeOpenFilesActivityResult()) { result ->
|
||||
if (result.isEmpty()) {
|
||||
Toast.makeText(activity, R.string.no_file_selected, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
|
||||
failedCallback()
|
||||
return@registerForActivityResult
|
||||
}
|
||||
val paths: Array<String> = Array(result.size) {"n = $it"}
|
||||
for (i in result.indices) {
|
||||
val path = FileUtils.getRealPathFromURI(activity, result[i])
|
||||
if (path != null) {
|
||||
paths[i] = path
|
||||
}
|
||||
@@ -64,24 +105,62 @@ object GeodeUtils {
|
||||
selectFilesCallback(paths)
|
||||
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()
|
||||
}
|
||||
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
|
||||
fun getLogcatCrashBuffer(): String {
|
||||
try {
|
||||
return try {
|
||||
val logcatProcess = Runtime.getRuntime().exec("logcat -v brief -b crash -d")
|
||||
|
||||
val logcatOutput = logcatProcess.inputStream.bufferedReader().readText()
|
||||
|
||||
return logcatOutput
|
||||
}
|
||||
catch (e: Exception) {
|
||||
logcatProcess.inputStream.bufferedReader().readText()
|
||||
} catch (e: Exception) {
|
||||
Log.e("Geode", "Failed to get logcat crash buffer", e)
|
||||
return ""
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +179,7 @@ object GeodeUtils {
|
||||
val manager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = manager.primaryClip
|
||||
if (clip != null && clip.itemCount > 0) {
|
||||
return clip.getItemAt(0).text.toString()
|
||||
return clip.getItemAt(0).coerceToText(this).toString()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
@@ -111,116 +190,183 @@ object GeodeUtils {
|
||||
activity.get()?.run {
|
||||
packageManager.getLaunchIntentForPackage(packageName)?.also {
|
||||
val mainIntent = Intent.makeRestartActivityTask(it.component)
|
||||
mainIntent.putExtra("restarted", true);
|
||||
mainIntent.putExtra("restarted", true)
|
||||
startActivity(mainIntent)
|
||||
exitProcess(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO As of now this is unused
|
||||
@JvmStatic
|
||||
fun openFolder(path: String): Boolean {
|
||||
activity.get()?.run {
|
||||
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("*/*")
|
||||
val context = activity.get()!!
|
||||
|
||||
if (intent.resolveActivity(packageManager) != null) {
|
||||
startActivity(intent)
|
||||
return true
|
||||
val pathFile = File(path)
|
||||
val baseDirectory = LaunchUtils.getBaseDirectory(context)
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
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
|
||||
fun selectFile(path: String): Boolean {
|
||||
activity.get()?.run {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
var uri: Uri?
|
||||
DocumentFile.fromFile(File(path)).also {
|
||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, it.uri)
|
||||
uri = it.uri
|
||||
}
|
||||
intent.setType("*/*")
|
||||
|
||||
if (intent.resolveActivity(packageManager) != null) {
|
||||
print("Geode Selectfile")
|
||||
fileResultLauncher.launch(intent)
|
||||
print("Geode launched ")
|
||||
return true
|
||||
return try {
|
||||
checkForFilePermissions(
|
||||
onSuccess = {
|
||||
openFileResultLauncher.launch(GeodeOpenFileActivityResult.OpenFileParams(arrayOf("*/*"), uri))
|
||||
},
|
||||
onFailure = { failedCallback() }
|
||||
)
|
||||
|
||||
true
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun selectFiles(path: String): Boolean {
|
||||
activity.get()?.run {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
var uri: Uri?
|
||||
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) {
|
||||
filesResultLauncher.launch(intent)
|
||||
return true
|
||||
return try {
|
||||
checkForFilePermissions(
|
||||
onSuccess = {
|
||||
openFilesResultLauncher.launch(GeodeOpenFilesActivityResult.OpenFileParams(arrayOf("*/*"), uri))
|
||||
},
|
||||
onFailure = { failedCallback() }
|
||||
)
|
||||
|
||||
true
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun selectFolder(path: String): Boolean {
|
||||
activity.get()?.run {
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
var uri: Uri?
|
||||
DocumentFile.fromFile(File(path)).also {
|
||||
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, it.uri)
|
||||
uri = it.uri
|
||||
}
|
||||
intent.setType("*/*")
|
||||
|
||||
if (intent.resolveActivity(packageManager) != null) {
|
||||
fileResultLauncher.launch(intent)
|
||||
return true
|
||||
return try {
|
||||
checkForFilePermissions(
|
||||
onSuccess = {
|
||||
openDirectoryResultLauncher.launch(uri)
|
||||
},
|
||||
onFailure = { failedCallback() }
|
||||
)
|
||||
|
||||
true
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun createFile(path: String): Boolean {
|
||||
activity.get()?.run {
|
||||
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("*/*")
|
||||
val initialPath = File(path)
|
||||
|
||||
if (intent.resolveActivity(packageManager) != null) {
|
||||
fileResultLauncher.launch(intent)
|
||||
return true
|
||||
return try {
|
||||
checkForFilePermissions(
|
||||
onSuccess = {
|
||||
saveFileResultLauncher.launch(GeodeSaveFileActivityResult.SaveFileParams(null, initialPath))
|
||||
},
|
||||
onFailure = { failedCallback() }
|
||||
)
|
||||
|
||||
true
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getBaseDirectory(): String {
|
||||
@@ -234,171 +380,212 @@ object GeodeUtils {
|
||||
return activity.filesDir.canonicalPath
|
||||
}
|
||||
|
||||
private val gameVersionMap = mapOf(
|
||||
37L to "2.200",
|
||||
38L to "2.205"
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun getGameVersion(): String {
|
||||
// these versions should be aligned to windows releases, not what android says
|
||||
activity.get()?.run {
|
||||
val versionCode = LaunchUtils.getGeometryDashVersionCode(packageManager)
|
||||
return gameVersionMap[versionCode] ?: LaunchUtils.getGeometryDashVersionString(packageManager)
|
||||
return GamePackageUtils.getUnifiedVersionName(packageManager)
|
||||
}
|
||||
|
||||
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.
|
||||
* Returns the current version code of the launcher.
|
||||
*/
|
||||
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
|
||||
@JvmStatic
|
||||
fun getLauncherVersion(): Int {
|
||||
return BuildConfig.VERSION_CODE
|
||||
}
|
||||
|
||||
fun isGeodeUri(uri: Uri): Boolean {
|
||||
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)
|
||||
|
||||
internal fun getVibrator(): Vibrator? = activity.get()?.run {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val manager = getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||
manager.defaultVibrator
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||
}
|
||||
}
|
||||
|
||||
// vibration
|
||||
|
||||
/**
|
||||
* If vibration is supported on the device.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun vibrateSupported(): Boolean = getVibrator()?.hasVibrator() == true
|
||||
|
||||
/**
|
||||
* Vibrates for the given amount of time.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun vibrate(ms: Long) {
|
||||
if (ms == 0L) {
|
||||
getVibrator()?.cancel()
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
getVibrator()?.vibrate(VibrationEffect.createOneShot(ms, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
getVibrator()?.vibrate(ms)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vibrates with the given pattern.
|
||||
* @see android.os.VibrationEffect.createWaveform
|
||||
*/
|
||||
@JvmStatic
|
||||
fun vibratePattern(pattern: LongArray, repeat: Int) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
getVibrator()?.vibrate(VibrationEffect.createWaveform(pattern, repeat))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
getVibrator()?.vibrate(pattern, repeat)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getScreenInsets(): IntArray? {
|
||||
if (!handleSafeArea || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
|
||||
return null
|
||||
}
|
||||
|
||||
return activity.get()?.run {
|
||||
val displayCutout = window.decorView.rootWindowInsets?.displayCutout ?: return null
|
||||
|
||||
intArrayOf(displayCutout.safeInsetLeft, displayCutout.safeInsetBottom, displayCutout.safeInsetRight, displayCutout.safeInsetTop)
|
||||
}
|
||||
}
|
||||
|
||||
const val ARGUMENT_SAFE_MODE = "--geode:safe-mode"
|
||||
|
||||
private var additionalLaunchArguments = arrayListOf<String>()
|
||||
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"
|
||||
const val CAPABILITY_INTERNAL_CALLBACKS = "internal_callbacks_v1"
|
||||
|
||||
private var capabilityListener: WeakReference<CapabilityListener?> = WeakReference(null)
|
||||
|
||||
fun setCapabilityListener(listener: CapabilityListener) {
|
||||
capabilityListener = WeakReference(listener)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports that the game client supports a feature.
|
||||
* These features typically require callbacks into the game, so ensure that the required methods are registered first.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun reportPlatformCapability(capability: String?): Boolean {
|
||||
if (capability.isNullOrEmpty()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return capabilityListener.get()?.onCapabilityAdded(capability) == true
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun openWebview(url: String) {
|
||||
val activity = activity.get() ?: return
|
||||
CustomTabsIntent.Builder()
|
||||
.setShowTitle(true)
|
||||
.build()
|
||||
.launchUrl(activity, url.toUri())
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
/**
|
||||
* Gives the timestamp of the next input callback, in nanoseconds.
|
||||
* Enable this by sending the "timestamp_inputs" platform capability.
|
||||
* @see reportPlatformCapability
|
||||
*/
|
||||
external fun setNextInputTimestamp(timestamp: Long)
|
||||
external fun setNextInputTimestampInternal(timestamp: Long)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.geode.launcher.utils
|
||||
|
||||
import android.graphics.drawable.AdaptiveIconDrawable
|
||||
import android.os.Build
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import com.geode.launcher.R
|
||||
|
||||
// https://gist.github.com/tkuenneth/ddf598663f041dc79960cda503d14448?permalink_comment_id=4660486#gistcomment-4660486
|
||||
@Composable
|
||||
fun adaptiveIconPainterResource(@DrawableRes id: Int): Painter {
|
||||
val context = LocalContext.current
|
||||
val res = LocalResources.current
|
||||
val theme = context.theme
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val adaptiveIcon = remember(id) {
|
||||
ResourcesCompat.getDrawable(res, id, theme) as? AdaptiveIconDrawable
|
||||
}
|
||||
if (adaptiveIcon != null) {
|
||||
remember(id) {
|
||||
BitmapPainter(adaptiveIcon.toBitmap().asImageBitmap())
|
||||
}
|
||||
} else {
|
||||
painterResource(id)
|
||||
}
|
||||
} else {
|
||||
painterResource(id)
|
||||
}
|
||||
}
|
||||
|
||||
enum class ApplicationIcon {
|
||||
DEFAULT,
|
||||
GEODE,
|
||||
PRIDE,
|
||||
TRANS;
|
||||
|
||||
companion object {
|
||||
fun fromId(id: String) = when (id) {
|
||||
"default" -> ApplicationIcon.DEFAULT
|
||||
"geode" -> ApplicationIcon.GEODE
|
||||
"pride" -> ApplicationIcon.PRIDE
|
||||
"trans" -> ApplicationIcon.TRANS
|
||||
else -> ApplicationIcon.DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
fun toId() = when (this) {
|
||||
ApplicationIcon.PRIDE -> "pride"
|
||||
ApplicationIcon.GEODE -> "geode"
|
||||
ApplicationIcon.DEFAULT -> "default"
|
||||
ApplicationIcon.TRANS -> "trans"
|
||||
}
|
||||
}
|
||||
|
||||
data class ApplicationIconDetails(
|
||||
val id: String,
|
||||
val component: String,
|
||||
@param:DrawableRes val iconId: Int,
|
||||
@param:StringRes val nameId: Int,
|
||||
)
|
||||
|
||||
object IconUtils {
|
||||
fun getIconDetails(id: ApplicationIcon) = when (id) {
|
||||
ApplicationIcon.DEFAULT -> ApplicationIconDetails(
|
||||
id = id.toId(),
|
||||
component = "MainActivity",
|
||||
iconId = R.mipmap.ic_launcher,
|
||||
nameId = R.string.application_icon_default
|
||||
)
|
||||
ApplicationIcon.GEODE -> ApplicationIconDetails(
|
||||
id = id.toId(),
|
||||
component = "MainActivityGeode",
|
||||
iconId = R.mipmap.ic_launcher_geode,
|
||||
nameId = R.string.application_icon_geode
|
||||
)
|
||||
ApplicationIcon.PRIDE -> ApplicationIconDetails(
|
||||
id = id.toId(),
|
||||
component = "MainActivityPride",
|
||||
iconId = R.mipmap.ic_launcher_pride,
|
||||
nameId = R.string.application_icon_pride
|
||||
)
|
||||
ApplicationIcon.TRANS -> ApplicationIconDetails(
|
||||
id = id.toId(),
|
||||
component = "MainActivityTrans",
|
||||
iconId = R.mipmap.ic_launcher_trans,
|
||||
nameId = R.string.application_icon_trans
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,96 +1,19 @@
|
||||
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.pm.ApplicationInfo
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import java.io.Serializable
|
||||
|
||||
object LaunchUtils {
|
||||
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 detectAbiMismatch(context: Context, packageInfo: PackageInfo, loadException: Error): Boolean {
|
||||
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)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
@Suppress("DEPRECATION")
|
||||
val applicationArchitecture: String = Build.CPU_ABI
|
||||
|
||||
val platformName: String = if (applicationArchitecture == "arm64-v8a")
|
||||
"android64" else "android32"
|
||||
val is64bit = applicationArchitecture == "arm64-v8a"
|
||||
|
||||
val platformName: String = if (is64bit) "android64" else "android32"
|
||||
|
||||
val geodeFilename: String = "Geode.$platformName.so"
|
||||
|
||||
@@ -124,7 +47,7 @@ object LaunchUtils {
|
||||
/**
|
||||
* Returns the directory that Geode/the game should base itself off of.
|
||||
*/
|
||||
fun getBaseDirectory(context: Context): File {
|
||||
fun getBaseDirectory(context: Context, ignoreProfile: Boolean = false): File {
|
||||
// deprecated, but seems to be the best choice of directory (i forced mat to test it)
|
||||
// also, is getting the first item the correct choice here?? what do they mean
|
||||
@Suppress("DEPRECATION")
|
||||
@@ -135,6 +58,14 @@ object LaunchUtils {
|
||||
val noMediaPath = File(dir, ".nomedia")
|
||||
noMediaPath.createNewFile()
|
||||
|
||||
val currentProfile = ProfileManager.get(context).getCurrentProfile()
|
||||
if (currentProfile != null && !ignoreProfile) {
|
||||
val profile = File(dir, "profiles/$currentProfile/")
|
||||
profile.mkdirs()
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
@@ -142,16 +73,48 @@ object LaunchUtils {
|
||||
return File(getBaseDirectory(context), "save")
|
||||
}
|
||||
|
||||
@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)
|
||||
const val CRASH_INDICATOR_NAME = "lastSessionDidCrash"
|
||||
|
||||
aspMethod.invoke(assetManager, packageInfo.applicationInfo.sourceDir)
|
||||
packageInfo.applicationInfo.splitSourceDirs?.forEach {
|
||||
aspMethod.invoke(assetManager, it)
|
||||
fun getCrashDirectory(context: Context): File {
|
||||
val base = getBaseDirectory(context)
|
||||
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 : Serializable {
|
||||
LINKER_NEEDS_64BIT,
|
||||
LINKER_NEEDS_32BIT,
|
||||
LINKER_FAILED_STL,
|
||||
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"
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.core.content.edit
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
@@ -20,8 +20,6 @@ import com.geode.launcher.BuildConfig
|
||||
*/
|
||||
class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
||||
companion object {
|
||||
private const val FILE_KEY = "GeodeLauncherPreferencesFileKey"
|
||||
|
||||
@Composable
|
||||
fun useBooleanPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<Boolean> {
|
||||
return usePreference(
|
||||
@@ -70,11 +68,14 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
||||
preferenceSet: (PreferenceUtils, Key, T) -> Unit
|
||||
): MutableState<T> {
|
||||
val context = LocalContext.current
|
||||
val sharedPreferences = context.getSharedPreferences(FILE_KEY, Context.MODE_PRIVATE)
|
||||
|
||||
val currentProfile = remember { ProfileManager.get(context).getCurrentFileKey() }
|
||||
val sharedPreferences = context.getSharedPreferences(currentProfile, Context.MODE_PRIVATE)
|
||||
|
||||
val preferences = get(sharedPreferences)
|
||||
|
||||
val preferenceValue = remember {
|
||||
// inferring the type causes an internal compiler error. lol
|
||||
val preferenceValue: MutableState<T> = remember {
|
||||
val state = mutableStateOf(preferenceGet(preferences, preferenceKey))
|
||||
object : MutableState<T> by state {
|
||||
override var value: T
|
||||
@@ -113,7 +114,8 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
||||
}
|
||||
|
||||
fun get(context: Context): PreferenceUtils {
|
||||
val sharedPreferences = context.getSharedPreferences(FILE_KEY, Context.MODE_PRIVATE)
|
||||
val currentFileKey = ProfileManager.get(context).getCurrentFileKey()
|
||||
val sharedPreferences = context.getSharedPreferences(currentFileKey, Context.MODE_PRIVATE)
|
||||
return get(sharedPreferences)
|
||||
}
|
||||
|
||||
@@ -123,7 +125,6 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
||||
}
|
||||
|
||||
enum class Key {
|
||||
LOAD_TESTING,
|
||||
LOAD_AUTOMATICALLY,
|
||||
UPDATE_AUTOMATICALLY,
|
||||
RELEASE_CHANNEL,
|
||||
@@ -133,20 +134,48 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
||||
BLACK_BACKGROUND,
|
||||
CURRENT_RELEASE_MODIFIED,
|
||||
LAST_DISMISSED_UPDATE,
|
||||
DISMISSED_GJ_UPDATE
|
||||
DISMISSED_GJ_UPDATE,
|
||||
LAUNCH_ARGUMENTS,
|
||||
LIMIT_ASPECT_RATIO,
|
||||
DISPLAY_MODE,
|
||||
FORCE_HRR,
|
||||
ENABLE_REDESIGN,
|
||||
RELEASE_CHANNEL_TAG,
|
||||
DEVELOPER_MODE,
|
||||
CLEANUP_APKS,
|
||||
CUSTOM_SYMBOL_LIST,
|
||||
CUSTOM_ASPECT_RATIO,
|
||||
SCREEN_ZOOM,
|
||||
SCREEN_ZOOM_FIT,
|
||||
LIMIT_FRAME_RATE,
|
||||
DISABLE_USER_THEME,
|
||||
SELECTED_ICON,
|
||||
DEVELOPER_SERVER,
|
||||
}
|
||||
|
||||
private fun defaultValueForBooleanKey(key: Key): Boolean {
|
||||
return when (key) {
|
||||
Key.UPDATE_AUTOMATICALLY -> true
|
||||
Key.RELEASE_CHANNEL -> BuildConfig.DEBUG
|
||||
Key.UPDATE_AUTOMATICALLY, Key.FORCE_HRR -> true
|
||||
Key.DEVELOPER_MODE -> BuildConfig.DEBUG
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun defaultValueForIntKey(key: Key) = when (key) {
|
||||
Key.DISPLAY_MODE -> if (this.getBoolean(Key.LIMIT_ASPECT_RATIO)) 1 else 0
|
||||
Key.SCREEN_ZOOM -> 100
|
||||
// people wanted a reset on nightly anyways, so this is a good excuse to do so
|
||||
else -> 0
|
||||
}
|
||||
|
||||
private fun defaultValueForStringKey(key: Key) = when (key) {
|
||||
Key.CUSTOM_ASPECT_RATIO -> "16_9"
|
||||
Key.SELECTED_ICON -> "default"
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun keyToName(key: Key): String {
|
||||
return when (key) {
|
||||
Key.LOAD_TESTING -> "PreferenceLoadTesting"
|
||||
Key.LOAD_AUTOMATICALLY -> "PreferenceLoadAutomatically"
|
||||
Key.UPDATE_AUTOMATICALLY -> "PreferenceUpdateAutomatically"
|
||||
Key.RELEASE_CHANNEL -> "PreferenceReleaseChannel"
|
||||
@@ -157,6 +186,22 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
||||
Key.CURRENT_RELEASE_MODIFIED -> "PreferenceReleaseModifiedHash"
|
||||
Key.LAST_DISMISSED_UPDATE -> "PreferenceLastDismissedUpdate"
|
||||
Key.DISMISSED_GJ_UPDATE -> "PreferenceDismissedGJUpdate"
|
||||
Key.LAUNCH_ARGUMENTS -> "PreferenceLaunchArguments"
|
||||
Key.LIMIT_ASPECT_RATIO -> "PreferenceLimitAspectRatio"
|
||||
Key.DISPLAY_MODE -> "PreferenceDisplayMode"
|
||||
Key.FORCE_HRR -> "PreferenceForceHighRefreshRate"
|
||||
Key.ENABLE_REDESIGN -> "PreferenceEnableRedesign"
|
||||
Key.RELEASE_CHANNEL_TAG -> "PreferenceReleaseChannelTag"
|
||||
Key.DEVELOPER_MODE -> "PreferenceDeveloperMode"
|
||||
Key.CLEANUP_APKS -> "PreferenceCleanupPackages"
|
||||
Key.CUSTOM_SYMBOL_LIST -> "PreferenceCustomSymbolList"
|
||||
Key.CUSTOM_ASPECT_RATIO -> "PreferenceCustomAspectRatio"
|
||||
Key.SCREEN_ZOOM -> "PreferenceScreenZoom"
|
||||
Key.SCREEN_ZOOM_FIT -> "PreferenceScreenZoomFit"
|
||||
Key.LIMIT_FRAME_RATE -> "PreferenceLimitFrameRate"
|
||||
Key.DISABLE_USER_THEME -> "PreferenceDisableUserTheme"
|
||||
Key.SELECTED_ICON -> "PreferenceSelectedIcon"
|
||||
Key.DEVELOPER_SERVER -> "PreferenceDeveloperServer"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,7 +232,7 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
||||
|
||||
fun getString(key: Key): String? {
|
||||
val keyName = keyToName(key)
|
||||
return sharedPreferences.getString(keyName, null)
|
||||
return sharedPreferences.getString(keyName, defaultValueForStringKey(key))
|
||||
}
|
||||
|
||||
fun setString(key: Key, value: String?) {
|
||||
@@ -211,7 +256,7 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
||||
|
||||
fun getInt(key: Key): Int {
|
||||
val keyName = keyToName(key)
|
||||
return sharedPreferences.getInt(keyName, 0)
|
||||
return sharedPreferences.getInt(keyName, defaultValueForIntKey(key))
|
||||
}
|
||||
|
||||
fun setInt(key: Key, value: Int) {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.geode.launcher.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
private const val FILE_KEY = "GeodeLauncherPreferencesFileKey"
|
||||
private const val PROFILES_FILE_KEY = "GeodeLauncherProfiles"
|
||||
|
||||
private const val PROFILE_LIST_SAVE_KEY = "profiles"
|
||||
private const val PROFILE_CURRENT_SAVE_KEY = "currentProfile"
|
||||
|
||||
@Serializable
|
||||
data class Profile(val path: String, val name: String)
|
||||
|
||||
class ProfileManager {
|
||||
private val applicationContext: Context
|
||||
private val preferences: SharedPreferences
|
||||
|
||||
companion object {
|
||||
private lateinit var managerInstance: ProfileManager
|
||||
|
||||
fun get(context: Context): ProfileManager {
|
||||
if (!Companion::managerInstance.isInitialized) {
|
||||
val applicationContext = context.applicationContext
|
||||
|
||||
managerInstance = ProfileManager(applicationContext)
|
||||
}
|
||||
|
||||
return managerInstance
|
||||
}
|
||||
}
|
||||
|
||||
constructor(applicationContext: Context) {
|
||||
this.applicationContext = applicationContext
|
||||
this.preferences = applicationContext.getSharedPreferences(PROFILES_FILE_KEY, Context.MODE_PRIVATE)
|
||||
|
||||
_storedProfiles.value = getProfiles()
|
||||
}
|
||||
|
||||
private val _storedProfiles = MutableStateFlow<List<Profile>>(emptyList())
|
||||
val storedProfiles = _storedProfiles.asStateFlow()
|
||||
|
||||
fun getCurrentProfile(): String? {
|
||||
return preferences.getString(PROFILE_CURRENT_SAVE_KEY, null)
|
||||
.takeUnless { it.isNullOrEmpty() }
|
||||
}
|
||||
|
||||
fun getCurrentFileKey(): String {
|
||||
val currentProfile = getCurrentProfile()
|
||||
?: return FILE_KEY
|
||||
|
||||
return "${FILE_KEY}_${currentProfile}"
|
||||
}
|
||||
|
||||
fun getProfile(path: String): Profile? {
|
||||
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null)
|
||||
return profilesSet?.firstNotNullOfOrNull {
|
||||
Json.decodeFromString<Profile>(it).takeIf { it.path == path }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a profile into the save, removing any duplicate profiles beforehand
|
||||
*/
|
||||
fun storeProfile(profile: Profile) {
|
||||
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) ?: emptySet<String>()
|
||||
|
||||
val removedList = profilesSet.filterTo(LinkedHashSet()) {
|
||||
val saveProfile = Json.decodeFromString<Profile>(it)
|
||||
saveProfile.path != profile.path
|
||||
}
|
||||
removedList.add(Json.encodeToString(profile))
|
||||
|
||||
preferences.edit {
|
||||
putStringSet(PROFILE_LIST_SAVE_KEY, removedList)
|
||||
}
|
||||
|
||||
_storedProfiles.value = getProfiles()
|
||||
}
|
||||
|
||||
fun deleteProfile(path: String) {
|
||||
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) ?: emptySet<String>()
|
||||
|
||||
val removedList = profilesSet.filterTo(LinkedHashSet()) {
|
||||
val saveProfile = Json.decodeFromString<Profile>(it)
|
||||
saveProfile.path != path
|
||||
}
|
||||
|
||||
preferences.edit {
|
||||
putStringSet(PROFILE_LIST_SAVE_KEY, removedList)
|
||||
}
|
||||
|
||||
_storedProfiles.value = getProfiles()
|
||||
}
|
||||
|
||||
fun deleteProfiles(paths: List<String>) {
|
||||
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) ?: emptySet<String>()
|
||||
|
||||
val removedList = profilesSet.filterTo(LinkedHashSet()) {
|
||||
val saveProfile = Json.decodeFromString<Profile>(it)
|
||||
!paths.contains(saveProfile.path)
|
||||
}
|
||||
|
||||
preferences.edit(commit = true) {
|
||||
putStringSet(PROFILE_LIST_SAVE_KEY, removedList)
|
||||
}
|
||||
|
||||
_storedProfiles.value = getProfiles()
|
||||
}
|
||||
|
||||
fun setCurrentProfile(profile: String?) {
|
||||
preferences.edit(commit = true) {
|
||||
if (profile.isNullOrEmpty()) {
|
||||
remove(PROFILE_CURRENT_SAVE_KEY)
|
||||
} else {
|
||||
putString(PROFILE_CURRENT_SAVE_KEY, profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getProfiles(): List<Profile> {
|
||||
val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) ?: emptySet<String>()
|
||||
|
||||
return profilesSet.map {
|
||||
Json.decodeFromString<Profile>(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.geode.launcher.utils
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/****************************************************************************
|
||||
Copyright (c) 2010-2011 cocos2d-x.org
|
||||
|
||||
http://www.cocos2d-x.org
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
****************************************************************************/
|
||||
package org.cocos2dx.lib
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.os.Build
|
||||
import android.view.Surface
|
||||
import android.view.WindowManager
|
||||
import java.lang.ref.WeakReference
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
class Cocos2dxAccelerometer : SensorEventListener {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
external fun onSensorChanged(pX: Float, pY: Float, pZ: Float, pTimestamp: Long)
|
||||
}
|
||||
|
||||
constructor(context: Context) {
|
||||
mContext = WeakReference(context)
|
||||
mSensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||
mAccelerometer = mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
|
||||
val display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
context.display
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
(context.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay
|
||||
}
|
||||
this.mNaturalOrientation = display.rotation
|
||||
}
|
||||
|
||||
private val mContext: WeakReference<Context>
|
||||
private val mSensorManager: SensorManager
|
||||
private val mAccelerometer: Sensor?
|
||||
private val mNaturalOrientation: Int
|
||||
|
||||
fun enable() {
|
||||
this.mSensorManager.registerListener(this, this.mAccelerometer, SensorManager.SENSOR_DELAY_GAME)
|
||||
}
|
||||
|
||||
fun setInterval(interval: Float) {
|
||||
this.mSensorManager.registerListener(this, this.mAccelerometer, (interval * 100000).roundToInt())
|
||||
}
|
||||
|
||||
fun disable() {
|
||||
this.mSensorManager.unregisterListener(this)
|
||||
}
|
||||
|
||||
override fun onSensorChanged(pSensorEvent: SensorEvent) {
|
||||
if (pSensorEvent.sensor.type != Sensor.TYPE_ACCELEROMETER) {
|
||||
return
|
||||
}
|
||||
|
||||
var (x, y, z) = pSensorEvent.values
|
||||
|
||||
/*
|
||||
* Because the axes are not swapped when the device's screen orientation
|
||||
* changes. So we should swap it here. In tablets such as Motorola Xoom,
|
||||
* the default orientation is landscape, so should consider this.
|
||||
*/
|
||||
val orientation = this.mContext.get()!!.resources.configuration.orientation
|
||||
|
||||
if ((orientation == Configuration.ORIENTATION_LANDSCAPE) && (this.mNaturalOrientation != Surface.ROTATION_0)) {
|
||||
val tmp = x
|
||||
x = -y
|
||||
y = tmp
|
||||
} else if ((orientation == Configuration.ORIENTATION_PORTRAIT) && (this.mNaturalOrientation != Surface.ROTATION_0)) {
|
||||
val tmp = x
|
||||
x = y
|
||||
y = -tmp
|
||||
}
|
||||
|
||||
Cocos2dxGLSurfaceView.queueAccelerometer(x, y, z, pSensorEvent.timestamp)
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(pSensor: Sensor, pAccuracy: Int) {}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package org.cocos2dx.lib
|
||||
|
||||
import android.content.Context
|
||||
import android.view.KeyEvent
|
||||
import androidx.appcompat.widget.AppCompatEditText
|
||||
|
||||
class Cocos2dxEditText(context: Context) : AppCompatEditText(context) {
|
||||
var cocos2dxGLSurfaceView: Cocos2dxGLSurfaceView? = null
|
||||
|
||||
override fun onKeyDown(keyCode: Int, keyEvent: KeyEvent): Boolean {
|
||||
super.onKeyDown(keyCode, keyEvent)
|
||||
if (keyCode != KeyEvent.KEYCODE_BACK) {
|
||||
return true
|
||||
}
|
||||
cocos2dxGLSurfaceView?.requestFocus()
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,25 @@
|
||||
package org.cocos2dx.lib
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.opengl.GLSurfaceView
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.Message
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.Surface
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.widget.AppCompatEditText
|
||||
import com.customRobTop.BaseRobTopActivity
|
||||
import com.geode.launcher.utils.GeodeUtils
|
||||
|
||||
private const val HANDLER_OPEN_IME_KEYBOARD = 2
|
||||
private const val HANDLER_CLOSE_IME_KEYBOARD = 3
|
||||
private const val MS_TO_NS = 1_000_000
|
||||
|
||||
class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
companion object {
|
||||
@@ -34,25 +41,36 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
msg.what = HANDLER_CLOSE_IME_KEYBOARD
|
||||
handler.sendMessage(msg)
|
||||
}
|
||||
|
||||
fun queueAccelerometer(x: Float, y: Float, z: Float, timestamp: Long) =
|
||||
cocos2dxGLSurfaceView.queueEvent {
|
||||
Cocos2dxAccelerometer.onSensorChanged(x, y, z, timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var cocos2dxRenderer: Cocos2dxRenderer
|
||||
var useKeyboardEvents = false
|
||||
set(value) {
|
||||
field = value
|
||||
cocos2dxRenderer.sendResizeEvents = value
|
||||
}
|
||||
|
||||
var cocos2dxEditText: Cocos2dxEditText? = null
|
||||
var sendTimestampEvents = false
|
||||
var sendInternalTimestampEvents = false
|
||||
var manualBackEvents = false
|
||||
|
||||
var cocos2dxEditText: AppCompatEditText? = null
|
||||
set(value) {
|
||||
field = value
|
||||
|
||||
field?.setOnEditorActionListener(cocos2dxTextInputWrapper)
|
||||
field?.cocos2dxGLSurfaceView = this
|
||||
requestFocus()
|
||||
}
|
||||
|
||||
fun initView() {
|
||||
setEGLContextClientVersion(2)
|
||||
isFocusableInTouchMode = true
|
||||
// if (Build.VERSION.SDK_INT >= 11) {
|
||||
preserveEGLContextOnPause = true
|
||||
// }
|
||||
cocos2dxGLSurfaceView = this
|
||||
cocos2dxTextInputWrapper = Cocos2dxTextInputWrapper(this)
|
||||
Cocos2dxGLSurfaceView.handler = object : Handler(Looper.getMainLooper()) {
|
||||
@@ -103,21 +121,52 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
fun updateRefreshRate() {
|
||||
val chosenDisplay = display.supportedModes?.maxByOrNull { it.refreshRate }
|
||||
if (chosenDisplay == null) {
|
||||
println("updateRefreshRate failed to find a display to maximize refresh rate...")
|
||||
return
|
||||
}
|
||||
|
||||
val chosenRefreshRate = chosenDisplay.refreshRate
|
||||
|
||||
println("updateRefreshRate: selecting refresh rate of ${chosenDisplay.refreshRate} (display ${chosenDisplay.modeId})")
|
||||
|
||||
holder.surface.setFrameRate(chosenRefreshRate, Surface.FRAME_RATE_COMPATIBILITY_DEFAULT)
|
||||
if (isAttachedToWindow) {
|
||||
(context as Activity).window.attributes.preferredRefreshRate = chosenRefreshRate
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
queueEvent { cocos2dxRenderer.handleOnPause() }
|
||||
renderMode = RENDERMODE_WHEN_DIRTY
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
renderMode = RENDERMODE_CONTINUOUSLY
|
||||
queueEvent { cocos2dxRenderer.handleOnResume() }
|
||||
}
|
||||
|
||||
private fun sendNextEventTimestamp(timestamp: Long) {
|
||||
if (sendTimestampEvents)
|
||||
GeodeUtils.setNextInputTimestamp(timestamp)
|
||||
|
||||
if (sendInternalTimestampEvents)
|
||||
GeodeUtils.setNextInputTimestampInternal(timestamp)
|
||||
}
|
||||
|
||||
override fun onTouchEvent(motionEvent: MotionEvent): Boolean {
|
||||
val pointerNumber = motionEvent.pointerCount
|
||||
val ids = IntArray(pointerNumber)
|
||||
val xs = 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) {
|
||||
ids[i] = motionEvent.getPointerId(i)
|
||||
xs[i] = motionEvent.getX(i)
|
||||
@@ -129,11 +178,8 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
val xDown = xs[0]
|
||||
val f = ys[0]
|
||||
queueEvent {
|
||||
cocos2dxRenderer.handleActionDown(
|
||||
idDown,
|
||||
xDown,
|
||||
f
|
||||
)
|
||||
sendNextEventTimestamp(timestamp)
|
||||
cocos2dxRenderer.handleActionDown(idDown, xDown, f)
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -141,26 +187,23 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
val idUp = motionEvent.getPointerId(0)
|
||||
val f2 = xs[0]
|
||||
val f3 = ys[0]
|
||||
queueEvent { cocos2dxRenderer.handleActionUp(idUp, f2, f3) }
|
||||
queueEvent {
|
||||
sendNextEventTimestamp(timestamp)
|
||||
cocos2dxRenderer.handleActionUp(idUp, f2, f3)
|
||||
}
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
queueEvent {
|
||||
cocos2dxRenderer.handleActionMove(
|
||||
ids,
|
||||
xs,
|
||||
ys
|
||||
)
|
||||
sendNextEventTimestamp(timestamp)
|
||||
cocos2dxRenderer.handleActionMove(ids, xs, ys)
|
||||
}
|
||||
true
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
queueEvent {
|
||||
cocos2dxRenderer.handleActionCancel(
|
||||
ids,
|
||||
xs,
|
||||
ys
|
||||
)
|
||||
sendNextEventTimestamp(timestamp)
|
||||
cocos2dxRenderer.handleActionCancel(ids, xs, ys)
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -170,11 +213,8 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
val xPointerDown = motionEvent.getX(indexPointerDown)
|
||||
val y = motionEvent.getY(indexPointerDown)
|
||||
queueEvent {
|
||||
cocos2dxRenderer.handleActionDown(
|
||||
idPointerDown,
|
||||
xPointerDown,
|
||||
y
|
||||
)
|
||||
sendNextEventTimestamp(timestamp)
|
||||
cocos2dxRenderer.handleActionDown(idPointerDown, xPointerDown, y)
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -184,11 +224,8 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
val xPointerUp = motionEvent.getX(indexPointUp)
|
||||
val y2 = motionEvent.getY(indexPointUp)
|
||||
queueEvent {
|
||||
cocos2dxRenderer.handleActionUp(
|
||||
idPointerUp,
|
||||
xPointerUp,
|
||||
y2
|
||||
)
|
||||
sendNextEventTimestamp(timestamp)
|
||||
cocos2dxRenderer.handleActionUp(idPointerUp, xPointerUp, y2)
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -207,7 +244,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) {
|
||||
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_MENU -> {
|
||||
if (keyEvent.repeatCount != 0 || BaseRobTopActivity.blockBackButton) {
|
||||
@@ -220,6 +257,98 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun sendKeyBack() {
|
||||
if (cocos2dxEditText?.isFocused == true) {
|
||||
requestFocus()
|
||||
}
|
||||
|
||||
if (BaseRobTopActivity.blockBackButton) {
|
||||
return
|
||||
}
|
||||
|
||||
if (useKeyboardEvents) {
|
||||
queueEvent {
|
||||
cocos2dxRenderer.handleKeyDown(KeyEvent.KEYCODE_BACK)
|
||||
}
|
||||
} else {
|
||||
val currentTime = System.nanoTime()
|
||||
queueEvent {
|
||||
sendNextEventTimestamp(currentTime)
|
||||
GeodeUtils.nativeKeyDown(KeyEvent.KEYCODE_BACK, 0, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||
if (!useKeyboardEvents) {
|
||||
return legacyKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
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 {
|
||||
sendNextEventTimestamp(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 {
|
||||
sendNextEventTimestamp(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 {
|
||||
sendNextEventTimestamp(timestamp)
|
||||
GeodeUtils.nativeActionScroll(scrollX, scrollY)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return super.onGenericMotionEvent(event)
|
||||
}
|
||||
|
||||
fun setCocos2dxRenderer(renderer: Cocos2dxRenderer) {
|
||||
this.cocos2dxRenderer = renderer
|
||||
setRenderer(this.cocos2dxRenderer)
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.cocos2dx.lib
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.res.AssetManager
|
||||
import android.os.Process
|
||||
import com.customRobTop.BaseRobTopActivity
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
@@ -11,9 +12,9 @@ import com.geode.launcher.utils.LaunchUtils
|
||||
object Cocos2dxHelper {
|
||||
// private val sCocos2dMusic: Cocos2dxMusic? = null
|
||||
// private val sCocos2dSound: Cocos2dxSound? = null
|
||||
// private val sAssetManager: AssetManager? = null
|
||||
// private val sCocos2dxAccelerometer: Cocos2dxAccelerometer? = null
|
||||
// private val sAccelerometerEnabled = false
|
||||
private var sAssetManager: AssetManager? = null
|
||||
private var sCocos2dxAccelerometer: Cocos2dxAccelerometer? = null
|
||||
private var sAccelerometerEnabled = false
|
||||
private var packageName: String? = null
|
||||
private var fileDirectory: String? = null
|
||||
|
||||
@@ -36,6 +37,40 @@ object Cocos2dxHelper {
|
||||
Process.killProcess(Process.myPid())
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getAssetManager(): AssetManager? {
|
||||
return sAssetManager
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun enableAccelerometer() {
|
||||
sAccelerometerEnabled = true
|
||||
sCocos2dxAccelerometer?.enable()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun setAccelerometerInterval(interval: Float) {
|
||||
sCocos2dxAccelerometer?.setInterval(interval)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun disableAccelerometer() {
|
||||
sAccelerometerEnabled = false
|
||||
sCocos2dxAccelerometer?.disable()
|
||||
}
|
||||
|
||||
fun onResume() {
|
||||
if (sAccelerometerEnabled) {
|
||||
sCocos2dxAccelerometer?.enable()
|
||||
}
|
||||
}
|
||||
|
||||
fun onPause() {
|
||||
if (sAccelerometerEnabled) {
|
||||
sCocos2dxAccelerometer?.disable()
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getDPI(): Int {
|
||||
return BaseRobTopActivity.me.get()?.resources?.configuration?.densityDpi ?: -1
|
||||
@@ -52,14 +87,14 @@ object Cocos2dxHelper {
|
||||
this.cocos2dxHelperListener = cocos2dxHelperListener
|
||||
packageName = applicationInfo.packageName
|
||||
fileDirectory = LaunchUtils.getSaveDirectory(context).absolutePath
|
||||
// Cocos2dxHelper.sCocos2dxAccelerometer = Cocos2dxAccelerometer(pContext)
|
||||
sCocos2dxAccelerometer = Cocos2dxAccelerometer(context)
|
||||
// Cocos2dxHelper.sCocos2dMusic = Cocos2dxMusic(pContext)
|
||||
// var simultaneousStreams: Int = Cocos2dxSound.MAX_SIMULTANEOUS_STREAMS_DEFAULT
|
||||
// if (Cocos2dxHelper.getDeviceModel().indexOf("GT-I9100") !== -1) {
|
||||
// simultaneousStreams = Cocos2dxSound.MAX_SIMULTANEOUS_STREAMS_I9100
|
||||
// }
|
||||
// Cocos2dxHelper.sCocos2dSound = Cocos2dxSound(pContext, simultaneousStreams)
|
||||
// Cocos2dxHelper.sAssetManager = pContext.getAssets()
|
||||
sAssetManager = context.assets
|
||||
Cocos2dxBitmap.setContext(context)
|
||||
// Cocos2dxETCLoader.setContext(pContext)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package org.cocos2dx.lib
|
||||
|
||||
import android.opengl.GLSurfaceView
|
||||
import android.os.Build
|
||||
import javax.microedition.khronos.egl.EGLConfig
|
||||
import javax.microedition.khronos.opengles.GL10
|
||||
|
||||
private const val NANOSECONDS_PER_SECOND = 1000000000L
|
||||
private const val NANOSECONDS_PER_MICROSECOND = 1000000L
|
||||
|
||||
@Suppress("unused", "KotlinJniMissingFunction")
|
||||
class Cocos2dxRenderer : GLSurfaceView.Renderer {
|
||||
class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceView.Renderer {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun setAnimationInterval(@Suppress("UNUSED_PARAMETER") animationInterval: Double) {
|
||||
@@ -59,6 +63,13 @@ class Cocos2dxRenderer : GLSurfaceView.Renderer {
|
||||
private var lastTickInNanoSeconds: Long = 0
|
||||
private var screenWidth = 0
|
||||
private var screenHeight = 0
|
||||
var sendResizeEvents = false
|
||||
var setFrameRate = false
|
||||
private var mAnimationInterval: Long? = null
|
||||
|
||||
fun limitFrameRate(rate: Int) {
|
||||
mAnimationInterval = NANOSECONDS_PER_SECOND/rate
|
||||
}
|
||||
|
||||
fun setScreenWidthAndHeight(surfaceWidth: Int, surfaceHeight: Int) {
|
||||
screenWidth = surfaceWidth
|
||||
@@ -70,10 +81,32 @@ class Cocos2dxRenderer : GLSurfaceView.Renderer {
|
||||
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()
|
||||
}
|
||||
|
||||
println("renderer surfaceChanged: ${width}x${height}@${handler.display.refreshRate}fps")
|
||||
}
|
||||
|
||||
override fun onDrawFrame(gl: GL10?) {
|
||||
val animationInterval = mAnimationInterval
|
||||
if (animationInterval == null) {
|
||||
nativeRender()
|
||||
} else {
|
||||
nativeRender()
|
||||
|
||||
val now = System.nanoTime()
|
||||
val interval = now - this.lastTickInNanoSeconds
|
||||
|
||||
if (interval < animationInterval) {
|
||||
try {
|
||||
Thread.sleep((animationInterval - interval) / NANOSECONDS_PER_MICROSECOND)
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
|
||||
lastTickInNanoSeconds = System.nanoTime()
|
||||
}
|
||||
}
|
||||
|
||||
fun handleActionDown(id: Int, x: Float, y: Float) {
|
||||
|
||||
@@ -52,12 +52,10 @@ class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurf
|
||||
cocos2dxGLSurfaceView.deleteBackward()
|
||||
}
|
||||
var text = textView.text.toString()
|
||||
if (text.compareTo("") == 0) {
|
||||
text = "\n"
|
||||
}
|
||||
if (10 != text[text.length - 1].code) {
|
||||
text += 10.toChar()
|
||||
if (!text.endsWith('\n')) {
|
||||
text += '\n'
|
||||
}
|
||||
|
||||
cocos2dxGLSurfaceView.insertText(text)
|
||||
}
|
||||
if (actionID != 6) {
|
||||
|
||||
@@ -10,8 +10,9 @@ object Cocos2dxTypefaces {
|
||||
|
||||
@Synchronized
|
||||
operator fun get(pContext: Context, pAssetName: String): Typeface? {
|
||||
var typeface: Typeface?
|
||||
var typeface2: Typeface
|
||||
val typeface: Typeface?
|
||||
val typeface2: Typeface
|
||||
|
||||
synchronized(Cocos2dxTypefaces::class.java) {
|
||||
if (!typefaceCache.containsKey(pAssetName)) {
|
||||
typeface2 = if (pAssetName.startsWith("/")) {
|
||||
|
||||
@@ -11,18 +11,24 @@ import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
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 mDecoder: MediaCodec? = null
|
||||
private var mExtractor: MediaExtractor? = null
|
||||
private var mInputBuffers: Array<ByteBuffer>? = null
|
||||
private var mInputFinished = false
|
||||
private var length = 0L
|
||||
private var mOutputBuffers: Array<ByteBuffer>? = null
|
||||
private var mOutputFinished = false
|
||||
private var sampleRate = 0
|
||||
|
||||
fun init(codecPtr: Long): Boolean {
|
||||
mCodecPtr = codecPtr
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="1024"
|
||||
android:viewportHeight="1024">
|
||||
<group android:scaleX="0.6689189"
|
||||
android:scaleY="0.6689189"
|
||||
android:translateX="169.51352"
|
||||
android:translateY="182.1982">
|
||||
<path
|
||||
android:pathData="M553.7,189.9L536.9,177.1C522.2,165.8 501.8,165.8 487.1,177.1L207.6,390.5C194,400.9 188.3,418.8 193.3,435.2L300.9,783.2C306.2,800.3 322.1,812.1 340.1,812.1H683.9C701.9,812.1 717.8,800.3 723.1,783.2L830.7,435.2C835.7,418.8 830,400.9 816.4,390.5L792,371.9L762,466.3L680,733C676.5,744.4 666,752.1 654.2,752.1H368C356.1,752.1 345.7,744.4 342.2,733L252.8,442.3C249.5,431.5 253.2,419.8 262.1,413L494.6,234.5C495.6,233.7 496.6,233.1 497.6,232.5L553.7,189.9Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.25"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M500.6,320.6C508.5,314.5 519.5,314.5 527.4,320.6L686,442.2C693.3,447.8 696.3,457.3 693.6,466.1L632.6,664.4C629.7,673.6 621.2,679.9 611.6,679.9H416.4C406.8,679.9 398.3,673.6 395.4,664.4L385.4,632H439.4C440,632.1 440.7,632.2 441.4,632.2H583.2C588,632.2 592.3,629 593.7,624.4L638,477.5C639.3,473.2 637.8,468.5 634.3,465.7L519.1,375.6C515.1,372.5 509.5,372.5 505.5,375.6L399.1,458.8L344.5,499.1L334.4,466.1C331.7,457.3 334.7,447.8 342,442.2L500.6,320.6Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.25"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M557.1,165.4L536.9,150C522.2,138.8 501.8,138.8 487.1,150L180.9,383.8C167.3,394.2 161.6,412.1 166.7,428.5L284.4,809.4C289.7,826.6 305.6,838.3 323.6,838.3H700.4C718.4,838.3 734.3,826.6 739.6,809.4L857.3,428.5C862.4,412.1 856.7,394.2 843.1,383.8L814.8,362.3L782.4,464.4L693.2,754.4C689.7,765.7 679.2,773.5 667.4,773.5H354.6C342.8,773.5 332.3,765.7 328.8,754.4L231.2,437C227.9,426.2 231.6,414.5 240.6,407.6L494.6,212.6C495.6,211.8 496.6,211.1 497.7,210.5L557.1,165.4Z"
|
||||
android:fillType="evenOdd"
|
||||
android:fillColor="#ffffff" />
|
||||
<path
|
||||
android:pathData="M500.6,284.3C508.5,278.2 519.5,278.2 527.4,284.3L716.8,429.5C724.1,435.1 727.1,444.6 724.4,453.4L651.6,689.9C648.8,699.2 640.3,705.5 630.6,705.5H397.4C387.7,705.5 379.2,699.2 376.4,689.9L363.9,649.5H425.7C426.4,649.6 427.1,649.7 427.9,649.7H596.1C601,649.7 605.2,646.5 606.6,641.9L659.1,468C660.5,463.6 659,458.9 655.4,456.1L518.8,349.3C514.8,346.2 509.2,346.2 505.2,349.3L379.9,447.3L316.2,494.3L303.6,453.4C300.9,444.6 303.9,435.1 311.2,429.5L500.6,284.3Z"
|
||||
android:fillType="evenOdd"
|
||||
android:fillColor="#ffffff" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -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,28 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="1024"
|
||||
android:viewportHeight="1024">
|
||||
<group android:scaleX="0.75" android:scaleY="0.75" android:translateX="128" android:translateY="128">
|
||||
<clip-path
|
||||
android:pathData="M0,0h1024v1024h-1024z"/>
|
||||
<path
|
||||
android:pathData="M0,0h1024v1024h-1024z"
|
||||
android:fillColor="#F4D48E"/>
|
||||
<path
|
||||
android:pathData="M512,512m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
|
||||
android:fillColor="#F4D48E"/>
|
||||
<path
|
||||
android:pathData="M719.5,289C618,241.7 526,152.5 320.5,152.5C115,152.5 3.5,248 -8.5,293C-20.5,338 -8.2,391.5 -13.5,390.5L-33,706.5H1145L1137.5,460C1115.5,426.8 1043.5,399 1007,391.5C970.5,384 822.5,337 719.5,289Z"
|
||||
android:fillColor="#F4A47B"/>
|
||||
<path
|
||||
android:pathData="M561.5,514.5C488.5,476.5 341,402 214.5,402C88,402 30.3,440.7 -8.5,461.5C-47.3,482.3 -54,485.2 -59,486.5L-68.5,808L563.5,814.5L561.5,514.5Z"
|
||||
android:fillColor="#E27880"/>
|
||||
<path
|
||||
android:pathData="M1020.5,551C994,521.2 893.7,454.1 662.5,452.5C539.2,451.6 363,505.6 259,577.5C163.9,643.2 43,680 -13,686L-124,934.5H1187.5L1020.5,551Z"
|
||||
android:fillColor="#B9588F"/>
|
||||
<path
|
||||
android:pathData="M819.5,856C638,856 518,772.5 359,772.5C200,772.5 100,860.5 65,869L-94,949.5V1041.5L1177.5,1025.5L1032.5,738C1028,758 967.4,856 819.5,856Z"
|
||||
android:fillColor="#714A9A"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,28 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="1024"
|
||||
android:viewportHeight="1024">
|
||||
<group android:scaleX="0.75" android:scaleY="0.75" android:translateX="128" android:translateY="128">
|
||||
<clip-path
|
||||
android:pathData="M0,0h1024v1024h-1024z"/>
|
||||
<path
|
||||
android:pathData="M0,0h1024v1024h-1024z"
|
||||
android:fillColor="#CC7C7C"/>
|
||||
<path
|
||||
android:pathData="M512,512m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
|
||||
android:fillColor="#CC7C7C"/>
|
||||
<path
|
||||
android:pathData="M719.5,289C618,241.7 526,152.5 320.5,152.5C115,152.5 3.5,248 -8.5,293C-20.5,338 -8.2,391.5 -13.5,390.5L-33,706.5H1145L1137.5,460C1115.5,426.8 1043.5,399 1007,391.5C970.5,384 822.5,337 719.5,289Z"
|
||||
android:fillColor="#C6B471"/>
|
||||
<path
|
||||
android:pathData="M561.5,514.5C488.5,476.5 341,402 214.5,402C88,402 30.3,440.7 -8.5,461.5C-47.3,482.3 -54,485.2 -59,486.5L-68.5,808L563.5,814.5L561.5,514.5Z"
|
||||
android:fillColor="#59AF7F"/>
|
||||
<path
|
||||
android:pathData="M1020.5,551C994,521.2 893.7,454.1 662.5,452.5C539.2,451.6 363,505.6 259,577.5C163.9,643.2 43,680 -13,686L-124,934.5H1187.5L1020.5,551Z"
|
||||
android:fillColor="#5F8CAB"/>
|
||||
<path
|
||||
android:pathData="M819.5,856C638,856 518,772.5 359,772.5C200,772.5 100,860.5 65,869L-94,949.5V1041.5L1177.5,1025.5L1032.5,738C1028,758 967.4,856 819.5,856Z"
|
||||
android:fillColor="#70558D"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,28 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="1024"
|
||||
android:viewportHeight="1024">
|
||||
<group android:scaleX="0.75" android:scaleY="0.75" android:translateX="128" android:translateY="128">
|
||||
<clip-path
|
||||
android:pathData="M0,0h1024v1024h-1024z"/>
|
||||
<path
|
||||
android:pathData="M0,0h1024v1024h-1024z"
|
||||
android:fillColor="#65B8C2"/>
|
||||
<path
|
||||
android:pathData="M512,512m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
|
||||
android:fillColor="#65B8C2"/>
|
||||
<path
|
||||
android:pathData="M719.5,289C618,241.7 526,152.5 320.5,152.5C115,152.5 3.5,248 -8.5,293C-20.5,338 -8.2,391.5 -13.5,390.5L-33,706.5H1145L1137.5,460C1115.5,426.8 1043.5,399 1007,391.5C970.5,384 822.5,337 719.5,289Z"
|
||||
android:fillColor="#DEA5CD"/>
|
||||
<path
|
||||
android:pathData="M561.5,514.5C488.5,476.5 341,402 214.5,402C88,402 30.3,440.7 -8.5,461.5C-47.3,482.3 -54,485.2 -59,486.5L-68.5,808L563.5,814.5L561.5,514.5Z"
|
||||
android:fillColor="#DEE8EA"/>
|
||||
<path
|
||||
android:pathData="M1020.5,551C994,521.2 893.7,454.1 662.5,452.5C539.2,451.6 363,505.6 259,577.5C163.9,643.2 43,680 -13,686L-124,934.5H1187.5L1020.5,551Z"
|
||||
android:fillColor="#C485BD"/>
|
||||
<path
|
||||
android:pathData="M819.5,856C638,856 518,772.5 359,772.5C200,772.5 100,860.5 65,869L-94,949.5V1041.5L1177.5,1025.5L1032.5,738C1028,758 967.4,856 819.5,856Z"
|
||||
android:fillColor="#259CB4"/>
|
||||
</group>
|
||||
</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="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z"/>
|
||||
</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="M313,520L537,744L480,800L160,480L480,160L537,216L313,440L800,440L800,520L313,520Z"/>
|
||||
</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="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,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="M382,720L154,492L211,435L382,606L749,239L806,296L382,720Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M336,680L280,624L424,480L280,337L336,281L480,425L623,281L679,337L535,480L679,624L623,680L480,536L336,680Z"/>
|
||||
</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="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="M160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L400,160L480,240L800,240Q833,240 856.5,263.5Q880,287 880,320L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM160,720L800,720Q800,720 800,720Q800,720 800,720L800,320Q800,320 800,320Q800,320 800,320L447,320L367,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720ZM160,720Q160,720 160,720Q160,720 160,720L160,240Q160,240 160,240Q160,240 160,240L160,240L160,320L160,320Q160,320 160,320Q160,320 160,320L160,720Q160,720 160,720Q160,720 160,720Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M440,680L520,680L520,440L440,440L440,680ZM480,360Q497,360 508.5,348.5Q520,337 520,320Q520,303 508.5,291.5Q497,280 480,280Q463,280 451.5,291.5Q440,303 440,320Q440,337 451.5,348.5Q463,360 480,360ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,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="M440,680L280,680Q197,680 138.5,621.5Q80,563 80,480Q80,397 138.5,338.5Q197,280 280,280L440,280L440,360L280,360Q230,360 195,395Q160,430 160,480Q160,530 195,565Q230,600 280,600L440,600L440,680ZM320,520L320,440L640,440L640,520L320,520ZM520,680L520,600L680,600Q730,600 765,565Q800,530 800,480Q800,430 765,395Q730,360 680,360L520,360L520,280L680,280Q763,280 821.5,338.5Q880,397 880,480Q880,563 821.5,621.5Q763,680 680,680L520,680Z"/>
|
||||
</vector>
|
||||
@@ -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,800Q447,800 423.5,776.5Q400,753 400,720Q400,687 423.5,663.5Q447,640 480,640Q513,640 536.5,663.5Q560,687 560,720Q560,753 536.5,776.5Q513,800 480,800ZM480,560Q447,560 423.5,536.5Q400,513 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,513 536.5,536.5Q513,560 480,560ZM480,320Q447,320 423.5,296.5Q400,273 400,240Q400,207 423.5,183.5Q447,160 480,160Q513,160 536.5,183.5Q560,207 560,240Q560,273 536.5,296.5Q513,320 480,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="M480,480Q414,480 367,433Q320,386 320,320Q320,254 367,207Q414,160 480,160Q546,160 593,207Q640,254 640,320Q640,386 593,433Q546,480 480,480ZM160,800L160,688Q160,654 177.5,625.5Q195,597 224,582Q286,551 350,535.5Q414,520 480,520Q546,520 610,535.5Q674,551 736,582Q765,597 782.5,625.5Q800,654 800,688L800,800L160,800ZM240,720L720,720L720,688Q720,677 714.5,668Q709,659 700,654Q646,627 591,613.5Q536,600 480,600Q424,600 369,613.5Q314,627 260,654Q251,659 245.5,668Q240,677 240,688L240,720ZM480,400Q513,400 536.5,376.5Q560,353 560,320Q560,287 536.5,263.5Q513,240 480,240Q447,240 423.5,263.5Q400,287 400,320Q400,353 423.5,376.5Q447,400 480,400ZM480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320ZM480,720L480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720Q480,720 480,720L480,720Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M720,560L720,440L600,440L600,360L720,360L720,240L800,240L800,360L920,360L920,440L800,440L800,560L720,560ZM360,480Q294,480 247,433Q200,386 200,320Q200,254 247,207Q294,160 360,160Q426,160 473,207Q520,254 520,320Q520,386 473,433Q426,480 360,480ZM40,800L40,688Q40,654 57.5,625.5Q75,597 104,582Q166,551 230,535.5Q294,520 360,520Q426,520 490,535.5Q554,551 616,582Q645,597 662.5,625.5Q680,654 680,688L680,800L40,800ZM120,720L600,720L600,688Q600,677 594.5,668Q589,659 580,654Q526,627 471,613.5Q416,600 360,600Q304,600 249,613.5Q194,627 140,654Q131,659 125.5,668Q120,677 120,688L120,720ZM360,400Q393,400 416.5,376.5Q440,353 440,320Q440,287 416.5,263.5Q393,240 360,240Q327,240 303.5,263.5Q280,287 280,320Q280,353 303.5,376.5Q327,400 360,400ZM360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320Q360,320 360,320ZM360,720L360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720Q360,720 360,720L360,720Z"/>
|
||||
</vector>
|
||||
@@ -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,760L320,200L760,480L320,760ZM400,480L400,480L400,480ZM400,614L610,480L400,346L400,614Z"/>
|
||||
</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="M480,800Q346,800 253,707Q160,614 160,480Q160,346 253,253Q346,160 480,160Q549,160 612,188.5Q675,217 720,270L720,160L800,160L800,440L520,440L520,360L688,360Q656,304 600.5,272Q545,240 480,240Q380,240 310,310Q240,380 240,480Q240,580 310,650Q380,720 480,720Q557,720 619,676Q681,632 706,560L790,560Q762,666 676,733Q590,800 480,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="M200,520L200,440L760,440L760,520L200,520Z"/>
|
||||
</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="M370,880L354,752Q341,747 329.5,740Q318,733 307,725L188,775L78,585L181,507Q180,500 180,493.5Q180,487 180,480Q180,473 180,466.5Q180,460 181,453L78,375L188,185L307,235Q318,227 330,220Q342,213 354,208L370,80L590,80L606,208Q619,213 630.5,220Q642,227 653,235L772,185L882,375L779,453Q780,460 780,466.5Q780,473 780,480Q780,487 780,493.5Q780,500 778,507L881,585L771,775L653,725Q642,733 630,740Q618,747 606,752L590,880L370,880ZM440,800L519,800L533,694Q564,686 590.5,670.5Q617,655 639,633L738,674L777,606L691,541Q696,527 698,511.5Q700,496 700,480Q700,464 698,448.5Q696,433 691,419L777,354L738,286L639,328Q617,305 590.5,289.5Q564,274 533,266L520,160L441,160L427,266Q396,274 369.5,289.5Q343,305 321,327L222,286L183,354L269,418Q264,433 262,448Q260,463 260,480Q260,496 262,511Q264,526 269,541L183,606L222,674L321,632Q343,655 369.5,670.5Q396,686 427,694L440,800ZM482,620Q540,620 581,579Q622,538 622,480Q622,422 581,381Q540,340 482,340Q423,340 382.5,381Q342,422 342,480Q342,538 382.5,579Q423,620 482,620ZM480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,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="M680,880Q630,880 595,845Q560,810 560,760Q560,754 563,732L282,568Q266,583 245,591.5Q224,600 200,600Q150,600 115,565Q80,530 80,480Q80,430 115,395Q150,360 200,360Q224,360 245,368.5Q266,377 282,392L563,228Q561,221 560.5,214.5Q560,208 560,200Q560,150 595,115Q630,80 680,80Q730,80 765,115Q800,150 800,200Q800,250 765,285Q730,320 680,320Q656,320 635,311.5Q614,303 598,288L317,452Q319,459 319.5,465.5Q320,472 320,480Q320,488 319.5,494.5Q319,501 317,508L598,672Q614,657 635,648.5Q656,640 680,640Q730,640 765,675Q800,710 800,760Q800,810 765,845Q730,880 680,880ZM680,800Q697,800 708.5,788.5Q720,777 720,760Q720,743 708.5,731.5Q697,720 680,720Q663,720 651.5,731.5Q640,743 640,760Q640,777 651.5,788.5Q663,800 680,800ZM200,520Q217,520 228.5,508.5Q240,497 240,480Q240,463 228.5,451.5Q217,440 200,440Q183,440 171.5,451.5Q160,463 160,480Q160,497 171.5,508.5Q183,520 200,520ZM680,240Q697,240 708.5,228.5Q720,217 720,200Q720,183 708.5,171.5Q697,160 680,160Q663,160 651.5,171.5Q640,183 640,200Q640,217 651.5,228.5Q663,240 680,240ZM680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760ZM200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480ZM680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Z"/>
|
||||
</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="M280,760L280,680L564,680Q627,680 673.5,640Q720,600 720,540Q720,480 673.5,440Q627,400 564,400L312,400L416,504L360,560L160,360L360,160L416,216L312,320L564,320Q661,320 730.5,383Q800,446 800,540Q800,634 730.5,697Q661,760 564,760L280,760Z"/>
|
||||
</vector>
|
||||
@@ -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="M40,840L480,80L920,840L40,840ZM178,760L782,760L480,240L178,760ZM480,720Q497,720 508.5,708.5Q520,697 520,680Q520,663 508.5,651.5Q497,640 480,640Q463,640 451.5,651.5Q440,663 440,680Q440,697 451.5,708.5Q463,720 480,720ZM440,600L520,600L520,400L440,400L440,600ZM480,500L480,500L480,500Z"/>
|
||||
</vector>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_geode_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_monochrome_shadow"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_pride_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_monochrome_shadow"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_trans_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_monochrome_shadow"/>
|
||||
</adaptive-icon>
|
||||
@@ -3,17 +3,22 @@
|
||||
<string name="no_activity_found">No activity found for this action.</string>
|
||||
|
||||
<!-- MainActivity strings -->
|
||||
<string name="game_not_found">Geometry Dash was not found.</string>
|
||||
<string name="game_not_found">Geometry Dash was not found.\nThe full version of the game is required.</string>
|
||||
<string name="launcher_launch">Launch</string>
|
||||
<string name="launcher_settings">Settings</string>
|
||||
<string name="launcher_download">Download</string>
|
||||
<string name="launcher_install">Install</string>
|
||||
<string name="launcher_title">Geode</string>
|
||||
<string name="launcher_logo_alt">geode logo</string>
|
||||
<string name="launcher_launch_icon_alt">play 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_retry_icon_alt">retry icon</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>
|
||||
<plurals name="automatically_load_countdown">
|
||||
<item quantity="one">Automatically launching game in %1$d second.</item>
|
||||
@@ -22,11 +27,56 @@
|
||||
<string name="launcher_warning_icon_alt">warning</string>
|
||||
<string name="launcher_error_icon_alt">error</string>
|
||||
<string name="launcher_unsupported_version_title">Unsupported version</string>
|
||||
<string name="launcher_unsupported_version_description">
|
||||
The current version of Geometry Dash is not compatible with the version supported by the launcher (%2$s). A launcher update is required to fix this.\nLaunch will continue, but crashes may occur!
|
||||
<string name="launcher_outdated_version_description">
|
||||
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 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_game_update_required_short">A Geometry Dash update is required.</string>
|
||||
<string name="launcher_game_update_required">The currently installed version of Geometry Dash is no longer supported. Please update Geometry Dash to continue receiving Geode updates.</string>
|
||||
|
||||
<string name="launcher_update_available">A new launcher update is available!</string>
|
||||
<string name="launcher_update_dismiss">Dismiss</string>
|
||||
<string name="launcher_update_view">View</string>
|
||||
<string name="launcher_update_external_link">View in browser</string>
|
||||
<string name="launcher_update_name">Geode Launcher v%1$s</string>
|
||||
<string name="launcher_update_changelog">Changelog</string>
|
||||
<string name="launcher_update_released">Released %1$s</string>
|
||||
<string name="safe_mode_enable_title">Enable safe mode?</string>
|
||||
<string name="safe_mode_enable_description">Safe mode disables loading of all mods, allowing one to remove problematic mods or diagnose crashes. If you\'re not experiencing issues with Geode, you should probably not be using safe mode.</string>
|
||||
<string name="launcher_failed_to_load">Geode failed to load.</string>
|
||||
<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 (%5$s)\nGD %4$s, Geode %3$s + v%6$s</string>
|
||||
|
||||
<string name="launcher_notification_settings">Launcher Settings</string>
|
||||
<string name="launcher_notification_update_failed">Failed to update Geode.</string>
|
||||
<string name="launcher_notification_update_success">Geode updated!</string>
|
||||
<string name="launcher_notification_compatibility">Unsupported Geometry Dash version.</string>
|
||||
<string name="launcher_notification_compatibility_description">An update is required to receive Geode updates.</string>
|
||||
<string name="launcher_notification_update_cta">Tap for more information.</string>
|
||||
|
||||
<string name="launcher_starting_game">Starting Geometry Dash…</string>
|
||||
<string name="launcher_downloading_update">Downloading update…</string>
|
||||
<string name="launcher_downloading_update_details">(%1$s/%2$s)</string>
|
||||
<string name="launcher_cancelled_manual">Launch paused.</string>
|
||||
<string name="launcher_cancelled_navigated_away">Launch paused.</string>
|
||||
<string name="launcher_cancelled_crash">Launch paused due to a crash.</string>
|
||||
<string name="launcher_cancelled_error">Could not start Geode.</string>
|
||||
<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 -->
|
||||
<string name="release_fetch_failed">Update failed.\n%1$s</string>
|
||||
@@ -34,36 +84,52 @@
|
||||
<string name="release_fetch_in_progress">Checking for new releases…</string>
|
||||
<string name="release_fetch_success">Geode updated!</string>
|
||||
<string name="release_fetch_downloading">Downloading update… (%1$s/%2$s)</string>
|
||||
<string name="release_fetch_downloading_indeterminate">Downloading update… (%1$s)</string>
|
||||
<string name="release_fetch_cancelling">Cancelling update…</string>
|
||||
<string name="release_fetch_no_internet">No internet found.</string>
|
||||
<string name="release_fetch_cancelled">Update cancelled.</string>
|
||||
<string name="release_fetch_button_retry">Retry</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>
|
||||
|
||||
<!-- GeometryDashActivity strings -->
|
||||
<string name="load_failed_link_error">Failed to link libraries</string>
|
||||
<string name="load_failed_link_error_description">Game libraries could not be found. This may occur if the architecture of the launcher and Geometry Dash do not match, or from a system bug.\nCheck application logs for more details.</string>
|
||||
<string name="load_failed_abi_error_description">The architecture of the launcher and Geometry Dash do not match. Please reinstall Geometry Dash from the Play Store or use the \"android32\" version of the launcher to resolve this issue.\nCheck application logs for more details.</string>
|
||||
<string name="load_failed_generic_error">Load failed</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_link_error_description">Game libraries could not be found. Try to:</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">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="load_failed_recommendation_dev_stl">Devs: If this is a locally built Geode version, please use the c++_shared ANDROID_STL!</string>
|
||||
|
||||
<string name="message_box_cancel">Cancel</string>
|
||||
<string name="message_box_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 -->
|
||||
<string name="title_activity_settings">Settings</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="text_copied">Copied to clipboard.</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_description">Launches the game after a short delay.</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_view_logs">View application logs</string>
|
||||
<string name="preferences_view_crashes">View crash dumps</string>
|
||||
|
||||
<string name="preference_theme_name">Theme</string>
|
||||
<string name="preference_theme_select">Select theme</string>
|
||||
@@ -71,14 +137,78 @@
|
||||
<string name="preference_theme_light">Light</string>
|
||||
<string name="preference_theme_dark">Dark</string>
|
||||
<string name="preference_black_background_name">Black background in dark theme</string>
|
||||
<string name="preference_disable_styles">Disable custom styling</string>
|
||||
<string name="preference_set_application_icon">Application icon</string>
|
||||
<string name="preference_force_hrr">Force maximum refresh rate</string>
|
||||
<string name="preference_enable_redesign">✨ Enable UI refresh</string>
|
||||
|
||||
<string name="preference_display_mode_name">Display mode</string>
|
||||
<string name="preference_display_mode_select">Select display mode</string>
|
||||
<string name="preference_display_mode_legacy">Limited (Custom)</string>
|
||||
<string name="preference_display_mode_fullscreen">Edge-to-edge</string>
|
||||
<string name="preference_display_mode_default">Default</string>
|
||||
|
||||
<string name="preference_custom_aspect_ratio_name">Aspect ratio</string>
|
||||
<string name="preference_custom_aspect_ratio_description">Limits aspect ratio of the game.</string>
|
||||
<string name="preference_custom_aspect_ratio_select">Select aspect ratio</string>
|
||||
<string name="preference_custom_aspect_ratio_16x9">16:9</string>
|
||||
<string name="preference_custom_aspect_ratio_16x10">16:10</string>
|
||||
<string name="preference_custom_aspect_ratio_4x3">4:3</string>
|
||||
<string name="preference_custom_aspect_ratio_1x1">1:1</string>
|
||||
|
||||
<string name="preference_screen_zoom_name">Screen zoom</string>
|
||||
<string name="preference_screen_zoom_description">Reduces the size of the game window.</string>
|
||||
<string name="preference_screen_zoom_select">Edit screen zoom</string>
|
||||
<string name="preference_screen_zoom_fit_name">Fit to screen</string>
|
||||
|
||||
<string name="preference_limit_framerate_name">Limit max framerate</string>
|
||||
<string name="preference_limit_framerate_description">Limits framerate during rendering.</string>
|
||||
<string name="preference_limit_framerate_select">Edit maximum framerate</string>
|
||||
<string name="preference_limit_framerate_default">Disabled</string>
|
||||
<string name="preference_limit_framerate_label">Framerate</string>
|
||||
<string name="preference_limit_framerate_suffix">FPS</string>
|
||||
<string name="preference_limit_framerate_value">%1$d FPS</string>
|
||||
<string name="preference_limit_framerate_error_max">This device supports at most %1$s FPS.</string>
|
||||
<string name="preference_limit_framerate_error_min">Framerate must be at least %1$s FPS.</string>
|
||||
<string name="preference_limit_framerate_subtract">Decrement</string>
|
||||
<string name="preference_limit_framerate_add">Increment</string>
|
||||
<string name="preference_limit_framerate_reset">Disable</string>
|
||||
|
||||
<string name="preference_category_updater">Updates</string>
|
||||
<string name="preference_update_automatically_name">Enable automatic updates</string>
|
||||
<string name="preference_release_channel_name">Use nightly release channel</string>
|
||||
|
||||
<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_category_about">About</string>
|
||||
|
||||
<string name="preference_launcher_version_name">Android Launcher</string>
|
||||
<string name="preference_launcher_version_description">v%1$s</string>
|
||||
<string name="preference_launcher_version">Android Launcher v%1$s</string>
|
||||
|
||||
<string name="preference_loader_version_name">Geode</string>
|
||||
<string name="preference_loader_version_description">%1$s</string>
|
||||
<string name="preference_loader_version">Geode %1$s</string>
|
||||
|
||||
<string name="preference_game_version_name">Geometry Dash</string>
|
||||
<string name="preference_game_version_description">%1$s (%2$s)</string>
|
||||
<string name="preference_game_version">Geometry Dash %1$s (%2$s)</string>
|
||||
<string name="preference_game_source_google">Google</string>
|
||||
<string name="preference_game_source_amazon">Amazon</string>
|
||||
<string name="preference_game_source_unknown">Unknown</string>
|
||||
|
||||
<string name="preference_architecture_name">Device</string>
|
||||
<string name="preference_architecture_description">%1$s (%2$s)</string>
|
||||
<string name="preference_architecture">%1$s (%2$s)</string>
|
||||
|
||||
<string name="preference_check_for_updates_button">Check for updates</string>
|
||||
<string name="preference_check_for_updates_description">Current loader version: %1$s</string>
|
||||
<string name="preference_launcher_version">Geode Launcher v%1$s</string>
|
||||
<string name="preference_check_for_updates_failed">Update failed. See logs for more details.</string>
|
||||
<string name="preference_check_for_updates_none_found">No updates found.</string>
|
||||
<string name="preference_check_for_updates_success">Geode has been updated!</string>
|
||||
@@ -86,16 +216,70 @@
|
||||
<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_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>
|
||||
<string name="preference_developer_options_dialog_title">Enable developer options?</string>
|
||||
|
||||
<string name="preference_profiles">Profiles</string>
|
||||
<string name="preference_profiles_create">Create profile</string>
|
||||
<string name="preference_profiles_name">Change profile</string>
|
||||
<string name="preference_profiles_select">Select profile</string>
|
||||
<string name="preference_profiles_create_name">Profile name</string>
|
||||
<string name="preference_profiles_create_action">Create</string>
|
||||
<string name="preference_profiles_select_value">%1$s</string>
|
||||
<string name="preference_profiles_current">Current profile: %1$s</string>
|
||||
<string name="preference_profiles_create_info">This profile will be created at %1$s.</string>
|
||||
<string name="preference_profiles_create_duplicate">A profile with this name already exists.</string>
|
||||
<string name="preference_profiles_delete_alt">Delete</string>
|
||||
<string name="preference_profiles_delete_undo_alt">Undo</string>
|
||||
|
||||
<!-- ApplicationLogsActivity strings -->
|
||||
<string name="title_activity_application_logs">Application Logs</string>
|
||||
<string name="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_delete">Clear all</string>
|
||||
<string name="application_logs_share">Share</string>
|
||||
<string name="application_log_copy_label">Log Message</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_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">Show crash buffer</string>
|
||||
<string name="application_logs_crash_only_enabled_alt">Enabled</string>
|
||||
|
||||
<!-- DeveloperSettingsActivity strings -->
|
||||
<string name="title_activity_developer_settings">Developer options</string>
|
||||
<string name="preference_developer_mode">Enable developer options</string>
|
||||
<string name="preference_developer_mode_about">Developer mode allows changing 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>
|
||||
<string name="preference_use_index_update_api">Use index release API</string>
|
||||
|
||||
<string name="preference_development_server_name">Enable development server</string>
|
||||
<string name="preference_development_server_description">Opens a public development server on port 9043.</string>
|
||||
|
||||
<string name="notification_channel_development_server">Development Server Service</string>
|
||||
<string name="notification_channel_development_server_description">Indicates the development server is currently active.</string>
|
||||
|
||||
<!-- CrashLogsActivity strings -->
|
||||
<string name="title_activity_crash_logs">Crash dumps</string>
|
||||
<string name="application_crashes_no_dumps">No crash dumps available.</string>
|
||||
<string name="application_crashes_delete_one">Delete</string>
|
||||
|
||||
<!-- ApplicationIconActivity strings -->
|
||||
<string name="title_activity_application_icon">Application icons</string>
|
||||
<string name="application_icon_set_failed">Failed to change icon.</string>
|
||||
<string name="application_icon_default">Default</string>
|
||||
<string name="application_icon_geode">Geode</string>
|
||||
<string name="application_icon_pride">Pride</string>
|
||||
<string name="application_icon_trans">Transgender</string>
|
||||
|
||||
|
||||
</resources>
|
||||
@@ -1,12 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<style name="Theme.NoActionBar.Fullscreen" parent="@style/Theme.AppCompat.Light.NoActionBar">
|
||||
<style name="Theme.NoActionBar.Fullscreen" parent="@style/Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowActionBar">false</item>
|
||||
<item name="android:windowFullscreen">true</item>
|
||||
<item name="android:windowContentOverlay">@null</item>
|
||||
<item name="android:windowBackground">@android:drawable/screen_background_dark</item>
|
||||
<item name="android:windowOptOutEdgeToEdgeEnforcement" tools:targetApi="35">true</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.GeodeLauncher" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
|
||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user