mirror of
https://github.com/geode-sdk/android-launcher.git
synced 2026-10-22 16:42:38 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dd122631c | ||
|
|
69188075a3 | ||
|
|
187d3f9819 | ||
|
|
559cedb0f0 | ||
|
|
232b0a52ec | ||
|
|
915c46f05d | ||
|
|
a6ceee55d8 | ||
|
|
f2697cfb3b | ||
|
|
1bb68349e5 | ||
|
|
a677cab912 | ||
|
|
5caa533a4a | ||
|
|
1a68cc5eb9 | ||
|
|
ce98734bb9 | ||
|
|
f17500ef7b | ||
|
|
ea3ed8415b | ||
|
|
41664e430c | ||
|
|
8b17e6591f | ||
|
|
1d47a54573 | ||
|
|
9307c74df4 | ||
|
|
df0cebaa89 | ||
|
|
7bab94bf40 | ||
|
|
6d5de931fc | ||
|
|
c404f977e5 | ||
|
|
3202851303 | ||
|
|
d1fdb26d44 | ||
|
|
4987aa00db | ||
|
|
97b1cc288f | ||
|
|
00367096e9 | ||
|
|
0a7a366056 | ||
|
|
60ce6ddee8 |
No files matched your search
@@ -60,8 +60,29 @@ jobs:
|
|||||||
mv $APK_PATH $OUT_PATH
|
mv $APK_PATH $OUT_PATH
|
||||||
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
|
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Upload development build
|
- name: Rename mappings
|
||||||
|
id: rename_mappings
|
||||||
|
if: ${{ matrix.config.lower == 'release' }}
|
||||||
|
run: |
|
||||||
|
SHORT_SHA=${GITHUB_SHA::7}
|
||||||
|
MAPPING_PATH=${{github.workspace}}/app/build/outputs/mapping/${{ matrix.config.lower }}/mapping.txt
|
||||||
|
OUT_PATH=${{github.workspace}}/mapping-${{ matrix.config.lower }}-$SHORT_SHA.txt
|
||||||
|
mv $MAPPING_PATH $OUT_PATH
|
||||||
|
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Upload release build
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
if: ${{ matrix.config.lower == 'release' }}
|
||||||
|
with:
|
||||||
|
name: geode-launcher-${{ matrix.config.lower }}
|
||||||
|
path: |
|
||||||
|
${{ steps.rename_apk_universal.outputs.path }}
|
||||||
|
${{ steps.rename_apk.outputs.path }}
|
||||||
|
${{ steps.rename_mappings.outputs.path }}
|
||||||
|
|
||||||
|
- name: Upload debug build
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: ${{ matrix.config.lower == 'debug' }}
|
||||||
with:
|
with:
|
||||||
name: geode-launcher-${{ matrix.config.lower }}
|
name: geode-launcher-${{ matrix.config.lower }}
|
||||||
path: |
|
path: |
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ android {
|
|||||||
applicationId = "com.geode.launcher"
|
applicationId = "com.geode.launcher"
|
||||||
minSdk = 23
|
minSdk = 23
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 24
|
versionCode = 26
|
||||||
versionName = "1.7.1"
|
versionName = "1.7.3"
|
||||||
|
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
useSupportLibrary = true
|
useSupportLibrary = true
|
||||||
@@ -29,7 +29,7 @@ android {
|
|||||||
@Suppress("UnstableApiUsage")
|
@Suppress("UnstableApiUsage")
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
cmake {
|
cmake {
|
||||||
arguments("-DUSE_TULIPHOOK:BOOL=OFF", "-DANDROID_STL=c++_shared")
|
arguments("-DANDROID_STL=c++_shared")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ android {
|
|||||||
}
|
}
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
cmake {
|
cmake {
|
||||||
version = "3.25.0+"
|
version = "4.0.0+"
|
||||||
path = file("src/main/cpp/CMakeLists.txt")
|
path = file("src/main/cpp/CMakeLists.txt")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@
|
|||||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
|
||||||
<uses-permission android:name="android.permission.VIBRATE" />
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
<uses-permission android:name="android.permission.LIGHTS" />
|
<uses-permission android:name="android.permission.LIGHTS" />
|
||||||
|
|
||||||
@@ -90,6 +89,24 @@
|
|||||||
<data android:scheme="geode-launcher" android:host="crashes" />
|
<data android:scheme="geode-launcher" android:host="crashes" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".preferences.GeodeLogsListingActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/title_activity_geode_logs"
|
||||||
|
android:theme="@style/Theme.GeodeLauncher">
|
||||||
|
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="geode-launcher" android:host="geode-logs" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".preferences.GeodeLogViewActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/Theme.GeodeLauncher">
|
||||||
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".preferences.ApplicationIconActivity"
|
android:name=".preferences.ApplicationIconActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
|
|||||||
@@ -1,47 +1,17 @@
|
|||||||
cmake_minimum_required(VERSION 3.18.1)
|
cmake_minimum_required(VERSION 4.1.2)
|
||||||
|
|
||||||
project(launcherfix)
|
project(launcherfix)
|
||||||
|
|
||||||
add_library(launcherfix SHARED launcher-fix.cpp)
|
add_library(launcherfix SHARED launcher-fix.cpp patcher.cpp)
|
||||||
|
|
||||||
target_compile_features(launcherfix PUBLIC cxx_std_20)
|
target_compile_features(launcherfix PUBLIC cxx_std_20)
|
||||||
set_target_properties(launcherfix PROPERTIES CXX_EXTENSIONS OFF)
|
set_target_properties(launcherfix PROPERTIES CXX_EXTENSIONS OFF)
|
||||||
|
|
||||||
include(FetchContent)
|
set_target_properties(launcherfix PROPERTIES
|
||||||
|
INTERPROCEDURAL_OPTIMIZATION ON
|
||||||
option(USE_TULIPHOOK "enables choice of hooking library" OFF)
|
UNITY_BUILD ON
|
||||||
if (USE_TULIPHOOK)
|
C_VISIBILITY_PRESET hidden
|
||||||
FetchContent_Declare(TulipHook
|
CXX_VISIBILITY_PRESET hidden
|
||||||
GIT_REPOSITORY https://github.com/geode-sdk/TulipHook.git
|
)
|
||||||
GIT_TAG v3.1.4
|
|
||||||
)
|
|
||||||
|
|
||||||
set(TULIP_LINK_SOURCE ON CACHE INTERNAL "")
|
|
||||||
FetchContent_MakeAvailable(TulipHook)
|
|
||||||
|
|
||||||
target_compile_definitions(launcherfix PRIVATE -DUSE_TULIPHOOK=1)
|
|
||||||
|
|
||||||
target_link_libraries(launcherfix PRIVATE TulipHook TulipHookInclude)
|
|
||||||
else()
|
|
||||||
FetchContent_Declare(dobby
|
|
||||||
# this is the last version that builds on android at all
|
|
||||||
GIT_REPOSITORY https://github.com/jmpews/Dobby
|
|
||||||
GIT_TAG 0932d69c320e786672361ab53825ba8f4245e9d3
|
|
||||||
)
|
|
||||||
|
|
||||||
FetchContent_GetProperties(dobby)
|
|
||||||
if(NOT dobby_POPULATED)
|
|
||||||
FetchContent_Populate(dobby)
|
|
||||||
|
|
||||||
# fixes Dobby build
|
|
||||||
# add_compile_definitions(typeof=__typeof__)
|
|
||||||
set(DOBBY_DEBUG OFF CACHE INTERNAL "" FORCE)
|
|
||||||
set(DOBBY_GENERATE_SHARED OFF CACHE INTERNAL "" FORCE)
|
|
||||||
|
|
||||||
add_subdirectory("${dobby_SOURCE_DIR}" ${dobby_BINARY_DIR} EXCLUDE_FROM_ALL)
|
|
||||||
target_include_directories(launcherfix PRIVATE ${dobby_SOURCE_DIR}/include)
|
|
||||||
target_link_libraries(launcherfix PRIVATE dobby)
|
|
||||||
endif()
|
|
||||||
endif()
|
|
||||||
|
|
||||||
target_link_libraries(launcherfix PRIVATE log)
|
target_link_libraries(launcherfix PRIVATE log)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <array>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
#include <link.h>
|
||||||
|
|
||||||
|
class DataPaths {
|
||||||
|
public:
|
||||||
|
static std::string original_data_path;
|
||||||
|
static std::string data_path;
|
||||||
|
|
||||||
|
static bool patch_exceptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
// this is every function that i thought would be relevant
|
||||||
|
constexpr std::array<const std::string_view, 12> remove_symbols{
|
||||||
|
"__gxx_personality_v0",
|
||||||
|
"__cxa_throw",
|
||||||
|
"__cxa_rethrow",
|
||||||
|
"__cxa_allocate_exception",
|
||||||
|
"__cxa_end_catch",
|
||||||
|
"__cxa_begin_catch",
|
||||||
|
"__cxa_guard_abort",
|
||||||
|
"__cxa_guard_acquire",
|
||||||
|
"__cxa_guard_release",
|
||||||
|
"__cxa_free_exception",
|
||||||
|
"__emutls_get_address", // added in 2.208
|
||||||
|
"__cxa_thread_atexit"
|
||||||
|
|
||||||
|
// android 10's libc doesn't have these symbols
|
||||||
|
// and the ndk is forbidden from exporting them!! so keep it for old mods...
|
||||||
|
// "_Unwind_RaiseException",
|
||||||
|
// "_Unwind_Resume"
|
||||||
|
};
|
||||||
|
|
||||||
|
FILE* fopen_hook(const char* pathname, const char* mode);
|
||||||
|
int rename_hook(const char* old_path, const char* new_path);
|
||||||
|
|
||||||
|
int on_dl_iterate(dl_phdr_info* info, size_t size, void* data);
|
||||||
@@ -1,36 +1,13 @@
|
|||||||
#include <dlfcn.h>
|
|
||||||
#include <jni.h>
|
#include <jni.h>
|
||||||
#include <fstream>
|
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <android/log.h>
|
|
||||||
#include <link.h>
|
|
||||||
|
|
||||||
#ifndef DISABLE_LAUNCHER_FIX
|
#include "base.h"
|
||||||
|
#include "log.hpp"
|
||||||
|
|
||||||
#ifdef USE_TULIPHOOK
|
std::string DataPaths::original_data_path{};
|
||||||
#include <tulip/TulipHook.hpp>
|
std::string DataPaths::data_path{};
|
||||||
#else
|
|
||||||
#include <dobby.h>
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif
|
bool DataPaths::patch_exceptions{};
|
||||||
|
|
||||||
class DataPaths {
|
|
||||||
public:
|
|
||||||
std::string original_data_path{};
|
|
||||||
std::string data_path{};
|
|
||||||
std::string load_symbols_from{};
|
|
||||||
|
|
||||||
static DataPaths& get_instance() {
|
|
||||||
static auto paths_instance = DataPaths();
|
|
||||||
return paths_instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
|
||||||
DataPaths() {}
|
|
||||||
};
|
|
||||||
|
|
||||||
extern "C"
|
extern "C"
|
||||||
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setDataPath(
|
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setDataPath(
|
||||||
@@ -41,25 +18,11 @@ JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setDataPath(
|
|||||||
auto is_copy = jboolean();
|
auto is_copy = jboolean();
|
||||||
auto data_path_str = env->GetStringUTFChars(data_path, &is_copy);
|
auto data_path_str = env->GetStringUTFChars(data_path, &is_copy);
|
||||||
|
|
||||||
DataPaths::get_instance().data_path = std::string(data_path_str);
|
DataPaths::data_path = std::string(data_path_str);
|
||||||
|
|
||||||
env->ReleaseStringUTFChars(data_path, data_path_str);
|
env->ReleaseStringUTFChars(data_path, data_path_str);
|
||||||
}
|
}
|
||||||
|
|
||||||
extern "C"
|
|
||||||
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_enableCustomSymbolList(
|
|
||||||
JNIEnv* env,
|
|
||||||
jobject,
|
|
||||||
jstring symbol_path
|
|
||||||
) {
|
|
||||||
auto is_copy = jboolean();
|
|
||||||
auto symbol_path_str = env->GetStringUTFChars(symbol_path, &is_copy);
|
|
||||||
|
|
||||||
DataPaths::get_instance().load_symbols_from = std::string(symbol_path_str);
|
|
||||||
|
|
||||||
env->ReleaseStringUTFChars(symbol_path, symbol_path_str);
|
|
||||||
}
|
|
||||||
|
|
||||||
extern "C"
|
extern "C"
|
||||||
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setOriginalDataPath(
|
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setOriginalDataPath(
|
||||||
JNIEnv *env,
|
JNIEnv *env,
|
||||||
@@ -69,227 +32,52 @@ JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setOriginalDataPath(
|
|||||||
auto is_copy = jboolean();
|
auto is_copy = jboolean();
|
||||||
auto data_path_str = env->GetStringUTFChars(data_path, &is_copy);
|
auto data_path_str = env->GetStringUTFChars(data_path, &is_copy);
|
||||||
|
|
||||||
DataPaths::get_instance().original_data_path = std::string(data_path_str);
|
DataPaths::original_data_path = std::string(data_path_str);
|
||||||
|
|
||||||
env->ReleaseStringUTFChars(data_path, data_path_str);
|
env->ReleaseStringUTFChars(data_path, data_path_str);
|
||||||
}
|
}
|
||||||
|
|
||||||
// i copied this function from the android linker
|
extern "C"
|
||||||
std::uint32_t elfhash(const char *_name) {
|
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_enableExceptionsRenaming(JNIEnv*, jobject) {
|
||||||
const unsigned char *name = reinterpret_cast<const unsigned char*>(_name);
|
DataPaths::patch_exceptions = true;
|
||||||
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
|
// this should be called after gd is loaded but before geode
|
||||||
extern "C"
|
extern "C" JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_performPatches(JNIEnv*, jobject) {
|
||||||
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_performExceptionsRenaming(JNIEnv*, jobject) {
|
|
||||||
dl_iterate_phdr(on_dl_iterate, nullptr);
|
dl_iterate_phdr(on_dl_iterate, nullptr);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::optional<std::string> redirect_path(const char* pathname) {
|
std::optional<std::string> redirect_path(const char* pathname) {
|
||||||
auto& data_path = DataPaths::get_instance().data_path;
|
std::string_view data_path = DataPaths::data_path;
|
||||||
auto& original_data_path = DataPaths::get_instance().original_data_path;
|
std::string_view original_data_path = DataPaths::original_data_path;
|
||||||
|
|
||||||
if (!data_path.empty() && !original_data_path.empty()) {
|
if (data_path.empty() || original_data_path.empty()) [[unlikely]] {
|
||||||
// call this a c string optimization
|
return std::nullopt;
|
||||||
if (std::strncmp(pathname, original_data_path.c_str(), original_data_path.size()) == 0) {
|
}
|
||||||
auto path = data_path + (pathname + original_data_path.size());
|
|
||||||
|
|
||||||
return path;
|
// call this a c string optimization
|
||||||
}
|
if (std::strncmp(pathname, original_data_path.data(), original_data_path.size()) == 0) {
|
||||||
|
auto remaining_path = (pathname + original_data_path.size());
|
||||||
|
auto remaining_len = strlen(remaining_path);
|
||||||
|
|
||||||
|
std::string x;
|
||||||
|
x.reserve(data_path.size() + remaining_len);
|
||||||
|
x.append(data_path);
|
||||||
|
x.append(remaining_path, remaining_len);
|
||||||
|
|
||||||
|
return x;
|
||||||
}
|
}
|
||||||
|
|
||||||
return std::nullopt;
|
return std::nullopt;
|
||||||
}
|
}
|
||||||
|
|
||||||
FILE* (*fopen_original)(const char *pathname, const char *mode);
|
|
||||||
FILE* fopen_hook(const char* pathname, const char* mode) {
|
FILE* fopen_hook(const char* pathname, const char* mode) {
|
||||||
auto path_str = redirect_path(pathname);
|
auto path_str = redirect_path(pathname);
|
||||||
auto redirect = path_str.has_value() ? path_str->c_str() : pathname;
|
auto redirect = path_str.has_value() ? path_str->c_str() : pathname;
|
||||||
|
|
||||||
return fopen_original(redirect, mode);
|
return fopen(redirect, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
int (*rename_original)(const char* old_path, const char* new_path);
|
|
||||||
int rename_hook(const char* old_path, const char* new_path) {
|
int rename_hook(const char* old_path, const char* new_path) {
|
||||||
auto old_path_str = redirect_path(old_path);
|
auto old_path_str = redirect_path(old_path);
|
||||||
auto old_redirect = old_path_str ? old_path_str->c_str() : old_path;
|
auto old_redirect = old_path_str ? old_path_str->c_str() : old_path;
|
||||||
@@ -297,63 +85,5 @@ int rename_hook(const char* old_path, const char* new_path) {
|
|||||||
auto new_path_str = redirect_path(new_path);
|
auto new_path_str = redirect_path(new_path);
|
||||||
auto new_redirect = new_path_str ? new_path_str->c_str() : new_path;
|
auto new_redirect = new_path_str ? new_path_str->c_str() : new_path;
|
||||||
|
|
||||||
return rename_original(old_redirect, new_redirect);
|
return rename(old_redirect, new_redirect);
|
||||||
}
|
|
||||||
|
|
||||||
bool hook_function(void* addr, auto* hook, auto** orig) {
|
|
||||||
#ifdef USE_TULIPHOOK
|
|
||||||
using namespace tulip::hook;
|
|
||||||
|
|
||||||
HandlerMetadata metadata = {
|
|
||||||
std::make_shared<PlatformConvention>(),
|
|
||||||
AbstractFunction::from(hook)
|
|
||||||
};
|
|
||||||
|
|
||||||
auto handler_result = createHandler(addr, metadata);
|
|
||||||
if (!handler_result) {
|
|
||||||
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-Fix",
|
|
||||||
"failed to create handler: %s\n", handler_result.unwrapErr().c_str());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
auto handler = *handler_result;
|
|
||||||
|
|
||||||
HookMetadata hook_metadata = {0};
|
|
||||||
|
|
||||||
createHook(handler, reinterpret_cast<void*>(hook), hook_metadata);
|
|
||||||
|
|
||||||
WrapperMetadata wrapper_metadata = {
|
|
||||||
std::make_unique<PlatformConvention>(),
|
|
||||||
AbstractFunction::from(hook)
|
|
||||||
};
|
|
||||||
|
|
||||||
auto wrapper = createWrapper(addr, std::move(wrapper_metadata));
|
|
||||||
if (!wrapper) {
|
|
||||||
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-Fix",
|
|
||||||
"failed to create wrapper: %s\n", wrapper.unwrapErr().c_str());
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
*reinterpret_cast<void**>(orig) = *wrapper;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
#else
|
|
||||||
DobbyHook(
|
|
||||||
addr,
|
|
||||||
reinterpret_cast<dobby_dummy_func_t>(hook),
|
|
||||||
reinterpret_cast<dobby_dummy_func_t*>(orig)
|
|
||||||
);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
[[gnu::constructor]] [[gnu::used]] void setup_hooks() {
|
|
||||||
#ifndef DISABLE_LAUNCHER_FIX
|
|
||||||
auto fopen_addr = dlsym(RTLD_NEXT, "fopen");
|
|
||||||
hook_function(fopen_addr, &fopen_hook, &fopen_original);
|
|
||||||
|
|
||||||
auto rename_addr = dlsym(RTLD_NEXT, "rename");
|
|
||||||
hook_function(rename_addr, &rename_hook, &rename_original);
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#define LOG_TAG "GeodeLauncher-Fix"
|
||||||
|
|
||||||
|
#include <android/log.h>
|
||||||
|
#include <format>
|
||||||
|
|
||||||
|
namespace log {
|
||||||
|
template<typename... Args>
|
||||||
|
void log_internal(android_LogPriority prio, std::format_string<Args...> fmt, Args&&... args) {
|
||||||
|
std::string str = std::format(fmt, std::forward<Args>(args)...);
|
||||||
|
__android_log_write(prio, LOG_TAG, str.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename... Args>
|
||||||
|
inline void debug(std::format_string<Args...> fmt, Args&&... args) {
|
||||||
|
log_internal(ANDROID_LOG_DEBUG, fmt, std::forward<Args>(args)...);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename... Args>
|
||||||
|
inline void info(std::format_string<Args...> fmt, Args&&... args) {
|
||||||
|
log_internal(ANDROID_LOG_INFO, fmt, std::forward<Args>(args)...);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename... Args>
|
||||||
|
inline void warn(std::format_string<Args...> fmt, Args&&... args) {
|
||||||
|
log_internal(ANDROID_LOG_WARN, fmt, std::forward<Args>(args)...);
|
||||||
|
}
|
||||||
|
|
||||||
|
template<typename... Args>
|
||||||
|
inline void error(std::format_string<Args...> fmt, Args&&... args) {
|
||||||
|
log_internal(ANDROID_LOG_ERROR, fmt, std::forward<Args>(args)...);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
#include <cstdint>
|
||||||
|
#include <span>
|
||||||
|
|
||||||
|
#include <link.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <sys/mman.h>
|
||||||
|
|
||||||
|
#include "log.hpp"
|
||||||
|
#include "base.h"
|
||||||
|
|
||||||
|
struct ElfDynamicState {
|
||||||
|
std::uint32_t* hash_table{};
|
||||||
|
char* str_table{};
|
||||||
|
ElfW(Sym)* sym_table{};
|
||||||
|
|
||||||
|
void* plt_table{};
|
||||||
|
std::uint64_t plt_table_size_bytes{};
|
||||||
|
bool plt_table_rela{};
|
||||||
|
|
||||||
|
std::uint64_t soname_idx{};
|
||||||
|
|
||||||
|
bool is_empty() const {
|
||||||
|
return !hash_table || !str_table || !sym_table || !plt_table;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// copied from tuliphook source :)
|
||||||
|
// tuliphook sets WRX perms, but we write to pages with W^X protection
|
||||||
|
bool set_mprotect(std::uintptr_t address, size_t size, int protection) {
|
||||||
|
auto const page_size = getpagesize();
|
||||||
|
auto const page_mask = page_size - 1;
|
||||||
|
|
||||||
|
auto const ptr = reinterpret_cast<uintptr_t>(address);
|
||||||
|
auto const aligned_ptr = ptr & (~page_mask);
|
||||||
|
auto const begin_size = ptr - aligned_ptr;
|
||||||
|
auto const page_count = (begin_size + size + page_mask) / page_size;
|
||||||
|
auto const aligned_size = page_count * page_size;
|
||||||
|
|
||||||
|
auto status = mprotect(reinterpret_cast<void*>(aligned_ptr), aligned_size, protection);
|
||||||
|
|
||||||
|
return status == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// i copied this function from the android linker
|
||||||
|
constexpr std::uint32_t elfhash(std::string_view name) {
|
||||||
|
std::uint32_t h = 0, g;
|
||||||
|
|
||||||
|
for (const auto c : name) {
|
||||||
|
h = (h << 4) + static_cast<unsigned char>(c);
|
||||||
|
g = h & 0xf0000000;
|
||||||
|
h ^= g;
|
||||||
|
h ^= g >> 24;
|
||||||
|
}
|
||||||
|
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr std::array<const std::uint8_t, 3> SYMBOL_PATCH_VALUE{":3"};
|
||||||
|
|
||||||
|
bool patch_symbol(std::uint32_t* hash_table, char* str_table, ElfW(Sym)* sym_table, const std::string_view orig_name) {
|
||||||
|
auto hash = elfhash(orig_name);
|
||||||
|
|
||||||
|
auto nbucket = hash_table[0];
|
||||||
|
auto nchain = hash_table[1];
|
||||||
|
auto bucket = &hash_table[2];
|
||||||
|
auto chain = &bucket[nbucket];
|
||||||
|
|
||||||
|
for (auto i = bucket[hash % nbucket]; i != 0; i = chain[i]) {
|
||||||
|
auto sym = sym_table + i;
|
||||||
|
auto sym_name = str_table + sym->st_name;
|
||||||
|
|
||||||
|
if (orig_name == sym_name) {
|
||||||
|
// there's probably no point to checking this, honestly
|
||||||
|
if (ELF_ST_BIND(sym->st_info) == STB_GLOBAL && ELF_ST_TYPE(sym->st_info) == STT_FUNC) {
|
||||||
|
// we found it! now go rename the symbol
|
||||||
|
|
||||||
|
log::debug("Disabling symbol at {} ({})", reinterpret_cast<void*>(sym_name), sym_name);
|
||||||
|
|
||||||
|
if (!set_mprotect(reinterpret_cast<std::uintptr_t>(sym_name), SYMBOL_PATCH_VALUE.size(), PROT_READ | PROT_WRITE)) {
|
||||||
|
log::warn("failed to patch symbol: could not set writable flag");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
memcpy(sym_name, SYMBOL_PATCH_VALUE.data(), SYMBOL_PATCH_VALUE.size());
|
||||||
|
|
||||||
|
if (!set_mprotect(reinterpret_cast<std::uintptr_t>(sym_name), SYMBOL_PATCH_VALUE.size(), PROT_READ)) {
|
||||||
|
log::warn("failed to patch symbol: could not remove writable flag");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::warn("could not find symbol {} to patch (hash: {})", orig_name, hash);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool perform_symbol_patches(dl_phdr_info* info, const ElfDynamicState& state) {
|
||||||
|
// patch symbol names
|
||||||
|
auto has_error = false;
|
||||||
|
for (const auto symbol : remove_symbols) {
|
||||||
|
if (!patch_symbol(state.hash_table, state.str_table, state.sym_table, symbol)) {
|
||||||
|
has_error = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return !has_error;
|
||||||
|
}
|
||||||
|
|
||||||
|
#if defined(__LP64__)
|
||||||
|
#define ELF_R_SYM(i) ELF64_R_SYM(i)
|
||||||
|
#define ELF_R_TYPE(i) ELF64_R_TYPE(i)
|
||||||
|
#else
|
||||||
|
#define ELF_R_SYM(i) ELF32_R_SYM(i)
|
||||||
|
#define ELF_R_TYPE(i) ELF32_R_TYPE(i)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
template <typename T, typename... Types>
|
||||||
|
concept is_one_of = (std::same_as<T, Types> || ...);
|
||||||
|
|
||||||
|
template <typename T> requires is_one_of<T, ElfW(Rela), ElfW(Rel)>
|
||||||
|
bool rewrite_symbol_reloc(std::uintptr_t addr, T& r, ElfW(Sym)* sym, void* func) {
|
||||||
|
auto type = ELF_R_TYPE(r.r_info);
|
||||||
|
|
||||||
|
// we're only interested in JUMP_SLOT symbols
|
||||||
|
if (type != R_AARCH64_JUMP_SLOT && type != R_ARM_JUMP_SLOT) {
|
||||||
|
log::warn("asked to patch unknown symbol type: {:#x}", type);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto write_addr = reinterpret_cast<std::uintptr_t*>(addr);
|
||||||
|
auto val = reinterpret_cast<std::uintptr_t>(func);
|
||||||
|
if constexpr (std::is_same_v<T, ElfW(Rela)>) {
|
||||||
|
// reloc = S + A
|
||||||
|
val += r.r_addend;
|
||||||
|
}
|
||||||
|
|
||||||
|
log::debug("Patching relocation at vaddr {:#x} (offset={:#x}): {:#x} => {:#x}", addr, r.r_offset, *write_addr, val);
|
||||||
|
|
||||||
|
if (!set_mprotect(addr, sizeof(std::uintptr_t), PROT_WRITE | PROT_READ)) {
|
||||||
|
log::warn("Failed to patch relocation: failed to add writable flag");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// why use memcpy when you can do this :)
|
||||||
|
*write_addr = val;
|
||||||
|
|
||||||
|
if (!set_mprotect(addr, sizeof(std::uintptr_t), PROT_READ)) {
|
||||||
|
log::warn("Failed to patch relocation: failed to remove writable flag");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
template <typename T> requires is_one_of<T, ElfW(Rela), ElfW(Rel)>
|
||||||
|
bool perform_reloc_patches(dl_phdr_info* info, const ElfDynamicState& state) {
|
||||||
|
auto table_size = state.plt_table_size_bytes / sizeof(T);
|
||||||
|
|
||||||
|
auto failed = false;
|
||||||
|
|
||||||
|
for (auto& plt : std::span{
|
||||||
|
reinterpret_cast<T*>(state.plt_table),
|
||||||
|
static_cast<std::size_t>(table_size)
|
||||||
|
}) {
|
||||||
|
auto sym = ELF_R_SYM(plt.r_info);
|
||||||
|
if (sym == 0) continue;
|
||||||
|
|
||||||
|
auto sym_data = state.sym_table + sym;
|
||||||
|
auto sym_name = state.str_table + sym_data->st_name;
|
||||||
|
|
||||||
|
auto reloc_addr = info->dlpi_addr + plt.r_offset;
|
||||||
|
|
||||||
|
if (strcmp(sym_name, "fopen") == 0) {
|
||||||
|
if (!rewrite_symbol_reloc(reloc_addr, plt, sym_data, reinterpret_cast<void*>(&fopen_hook))) {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
} else if (strcmp(sym_name, "rename") == 0) {
|
||||||
|
if (!rewrite_symbol_reloc(reloc_addr, plt, sym_data, reinterpret_cast<void*>(&rename_hook))) {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return !failed;
|
||||||
|
}
|
||||||
|
|
||||||
|
int on_dl_iterate(dl_phdr_info* info, size_t size, void* data) {
|
||||||
|
// this is probably going to be gd
|
||||||
|
if (info->dlpi_name != nullptr && strstr(info->dlpi_name, "libcocos2dcpp.so") != nullptr) {
|
||||||
|
log::debug("Base address of libcocos2dcpp: {:#x}", info->dlpi_addr);
|
||||||
|
|
||||||
|
// step 1: get the dynamic table
|
||||||
|
std::uintptr_t dyn_addr = 0u;
|
||||||
|
|
||||||
|
for (auto& phdr : std::span{ info->dlpi_phdr, info->dlpi_phnum}) {
|
||||||
|
if (phdr.p_type == PT_DYNAMIC) {
|
||||||
|
dyn_addr = info->dlpi_addr + phdr.p_vaddr;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dyn_addr == 0u) {
|
||||||
|
log::warn("failed to find libcocos dynamic section");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// step 2: get the symbol table
|
||||||
|
auto dyn_entry = reinterpret_cast<ElfW(Dyn)*>(dyn_addr);
|
||||||
|
auto dyn_end_reached = false;
|
||||||
|
|
||||||
|
ElfDynamicState state{};
|
||||||
|
|
||||||
|
while (!dyn_end_reached) {
|
||||||
|
auto tag = dyn_entry->d_tag;
|
||||||
|
|
||||||
|
switch (dyn_entry->d_tag) {
|
||||||
|
case DT_SYMTAB:
|
||||||
|
state.sym_table = reinterpret_cast<ElfW(Sym)*>(info->dlpi_addr + dyn_entry->d_un.d_val);
|
||||||
|
break;
|
||||||
|
case DT_STRTAB:
|
||||||
|
state.str_table = reinterpret_cast<char*>(info->dlpi_addr + dyn_entry->d_un.d_val);
|
||||||
|
break;
|
||||||
|
case DT_HASH:
|
||||||
|
state.hash_table = reinterpret_cast<std::uint32_t*>(info->dlpi_addr + dyn_entry->d_un.d_val);
|
||||||
|
break;
|
||||||
|
case DT_SONAME:
|
||||||
|
state.soname_idx = dyn_entry->d_un.d_val;
|
||||||
|
break;
|
||||||
|
case DT_JMPREL:
|
||||||
|
state.plt_table = reinterpret_cast<void*>(info->dlpi_addr + dyn_entry->d_un.d_val);
|
||||||
|
break;
|
||||||
|
case DT_PLTREL: {
|
||||||
|
// for Android, arm64 = DT_RELA, arm32 = DT_REL
|
||||||
|
// could just gate this based on architecture but whatever
|
||||||
|
|
||||||
|
auto val = dyn_entry->d_un.d_val;
|
||||||
|
state.plt_table_rela = val == DT_RELA;
|
||||||
|
if (val != DT_RELA && val != DT_REL) {
|
||||||
|
log::warn("unrecognized PLTREL type {:#x}", val);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case DT_PLTRELSZ:
|
||||||
|
state.plt_table_size_bytes = dyn_entry->d_un.d_val;
|
||||||
|
break;
|
||||||
|
case DT_NULL:
|
||||||
|
dyn_end_reached = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
dyn_entry++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.is_empty()) {
|
||||||
|
log::error("failed to parse dynamic section (at least one table is null)");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool failed = false;
|
||||||
|
|
||||||
|
auto so_name = state.str_table + state.soname_idx;
|
||||||
|
if (strcmp(so_name, "libcocos2dcpp.so") != 0) {
|
||||||
|
log::warn("DT_SONAME gives us {}! that's not good", so_name);
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto reloc_failed = state.plt_table_rela
|
||||||
|
? !perform_reloc_patches<ElfW(Rela)>(info, state)
|
||||||
|
: !perform_reloc_patches<ElfW(Rel)>(info, state);
|
||||||
|
if (reloc_failed) {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DataPaths::patch_exceptions) {
|
||||||
|
if (!perform_symbol_patches(info, state)) {
|
||||||
|
failed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
log::info(
|
||||||
|
"symbol patch diagnostics\nlibrary path: {}\naddrs: base={:#x} sym={} str={} hash={} plt={}",
|
||||||
|
info->dlpi_name,
|
||||||
|
info->dlpi_addr,
|
||||||
|
reinterpret_cast<void*>(state.sym_table),
|
||||||
|
reinterpret_cast<void*>(state.str_table),
|
||||||
|
reinterpret_cast<void*>(state.hash_table),
|
||||||
|
state.plt_table
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -24,8 +24,10 @@ import androidx.core.view.WindowCompat
|
|||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.WindowInsetsControllerCompat
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.customRobTop.BaseRobTopActivity
|
import com.customRobTop.BaseRobTopActivity
|
||||||
import com.customRobTop.JniToCpp
|
import com.customRobTop.JniToCpp
|
||||||
|
import com.geode.launcher.main.FullScreenLoadingIndicator
|
||||||
import com.geode.launcher.main.LaunchNotification
|
import com.geode.launcher.main.LaunchNotification
|
||||||
import com.geode.launcher.main.determineDisplayedCards
|
import com.geode.launcher.main.determineDisplayedCards
|
||||||
import com.geode.launcher.utils.Constants
|
import com.geode.launcher.utils.Constants
|
||||||
@@ -35,13 +37,14 @@ import com.geode.launcher.utils.GamePackageUtils
|
|||||||
import com.geode.launcher.utils.GeodeUtils
|
import com.geode.launcher.utils.GeodeUtils
|
||||||
import com.geode.launcher.utils.LaunchUtils
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
import com.geode.launcher.utils.PreferenceUtils
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import org.cocos2dx.lib.Cocos2dxGLSurfaceView
|
import org.cocos2dx.lib.Cocos2dxGLSurfaceView
|
||||||
import org.cocos2dx.lib.Cocos2dxHelper
|
import org.cocos2dx.lib.Cocos2dxHelper
|
||||||
import org.cocos2dx.lib.Cocos2dxRenderer
|
import org.cocos2dx.lib.Cocos2dxRenderer
|
||||||
import org.fmod.FMOD
|
import org.fmod.FMOD
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import javax.microedition.khronos.egl.EGL10
|
import javax.microedition.khronos.egl.EGL10
|
||||||
import javax.microedition.khronos.egl.EGLConfig
|
import javax.microedition.khronos.egl.EGLConfig
|
||||||
@@ -91,103 +94,88 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
val rootLayout = setupContext()
|
||||||
createVersionFile()
|
val loadingView = ComposeView(this).apply {
|
||||||
tryLoadGame()
|
setContent {
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
FullScreenLoadingIndicator()
|
||||||
Log.e("GeodeLauncher", "Library linkage failure", e)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// generates helpful information for use in debugging library load failures
|
if (rootLayout != null) {
|
||||||
val gdPackageInfo = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
rootLayout.addView(loadingView)
|
||||||
val abiMismatch = GamePackageUtils.detectAbiMismatch(this, gdPackageInfo, e)
|
} else {
|
||||||
|
setContentView(loadingView)
|
||||||
|
}
|
||||||
|
|
||||||
val is64bit = LaunchUtils.is64bit
|
lifecycleScope.launch {
|
||||||
val errorMessage = when {
|
val error = preloadGame(rootLayout)
|
||||||
abiMismatch && is64bit -> LaunchUtils.LauncherError.LINKER_NEEDS_32BIT
|
if (error != null) {
|
||||||
abiMismatch -> LaunchUtils.LauncherError.LINKER_NEEDS_64BIT
|
returnToMain(error)
|
||||||
e.message?.contains("__gxx_personality_v0") == true ->
|
|
||||||
LaunchUtils.LauncherError.LINKER_FAILED_STL
|
|
||||||
else -> LaunchUtils.LauncherError.LINKER_FAILED
|
|
||||||
}
|
}
|
||||||
|
|
||||||
returnToMain(errorMessage, e.message, e.stackTraceToString())
|
rootLayout?.removeView(loadingView)
|
||||||
|
|
||||||
return
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("GeodeLauncher", "Uncaught error during game load", e)
|
|
||||||
|
|
||||||
returnToMain(
|
|
||||||
LaunchUtils.LauncherError.GENERIC,
|
|
||||||
e.message ?: "Unknown Exception",
|
|
||||||
e.stackTraceToString()
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun preloadGame(rootLayout: FrameLayout?): LaunchUtils.FullLauncherError? = try {
|
||||||
|
createVersionFile()
|
||||||
|
val loadFailed = tryLoadGame()
|
||||||
|
|
||||||
|
val baseView = createView()
|
||||||
|
|
||||||
|
if (rootLayout != null) {
|
||||||
|
rootLayout.addView(baseView)
|
||||||
|
} else {
|
||||||
|
setContentView(baseView)
|
||||||
|
}
|
||||||
|
|
||||||
|
mGLSurfaceView?.manualBackEvents = true
|
||||||
|
|
||||||
|
setupNotificationView(baseView, loadFailed)
|
||||||
|
|
||||||
|
null
|
||||||
|
} catch (e: UnsatisfiedLinkError) {
|
||||||
|
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 = GamePackageUtils.detectAbiMismatch(this, gdPackageInfo, e)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchUtils.FullLauncherError(
|
||||||
|
errorMessage,
|
||||||
|
e.message ?: "Unknown exception",
|
||||||
|
e.stackTraceToString()
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("GeodeLauncher", "Uncaught error during game load", e)
|
||||||
|
|
||||||
|
LaunchUtils.FullLauncherError(
|
||||||
|
LaunchUtils.LauncherError.GENERIC,
|
||||||
|
e.message ?: "Unknown Exception",
|
||||||
|
e.stackTraceToString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupContext(): FrameLayout? {
|
||||||
|
Cocos2dxHelper.init(this, this)
|
||||||
|
|
||||||
|
GeodeUtils.setContext(this)
|
||||||
|
GeodeUtils.setCapabilityListener(this)
|
||||||
|
|
||||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||||
override fun handleOnBackPressed() {
|
override fun handleOnBackPressed() {
|
||||||
mGLSurfaceView?.sendKeyBack()
|
mGLSurfaceView?.sendKeyBack()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
mGLSurfaceView?.manualBackEvents = true
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startActivity(launchIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun tryLoadGame() {
|
|
||||||
val gdPackageInfo = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
|
||||||
|
|
||||||
setupRedirection(gdPackageInfo)
|
|
||||||
|
|
||||||
Cocos2dxHelper.init(this, this)
|
|
||||||
|
|
||||||
GeodeUtils.setContext(this)
|
|
||||||
GeodeUtils.setCapabilityListener(this)
|
|
||||||
|
|
||||||
tryLoadLibrary(gdPackageInfo, Constants.FMOD_LIB_NAME)
|
|
||||||
tryLoadLibrary(gdPackageInfo, Constants.COCOS_LIB_NAME)
|
|
||||||
|
|
||||||
if (GamePackageUtils.getGameVersionCode(packageManager) >= 39L) {
|
|
||||||
/*
|
|
||||||
val customSymbols = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.CUSTOM_SYMBOL_LIST)
|
|
||||||
if (customSymbols) {
|
|
||||||
val symbolFile = File(LaunchUtils.getBaseDirectory(this), "exception_symbols.txt")
|
|
||||||
LauncherFix.enableCustomSymbolList(symbolFile.path)
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
// this fix requires geode v3, which is 2.206+
|
|
||||||
// there is a short period in which 2.206 users will still have geode v2, but whatever. ig
|
|
||||||
LauncherFix.performExceptionsRenaming()
|
|
||||||
}
|
|
||||||
|
|
||||||
loadInternalMods()
|
|
||||||
|
|
||||||
val baseView = createView()
|
|
||||||
|
|
||||||
if (!GeodeUtils.handleSafeArea && Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
|
if (!GeodeUtils.handleSafeArea && Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
|
||||||
val frameLayoutParams = ViewGroup.LayoutParams(
|
val frameLayoutParams = ViewGroup.LayoutParams(
|
||||||
@@ -210,15 +198,61 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
WindowInsets.CONSUMED
|
WindowInsets.CONSUMED
|
||||||
}
|
}
|
||||||
|
|
||||||
frameLayout.addView(baseView)
|
|
||||||
setContentView(frameLayout)
|
setContentView(frameLayout)
|
||||||
} else {
|
return frameLayout
|
||||||
setContentView(baseView)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun createVersionFile() = runCatching {
|
||||||
|
val gameVersion = GamePackageUtils.getGameVersionCode(packageManager)
|
||||||
|
|
||||||
|
val versionPath = File(filesDir, "game_version.txt")
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (versionPath.exists()) {
|
||||||
|
versionPath.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
versionPath.writeText("$gameVersion")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun returnToMain(message: LaunchUtils.FullLauncherError? = null, ) {
|
||||||
|
val launchIntent = Intent(this, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
|
||||||
|
if (message != null) {
|
||||||
|
putExtra(LaunchUtils.LAUNCHER_KEY_RETURN_ERROR, message.error)
|
||||||
|
putExtra(LaunchUtils.LAUNCHER_KEY_RETURN_MESSAGE, message.returnMessage)
|
||||||
|
putExtra(LaunchUtils.LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE, message.extendedMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun tryLoadGame(): Boolean {
|
||||||
|
val gdPackageInfo = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
|
||||||
|
setupRedirection(gdPackageInfo)
|
||||||
|
|
||||||
|
tryLoadLibrary(gdPackageInfo, Constants.FMOD_LIB_NAME)
|
||||||
|
tryLoadLibrary(gdPackageInfo, Constants.COCOS_LIB_NAME)
|
||||||
|
|
||||||
|
if (GamePackageUtils.getGameVersionCode(packageManager) >= 39L) {
|
||||||
|
// this fix requires geode v3, which is 2.206+
|
||||||
|
// there is a short period in which 2.206 users will still have geode v2, but whatever. ig
|
||||||
|
LauncherFix.enableExceptionsRenaming()
|
||||||
|
}
|
||||||
|
|
||||||
|
LauncherFix.performPatches()
|
||||||
|
|
||||||
|
loadInternalMods()
|
||||||
|
|
||||||
setupPostLibraryLoad(gdPackageInfo)
|
setupPostLibraryLoad(gdPackageInfo)
|
||||||
|
|
||||||
val geodeFailed = try {
|
return try {
|
||||||
loadGeodeLibrary()
|
loadGeodeLibrary()
|
||||||
false
|
false
|
||||||
} catch (e: UnsatisfiedLinkError) {
|
} catch (e: UnsatisfiedLinkError) {
|
||||||
@@ -228,8 +262,6 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
handleGeodeException(e)
|
handleGeodeException(e)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
setupNotificationView(baseView, geodeFailed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupNotificationView(baseView: FrameLayout, geodeFailed: Boolean) {
|
private fun setupNotificationView(baseView: FrameLayout, geodeFailed: Boolean) {
|
||||||
@@ -289,7 +321,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||||
private fun tryLoadLibrary(packageInfo: PackageInfo, libraryName: String) {
|
private suspend fun tryLoadLibrary(packageInfo: PackageInfo, libraryName: String) {
|
||||||
val nativeDir = getNativeLibraryDirectory(packageInfo.applicationInfo!!)
|
val nativeDir = getNativeLibraryDirectory(packageInfo.applicationInfo!!)
|
||||||
val libraryPath = if (nativeDir.endsWith('/')) "${nativeDir}lib$libraryName.so" else "$nativeDir/lib$libraryName.so"
|
val libraryPath = if (nativeDir.endsWith('/')) "${nativeDir}lib$libraryName.so" else "$nativeDir/lib$libraryName.so"
|
||||||
|
|
||||||
@@ -308,7 +340,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||||
private fun loadLibraryCopy(libraryName: String, libraryPath: String) {
|
private suspend fun loadLibraryCopy(libraryName: String, libraryPath: String) {
|
||||||
if (libraryPath.contains("!/")) {
|
if (libraryPath.contains("!/")) {
|
||||||
// library in apk can't be loaded directly
|
// library in apk can't be loaded directly
|
||||||
loadLibraryFromAssetsCopy(libraryName)
|
loadLibraryFromAssetsCopy(libraryName)
|
||||||
@@ -318,11 +350,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
val library = File(libraryPath)
|
val library = File(libraryPath)
|
||||||
val libraryCopy = File(cacheDir, "lib$libraryName.so")
|
val libraryCopy = File(cacheDir, "lib$libraryName.so")
|
||||||
|
|
||||||
libraryCopy.outputStream().use { libraryOutput ->
|
DownloadUtils.copyFile(library, libraryCopy)
|
||||||
library.inputStream().use { inputStream ->
|
|
||||||
DownloadUtils.copyFile(inputStream, libraryOutput)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
System.load(libraryCopy.path)
|
System.load(libraryCopy.path)
|
||||||
|
|
||||||
@@ -330,7 +358,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||||
private fun loadLibraryFromAssetsCopy(libraryName: String) {
|
private suspend fun loadLibraryFromAssetsCopy(libraryName: String) {
|
||||||
// loads a library loaded in assets
|
// loads a library loaded in assets
|
||||||
// this copies the library to a non-compressed directory
|
// this copies the library to a non-compressed directory
|
||||||
|
|
||||||
@@ -345,9 +373,11 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
// there doesn't seem to be a way to load a library from a file descriptor
|
// there doesn't seem to be a way to load a library from a file descriptor
|
||||||
val libraryCopy = File(cacheDir, "lib$libraryName.so")
|
val libraryCopy = File(cacheDir, "lib$libraryName.so")
|
||||||
|
|
||||||
libraryCopy.outputStream().use { libraryOutput ->
|
withContext(Dispatchers.IO) {
|
||||||
libraryFd.createInputStream().use { inputStream ->
|
libraryCopy.outputStream().use { libraryOutput ->
|
||||||
DownloadUtils.copyFile(inputStream, libraryOutput)
|
libraryFd.createInputStream().use { inputStream ->
|
||||||
|
DownloadUtils.copyFile(inputStream, libraryOutput)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,7 +412,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||||
private fun loadGeodeLibrary() {
|
private suspend fun loadGeodeLibrary() {
|
||||||
// Load Geode if exists
|
// Load Geode if exists
|
||||||
// bundling the object with the application allows for nicer backtraces
|
// bundling the object with the application allows for nicer backtraces
|
||||||
try {
|
try {
|
||||||
@@ -403,22 +433,23 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
|||||||
// also i get 20 million permission denied errors
|
// also i get 20 million permission denied errors
|
||||||
val externalGeodePath = LaunchUtils.getInstalledGeodePath(this)!!
|
val externalGeodePath = LaunchUtils.getInstalledGeodePath(this)!!
|
||||||
|
|
||||||
val copiedPath = File(filesDir.path, "copied")
|
val copiedPath = File(filesDir, "copied")
|
||||||
if (copiedPath.exists()) {
|
if (copiedPath.exists()) {
|
||||||
copiedPath.deleteRecursively()
|
copiedPath.deleteRecursively()
|
||||||
}
|
}
|
||||||
copiedPath.mkdir()
|
copiedPath.mkdir()
|
||||||
|
|
||||||
val copiedGeodePath = File(copiedPath.path, "Geode.so")
|
val copiedGeodePath = File(copiedPath, "Geode.so")
|
||||||
|
|
||||||
if (externalGeodePath.exists()) {
|
if (externalGeodePath.exists()) {
|
||||||
DownloadUtils.copyFile(
|
DownloadUtils.copyFile(
|
||||||
FileInputStream(externalGeodePath),
|
externalGeodePath,
|
||||||
FileOutputStream(copiedGeodePath)
|
copiedGeodePath
|
||||||
)
|
)
|
||||||
|
|
||||||
if (copiedGeodePath.exists()) {
|
if (copiedGeodePath.exists()) {
|
||||||
println("Loading Geode from ${externalGeodePath.name}")
|
println("Loading Geode from ${externalGeodePath.name}")
|
||||||
|
|
||||||
System.load(copiedGeodePath.path)
|
System.load(copiedGeodePath.path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ object LauncherFix {
|
|||||||
|
|
||||||
external fun setOriginalDataPath(dataPath: String)
|
external fun setOriginalDataPath(dataPath: String)
|
||||||
|
|
||||||
external fun performExceptionsRenaming()
|
external fun enableExceptionsRenaming()
|
||||||
|
|
||||||
external fun enableCustomSymbolList(symbolsPath: String)
|
external fun performPatches()
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.geode.launcher.log
|
||||||
|
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
abstract class BaseDirectoryViewModel<T>(protected val baseDirectory: File) : ViewModel() {
|
||||||
|
private val _lineState = mutableStateListOf<T>()
|
||||||
|
val lineState = _lineState
|
||||||
|
|
||||||
|
private val _isLoading = MutableStateFlow(false)
|
||||||
|
val isLoading = _isLoading.asStateFlow()
|
||||||
|
|
||||||
|
private var logJob: Job? = null
|
||||||
|
|
||||||
|
abstract fun getFileList(): List<T>
|
||||||
|
|
||||||
|
abstract fun getItemKey(item: T): Any
|
||||||
|
|
||||||
|
open fun clearAllFiles() {
|
||||||
|
logJob?.cancel()
|
||||||
|
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
if (baseDirectory.exists()) {
|
||||||
|
val files = baseDirectory.listFiles()
|
||||||
|
files?.forEach {
|
||||||
|
it.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_lineState.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadLogs() {
|
||||||
|
_isLoading.value = true
|
||||||
|
|
||||||
|
logJob?.cancel()
|
||||||
|
logJob = viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val lines = getFileList()
|
||||||
|
|
||||||
|
_lineState.clear()
|
||||||
|
_lineState.addAll(lines)
|
||||||
|
|
||||||
|
_isLoading.value = false
|
||||||
|
|
||||||
|
logJob = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeFile(filename: String) {
|
||||||
|
logJob?.cancel()
|
||||||
|
|
||||||
|
val crashPath = File(baseDirectory, filename)
|
||||||
|
if (crashPath.exists()) {
|
||||||
|
crashPath.delete()
|
||||||
|
loadLogs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadLogs()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,54 +1,40 @@
|
|||||||
package com.geode.launcher.log
|
package com.geode.launcher.log
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
import com.geode.launcher.utils.LaunchUtils
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import kotlin.time.ExperimentalTime
|
|
||||||
import kotlin.time.Instant
|
import kotlin.time.Instant
|
||||||
|
|
||||||
data class CrashDump @OptIn(ExperimentalTime::class) constructor(
|
data class CrashDump(
|
||||||
val filename: String,
|
val filename: String,
|
||||||
val lastModified: Instant
|
val lastModified: Instant
|
||||||
)
|
)
|
||||||
|
|
||||||
class CrashViewModel(private val application: Application) : ViewModel() {
|
class CrashViewModel(crashDirectory: File) : BaseDirectoryViewModel<CrashDump>(crashDirectory) {
|
||||||
companion object {
|
companion object {
|
||||||
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||||
initializer {
|
initializer {
|
||||||
val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as Application
|
val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as Application
|
||||||
|
val crashDirectory = LaunchUtils.getCrashDirectory(application)
|
||||||
|
|
||||||
CrashViewModel(
|
CrashViewModel(crashDirectory)
|
||||||
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
|
|
||||||
|
|
||||||
var hasIndicator = false
|
var hasIndicator = false
|
||||||
|
|
||||||
private fun getCrashList(): List<CrashDump> {
|
override fun getItemKey(item: CrashDump) = item.filename
|
||||||
val crashDirectory = LaunchUtils.getCrashDirectory(application)
|
|
||||||
|
override fun getFileList(): List<CrashDump> {
|
||||||
|
val crashDirectory = baseDirectory
|
||||||
|
|
||||||
if (!crashDirectory.exists()) {
|
if (!crashDirectory.exists()) {
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
@@ -72,8 +58,7 @@ class CrashViewModel(private val application: Application) : ViewModel() {
|
|||||||
|
|
||||||
fun clearIndicator() {
|
fun clearIndicator() {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val crashDirectory = LaunchUtils.getCrashDirectory(application)
|
File(baseDirectory, LaunchUtils.CRASH_INDICATOR_NAME).delete()
|
||||||
File(crashDirectory, LaunchUtils.CRASH_INDICATOR_NAME).delete()
|
|
||||||
|
|
||||||
hasIndicator = false
|
hasIndicator = false
|
||||||
}
|
}
|
||||||
@@ -81,56 +66,14 @@ class CrashViewModel(private val application: Application) : ViewModel() {
|
|||||||
|
|
||||||
fun createIndicator() {
|
fun createIndicator() {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val crashDirectory = LaunchUtils.getCrashDirectory(application)
|
File(baseDirectory, LaunchUtils.CRASH_INDICATOR_NAME).createNewFile()
|
||||||
File(crashDirectory, LaunchUtils.CRASH_INDICATOR_NAME).createNewFile()
|
|
||||||
|
|
||||||
hasIndicator = true
|
hasIndicator = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearCrashes() {
|
override fun clearAllFiles() {
|
||||||
logJob?.cancel()
|
super.clearAllFiles()
|
||||||
|
hasIndicator = false
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
val crashDirectory = LaunchUtils.getCrashDirectory(application)
|
|
||||||
if (crashDirectory.exists()) {
|
|
||||||
val files = crashDirectory.listFiles()
|
|
||||||
files?.forEach {
|
|
||||||
it.delete()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_lineState.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadLogs() {
|
|
||||||
_isLoading.value = true
|
|
||||||
|
|
||||||
logJob?.cancel()
|
|
||||||
logJob = viewModelScope.launch(Dispatchers.IO) {
|
|
||||||
val lines = getCrashList()
|
|
||||||
|
|
||||||
_lineState.clear()
|
|
||||||
_lineState.addAll(lines)
|
|
||||||
|
|
||||||
_isLoading.value = false
|
|
||||||
|
|
||||||
logJob = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeCrash(filename: String) {
|
|
||||||
logJob?.cancel()
|
|
||||||
|
|
||||||
val crashPath = File(LaunchUtils.getCrashDirectory(application), filename)
|
|
||||||
if (crashPath.exists()) {
|
|
||||||
crashPath.delete()
|
|
||||||
loadLogs()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
loadLogs()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.geode.launcher.log
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.ViewModelProvider
|
||||||
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.time.Instant
|
||||||
|
|
||||||
|
data class GeodeLog(
|
||||||
|
val filename: String,
|
||||||
|
val lastModified: Instant,
|
||||||
|
val fileSize: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
class GeodeLogsViewModel(crashDirectory: File) : BaseDirectoryViewModel<GeodeLog>(crashDirectory) {
|
||||||
|
companion object {
|
||||||
|
val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||||
|
initializer {
|
||||||
|
val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as Application
|
||||||
|
val crashDirectory = LaunchUtils.getGeodeLogsDirectory(application)
|
||||||
|
|
||||||
|
GeodeLogsViewModel(crashDirectory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemKey(item: GeodeLog) = item.filename
|
||||||
|
|
||||||
|
override fun getFileList(): List<GeodeLog> {
|
||||||
|
if (!baseDirectory.exists()) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
val children = baseDirectory.listFiles()
|
||||||
|
?: return emptyList()
|
||||||
|
|
||||||
|
return children
|
||||||
|
.map {
|
||||||
|
GeodeLog(
|
||||||
|
filename = it.name,
|
||||||
|
lastModified = Instant.fromEpochMilliseconds(it.lastModified()),
|
||||||
|
fileSize = it.length()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sortedByDescending { it.lastModified }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getFileText(filename: String): List<String> {
|
||||||
|
val logFile = File(baseDirectory, filename)
|
||||||
|
if (!logFile.exists()) {
|
||||||
|
return listOf("File does not exist!")
|
||||||
|
}
|
||||||
|
|
||||||
|
return runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
logFile.readLines()
|
||||||
|
}
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
@@ -38,12 +39,10 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalResources
|
import androidx.compose.ui.platform.LocalResources
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.LinkAnnotation
|
import androidx.compose.ui.text.LinkAnnotation
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.TextLinkStyles
|
import androidx.compose.ui.text.TextLinkStyles
|
||||||
@@ -180,33 +179,35 @@ fun ErrorInfoDescription(description: String, modifier: Modifier = Modifier) {
|
|||||||
GamePackageUtils.getUnifiedVersionName(context.packageManager)
|
GamePackageUtils.getUnifiedVersionName(context.packageManager)
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = modifier) {
|
SelectionContainer {
|
||||||
Text(
|
Column(modifier = modifier) {
|
||||||
stringResource(R.string.launcher_error_details),
|
Text(
|
||||||
style = Typography.headlineSmall
|
stringResource(R.string.launcher_error_details),
|
||||||
)
|
style = Typography.headlineSmall
|
||||||
|
)
|
||||||
|
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
description,
|
description,
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace,
|
||||||
style = Typography.bodyMedium
|
style = Typography.bodyMedium
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.size(4.dp))
|
Spacer(Modifier.size(4.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.launcher_error_device_info,
|
stringResource(R.string.launcher_error_device_info,
|
||||||
Build.MODEL,
|
Build.MODEL,
|
||||||
Build.VERSION.RELEASE,
|
Build.VERSION.RELEASE,
|
||||||
currentRelease ?: "unknown",
|
currentRelease ?: "unknown",
|
||||||
gameVersion,
|
gameVersion,
|
||||||
LaunchUtils.applicationArchitecture,
|
LaunchUtils.applicationArchitecture,
|
||||||
BuildConfig.VERSION_NAME
|
BuildConfig.VERSION_NAME
|
||||||
),
|
),
|
||||||
style = Typography.labelMedium
|
style = Typography.labelMedium
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,16 @@ import androidx.compose.animation.shrinkHorizontally
|
|||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.layout.displayCutoutPadding
|
import androidx.compose.foundation.layout.displayCutoutPadding
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.LocalContentColor
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedCard
|
import androidx.compose.material3.OutlinedCard
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
@@ -120,9 +123,10 @@ fun LaunchNotification(cards: List<LaunchNotificationType>) {
|
|||||||
val theme = Theme.fromInt(themeOption)
|
val theme = Theme.fromInt(themeOption)
|
||||||
|
|
||||||
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||||
|
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||||
|
|
||||||
CompositionLocalProvider(LocalTheme provides theme) {
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
|
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||||
// surface is not in use, so this is unfortunately not provided
|
// surface is not in use, so this is unfortunately not provided
|
||||||
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
|
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
|
||||||
Column(
|
Column(
|
||||||
@@ -279,4 +283,27 @@ fun ForwardCompatibilityContent(modifier: Modifier = Modifier) {
|
|||||||
},
|
},
|
||||||
modifier = modifier.width(IntrinsicSize.Max)
|
modifier = modifier.width(IntrinsicSize.Max)
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FullScreenLoadingIndicator() {
|
||||||
|
val theme = Theme.DARK
|
||||||
|
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
|
GeodeLauncherTheme(theme = theme, blackBackground = true, dynamicColor = !dynamicColorOption) {
|
||||||
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
var showProgress by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
delay(1000L)
|
||||||
|
showProgress = true
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(showProgress) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -160,10 +160,6 @@ object LauncherUpdater {
|
|||||||
PackageInstaller.SessionParams.MODE_FULL_INSTALL,
|
PackageInstaller.SessionParams.MODE_FULL_INSTALL,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
|
||||||
}
|
|
||||||
|
|
||||||
val sessionId = installer.createSession(params)
|
val sessionId = installer.createSession(params)
|
||||||
installer.openSession(sessionId).use { session ->
|
installer.openSession(sessionId).use { session ->
|
||||||
session.openWrite("package", 0, length).use { sessionStream ->
|
session.openWrite("package", 0, length).use { sessionStream ->
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.geode.launcher.preferences
|
package com.geode.launcher.preferences
|
||||||
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
@@ -9,7 +8,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
|||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@@ -18,6 +16,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -42,13 +41,8 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.ClipEntry
|
|
||||||
import androidx.compose.ui.platform.LocalClipboard
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
|
||||||
import androidx.compose.ui.platform.LocalResources
|
import androidx.compose.ui.platform.LocalResources
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -61,6 +55,8 @@ import com.geode.launcher.R
|
|||||||
import com.geode.launcher.log.LogLine
|
import com.geode.launcher.log.LogLine
|
||||||
import com.geode.launcher.log.LogPriority
|
import com.geode.launcher.log.LogPriority
|
||||||
import com.geode.launcher.log.LogViewModel
|
import com.geode.launcher.log.LogViewModel
|
||||||
|
import com.geode.launcher.preferences.components.SelectDialog
|
||||||
|
import com.geode.launcher.preferences.components.SelectOption
|
||||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
import com.geode.launcher.ui.theme.LocalTheme
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
import com.geode.launcher.ui.theme.Theme
|
import com.geode.launcher.ui.theme.Theme
|
||||||
@@ -144,46 +140,24 @@ fun SelectLogLevelDialog(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LogCard(logLine: LogLine, modifier: Modifier = Modifier) {
|
fun LogCard(logLine: LogLine, modifier: Modifier = Modifier) {
|
||||||
val haptics = LocalHapticFeedback.current
|
SelectionContainer {
|
||||||
val clipboard = LocalClipboard.current
|
Column(
|
||||||
val resources = LocalResources.current
|
modifier = modifier
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"[${logLine.priority.toChar()}] ${logLine.formattedTime}",
|
||||||
|
style = Typography.labelSmall,
|
||||||
|
color = getLogColor(logLine.priority),
|
||||||
|
fontFamily = robotoMonoFamily
|
||||||
|
)
|
||||||
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
Text(
|
||||||
|
"[${logLine.tag}]: ${logLine.messageTrimmed}",
|
||||||
Column(
|
fontFamily = robotoMonoFamily,
|
||||||
modifier = modifier
|
style = Typography.bodyMedium
|
||||||
.padding(8.dp)
|
)
|
||||||
.pointerInput(Unit) {
|
}
|
||||||
detectTapGestures(
|
|
||||||
onLongPress = {
|
|
||||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
|
||||||
|
|
||||||
val clipboardLabel = resources.getString(R.string.application_log_copy_label)
|
|
||||||
val clipboardData = logLine.asSimpleString
|
|
||||||
|
|
||||||
coroutineScope.launch {
|
|
||||||
clipboard.setClipEntry(
|
|
||||||
ClipEntry(ClipData.newPlainText(
|
|
||||||
clipboardLabel, clipboardData
|
|
||||||
))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"[${logLine.priority.toChar()}] ${logLine.formattedTime}",
|
|
||||||
style = Typography.labelSmall,
|
|
||||||
color = getLogColor(logLine.priority),
|
|
||||||
fontFamily = robotoMonoFamily
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
"[${logLine.tag}]: ${logLine.messageTrimmed}",
|
|
||||||
fontFamily = robotoMonoFamily,
|
|
||||||
style = Typography.bodyMedium
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,43 +15,28 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.offset
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
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.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -60,16 +45,15 @@ import com.geode.launcher.R
|
|||||||
import com.geode.launcher.UserDirectoryProvider
|
import com.geode.launcher.UserDirectoryProvider
|
||||||
import com.geode.launcher.log.CrashDump
|
import com.geode.launcher.log.CrashDump
|
||||||
import com.geode.launcher.log.CrashViewModel
|
import com.geode.launcher.log.CrashViewModel
|
||||||
|
import com.geode.launcher.preferences.components.DirectoryListingScreen
|
||||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
import com.geode.launcher.ui.theme.LocalTheme
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
import com.geode.launcher.ui.theme.Theme
|
import com.geode.launcher.ui.theme.Theme
|
||||||
import com.geode.launcher.ui.theme.Typography
|
|
||||||
import com.geode.launcher.ui.theme.robotoMonoFamily
|
import com.geode.launcher.ui.theme.robotoMonoFamily
|
||||||
import com.geode.launcher.utils.LaunchUtils
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
import com.geode.launcher.utils.PreferenceUtils
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import kotlin.time.ExperimentalTime
|
|
||||||
import kotlin.time.toJavaInstant
|
import kotlin.time.toJavaInstant
|
||||||
|
|
||||||
class CrashLogsActivity : ComponentActivity() {
|
class CrashLogsActivity : ComponentActivity() {
|
||||||
@@ -124,7 +108,6 @@ fun shareCrash(context: Context, filename: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalTime::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CrashCard(crashDump: CrashDump, crashViewModel: CrashViewModel, modifier: Modifier = Modifier) {
|
fun CrashCard(crashDump: CrashDump, crashViewModel: CrashViewModel, modifier: Modifier = Modifier) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier
|
||||||
@@ -169,7 +152,7 @@ fun CrashCard(crashDump: CrashDump, crashViewModel: CrashViewModel, modifier: Mo
|
|||||||
}
|
}
|
||||||
|
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
crashViewModel.removeCrash(crashDump.filename)
|
crashViewModel.removeFile(crashDump.filename)
|
||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(R.drawable.icon_delete),
|
painterResource(R.drawable.icon_delete),
|
||||||
@@ -180,155 +163,58 @@ fun CrashCard(crashDump: CrashDump, crashViewModel: CrashViewModel, modifier: Mo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CrashLogsScreen(
|
fun CrashLogsScreen(
|
||||||
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||||
crashViewModel: CrashViewModel = viewModel(factory = CrashViewModel.Factory)
|
crashViewModel: CrashViewModel = viewModel(factory = CrashViewModel.Factory)
|
||||||
) {
|
) {
|
||||||
val logLines = crashViewModel.lineState
|
DirectoryListingScreen(
|
||||||
val isLoading by crashViewModel.isLoading.collectAsState()
|
onBackPressedDispatcher,
|
||||||
|
crashViewModel,
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
titleId = R.string.title_activity_crash_logs,
|
||||||
|
noneAvailableId = R.string.application_crashes_no_dumps,
|
||||||
var showMoreOptions by remember { mutableStateOf(false) }
|
additionalOptions = { onDismiss ->
|
||||||
|
val developerMode by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
|
||||||
Scaffold(
|
if (developerMode) {
|
||||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
if (crashViewModel.hasIndicator) {
|
||||||
topBar = {
|
DropdownMenuItem(
|
||||||
Column {
|
leadingIcon = {
|
||||||
TopAppBar(
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(R.drawable.icon_arrow_back),
|
painterResource(R.drawable.icon_remove_selection),
|
||||||
contentDescription = stringResource(R.string.back_icon_alt)
|
contentDescription = null
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.application_crashes_clear_indicator))
|
||||||
|
}, onClick = {
|
||||||
|
crashViewModel.clearIndicator()
|
||||||
|
onDismiss()
|
||||||
}
|
}
|
||||||
},
|
)
|
||||||
actions = {
|
} else {
|
||||||
IconButton(onClick = { showMoreOptions = !showMoreOptions }) {
|
DropdownMenuItem(
|
||||||
|
leadingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(R.drawable.icon_more_vert),
|
painterResource(R.drawable.icon_skull),
|
||||||
contentDescription = stringResource(R.string.application_logs_more)
|
contentDescription = null
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.application_crashes_create_indicator))
|
||||||
|
}, onClick = {
|
||||||
|
crashViewModel.createIndicator()
|
||||||
|
onDismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
DropdownMenu(
|
|
||||||
expanded = showMoreOptions,
|
|
||||||
onDismissRequest = { showMoreOptions = false }
|
|
||||||
) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
painterResource(R.drawable.icon_delete),
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Text(stringResource(R.string.application_logs_delete))
|
|
||||||
}, onClick = {
|
|
||||||
crashViewModel.clearCrashes()
|
|
||||||
showMoreOptions = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
val developerMode by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
|
|
||||||
if (developerMode) {
|
|
||||||
|
|
||||||
if (crashViewModel.hasIndicator) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
painterResource(R.drawable.icon_remove_selection),
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Text(stringResource(R.string.application_crashes_clear_indicator))
|
|
||||||
}, onClick = {
|
|
||||||
crashViewModel.clearIndicator()
|
|
||||||
showMoreOptions = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
DropdownMenuItem(
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
painterResource(R.drawable.icon_skull),
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
},
|
|
||||||
text = {
|
|
||||||
Text(stringResource(R.string.application_crashes_create_indicator))
|
|
||||||
}, onClick = {
|
|
||||||
crashViewModel.createIndicator()
|
|
||||||
showMoreOptions = false
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.title_activity_crash_logs),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
},
|
|
||||||
scrollBehavior = scrollBehavior,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
LinearProgressIndicator(
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { innerPadding ->
|
) { line, shape ->
|
||||||
LazyColumn(
|
CrashCard(
|
||||||
Modifier
|
line,
|
||||||
.padding(innerPadding)
|
crashViewModel,
|
||||||
.fillMaxWidth(),
|
modifier = Modifier.clip(shape)
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,4 +224,4 @@ fun CrashLogsPreview() {
|
|||||||
GeodeLauncherTheme {
|
GeodeLauncherTheme {
|
||||||
CrashLogsScreen(null)
|
CrashLogsScreen(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,6 +41,13 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.geode.launcher.R
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.preferences.components.OptionsButton
|
||||||
|
import com.geode.launcher.preferences.components.OptionsGroup
|
||||||
|
import com.geode.launcher.preferences.components.ProfileCreateCard
|
||||||
|
import com.geode.launcher.preferences.components.ProfileSelectCard
|
||||||
|
import com.geode.launcher.preferences.components.SettingsCard
|
||||||
|
import com.geode.launcher.preferences.components.SettingsSelectCard
|
||||||
|
import com.geode.launcher.preferences.components.SettingsStringCard
|
||||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
import com.geode.launcher.ui.theme.LocalTheme
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
import com.geode.launcher.ui.theme.Theme
|
import com.geode.launcher.ui.theme.Theme
|
||||||
@@ -151,10 +158,14 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
|
|||||||
title = stringResource(R.string.preference_launch_arguments_name),
|
title = stringResource(R.string.preference_launch_arguments_name),
|
||||||
dialogTitle = stringResource(R.string.preference_launch_arguments_set_title),
|
dialogTitle = stringResource(R.string.preference_launch_arguments_set_title),
|
||||||
preferenceKey = PreferenceUtils.Key.LAUNCH_ARGUMENTS,
|
preferenceKey = PreferenceUtils.Key.LAUNCH_ARGUMENTS,
|
||||||
filterInput = { it.filter { c ->
|
filterInput = {
|
||||||
// if only there was a better way to define this!
|
it.filter { c ->
|
||||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*(){}<>[]?:;'\"~`-_+=\\| ".contains(c)
|
// if only there was a better way to define this!
|
||||||
}}
|
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*(){}<>[]?:;'\"~`-_+=\\| ".contains(
|
||||||
|
c
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -179,13 +190,27 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
|
|||||||
title = stringResource(R.string.preferences_view_logs),
|
title = stringResource(R.string.preferences_view_logs),
|
||||||
icon = {
|
icon = {
|
||||||
Icon(
|
Icon(
|
||||||
painterResource(R.drawable.icon_description),
|
painterResource(R.drawable.icon_mobile_code),
|
||||||
contentDescription = null
|
contentDescription = null
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onClick = { onOpenLogs(context) }
|
onClick = { onOpenLogs(context) }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preferences_view_geode_logs),
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_description),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
val launchIntent = Intent(context, GeodeLogsListingActivity::class.java)
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
OptionsButton(
|
OptionsButton(
|
||||||
title = stringResource(R.string.preferences_copy_external_button),
|
title = stringResource(R.string.preferences_copy_external_button),
|
||||||
description = LaunchUtils.getBaseDirectory(context).path,
|
description = LaunchUtils.getBaseDirectory(context).path,
|
||||||
@@ -224,9 +249,21 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
|
|||||||
preferenceKey = PreferenceUtils.Key.WAIT_PERIOD,
|
preferenceKey = PreferenceUtils.Key.WAIT_PERIOD,
|
||||||
options = linkedMapOf(
|
options = linkedMapOf(
|
||||||
0 to stringResource(R.string.preference_launch_delay_instant),
|
0 to stringResource(R.string.preference_launch_delay_instant),
|
||||||
1 to pluralStringResource(R.plurals.preference_launch_delay_value, 1, 1),
|
1 to pluralStringResource(
|
||||||
3 to pluralStringResource(R.plurals.preference_launch_delay_value, 3, 3),
|
R.plurals.preference_launch_delay_value,
|
||||||
5 to pluralStringResource(R.plurals.preference_launch_delay_value, 5, 5),
|
1,
|
||||||
|
1
|
||||||
|
),
|
||||||
|
3 to pluralStringResource(
|
||||||
|
R.plurals.preference_launch_delay_value,
|
||||||
|
3,
|
||||||
|
3
|
||||||
|
),
|
||||||
|
5 to pluralStringResource(
|
||||||
|
R.plurals.preference_launch_delay_value,
|
||||||
|
5,
|
||||||
|
5
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package com.geode.launcher.preferences
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.OnBackPressedDispatcher
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.log.GeodeLogsViewModel
|
||||||
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
|
import com.geode.launcher.ui.theme.Theme
|
||||||
|
import com.geode.launcher.ui.theme.Typography
|
||||||
|
import com.geode.launcher.ui.theme.robotoMonoFamily
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
|
||||||
|
|
||||||
|
class GeodeLogViewActivity : ComponentActivity() {
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_LOG_VIEW_FILENAME = "EXTRA_LOG_FILENAME"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
val filename = intent.getStringExtra(EXTRA_LOG_VIEW_FILENAME)
|
||||||
|
if (filename.isNullOrEmpty()) {
|
||||||
|
val launchIntent = Intent(this, GeodeLogsListingActivity::class.java)
|
||||||
|
startActivity(launchIntent)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||||
|
val theme = Theme.fromInt(themeOption)
|
||||||
|
|
||||||
|
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||||
|
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
|
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
GeodeLogView(onBackPressedDispatcher, filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun GeodeLogView(
|
||||||
|
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||||
|
filename: String,
|
||||||
|
geodeLogsViewModel: GeodeLogsViewModel = viewModel(factory = GeodeLogsViewModel.Factory)
|
||||||
|
) {
|
||||||
|
var loadingFile by remember { mutableStateOf(true) }
|
||||||
|
var description by remember { mutableStateOf(emptyList<String>()) }
|
||||||
|
|
||||||
|
LaunchedEffect(filename) {
|
||||||
|
loadingFile = true
|
||||||
|
description = geodeLogsViewModel.getFileText(filename)
|
||||||
|
loadingFile = false
|
||||||
|
}
|
||||||
|
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
Column {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_arrow_back),
|
||||||
|
contentDescription = stringResource(R.string.back_icon_alt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
filename,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (loadingFile) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
if (!loadingFile) {
|
||||||
|
if (description.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.application_geode_logs_empty),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = Typography.bodyLarge,
|
||||||
|
modifier = Modifier.fillMaxSize().padding(8.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
SelectionContainer {
|
||||||
|
LazyColumn(modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
|
items(description) { line ->
|
||||||
|
Text(
|
||||||
|
line,
|
||||||
|
fontFamily = robotoMonoFamily,
|
||||||
|
style = Typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package com.geode.launcher.preferences
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.DocumentsContract
|
||||||
|
import android.text.format.Formatter
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.OnBackPressedDispatcher
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.UserDirectoryProvider
|
||||||
|
import com.geode.launcher.log.GeodeLog
|
||||||
|
import com.geode.launcher.log.GeodeLogsViewModel
|
||||||
|
import com.geode.launcher.preferences.components.DirectoryListingScreen
|
||||||
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
|
import com.geode.launcher.ui.theme.Theme
|
||||||
|
import com.geode.launcher.ui.theme.robotoMonoFamily
|
||||||
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class GeodeLogsListingActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContent {
|
||||||
|
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||||
|
val theme = Theme.fromInt(themeOption)
|
||||||
|
|
||||||
|
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||||
|
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalTheme provides theme) {
|
||||||
|
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
GeodeLogsListingScreen(onBackPressedDispatcher)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shareLog(context: Context, filename: String) {
|
||||||
|
val baseDirectory = LaunchUtils.getBaseDirectory(context)
|
||||||
|
val crashPath = File(LaunchUtils.getGeodeLogsDirectory(context), filename)
|
||||||
|
|
||||||
|
if (!crashPath.exists()) {
|
||||||
|
Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val providerLocation = crashPath.toRelativeString(baseDirectory)
|
||||||
|
val documentsPath = "${UserDirectoryProvider.ROOT}${providerLocation}"
|
||||||
|
|
||||||
|
val uri = DocumentsContract.buildDocumentUri("${context.packageName}.user", documentsPath)
|
||||||
|
|
||||||
|
val shareIntent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
putExtra(Intent.EXTRA_STREAM, uri)
|
||||||
|
type = "text/plain"
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
context.startActivity(shareIntent)
|
||||||
|
} catch (_: ActivityNotFoundException) {
|
||||||
|
Toast.makeText(context, R.string.no_activity_found, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GeodeLogCard(geodeLog: GeodeLog, geodeLogsViewModel: GeodeLogsViewModel, modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 1.dp, horizontal = 8.dp)
|
||||||
|
.height(64.dp)
|
||||||
|
.then(modifier)
|
||||||
|
.clickable {
|
||||||
|
val launchIntent = Intent(context, GeodeLogViewActivity::class.java).run {
|
||||||
|
putExtra(GeodeLogViewActivity.EXTRA_LOG_VIEW_FILENAME, geodeLog.filename)
|
||||||
|
}
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||||
|
.padding(vertical = 8.dp, horizontal = 8.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.SpaceAround,
|
||||||
|
modifier = Modifier.fillMaxHeight().offset(x = 12.dp).weight(0.7f)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
geodeLog.filename,
|
||||||
|
fontFamily = robotoMonoFamily,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
maxLines = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val formattedSize = remember(geodeLog.fileSize) {
|
||||||
|
Formatter.formatShortFileSize(context, geodeLog.fileSize)
|
||||||
|
}
|
||||||
|
Text(formattedSize, style = MaterialTheme.typography.labelMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(modifier = Modifier.weight(0.3f), horizontalArrangement = Arrangement.End) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
IconButton(onClick = {
|
||||||
|
shareLog(context, geodeLog.filename)
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_share),
|
||||||
|
contentDescription = stringResource(R.string.application_logs_share)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = {
|
||||||
|
geodeLogsViewModel.removeFile(geodeLog.filename)
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_delete),
|
||||||
|
contentDescription = stringResource(R.string.application_crashes_delete_one)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GeodeLogsListingScreen(
|
||||||
|
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||||
|
geodeLogsViewModel: GeodeLogsViewModel = viewModel(factory = GeodeLogsViewModel.Factory)
|
||||||
|
) {
|
||||||
|
DirectoryListingScreen(
|
||||||
|
onBackPressedDispatcher,
|
||||||
|
geodeLogsViewModel,
|
||||||
|
titleId = R.string.title_activity_geode_logs,
|
||||||
|
noneAvailableId = R.string.application_geode_logs_no_logs,
|
||||||
|
) { line, shape ->
|
||||||
|
GeodeLogCard(
|
||||||
|
line,
|
||||||
|
geodeLogsViewModel,
|
||||||
|
modifier = Modifier.clip(shape)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.geode.launcher.preferences
|
|||||||
|
|
||||||
import android.app.UiModeManager
|
import android.app.UiModeManager
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.ClipData
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -27,6 +28,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -37,6 +39,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
@@ -50,9 +53,12 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalResources
|
import androidx.compose.ui.platform.LocalResources
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
@@ -67,9 +73,23 @@ import com.geode.launcher.BuildConfig
|
|||||||
import com.geode.launcher.MainActivity
|
import com.geode.launcher.MainActivity
|
||||||
import com.geode.launcher.R
|
import com.geode.launcher.R
|
||||||
import com.geode.launcher.UserDirectoryProvider
|
import com.geode.launcher.UserDirectoryProvider
|
||||||
|
import com.geode.launcher.preferences.components.BackupButton
|
||||||
|
import com.geode.launcher.preferences.components.OptionsButton
|
||||||
|
import com.geode.launcher.preferences.components.OptionsCard
|
||||||
|
import com.geode.launcher.preferences.components.OptionsGroup
|
||||||
|
import com.geode.launcher.preferences.components.OptionsLabel
|
||||||
|
import com.geode.launcher.preferences.components.OptionsTitle
|
||||||
|
import com.geode.launcher.preferences.components.SafeModeDialog
|
||||||
|
import com.geode.launcher.preferences.components.SettingsCard
|
||||||
|
import com.geode.launcher.preferences.components.SettingsFPSCard
|
||||||
|
import com.geode.launcher.preferences.components.SettingsRangeCard
|
||||||
|
import com.geode.launcher.preferences.components.SettingsSelectCard
|
||||||
|
import com.geode.launcher.preferences.components.SettingsStringSelectCard
|
||||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
import com.geode.launcher.ui.theme.LocalTheme
|
import com.geode.launcher.ui.theme.LocalTheme
|
||||||
import com.geode.launcher.ui.theme.Theme
|
import com.geode.launcher.ui.theme.Theme
|
||||||
|
import com.geode.launcher.ui.theme.Typography
|
||||||
|
import com.geode.launcher.ui.theme.robotoMonoFamily
|
||||||
import com.geode.launcher.updater.ReleaseViewModel
|
import com.geode.launcher.updater.ReleaseViewModel
|
||||||
import com.geode.launcher.utils.ApplicationIcon
|
import com.geode.launcher.utils.ApplicationIcon
|
||||||
import com.geode.launcher.utils.Constants
|
import com.geode.launcher.utils.Constants
|
||||||
@@ -77,6 +97,7 @@ import com.geode.launcher.utils.GamePackageUtils
|
|||||||
import com.geode.launcher.utils.IconUtils
|
import com.geode.launcher.utils.IconUtils
|
||||||
import com.geode.launcher.utils.LaunchUtils
|
import com.geode.launcher.utils.LaunchUtils
|
||||||
import com.geode.launcher.utils.PreferenceUtils
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import java.net.ConnectException
|
import java.net.ConnectException
|
||||||
import java.net.UnknownHostException
|
import java.net.UnknownHostException
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
@@ -139,6 +160,53 @@ fun onOpenFileManager(context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UpdateErrorDialog(
|
||||||
|
error: Exception,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val clipboard = LocalClipboard.current
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
val clipboardLabel = stringResource(R.string.application_log_copy_label)
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_warning),
|
||||||
|
contentDescription = null,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
title = { Text(stringResource(R.string.preference_check_for_updates_help_title)) },
|
||||||
|
text = {
|
||||||
|
SelectionContainer {
|
||||||
|
Text(
|
||||||
|
error.stackTraceToString(),
|
||||||
|
fontFamily = robotoMonoFamily,
|
||||||
|
style = Typography.bodyMedium,
|
||||||
|
modifier = Modifier.verticalScroll(rememberScrollState())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
clipboard.setClipEntry(
|
||||||
|
ClipEntry(ClipData.newPlainText(clipboardLabel, error.stackTraceToString()))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text(stringResource(R.string.preference_check_for_updates_help_copy))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.message_box_accept))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismissRequest = onDismiss
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun UpdateIndicator(
|
fun UpdateIndicator(
|
||||||
snackbarHostState: SnackbarHostState,
|
snackbarHostState: SnackbarHostState,
|
||||||
@@ -148,6 +216,17 @@ fun UpdateIndicator(
|
|||||||
val resources = LocalResources.current
|
val resources = LocalResources.current
|
||||||
|
|
||||||
var enablePopup by remember { mutableStateOf(false) }
|
var enablePopup by remember { mutableStateOf(false) }
|
||||||
|
var enableHelp by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
var currentError by remember { mutableStateOf<Exception?>(null) }
|
||||||
|
|
||||||
|
val currentErrorS = currentError
|
||||||
|
if (currentErrorS != null && enableHelp) {
|
||||||
|
UpdateErrorDialog(currentErrorS) {
|
||||||
|
enableHelp = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
when (updateStatus) {
|
when (updateStatus) {
|
||||||
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
|
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
|
||||||
@@ -185,7 +264,12 @@ fun UpdateIndicator(
|
|||||||
else -> resources.getString(R.string.preference_check_for_updates_failed)
|
else -> resources.getString(R.string.preference_check_for_updates_failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
snackbarHostState.showSnackbar(message)
|
currentError = updateStatus.exception
|
||||||
|
|
||||||
|
val snackbarResult = snackbarHostState.showSnackbar(message, resources.getString(R.string.preference_check_for_updates_failed_action))
|
||||||
|
if (snackbarResult == SnackbarResult.ActionPerformed) {
|
||||||
|
enableHelp = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
is ReleaseViewModel.ReleaseUIState.Finished -> {
|
is ReleaseViewModel.ReleaseUIState.Finished -> {
|
||||||
if (updateStatus.hasUpdated) {
|
if (updateStatus.hasUpdated) {
|
||||||
@@ -394,6 +478,33 @@ fun GameplaySettingsGroup() {
|
|||||||
preferenceKey = PreferenceUtils.Key.LIMIT_FRAME_RATE,
|
preferenceKey = PreferenceUtils.Key.LIMIT_FRAME_RATE,
|
||||||
maxFrameRate = maxFrameRate
|
maxFrameRate = maxFrameRate
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var showSafeModeDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
OptionsButton(
|
||||||
|
title = stringResource(R.string.preference_launch_safe_mode),
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_shield),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
showSafeModeDialog = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSafeModeDialog) {
|
||||||
|
SafeModeDialog(onDismiss = {
|
||||||
|
showSafeModeDialog = false
|
||||||
|
}) {
|
||||||
|
val launchIntent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
// hardcode it, why not
|
||||||
|
action = Intent.ACTION_VIEW
|
||||||
|
data = "geode-launcher://main/launch?safe-mode=true".toUri()
|
||||||
|
}
|
||||||
|
context.startActivity(launchIntent)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -432,7 +543,10 @@ fun UpdateSettingsGroup(releaseViewModel: ReleaseViewModel, snackbarHostState: S
|
|||||||
contentDescription = null
|
contentDescription = null
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
description = stringResource(R.string.preference_check_for_updates_description, checkAgoString)
|
description = stringResource(
|
||||||
|
R.string.preference_check_for_updates_description,
|
||||||
|
checkAgoString
|
||||||
|
)
|
||||||
.takeIf { lastUpdateCheck != 0L }
|
.takeIf { lastUpdateCheck != 0L }
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -481,33 +595,6 @@ fun AdvancedSettingsGroup(developerModeEnabled: Boolean, snackbarHostState: Snac
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
var showSafeModeDialog by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
OptionsButton(
|
|
||||||
title = stringResource(R.string.preference_launch_safe_mode),
|
|
||||||
icon = {
|
|
||||||
Icon(
|
|
||||||
painterResource(R.drawable.icon_shield),
|
|
||||||
contentDescription = null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
showSafeModeDialog = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showSafeModeDialog) {
|
|
||||||
SafeModeDialog(onDismiss = {
|
|
||||||
showSafeModeDialog = false
|
|
||||||
}) {
|
|
||||||
val launchIntent = Intent(context, MainActivity::class.java).apply {
|
|
||||||
// hardcode it, why not
|
|
||||||
action = Intent.ACTION_VIEW
|
|
||||||
data = "geode-launcher://main/launch?safe-mode=true".toUri()
|
|
||||||
}
|
|
||||||
context.startActivity(launchIntent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BackupButton(snackbarHostState)
|
BackupButton(snackbarHostState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -524,14 +611,21 @@ fun AboutSettingsGroup(developerModeEnabled: Boolean) {
|
|||||||
|
|
||||||
OptionsButton(
|
OptionsButton(
|
||||||
stringResource(R.string.preference_launcher_version_name),
|
stringResource(R.string.preference_launcher_version_name),
|
||||||
stringResource(R.string.preference_launcher_version_description, BuildConfig.VERSION_NAME),
|
stringResource(
|
||||||
|
R.string.preference_launcher_version_description,
|
||||||
|
BuildConfig.VERSION_NAME
|
||||||
|
),
|
||||||
displayInline = true
|
displayInline = true
|
||||||
) {
|
) {
|
||||||
launcherButtonTapped += 1
|
launcherButtonTapped += 1
|
||||||
|
|
||||||
if (launcherButtonTapped >= 7) {
|
if (launcherButtonTapped >= 7) {
|
||||||
if (developerModeEnabled) {
|
if (developerModeEnabled) {
|
||||||
Toast.makeText(context, R.string.preference_enable_developer_again, Toast.LENGTH_SHORT)
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
R.string.preference_enable_developer_again,
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
)
|
||||||
.show()
|
.show()
|
||||||
} else {
|
} else {
|
||||||
showDeveloperDialog = true
|
showDeveloperDialog = true
|
||||||
@@ -546,7 +640,8 @@ fun AboutSettingsGroup(developerModeEnabled: Boolean) {
|
|||||||
showDeveloperDialog = false
|
showDeveloperDialog = false
|
||||||
},
|
},
|
||||||
onEnable = {
|
onEnable = {
|
||||||
PreferenceUtils.get(context).setBoolean(PreferenceUtils.Key.DEVELOPER_MODE, true)
|
PreferenceUtils.get(context)
|
||||||
|
.setBoolean(PreferenceUtils.Key.DEVELOPER_MODE, true)
|
||||||
showDeveloperDialog = false
|
showDeveloperDialog = false
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -554,7 +649,10 @@ fun AboutSettingsGroup(developerModeEnabled: Boolean) {
|
|||||||
|
|
||||||
OptionsLabel(
|
OptionsLabel(
|
||||||
stringResource(R.string.preference_loader_version_name),
|
stringResource(R.string.preference_loader_version_name),
|
||||||
stringResource(R.string.preference_loader_version_description, currentRelease ?: "unknown"),
|
stringResource(
|
||||||
|
R.string.preference_loader_version_description,
|
||||||
|
currentRelease ?: "unknown"
|
||||||
|
),
|
||||||
displayInline = true
|
displayInline = true
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -568,22 +666,32 @@ fun AboutSettingsGroup(developerModeEnabled: Boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val gameSource = remember {
|
val gameSource = remember {
|
||||||
resources.getString(when (GamePackageUtils.identifyGameSource(context.packageManager)) {
|
resources.getString(
|
||||||
GamePackageUtils.GameSource.GOOGLE -> R.string.preference_game_source_google
|
when (GamePackageUtils.identifyGameSource(context.packageManager)) {
|
||||||
GamePackageUtils.GameSource.AMAZON -> R.string.preference_game_source_amazon
|
GamePackageUtils.GameSource.GOOGLE -> R.string.preference_game_source_google
|
||||||
else -> R.string.preference_game_source_unknown
|
GamePackageUtils.GameSource.AMAZON -> R.string.preference_game_source_amazon
|
||||||
})
|
else -> R.string.preference_game_source_unknown
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
OptionsLabel(
|
OptionsLabel(
|
||||||
stringResource(R.string.preference_game_version_name),
|
stringResource(R.string.preference_game_version_name),
|
||||||
stringResource(R.string.preference_game_version_description, gameVersion, gameSource),
|
stringResource(
|
||||||
|
R.string.preference_game_version_description,
|
||||||
|
gameVersion,
|
||||||
|
gameSource
|
||||||
|
),
|
||||||
displayInline = true
|
displayInline = true
|
||||||
)
|
)
|
||||||
|
|
||||||
OptionsLabel(
|
OptionsLabel(
|
||||||
stringResource(R.string.preference_architecture_name),
|
stringResource(R.string.preference_architecture_name),
|
||||||
stringResource(R.string.preference_architecture_description, Build.MODEL, LaunchUtils.applicationArchitecture),
|
stringResource(
|
||||||
|
R.string.preference_architecture_description,
|
||||||
|
Build.MODEL,
|
||||||
|
LaunchUtils.applicationArchitecture
|
||||||
|
),
|
||||||
displayInline = true
|
displayInline = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package com.geode.launcher.preferences.components
|
||||||
|
|
||||||
|
import androidx.activity.OnBackPressedDispatcher
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.log.BaseDirectoryViewModel
|
||||||
|
import com.geode.launcher.ui.theme.Typography
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun CrashLogsTopBar(
|
||||||
|
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||||
|
directoryViewModel: BaseDirectoryViewModel<*>,
|
||||||
|
scrollBehavior: TopAppBarScrollBehavior,
|
||||||
|
@StringRes titleId: Int,
|
||||||
|
additionalOptions: (@Composable (() -> Unit) -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
val isLoading by directoryViewModel.isLoading.collectAsState()
|
||||||
|
var showMoreOptions by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column {
|
||||||
|
TopAppBar(
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_arrow_back),
|
||||||
|
contentDescription = stringResource(R.string.back_icon_alt)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { showMoreOptions = !showMoreOptions }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_more_vert),
|
||||||
|
contentDescription = stringResource(R.string.application_logs_more)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showMoreOptions,
|
||||||
|
onDismissRequest = { showMoreOptions = false }
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_delete),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
},
|
||||||
|
text = {
|
||||||
|
Text(stringResource(R.string.application_logs_delete))
|
||||||
|
}, onClick = {
|
||||||
|
directoryViewModel.clearAllFiles()
|
||||||
|
showMoreOptions = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
additionalOptions?.invoke {
|
||||||
|
showMoreOptions = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
stringResource(titleId),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
scrollBehavior = scrollBehavior,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun <T> CrashLogsListing(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
directoryViewModel: BaseDirectoryViewModel<T>,
|
||||||
|
@StringRes noneAvailableId: Int,
|
||||||
|
logCard: @Composable (T, Shape) -> Unit,
|
||||||
|
) {
|
||||||
|
val logLines = directoryViewModel.lineState
|
||||||
|
val isLoading by directoryViewModel.isLoading.collectAsState()
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier,
|
||||||
|
verticalArrangement = Arrangement.SpaceEvenly,
|
||||||
|
) {
|
||||||
|
if (!isLoading && logLines.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
stringResource(noneAvailableId),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
style = Typography.bodyLarge,
|
||||||
|
modifier = Modifier.fillMaxSize().padding(8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val itemsSize = logLines.size
|
||||||
|
|
||||||
|
itemsIndexed(
|
||||||
|
logLines,
|
||||||
|
key = { index, line -> directoryViewModel.getItemKey(line) }
|
||||||
|
) { index, line ->
|
||||||
|
val shape = RoundedCornerShape(
|
||||||
|
topStart = if (index == 0) 16.dp else 4.dp,
|
||||||
|
topEnd = if (index == 0) 16.dp else 4.dp,
|
||||||
|
bottomStart = if (index + 1 == itemsSize) 16.dp else 4.dp,
|
||||||
|
bottomEnd = if (index + 1 == itemsSize) 16.dp else 4.dp
|
||||||
|
)
|
||||||
|
|
||||||
|
logCard(line, shape)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun <T> DirectoryListingScreen(
|
||||||
|
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||||
|
directoryViewModel: BaseDirectoryViewModel<T>,
|
||||||
|
@StringRes titleId: Int,
|
||||||
|
@StringRes noneAvailableId: Int,
|
||||||
|
additionalOptions: (@Composable (() -> Unit) -> Unit)? = null,
|
||||||
|
logCard: @Composable (T, Shape) -> Unit,
|
||||||
|
) {
|
||||||
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||||
|
topBar = {
|
||||||
|
CrashLogsTopBar(
|
||||||
|
onBackPressedDispatcher,
|
||||||
|
directoryViewModel,
|
||||||
|
scrollBehavior,
|
||||||
|
titleId,
|
||||||
|
additionalOptions = additionalOptions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
CrashLogsListing(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(innerPadding)
|
||||||
|
.fillMaxWidth(),
|
||||||
|
directoryViewModel,
|
||||||
|
noneAvailableId = noneAvailableId,
|
||||||
|
logCard = logCard
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package com.geode.launcher.preferences
|
package com.geode.launcher.preferences.components
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
+3
-456
@@ -1,4 +1,4 @@
|
|||||||
package com.geode.launcher.preferences
|
package com.geode.launcher.preferences.components
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -8,41 +8,28 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.defaultMinSize
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.selection.toggleable
|
import androidx.compose.foundation.selection.toggleable
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.RadioButton
|
|
||||||
import androidx.compose.material3.Slider
|
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -51,21 +38,17 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.Role
|
import androidx.compose.ui.semantics.Role
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import com.geode.launcher.R
|
import com.geode.launcher.R
|
||||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||||
import com.geode.launcher.ui.theme.Typography
|
import com.geode.launcher.ui.theme.Typography
|
||||||
import com.geode.launcher.utils.LabelledText
|
import com.geode.launcher.utils.LabelledText
|
||||||
import com.geode.launcher.utils.PreferenceUtils
|
import com.geode.launcher.utils.PreferenceUtils
|
||||||
import com.geode.launcher.utils.Profile
|
|
||||||
import com.geode.launcher.utils.ProfileManager
|
import com.geode.launcher.utils.ProfileManager
|
||||||
|
import kotlin.collections.get
|
||||||
import kotlin.math.log10
|
import kotlin.math.log10
|
||||||
import kotlin.math.max
|
|
||||||
import kotlin.math.min
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
@@ -237,244 +220,6 @@ fun SettingsStringCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun StringDialog(
|
|
||||||
title: String,
|
|
||||||
onDismissRequest: () -> Unit,
|
|
||||||
onSelect: (String) -> Unit,
|
|
||||||
initialValue: String,
|
|
||||||
filterInput: ((String) -> String)? = null
|
|
||||||
) {
|
|
||||||
var enteredValue by remember { mutableStateOf(initialValue) }
|
|
||||||
|
|
||||||
BaseDialog(
|
|
||||||
onDismissRequest = { onDismissRequest() },
|
|
||||||
title = title,
|
|
||||||
content = {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = enteredValue,
|
|
||||||
onValueChange = {
|
|
||||||
enteredValue = filterInput?.invoke(it) ?: it
|
|
||||||
},
|
|
||||||
singleLine = true,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Ascii,
|
|
||||||
autoCorrectEnabled = false
|
|
||||||
),
|
|
||||||
trailingIcon = {
|
|
||||||
if (enteredValue.isNotEmpty()) {
|
|
||||||
IconButton(onClick = { enteredValue = "" }) {
|
|
||||||
Icon(
|
|
||||||
painterResource(R.drawable.icon_close_small),
|
|
||||||
contentDescription = stringResource(R.string.preference_text_clear)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = { onSelect(enteredValue) }) {
|
|
||||||
Text(stringResource(R.string.message_box_accept))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismissRequest) {
|
|
||||||
Text(stringResource(R.string.message_box_cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RangeDialog(
|
|
||||||
title: String,
|
|
||||||
onDismissRequest: () -> Unit,
|
|
||||||
onSelect: (Int) -> Unit,
|
|
||||||
labelSuffix: String,
|
|
||||||
initialValue: Int,
|
|
||||||
range: IntRange,
|
|
||||||
scale: Int,
|
|
||||||
step: Int,
|
|
||||||
children: @Composable () -> Unit
|
|
||||||
) {
|
|
||||||
var enteredValue by remember {
|
|
||||||
mutableFloatStateOf(initialValue / scale.toFloat())
|
|
||||||
}
|
|
||||||
|
|
||||||
BaseDialog(
|
|
||||||
onDismissRequest = { onDismissRequest() },
|
|
||||||
title = title,
|
|
||||||
content = {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
val precision = log10(scale.toFloat()).roundToInt()
|
|
||||||
|
|
||||||
Slider(
|
|
||||||
value = enteredValue,
|
|
||||||
onValueChange = { enteredValue = it },
|
|
||||||
valueRange = (range.first.toFloat()/scale)..(range.last.toFloat()/scale),
|
|
||||||
steps = ((range.last - range.first) - 1) / step,
|
|
||||||
modifier = Modifier.weight(1.0f)
|
|
||||||
)
|
|
||||||
|
|
||||||
Text("%.${precision}f$labelSuffix".format(enteredValue))
|
|
||||||
}
|
|
||||||
|
|
||||||
children()
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = { onSelect((enteredValue * scale).roundToInt()) }) {
|
|
||||||
Text(stringResource(R.string.message_box_accept))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismissRequest) {
|
|
||||||
Text(stringResource(R.string.message_box_cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun FrameRateDialog(
|
|
||||||
title: String,
|
|
||||||
onDismissRequest: () -> Unit,
|
|
||||||
onSelect: (Int) -> Unit,
|
|
||||||
initialValue: Int,
|
|
||||||
maxFrameRate: Int,
|
|
||||||
) {
|
|
||||||
var enteredValue by remember {
|
|
||||||
mutableStateOf(
|
|
||||||
if (initialValue == 0) maxFrameRate.toString()
|
|
||||||
else initialValue.toString()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val minFrameRate = 5
|
|
||||||
val currentValue = enteredValue.toIntOrNull()
|
|
||||||
val maximumReached = currentValue != null && currentValue > maxFrameRate
|
|
||||||
val minimumReached = currentValue != null && currentValue < minFrameRate
|
|
||||||
|
|
||||||
BaseDialog(
|
|
||||||
onDismissRequest = { onDismissRequest() },
|
|
||||||
title = title,
|
|
||||||
content = {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
IconButton(onClick = {
|
|
||||||
val prevValue = max((currentValue ?: 0) - 5, minFrameRate)
|
|
||||||
enteredValue = prevValue.toString()
|
|
||||||
}, enabled = currentValue == null || currentValue > minFrameRate) {
|
|
||||||
Icon(
|
|
||||||
painterResource(R.drawable.icon_remove),
|
|
||||||
contentDescription = stringResource(R.string.preference_limit_framerate_subtract)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = enteredValue,
|
|
||||||
onValueChange = {
|
|
||||||
enteredValue = it
|
|
||||||
},
|
|
||||||
singleLine = true,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Number,
|
|
||||||
),
|
|
||||||
suffix = {
|
|
||||||
Text(stringResource(R.string.preference_limit_framerate_suffix))
|
|
||||||
},
|
|
||||||
label = {
|
|
||||||
Text(stringResource(R.string.preference_limit_framerate_label))
|
|
||||||
},
|
|
||||||
isError = minimumReached || maximumReached,
|
|
||||||
modifier = Modifier.weight(1.0f),
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
|
|
||||||
IconButton(onClick = {
|
|
||||||
val nextValue = min((currentValue ?: 0) + 5, maxFrameRate)
|
|
||||||
enteredValue = nextValue.toString()
|
|
||||||
}, enabled = currentValue == null || currentValue < maxFrameRate) {
|
|
||||||
Icon(
|
|
||||||
painterResource(R.drawable.icon_add),
|
|
||||||
contentDescription = stringResource(R.string.preference_limit_framerate_add)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
IconButton(onClick = { onSelect(0) }) {
|
|
||||||
Icon(
|
|
||||||
painterResource(R.drawable.icon_delete),
|
|
||||||
contentDescription = stringResource(R.string.preference_limit_framerate_reset)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (minimumReached) {
|
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.preference_limit_framerate_error_min, minFrameRate),
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
)
|
|
||||||
} else if (maximumReached) {
|
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
stringResource(
|
|
||||||
R.string.preference_limit_framerate_error_max,
|
|
||||||
maxFrameRate
|
|
||||||
),
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
// automatically disable if already max
|
|
||||||
if (currentValue == maxFrameRate) {
|
|
||||||
onSelect(0)
|
|
||||||
} else {
|
|
||||||
onSelect(currentValue ?: 0)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = !minimumReached && !maximumReached && currentValue != null
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.message_box_accept))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismissRequest) {
|
|
||||||
Text(stringResource(R.string.message_box_cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsFPSCard(
|
fun SettingsFPSCard(
|
||||||
title: String,
|
title: String,
|
||||||
@@ -570,120 +315,6 @@ fun SettingsRangeCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal val LocalSelectValue = compositionLocalOf<Any> { 0 }
|
|
||||||
internal val LocalSelectSetValue = staticCompositionLocalOf<(Any) -> Unit> { {} }
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun <T> SelectOption(name: String, value: T, enabled: Boolean = true, leadingContent: @Composable (Boolean) -> Unit = {}) {
|
|
||||||
val currentValue = LocalSelectValue.current
|
|
||||||
val setValue = LocalSelectSetValue.current
|
|
||||||
|
|
||||||
val isSelected = currentValue.equals(value)
|
|
||||||
|
|
||||||
// do not give the row or column padding!! it messes up the selection effect
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(
|
|
||||||
onClick = { setValue(value as Any) },
|
|
||||||
role = Role.RadioButton,
|
|
||||||
enabled = enabled
|
|
||||||
)
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
) {
|
|
||||||
Row(modifier = Modifier.weight(1.0f, true), verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
RadioButton(
|
|
||||||
selected = isSelected,
|
|
||||||
onClick = { setValue(value as Any) },
|
|
||||||
enabled = enabled
|
|
||||||
)
|
|
||||||
Text(name, style = Typography.bodyMedium)
|
|
||||||
}
|
|
||||||
|
|
||||||
leadingContent(isSelected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ProfileCreateDialog(onDismissRequest: () -> Unit) {
|
|
||||||
var enteredValue by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
val filename = enteredValue.take(16)
|
|
||||||
.lowercase()
|
|
||||||
.map {
|
|
||||||
if ("qwertyuiopasdfghjklzxcvbnm1234567890-_.".contains(it))
|
|
||||||
it else '_'
|
|
||||||
}
|
|
||||||
.joinToString("")
|
|
||||||
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
val minimumReached = enteredValue.isEmpty() || filename.isEmpty()
|
|
||||||
|
|
||||||
val profileManager = ProfileManager.get(context)
|
|
||||||
val currentProfiles = remember {
|
|
||||||
profileManager.getProfiles().map { it.path }.toSet()
|
|
||||||
}
|
|
||||||
|
|
||||||
val isDuplicate = currentProfiles.contains(filename)
|
|
||||||
|
|
||||||
BaseDialog(
|
|
||||||
onDismissRequest = { onDismissRequest() },
|
|
||||||
title = stringResource(R.string.preference_profiles_create),
|
|
||||||
content = {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = enteredValue,
|
|
||||||
onValueChange = {
|
|
||||||
enteredValue = it
|
|
||||||
},
|
|
||||||
singleLine = true,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
|
||||||
keyboardType = KeyboardType.Text,
|
|
||||||
),
|
|
||||||
label = {
|
|
||||||
Text(stringResource(R.string.preference_profiles_create_name))
|
|
||||||
},
|
|
||||||
isError = isDuplicate,
|
|
||||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isDuplicate) {
|
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
|
|
||||||
Text(stringResource(R.string.preference_profiles_create_duplicate))
|
|
||||||
} else if (!minimumReached) {
|
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
|
|
||||||
Text(stringResource(R.string.preference_profiles_create_info, filename))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
ProfileManager.get(context).storeProfile(Profile(filename, enteredValue))
|
|
||||||
onDismissRequest()
|
|
||||||
},
|
|
||||||
enabled = !minimumReached && !isDuplicate
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.preference_profiles_create_action))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismissRequest) {
|
|
||||||
Text(stringResource(R.string.message_box_cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileCreateCard() {
|
fun ProfileCreateCard() {
|
||||||
var showDialog by remember { mutableStateOf(false) }
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
@@ -713,7 +344,7 @@ fun ProfileSelectCard() {
|
|||||||
profileManager.getCurrentProfile()
|
profileManager.getCurrentProfile()
|
||||||
}
|
}
|
||||||
|
|
||||||
var currentProfileName = remember {
|
val currentProfileName = remember {
|
||||||
val savedProfile = currentProfileId
|
val savedProfile = currentProfileId
|
||||||
if (savedProfile == null) {
|
if (savedProfile == null) {
|
||||||
"Default"
|
"Default"
|
||||||
@@ -803,90 +434,6 @@ fun ProfileSelectCard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun BaseDialog(
|
|
||||||
title: String,
|
|
||||||
onDismissRequest: () -> Unit,
|
|
||||||
dismissButton: @Composable () -> Unit,
|
|
||||||
confirmButton: @Composable () -> Unit,
|
|
||||||
content: @Composable (ColumnScope.() -> Unit),
|
|
||||||
) {
|
|
||||||
Dialog(onDismissRequest = onDismissRequest) {
|
|
||||||
Card(
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
// styling a dialog is actually a little hard if you're doing what i'm doing
|
|
||||||
// maybe there's a better way to make these padding values...
|
|
||||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style = Typography.titleLarge,
|
|
||||||
modifier = Modifier.padding(
|
|
||||||
start = 28.dp,
|
|
||||||
top = 24.dp,
|
|
||||||
bottom = 12.dp
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
content()
|
|
||||||
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.End,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(
|
|
||||||
bottom = 16.dp,
|
|
||||||
end = 16.dp,
|
|
||||||
top = 4.dp
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
dismissButton()
|
|
||||||
confirmButton()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun <T> SelectDialog(
|
|
||||||
title: String,
|
|
||||||
onDismissRequest: () -> Unit,
|
|
||||||
onSelect: (T) -> Unit,
|
|
||||||
initialValue: T,
|
|
||||||
options: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
val (selectedValue, setSelectedValue) = remember { mutableStateOf(initialValue) }
|
|
||||||
|
|
||||||
BaseDialog(
|
|
||||||
title = title,
|
|
||||||
onDismissRequest = onDismissRequest,
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismissRequest) {
|
|
||||||
Text(stringResource(R.string.message_box_cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = { onSelect(selectedValue) }) {
|
|
||||||
Text(stringResource(R.string.message_box_accept))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalSelectValue provides (selectedValue as Any),
|
|
||||||
LocalSelectSetValue provides ({
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
setSelectedValue(it as T)
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
options()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OptionsButton(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, displayInline: Boolean = false, onClick: () -> Unit) {
|
fun OptionsButton(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, displayInline: Boolean = false, onClick: () -> Unit) {
|
||||||
OptionsCard(
|
OptionsCard(
|
||||||
@@ -0,0 +1,490 @@
|
|||||||
|
package com.geode.launcher.preferences.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import com.geode.launcher.R
|
||||||
|
import com.geode.launcher.ui.theme.Typography
|
||||||
|
import com.geode.launcher.utils.Profile
|
||||||
|
import com.geode.launcher.utils.ProfileManager
|
||||||
|
import kotlin.math.log10
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun StringDialog(
|
||||||
|
title: String,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onSelect: (String) -> Unit,
|
||||||
|
initialValue: String,
|
||||||
|
filterInput: ((String) -> String)? = null
|
||||||
|
) {
|
||||||
|
var enteredValue by remember { mutableStateOf(initialValue) }
|
||||||
|
|
||||||
|
BaseDialog(
|
||||||
|
onDismissRequest = { onDismissRequest() },
|
||||||
|
title = title,
|
||||||
|
content = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = enteredValue,
|
||||||
|
onValueChange = {
|
||||||
|
enteredValue = filterInput?.invoke(it) ?: it
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Ascii,
|
||||||
|
autoCorrectEnabled = false
|
||||||
|
),
|
||||||
|
trailingIcon = {
|
||||||
|
if (enteredValue.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { enteredValue = "" }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_close_small),
|
||||||
|
contentDescription = stringResource(R.string.preference_text_clear)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onSelect(enteredValue) }) {
|
||||||
|
Text(stringResource(R.string.message_box_accept))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RangeDialog(
|
||||||
|
title: String,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onSelect: (Int) -> Unit,
|
||||||
|
labelSuffix: String,
|
||||||
|
initialValue: Int,
|
||||||
|
range: IntRange,
|
||||||
|
scale: Int,
|
||||||
|
step: Int,
|
||||||
|
children: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
var enteredValue by remember {
|
||||||
|
mutableFloatStateOf(initialValue / scale.toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseDialog(
|
||||||
|
onDismissRequest = { onDismissRequest() },
|
||||||
|
title = title,
|
||||||
|
content = {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||||
|
) {
|
||||||
|
val precision = log10(scale.toFloat()).roundToInt()
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value = enteredValue,
|
||||||
|
onValueChange = { enteredValue = it },
|
||||||
|
valueRange = (range.first.toFloat()/scale)..(range.last.toFloat()/scale),
|
||||||
|
steps = ((range.last - range.first) - 1) / step,
|
||||||
|
modifier = Modifier.weight(1.0f)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text("%.${precision}f$labelSuffix".format(enteredValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
children()
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onSelect((enteredValue * scale).roundToInt()) }) {
|
||||||
|
Text(stringResource(R.string.message_box_accept))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun FrameRateDialog(
|
||||||
|
title: String,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onSelect: (Int) -> Unit,
|
||||||
|
initialValue: Int,
|
||||||
|
maxFrameRate: Int,
|
||||||
|
) {
|
||||||
|
var enteredValue by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
if (initialValue == 0) maxFrameRate.toString()
|
||||||
|
else initialValue.toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val minFrameRate = 5
|
||||||
|
val currentValue = enteredValue.toIntOrNull()
|
||||||
|
val maximumReached = currentValue != null && currentValue > maxFrameRate
|
||||||
|
val minimumReached = currentValue != null && currentValue < minFrameRate
|
||||||
|
|
||||||
|
BaseDialog(
|
||||||
|
onDismissRequest = { onDismissRequest() },
|
||||||
|
title = title,
|
||||||
|
content = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = {
|
||||||
|
val prevValue = max((currentValue ?: 0) - 5, minFrameRate)
|
||||||
|
enteredValue = prevValue.toString()
|
||||||
|
}, enabled = currentValue == null || currentValue > minFrameRate) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_remove),
|
||||||
|
contentDescription = stringResource(R.string.preference_limit_framerate_subtract)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = enteredValue,
|
||||||
|
onValueChange = {
|
||||||
|
enteredValue = it
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Number,
|
||||||
|
),
|
||||||
|
suffix = {
|
||||||
|
Text(stringResource(R.string.preference_limit_framerate_suffix))
|
||||||
|
},
|
||||||
|
label = {
|
||||||
|
Text(stringResource(R.string.preference_limit_framerate_label))
|
||||||
|
},
|
||||||
|
isError = minimumReached || maximumReached,
|
||||||
|
modifier = Modifier.weight(1.0f),
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
IconButton(onClick = {
|
||||||
|
val nextValue = min((currentValue ?: 0) + 5, maxFrameRate)
|
||||||
|
enteredValue = nextValue.toString()
|
||||||
|
}, enabled = currentValue == null || currentValue < maxFrameRate) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_add),
|
||||||
|
contentDescription = stringResource(R.string.preference_limit_framerate_add)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = { onSelect(0) }) {
|
||||||
|
Icon(
|
||||||
|
painterResource(R.drawable.icon_delete),
|
||||||
|
contentDescription = stringResource(R.string.preference_limit_framerate_reset)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (minimumReached) {
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.preference_limit_framerate_error_min, minFrameRate),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
} else if (maximumReached) {
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
R.string.preference_limit_framerate_error_max,
|
||||||
|
maxFrameRate
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
// automatically disable if already max
|
||||||
|
if (currentValue == maxFrameRate) {
|
||||||
|
onSelect(0)
|
||||||
|
} else {
|
||||||
|
onSelect(currentValue ?: 0)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !minimumReached && !maximumReached && currentValue != null
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.message_box_accept))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ProfileCreateDialog(onDismissRequest: () -> Unit) {
|
||||||
|
var enteredValue by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
val filename = enteredValue.take(16)
|
||||||
|
.lowercase()
|
||||||
|
.map {
|
||||||
|
if ("qwertyuiopasdfghjklzxcvbnm1234567890-_.".contains(it))
|
||||||
|
it else '_'
|
||||||
|
}
|
||||||
|
.joinToString("")
|
||||||
|
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val minimumReached = enteredValue.isEmpty() || filename.isEmpty()
|
||||||
|
|
||||||
|
val profileManager = ProfileManager.get(context)
|
||||||
|
val currentProfiles = remember {
|
||||||
|
profileManager.getProfiles().map { it.path }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
val isDuplicate = currentProfiles.contains(filename)
|
||||||
|
|
||||||
|
BaseDialog(
|
||||||
|
onDismissRequest = { onDismissRequest() },
|
||||||
|
title = stringResource(R.string.preference_profiles_create),
|
||||||
|
content = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = enteredValue,
|
||||||
|
onValueChange = {
|
||||||
|
enteredValue = it
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Text,
|
||||||
|
),
|
||||||
|
label = {
|
||||||
|
Text(stringResource(R.string.preference_profiles_create_name))
|
||||||
|
},
|
||||||
|
isError = isDuplicate,
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isDuplicate) {
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
Text(stringResource(R.string.preference_profiles_create_duplicate))
|
||||||
|
} else if (!minimumReached) {
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
|
||||||
|
Text(stringResource(R.string.preference_profiles_create_info, filename))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
ProfileManager.get(context).storeProfile(Profile(filename, enteredValue))
|
||||||
|
onDismissRequest()
|
||||||
|
},
|
||||||
|
enabled = !minimumReached && !isDuplicate
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.preference_profiles_create_action))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BaseDialog(
|
||||||
|
title: String,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
dismissButton: @Composable () -> Unit,
|
||||||
|
confirmButton: @Composable () -> Unit,
|
||||||
|
content: @Composable (ColumnScope.() -> Unit),
|
||||||
|
) {
|
||||||
|
Dialog(onDismissRequest = onDismissRequest) {
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// styling a dialog is actually a little hard if you're doing what i'm doing
|
||||||
|
// maybe there's a better way to make these padding values...
|
||||||
|
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = Typography.titleLarge,
|
||||||
|
modifier = Modifier.padding(
|
||||||
|
start = 28.dp,
|
||||||
|
top = 24.dp,
|
||||||
|
bottom = 12.dp
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
content()
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(
|
||||||
|
bottom = 16.dp,
|
||||||
|
end = 16.dp,
|
||||||
|
top = 4.dp
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
dismissButton()
|
||||||
|
confirmButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val LocalSelectValue = compositionLocalOf<Any> { 0 }
|
||||||
|
internal val LocalSelectSetValue = staticCompositionLocalOf<(Any) -> Unit> { {} }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun <T> SelectOption(name: String, value: T, enabled: Boolean = true, leadingContent: @Composable (Boolean) -> Unit = {}) {
|
||||||
|
val currentValue = LocalSelectValue.current
|
||||||
|
val setValue = LocalSelectSetValue.current
|
||||||
|
|
||||||
|
val isSelected = currentValue.equals(value)
|
||||||
|
|
||||||
|
// do not give the row or column padding!! it messes up the selection effect
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(
|
||||||
|
onClick = { setValue(value as Any) },
|
||||||
|
role = Role.RadioButton,
|
||||||
|
enabled = enabled
|
||||||
|
)
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
) {
|
||||||
|
Row(modifier = Modifier.weight(1.0f, true), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
RadioButton(
|
||||||
|
selected = isSelected,
|
||||||
|
onClick = { setValue(value as Any) },
|
||||||
|
enabled = enabled
|
||||||
|
)
|
||||||
|
Text(name, style = Typography.bodyMedium)
|
||||||
|
}
|
||||||
|
|
||||||
|
leadingContent(isSelected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun <T> SelectDialog(
|
||||||
|
title: String,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onSelect: (T) -> Unit,
|
||||||
|
initialValue: T,
|
||||||
|
options: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val (selectedValue, setSelectedValue) = remember { mutableStateOf(initialValue) }
|
||||||
|
|
||||||
|
BaseDialog(
|
||||||
|
title = title,
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) {
|
||||||
|
Text(stringResource(R.string.message_box_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { onSelect(selectedValue) }) {
|
||||||
|
Text(stringResource(R.string.message_box_accept))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalSelectValue provides (selectedValue as Any),
|
||||||
|
LocalSelectSetValue provides ({
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
setSelectedValue(it as T)
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
options()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import kotlinx.coroutines.launch
|
|||||||
import okhttp3.Cache
|
import okhttp3.Cache
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
|
import okio.sink
|
||||||
import okio.source
|
import okio.source
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
@@ -149,12 +150,15 @@ class ReleaseManager private constructor(
|
|||||||
try {
|
try {
|
||||||
DownloadUtils.downloadStream(
|
DownloadUtils.downloadStream(
|
||||||
httpClient,
|
httpClient,
|
||||||
download.url
|
download.url,
|
||||||
).use { fileStream ->
|
onResponse = { body ->
|
||||||
outputFile.outputStream().use {
|
body.source().use { source ->
|
||||||
fileStream.copyTo(it)
|
outputFile.sink().buffer().use { sink ->
|
||||||
|
sink.writeAll(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
return Result.failure(e)
|
return Result.failure(e)
|
||||||
}
|
}
|
||||||
@@ -178,42 +182,60 @@ class ReleaseManager private constructor(
|
|||||||
// clone the file instance as renameTo may move the original file
|
// clone the file instance as renameTo may move the original file
|
||||||
val tempFilePath = outputFile.path
|
val tempFilePath = outputFile.path
|
||||||
|
|
||||||
|
val geodeFile = getGeodeOutputPath()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val fileStream = DownloadUtils.downloadStream(
|
// tempFile should be in same path as geodeFile
|
||||||
|
val geodeParent = geodeFile.parentFile
|
||||||
|
if (geodeParent != null && !geodeParent.exists()) {
|
||||||
|
geodeParent.mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
DownloadUtils.downloadStream(
|
||||||
httpClient,
|
httpClient,
|
||||||
releaseAsset.url
|
releaseAsset.url,
|
||||||
) { progress, outOf ->
|
onProgress = { progress, outOf ->
|
||||||
_uiState.value = ReleaseManagerState.InDownload(progress, outOf)
|
_uiState.value = ReleaseManagerState.InDownload(progress, outOf)
|
||||||
}
|
},
|
||||||
|
onResponse = { body ->
|
||||||
|
DownloadUtils.extractFileFromZipStream(
|
||||||
|
body.byteStream(),
|
||||||
|
outputFile,
|
||||||
|
geodeFile.name
|
||||||
|
)
|
||||||
|
|
||||||
outputFile.parentFile?.mkdirs()
|
// work around a permission issue from adb push
|
||||||
|
if (geodeFile.exists()) {
|
||||||
|
geodeFile.delete()
|
||||||
|
}
|
||||||
|
|
||||||
val geodeFile = getGeodeOutputPath()
|
val renameSuccessful = runCatching {
|
||||||
|
outputFile.renameTo(geodeFile)
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
DownloadUtils.extractFileFromZipStream(
|
if (!renameSuccessful) {
|
||||||
fileStream,
|
// attempt a manual copy if rename fails for whatever reason
|
||||||
outputFile.outputStream(),
|
DownloadUtils.copyFile(outputFile, geodeFile)
|
||||||
geodeFile.name
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// work around a permission issue from adb push
|
|
||||||
if (geodeFile.exists()) {
|
|
||||||
geodeFile.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
outputFile.renameTo(geodeFile)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
sendError(e)
|
sendError(e)
|
||||||
return
|
return
|
||||||
} finally {
|
} finally {
|
||||||
val tempFileClone = File(tempFilePath)
|
val tempFileClone = File(tempFilePath)
|
||||||
try {
|
runCatching {
|
||||||
if (tempFileClone.exists()) tempFileClone.delete()
|
if (tempFileClone.exists()) tempFileClone.delete()
|
||||||
} catch (_: Exception) { }
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// extraction performed
|
// extraction performed
|
||||||
updatePreferences(release)
|
updatePreferences(release)
|
||||||
|
|
||||||
|
val currentTime = Clock.System.now().toEpochMilliseconds()
|
||||||
|
PreferenceUtils.get(applicationContext)
|
||||||
|
.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
|
||||||
|
|
||||||
_uiState.value = ReleaseManagerState.Finished(true)
|
_uiState.value = ReleaseManagerState.Finished(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,11 +272,12 @@ class ReleaseManager private constructor(
|
|||||||
_availableLauncherUpdateDetails.value = launcherRelease
|
_availableLauncherUpdateDetails.value = launcherRelease
|
||||||
|
|
||||||
val launcherTag = launcherRelease.release.tagName
|
val launcherTag = launcherRelease.release.tagName
|
||||||
|
|
||||||
|
PreferenceUtils.get(applicationContext)
|
||||||
|
.setString(PreferenceUtils.Key.LAST_LAUNCHER_UPDATE, launcherTag)
|
||||||
|
|
||||||
if (launcherTag != BuildConfig.VERSION_NAME) {
|
if (launcherTag != BuildConfig.VERSION_NAME) {
|
||||||
_availableLauncherUpdateTag.value = launcherTag
|
_availableLauncherUpdateTag.value = launcherTag
|
||||||
|
|
||||||
PreferenceUtils.get(applicationContext)
|
|
||||||
.setString(PreferenceUtils.Key.LAST_LAUNCHER_UPDATE, launcherTag)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,10 +333,10 @@ class ReleaseManager private constructor(
|
|||||||
|
|
||||||
checkLauncherUpdate()
|
checkLauncherUpdate()
|
||||||
|
|
||||||
val currentTime = Clock.System.now().toEpochMilliseconds()
|
|
||||||
sharedPreferences.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
|
|
||||||
|
|
||||||
if (release == null) {
|
if (release == null) {
|
||||||
|
val currentTime = Clock.System.now().toEpochMilliseconds()
|
||||||
|
sharedPreferences.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
|
||||||
|
|
||||||
_uiState.value = ReleaseManagerState.Finished()
|
_uiState.value = ReleaseManagerState.Finished()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -323,6 +346,9 @@ class ReleaseManager private constructor(
|
|||||||
|
|
||||||
// check if an update is needed
|
// check if an update is needed
|
||||||
if (latestVersion == currentVersion && !fileWasExternallyModified()) {
|
if (latestVersion == currentVersion && !fileWasExternallyModified()) {
|
||||||
|
val currentTime = Clock.System.now().toEpochMilliseconds()
|
||||||
|
sharedPreferences.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
|
||||||
|
|
||||||
_uiState.value = ReleaseManagerState.Finished()
|
_uiState.value = ReleaseManagerState.Finished()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -372,7 +398,17 @@ class ReleaseManager private constructor(
|
|||||||
val geodeName = LaunchUtils.geodeFilename
|
val geodeName = LaunchUtils.geodeFilename
|
||||||
val geodeDirectory = LaunchUtils.getBaseDirectory(applicationContext)
|
val geodeDirectory = LaunchUtils.getBaseDirectory(applicationContext)
|
||||||
|
|
||||||
val tempFile = File.createTempFile(geodeName, null, geodeDirectory)
|
// warning!! while File::createTempFile may look tempting, a certain brand of phones has a messed up implementation of it
|
||||||
|
// so we're making a temp file manually (as long as it doesn't collide with the geode download, it's okay)
|
||||||
|
|
||||||
|
val alphabet = ('A'..'Z') + ('a'..'z') + ('0'..'9')
|
||||||
|
val suffix = buildString(8) {
|
||||||
|
repeat(8) { append(alphabet.random()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val tmpName = "tmp-$suffix.$geodeName"
|
||||||
|
|
||||||
|
val tempFile = File(geodeDirectory, tmpName)
|
||||||
|
|
||||||
return tempFile
|
return tempFile
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ object Constants {
|
|||||||
const val SUPPORTED_VERSION_CODE_MIN_WARNING = 40L
|
const val SUPPORTED_VERSION_CODE_MIN_WARNING = 40L
|
||||||
|
|
||||||
// anything above this version code will show a warning when starting the game
|
// anything above this version code will show a warning when starting the game
|
||||||
const val SUPPORTED_VERSION_CODE = 40L
|
const val SUPPORTED_VERSION_CODE = 41L
|
||||||
|
|
||||||
const val SUPPORTED_VERSION_STRING = "2.2.143"
|
const val SUPPORTED_VERSION_STRING = "2.2.144"
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,9 @@ package com.geode.launcher.utils
|
|||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.FileUtils
|
import android.os.FileUtils
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.runInterruptible
|
import kotlinx.coroutines.runInterruptible
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.MediaType
|
import okhttp3.MediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
@@ -12,11 +14,14 @@ import okhttp3.coroutines.executeAsync
|
|||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
import okio.BufferedSink
|
import okio.BufferedSink
|
||||||
import okio.BufferedSource
|
import okio.BufferedSource
|
||||||
|
import okio.FileSystem
|
||||||
import okio.ForwardingSink
|
import okio.ForwardingSink
|
||||||
import okio.ForwardingSource
|
import okio.ForwardingSource
|
||||||
|
import okio.Path.Companion.toOkioPath
|
||||||
import okio.Sink
|
import okio.Sink
|
||||||
import okio.Source
|
import okio.Source
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
@@ -30,8 +35,9 @@ object DownloadUtils {
|
|||||||
suspend fun downloadStream(
|
suspend fun downloadStream(
|
||||||
httpClient: OkHttpClient,
|
httpClient: OkHttpClient,
|
||||||
url: String,
|
url: String,
|
||||||
onProgress: ProgressCallback? = null
|
onProgress: ProgressCallback? = null,
|
||||||
): InputStream {
|
onResponse: suspend (ResponseBody) -> Unit,
|
||||||
|
) {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.build()
|
.build()
|
||||||
@@ -58,28 +64,35 @@ object DownloadUtils {
|
|||||||
val progressClient = progressClientBuilder.build()
|
val progressClient = progressClientBuilder.build()
|
||||||
|
|
||||||
val call = progressClient.newCall(request)
|
val call = progressClient.newCall(request)
|
||||||
val response = call.executeAsync()
|
call.executeAsync().use { response ->
|
||||||
|
when (response.code) {
|
||||||
return response.body.byteStream()
|
200 -> onResponse(response.body)
|
||||||
|
else -> throw IOException("unexpected response ${response.code}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun extractFileFromZipStream(inputStream: InputStream, outputStream: OutputStream, zipPath: String) {
|
/**
|
||||||
|
* Extracts a file named by zipPath from inputStream and copies it to outputStream
|
||||||
|
*
|
||||||
|
* inputStream and outputStream are both closed after this function executes
|
||||||
|
*/
|
||||||
|
suspend fun extractFileFromZipStream(inputStream: InputStream, output: File, zipPath: String) {
|
||||||
// note to self: ZipInputStreams are a little silly
|
// note to self: ZipInputStreams are a little silly
|
||||||
// (runInterruptible allows it to cancel, otherwise it waits for the stream to finish)
|
// (runInterruptible allows it to cancel, otherwise it waits for the stream to finish)
|
||||||
runInterruptible {
|
runInterruptible {
|
||||||
inputStream.use {
|
inputStream.use { input ->
|
||||||
outputStream.use {
|
output.outputStream().use { out ->
|
||||||
// nice indentation
|
// nice indentation
|
||||||
val zip = ZipInputStream(inputStream)
|
ZipInputStream(input).use { zip ->
|
||||||
zip.use {
|
var entry = zip.nextEntry
|
||||||
var entry = it.nextEntry
|
|
||||||
while (entry != null) {
|
while (entry != null) {
|
||||||
if (entry.name == zipPath) {
|
if (entry.name == zipPath) {
|
||||||
it.copyTo(outputStream)
|
zip.copyTo(out)
|
||||||
return@runInterruptible
|
return@runInterruptible
|
||||||
}
|
}
|
||||||
|
|
||||||
entry = it.nextEntry
|
entry = zip.nextEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
// no matching entries found, throw exception
|
// no matching entries found, throw exception
|
||||||
@@ -90,6 +103,22 @@ object DownloadUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun copyFile(from: File, to: File) {
|
||||||
|
val fs = FileSystem.SYSTEM
|
||||||
|
|
||||||
|
val fromPath = from.toOkioPath()
|
||||||
|
val toPath = to.toOkioPath()
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
fs.copy(fromPath, toPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies data from inputStream to outputStream
|
||||||
|
*
|
||||||
|
* Both streams are closed upon the conclusion of this function.
|
||||||
|
*/
|
||||||
fun copyFile(inputStream: InputStream, outputStream: OutputStream) {
|
fun copyFile(inputStream: InputStream, outputStream: OutputStream) {
|
||||||
// gotta love copying
|
// gotta love copying
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
|||||||
@@ -69,8 +69,15 @@ object GamePackageUtils {
|
|||||||
packageInfo.applicationInfo!!.flags and ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS == ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS
|
packageInfo.applicationInfo!!.flags and ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS == ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS
|
||||||
val isSplit = (packageInfo.applicationInfo?.splitSourceDirs?.size ?: 0) > 1
|
val isSplit = (packageInfo.applicationInfo?.splitSourceDirs?.size ?: 0) > 1
|
||||||
|
|
||||||
|
val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
packageInfo.longVersionCode
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
packageInfo.versionCode.toLong()
|
||||||
|
}
|
||||||
|
|
||||||
val metadata =
|
val metadata =
|
||||||
"Geometry Dash metadata:\nSplit sources: $isSplit\nExtracted libraries: $isExtracted\nLauncher architecture: $abi"
|
"Geometry Dash metadata:\nSplit sources: $isSplit\nExtracted libraries: $isExtracted\nLauncher architecture: $abi\nGame version: $versionCode"
|
||||||
Log.i("GeodeLauncher", metadata)
|
Log.i("GeodeLauncher", metadata)
|
||||||
|
|
||||||
// easiest check! these messages are hardcoded in bionic/linker/linker_phdr.cpp
|
// easiest check! these messages are hardcoded in bionic/linker/linker_phdr.cpp
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import android.content.pm.PackageManager
|
|||||||
import android.hardware.lights.Light
|
import android.hardware.lights.Light
|
||||||
import android.hardware.lights.LightState
|
import android.hardware.lights.LightState
|
||||||
import android.hardware.lights.LightsRequest
|
import android.hardware.lights.LightsRequest
|
||||||
|
import android.net.ConnectivityManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
@@ -207,24 +208,20 @@ object GeodeUtils {
|
|||||||
val context = activity.get()!!
|
val context = activity.get()!!
|
||||||
|
|
||||||
val pathFile = File(path)
|
val pathFile = File(path)
|
||||||
val baseDirectory = LaunchUtils.getBaseDirectory(context)
|
val baseDirectory = LaunchUtils.getBaseDirectory(context, true)
|
||||||
val isInternalPath = pathFile.startsWith(baseDirectory)
|
val isInternalPath = pathFile.startsWith(baseDirectory)
|
||||||
|
|
||||||
val intent = if (isInternalPath) {
|
val intent = if (isInternalPath) {
|
||||||
// TODO: figure out how to get this to point to the path it should be pointing at
|
val relativePath = pathFile.relativeTo(baseDirectory)
|
||||||
// (the best i got was pointing at a file)
|
val uri = DocumentsContract.buildDocumentUri(
|
||||||
// val relativePath = pathFile.relativeTo(baseDirectory)
|
"com.android.externalstorage.documents",
|
||||||
|
"primary:Android/media/${context.packageName}/$relativePath"
|
||||||
|
)
|
||||||
|
|
||||||
Intent(Intent.ACTION_VIEW).apply {
|
Intent(Intent.ACTION_VIEW).apply {
|
||||||
data = DocumentsContract.buildRootUri(
|
setDataAndType(uri, DocumentsContract.Document.MIME_TYPE_DIR)
|
||||||
"${context.packageName}.user", UserDirectoryProvider.ROOT
|
|
||||||
)
|
|
||||||
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
|
|
||||||
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION or
|
|
||||||
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||||
@@ -771,6 +768,11 @@ object GeodeUtils {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getConnectivityManager(): ConnectivityManager? = activity.get()?.run {
|
||||||
|
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
}
|
||||||
|
|
||||||
external fun nativeKeyUp(keyCode: Int, modifiers: Int)
|
external fun nativeKeyUp(keyCode: Int, modifiers: Int)
|
||||||
external fun nativeKeyDown(keyCode: Int, modifiers: Int, isRepeating: Boolean)
|
external fun nativeKeyDown(keyCode: Int, modifiers: Int, isRepeating: Boolean)
|
||||||
external fun nativeActionScroll(scrollX: Float, scrollY: Float)
|
external fun nativeActionScroll(scrollX: Float, scrollY: Float)
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ object LaunchUtils {
|
|||||||
return File(base, "game/geode/crashlogs/")
|
return File(base, "game/geode/crashlogs/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getGeodeLogsDirectory(context: Context): File {
|
||||||
|
val base = getBaseDirectory(context)
|
||||||
|
return File(base, "game/geode/logs/")
|
||||||
|
}
|
||||||
|
|
||||||
fun getLastCrash(context: Context): File? {
|
fun getLastCrash(context: Context): File? {
|
||||||
val crashDirectory = getCrashDirectory(context)
|
val crashDirectory = getCrashDirectory(context)
|
||||||
if (!crashDirectory.exists()) {
|
if (!crashDirectory.exists()) {
|
||||||
@@ -114,6 +119,12 @@ object LaunchUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class FullLauncherError(
|
||||||
|
val error: LauncherError,
|
||||||
|
val returnMessage: String,
|
||||||
|
val extendedMessage: String? = null,
|
||||||
|
) : Serializable
|
||||||
|
|
||||||
const val LAUNCHER_KEY_RETURN_ERROR = "return_error"
|
const val LAUNCHER_KEY_RETURN_ERROR = "return_error"
|
||||||
const val LAUNCHER_KEY_RETURN_MESSAGE = "return_message"
|
const val LAUNCHER_KEY_RETURN_MESSAGE = "return_message"
|
||||||
const val LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE = "return_extended_message"
|
const val LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE = "return_extended_message"
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="960"
|
||||||
|
android:viewportHeight="960"
|
||||||
|
android:tint="?attr/colorControlNormal">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M280,840L280,840Q280,840 280,840Q280,840 280,840L280,120Q280,120 280,120Q280,120 280,120L280,120Q280,120 280,120Q280,120 280,120L280,520L280,520Q280,520 280,590Q280,660 280,760L280,840ZM508.5,228.5Q520,217 520,200Q520,183 508.5,171.5Q497,160 480,160Q463,160 451.5,171.5Q440,183 440,200Q440,217 451.5,228.5Q463,240 480,240Q497,240 508.5,228.5ZM280,920Q247,920 223.5,896.5Q200,873 200,840L200,120Q200,87 223.5,63.5Q247,40 280,40L680,40Q713,40 736.5,63.5Q760,87 760,120L760,244Q778,251 789,266Q800,281 800,300L800,380Q800,399 789,414Q778,429 760,436L760,520L680,520L680,120Q680,120 680,120Q680,120 680,120L280,120Q280,120 280,120Q280,120 280,120L280,840Q280,840 280,840Q280,840 280,840L360,840L360,920L280,920ZM584,904L440,760L584,616L640,673L553,760L640,847L584,904ZM776,904L720,847L807,760L720,673L776,616L920,760L776,904Z"/>
|
||||||
|
</vector>
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
<string name="launcher_notification_update_success">Geode updated!</string>
|
<string name="launcher_notification_update_success">Geode updated!</string>
|
||||||
<string name="launcher_notification_compatibility">Unsupported Geometry Dash version.</string>
|
<string name="launcher_notification_compatibility">Unsupported Geometry Dash version.</string>
|
||||||
<string name="launcher_notification_compatibility_description">An update is required to receive Geode updates.</string>
|
<string name="launcher_notification_compatibility_description">An update is required to receive Geode updates.</string>
|
||||||
<string name="launcher_notification_forwards_compatibility_description">Geode failed to load.</string>
|
<string name="launcher_notification_forwards_compatibility_description">Geode is currently unavailable.</string>
|
||||||
<string name="launcher_notification_forwards_compatibility">Unsupported Geometry Dash version.</string>
|
<string name="launcher_notification_forwards_compatibility">Unsupported Geometry Dash version.</string>
|
||||||
|
|
||||||
<string name="launcher_notification_update_cta">Tap for more information.</string>
|
<string name="launcher_notification_update_cta">Tap for more information.</string>
|
||||||
@@ -145,6 +145,7 @@
|
|||||||
<string name="preferences_open_file_manager">Open file manager</string>
|
<string name="preferences_open_file_manager">Open file manager</string>
|
||||||
<string name="preferences_view_logs">View application logs</string>
|
<string name="preferences_view_logs">View application logs</string>
|
||||||
<string name="preferences_view_crashes">View crash dumps</string>
|
<string name="preferences_view_crashes">View crash dumps</string>
|
||||||
|
<string name="preferences_view_geode_logs">View Geode logs</string>
|
||||||
|
|
||||||
<string name="preference_theme_name">Theme</string>
|
<string name="preference_theme_name">Theme</string>
|
||||||
<string name="preference_theme_select">Select theme</string>
|
<string name="preference_theme_select">Select theme</string>
|
||||||
@@ -234,7 +235,12 @@
|
|||||||
|
|
||||||
<string name="preference_check_for_updates_button">Check for updates</string>
|
<string name="preference_check_for_updates_button">Check for updates</string>
|
||||||
<string name="preference_check_for_updates_description">Last checked %1$s</string>
|
<string name="preference_check_for_updates_description">Last checked %1$s</string>
|
||||||
<string name="preference_check_for_updates_failed">Update failed. See logs for more details.</string>
|
<string name="preference_check_for_updates_failed">Update failed.</string>
|
||||||
|
<string name="preference_check_for_updates_failed_action">Help</string>
|
||||||
|
|
||||||
|
<string name="preference_check_for_updates_help_title">Update failed.</string>
|
||||||
|
<string name="preference_check_for_updates_help_copy">Copy</string>
|
||||||
|
|
||||||
<string name="preference_check_for_updates_none_found">No updates found.</string>
|
<string name="preference_check_for_updates_none_found">No updates found.</string>
|
||||||
<string name="preference_check_for_updates_success">Geode has been updated!</string>
|
<string name="preference_check_for_updates_success">Geode has been updated!</string>
|
||||||
<string name="preference_check_for_updates_downloading">Downloading update…</string>
|
<string name="preference_check_for_updates_downloading">Downloading update…</string>
|
||||||
@@ -331,4 +337,9 @@
|
|||||||
<string name="application_icon_trans">Transgender</string>
|
<string name="application_icon_trans">Transgender</string>
|
||||||
<string name="application_icon_confirm">Change icon to %1$s?</string>
|
<string name="application_icon_confirm">Change icon to %1$s?</string>
|
||||||
<string name="application_icon_confirm_description">Geode Launcher will close itself to change the application icon.</string>
|
<string name="application_icon_confirm_description">Geode Launcher will close itself to change the application icon.</string>
|
||||||
|
|
||||||
|
<!-- GeodeLogsListingActivity strings -->
|
||||||
|
<string name="title_activity_geode_logs">Geode logs</string>
|
||||||
|
<string name="application_geode_logs_no_logs">No logs available.</string>
|
||||||
|
<string name="application_geode_logs_empty">File was empty.</string>
|
||||||
</resources>
|
</resources>
|
||||||
Reference in new issue
Block a user