Compare commits

..
75 Commits
Author SHA1 Message Date
qimiko 3853a469cd add internal request all files perm 2024-02-04 12:46:25 -07:00
qimiko 62b31b8924 hide load failure option 2024-02-03 18:32:14 -07:00
qimiko ab1d0c96f4 have internal links open storage provider 2024-02-03 18:30:54 -07:00
qimiko 96354096e2 minor update 2024-02-03 18:08:31 -07:00
qimiko 25b686f183 fix update warning dismiss 2024-02-03 18:06:07 -07:00
qimiko 7694236434 disallow ignoring older gd version warning 2024-02-03 18:03:40 -07:00
qimiko 7c0f1da312 clear button for text inputs 2024-02-03 05:28:19 -07:00
qimiko 019d9747b5 add ui to launch in safe mode
closes #17
2024-02-02 02:06:43 -07:00
qimiko e806ec223a use all files permission on newer devices 2024-02-02 00:04:07 -07:00
qimiko 9f58e76f97 maintenance 2 2024-02-01 13:15:30 -07:00
qimiko 371750a184 enable gradle cache 2024-01-31 16:51:09 -07:00
qimiko 2494bda4e8 send vibration on log long press 2024-01-30 03:17:23 -07:00
qimiko b67be09cd3 update readme 2024-01-30 02:22:49 -07:00
qimiko 09b1c9bc3a forgot to set failure callback :( 2024-01-30 02:20:48 -07:00
qimiko 483fb56e25 feeling gradle 2024-01-30 02:20:48 -07:00
Chloe 7857fae68f appease github 2024-01-30 00:46:19 -07:00
qimiko 61b84cd223 accidentally left in an import 2024-01-30 00:40:08 -07:00
qimiko bd67332adc remove safe mode launch code
no sense having it in onLaunch parameters until i figure out how i want the ui to work
2024-01-30 00:39:04 -07:00
qimiko 84657fd6b3 add safe mode handling 2024-01-30 00:06:29 -07:00
qimiko f197b45f9e increment launcher version 2024-01-29 23:49:07 -07:00
qimiko d0dc8bec5d build file maintenance 2024-01-29 23:45:56 -07:00
qimiko c9e0408eb2 close dropdown on dialog open 2024-01-28 19:02:13 -07:00
qimiko b50a5b2b54 add option to filter by log level 2024-01-28 18:48:11 -07:00
qimiko c8e3d7404c allow downgrading geode 2024-01-28 17:55:01 -07:00
qimiko c4c41e324e move settings components into another file 2024-01-28 16:16:55 -07:00
qimiko 4d324913df promote load errors to log errors 2024-01-28 06:51:51 -07:00
qimiko 19bd549b1f request file permissions when necessary
fixes #8 by making it irrelevant
2024-01-28 00:20:18 -07:00
qimiko 9dc501c9ab make geode load failures a hard error 2024-01-27 23:16:33 -07:00
qimiko 5521869ac9 add launch arguments option 2024-01-27 22:55:34 -07:00
mat c9a712f692 im stupid 2024-01-27 13:21:46 -03:00
mat 6a0b3fc8bd we're stupid 2024-01-27 13:17:21 -03:00
alk 684fd99b83 add release as a build i think 2024-01-27 18:34:18 +03:00
qimiko fd83daa776 generate more specific warnings 2024-01-27 03:13:37 -07:00
qimiko 9a87716635 add fallback mode to log export 2024-01-27 02:30:36 -07:00
Chloe 31566ce64b Merge pull request #12 from dankmeme01/main
Add support for requesting permissions from native code
2024-01-26 15:24:48 -07:00
dankmeme01 d078879e7b change handling of failures 2024-01-26 23:04:33 +01:00
dankmeme01 5fa42b6ca5 remove unused imports 2024-01-26 22:35:28 +01:00
dankmeme01 e164b87436 make permission requesting use launchers and add callback 2024-01-26 22:35:08 +01:00
dankmeme01 433d6a0f46 add support for requesting permissions from mods or Geode 2024-01-26 21:30:30 +01:00
mat cf8b1edbe2 Merge pull request #11 from Fleeym/crashfix
Hopefully fix crashes when tabbing out and back in
2024-01-25 15:39:55 -03:00
Fleeym 500e00a9cc ask for more file permission stuff 2024-01-25 19:58:18 +02:00
Fleeym c3f80e9c74 fix opengl context being deleted after it no longer exists 2024-01-25 19:58:09 +02:00
Chloe 7eb06e175f Merge pull request #10 from Fleeym/flame-stuff
fix file utils, move them to a separate class
2024-01-24 17:20:52 -07:00
Chloe 141681be05 add license 2024-01-23 20:24:37 -07:00
Fleeym dcfab5e001 fix file utils, move them to a separate class 2024-01-24 04:45:09 +02:00
qimiko 498bbf4528 accidentally broke a dependency 2024-01-23 05:14:36 -07:00
qimiko dc72608622 properly set value in set 2024-01-23 05:11:56 -07:00
qimiko a50e0a0830 remove extra println 2024-01-23 05:05:06 -07:00
qimiko db707dfce1 only show unsupported version once 2024-01-23 05:03:37 -07:00
qimiko 929cc7615e api to set preference through compose 2024-01-23 05:03:18 -07:00
qimiko bb2e3fbb1b version strings are meaningless 2024-01-23 04:48:55 -07:00
qimiko 7d548aef6c remove unused import 2024-01-23 03:18:54 -07:00
qimiko 7f64236e89 add launcher update check 2024-01-23 03:15:39 -07:00
qimiko 199b6f142b use nightly by default in debug mode 2024-01-23 03:12:44 -07:00
qimiko af87320f11 clarify unsupported version warning 2024-01-23 02:18:48 -07:00
qimiko 665756d85b automatically trim log messages 2024-01-23 01:33:54 -07:00
qimiko 590006413c filter by crash buffer option 2024-01-23 01:31:38 -07:00
qimiko 1b079fa3ff add ability to export logs to file 2024-01-23 01:01:14 -07:00
qimiko cb16db3bf9 use specific message for abi differences 2024-01-23 00:19:55 -07:00
qimiko de6fad41f6 fix nightly download url 2024-01-22 11:38:11 -07:00
qimiko d1a43230a9 gd may have updated 2024-01-22 08:16:42 -07:00
qimiko 442ef83339 show launcher version in settings 2024-01-22 00:28:29 -07:00
qimiko 1eab544692 reorganize release api 2024-01-22 00:16:01 -07:00
qimiko af0ebeab84 save game during onstop 2024-01-21 16:15:22 -07:00
qimiko 0844dc97d2 use kotlin stream copyto for file copies 2024-01-21 16:15:11 -07:00
qimiko 42c10c2729 navigation bar visibility changes 2024-01-21 03:40:26 -07:00
qimiko cf568bcbe2 use release hash instead of modified time 2024-01-20 20:28:08 -07:00
qimiko 36152d8773 add icon to retry button 2024-01-20 20:26:00 -07:00
qimiko 3f371be097 pretend log messages are utf8 2024-01-20 20:17:39 -07:00
qimiko 5eba097e38 only request permissions when needed 2024-01-20 18:37:59 -07:00
qimiko 7e868d8d0a log optimizations 2024-01-20 18:09:53 -07:00
qimiko 97b851cbc1 add to the version string 2024-01-20 06:28:29 -07:00
qimiko 397cb9b9bd add in app log viewer 2024-01-20 06:20:50 -07:00
qimiko 05eb3af243 add internal directory method 2024-01-20 06:01:06 -07:00
qimiko e7a3b72fad disable automatic launch on error 2024-01-19 23:25:58 -07:00
40 changed files with 2551 additions and 817 deletions

No files matched your search

+16 -7
View File
@@ -7,6 +7,15 @@ on:
jobs: jobs:
build: build:
strategy:
matrix:
config:
- name: Release
lower: release
- name: Debug
lower: debug
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -27,7 +36,7 @@ jobs:
- name: Build project - name: Build project
run: | run: |
./gradlew app:assembleDebug \ ./gradlew app:assemble${{ matrix.config.name }} \
-Pandroid.injected.signing.store.file="$HOME/release.keystore" \ -Pandroid.injected.signing.store.file="$HOME/release.keystore" \
-Pandroid.injected.signing.store.password="${{secrets.KEYSTORE_PASSWORD}}" \ -Pandroid.injected.signing.store.password="${{secrets.KEYSTORE_PASSWORD}}" \
-Pandroid.injected.signing.key.alias="${{secrets.KEY_ALIAS}}" \ -Pandroid.injected.signing.key.alias="${{secrets.KEY_ALIAS}}" \
@@ -37,8 +46,8 @@ jobs:
id: rename_apk_universal id: rename_apk_universal
run: | run: |
SHORT_SHA=${GITHUB_SHA::7} SHORT_SHA=${GITHUB_SHA::7}
APK_PATH=${{github.workspace}}/app/build/outputs/apk/debug/app-universal-debug.apk APK_PATH=${{github.workspace}}/app/build/outputs/apk/${{ matrix.config.lower }}/app-universal-${{ matrix.config.lower }}.apk
OUT_PATH=${{github.workspace}}/geode-launcher-debug-$SHORT_SHA.apk OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-$SHORT_SHA.apk
mv $APK_PATH $OUT_PATH mv $APK_PATH $OUT_PATH
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
@@ -46,15 +55,15 @@ jobs:
id: rename_apk id: rename_apk
run: | run: |
SHORT_SHA=${GITHUB_SHA::7} SHORT_SHA=${GITHUB_SHA::7}
APK_PATH=${{github.workspace}}/app/build/outputs/apk/debug/app-armeabi-v7a-debug.apk APK_PATH=${{github.workspace}}/app/build/outputs/apk/${{ matrix.config.lower }}/app-armeabi-v7a-${{ matrix.config.lower }}.apk
OUT_PATH=${{github.workspace}}/geode-launcher-debug-android32-$SHORT_SHA.apk OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-android32-$SHORT_SHA.apk
mv $APK_PATH $OUT_PATH mv $APK_PATH $OUT_PATH
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
- name: Upload development build - name: Upload development build
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: geode-launcher-debug name: geode-launcher-${{ matrix.config.lower }}
path: | path: |
${{ steps.rename_apk_universal.outputs.path }} ${{ steps.rename_apk_universal.outputs.path }}
${{ steps.rename_apk.outputs.path }} ${{ steps.rename_apk.outputs.path }}
+23
View File
@@ -0,0 +1,23 @@
Boost Software License - Version 1.0 - August 17th, 2003
Permission is hereby granted, free of charge, to any person or organization
obtaining a copy of the software and accompanying documentation covered by
this license (the "Software") to use, reproduce, display, distribute,
execute, and transmit the Software, and to prepare derivative works of the
Software, and to permit third-parties to whom the Software is furnished to
do so, all subject to the following:
The copyright notices in the Software and this entire statement, including
the above license grant, this restriction and the following disclaimer,
must be included in all copies of the Software, in whole or in part, and
all derivative works of the Software, unless such copies or derivative
works are solely in the form of machine-executable object code generated by
a source language processor.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT
SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE
FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
-4
View File
@@ -1,7 +1,3 @@
# Geode Android Launcher # Geode Android Launcher
Launches a vanilla copy of Geometry Dash with the Geode loader added. Launches a vanilla copy of Geometry Dash with the Geode loader added.
## TODO:
* Figure out better way of packaging libgeode
-96
View File
@@ -1,96 +0,0 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.serialization'
}
android {
compileSdk 34
defaultConfig {
applicationId "com.geode.launcher"
minSdk 23
targetSdk 34
versionCode 5
versionName "1.0.0-beta.2"
vectorDrawables {
useSupportLibrary true
}
externalNativeBuild {
cmake {
arguments "-DUSE_TULIPHOOK:BOOL=OFF"
}
}
ndkConfig.abiFilters "arm64-v8a", "armeabi-v7a"
}
splits {
abi {
enable true
reset()
//noinspection ChromeOsAbiSupport (not my fault)
include "arm64-v8a", "armeabi-v7a"
universalApk true
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
// enables a polyfill for java Instant on api levels < 26 (used for updater)
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion compose_compiler
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
externalNativeBuild {
cmake {
path file('src/main/cpp/CMakeLists.txt')
}
}
namespace 'com.geode.launcher'
ndkVersion '26.1.10909125'
}
dependencies {
implementation 'androidx.core:core-ktx:1.12.0'
implementation "androidx.compose.ui:ui:$compose_version"
implementation 'androidx.compose.material3:material3:1.1.2'
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0'
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0"
implementation 'androidx.activity:activity-compose:1.8.2'
implementation 'androidx.activity:activity-ktx:1.8.2'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation "androidx.documentfile:documentfile:1.0.1"
implementation "com.squareup.okio:okio:3.7.0"
implementation "com.squareup.okhttp3:okhttp:4.12.0"
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0'
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.5.0'
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.6.0"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"
coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:2.0.4"
}
+104
View File
@@ -0,0 +1,104 @@
val composeCompiler: String by rootProject.extra
val composeBOM: String by rootProject.extra
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
compileSdk = 34
defaultConfig {
applicationId = "com.geode.launcher"
minSdk = 23
targetSdk = 34
versionCode = 11
versionName = "1.2.1"
vectorDrawables {
useSupportLibrary = true
}
@Suppress("UnstableApiUsage")
externalNativeBuild {
cmake {
arguments("-DUSE_TULIPHOOK:BOOL=OFF")
}
}
//noinspection ChromeOsAbiSupport (not my fault)
ndk.abiFilters += listOf("arm64-v8a", "armeabi-v7a")
}
splits {
abi {
isEnable = true
reset()
//noinspection ChromeOsAbiSupport. i'm sorry!
include("arm64-v8a", "armeabi-v7a")
isUniversalApk = true
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
compileOptions {
// enables a polyfill for java Instant on api levels < 26 (used for updater)
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = composeCompiler
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
}
}
namespace = "com.geode.launcher"
ndkVersion = "26.1.10909125"
}
dependencies {
implementation (platform("androidx.compose:compose-bom:$composeBOM"))
implementation ("androidx.core:core-ktx:1.12.0")
implementation ("androidx.compose.ui:ui")
implementation ("androidx.compose.material3:material3")
implementation ("androidx.compose.ui:ui-tooling-preview")
implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation ("androidx.activity:activity-compose:1.8.2")
implementation ("androidx.activity:activity-ktx:1.8.2")
implementation ("androidx.appcompat:appcompat:1.6.1")
implementation ("androidx.documentfile:documentfile:1.0.1")
implementation ("com.squareup.okio:okio:3.7.0")
implementation ("com.squareup.okhttp3:okhttp:4.12.0")
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2")
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.6.2")
implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.5.0")
debugImplementation ("androidx.compose.ui:ui-tooling")
debugImplementation ("androidx.compose.ui:ui-test-manifest")
coreLibraryDesugaring ("com.android.tools:desugar_jdk_libs:2.0.4")
}
+18 -5
View File
@@ -9,9 +9,15 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/> android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_LOGS" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-feature <uses-feature
android:name="android.hardware.wifi" android:name="android.hardware.wifi"
@@ -28,6 +34,11 @@
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/Theme.GeodeLauncher" android:theme="@style/Theme.GeodeLauncher"
tools:targetApi="31"> tools:targetApi="31">
<activity
android:name=".ApplicationLogsActivity"
android:exported="false"
android:label="@string/title_activity_application_logs"
android:theme="@style/Theme.GeodeLauncher" />
<activity <activity
android:name=".SettingsActivity" android:name=".SettingsActivity"
android:exported="false" android:exported="false"
@@ -58,11 +69,12 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<provider <provider
android:authorities="com.geode.launcher.user"
android:name=".UserDirectoryProvider" android:name=".UserDirectoryProvider"
android:grantUriPermissions="true" android:authorities="com.geode.launcher.user"
android:exported="true" android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS"> android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter> <intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" /> <action android:name="android.content.action.DOCUMENTS_PROVIDER" />
@@ -78,4 +90,5 @@
android:resource="@xml/provider_paths" /> android:resource="@xml/provider_paths" />
</provider> </provider>
</application> </application>
</manifest> </manifest>
@@ -0,0 +1,417 @@
package com.geode.launcher
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Divider
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.geode.launcher.log.LogLine
import com.geode.launcher.log.LogPriority
import com.geode.launcher.log.LogViewModel
import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.launch
class ApplicationLogsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
ApplicationLogsScreen(onBackPressedDispatcher)
}
}
}
}
}
}
@Composable
fun getLogColor(priority: LogPriority): Color {
val isDark = LocalTheme.current != Theme.LIGHT
// i just copied these from my ide tbh
return if (isDark) {
when (priority) {
LogPriority.ERROR -> Color(0xff_df_51_51)
LogPriority.WARN -> Color(0xff_bc_b5_00)
LogPriority.FATAL -> Color(0xff_b8_b6_fc)
LogPriority.DEBUG -> Color(0xff_aa_aa_aa)
LogPriority.VERBOSE -> Color(0xff_77_77_77)
else -> Color.Unspecified
}
} else {
when (priority) {
LogPriority.ERROR -> Color(0xff_df_51_51)
LogPriority.WARN -> Color(0xff_ea_b7_00)
LogPriority.FATAL -> Color(0xff_a1_42_f4)
LogPriority.DEBUG -> Color(0xff_77_77_77)
LogPriority.VERBOSE -> Color(0xff_aa_aa_aa)
else -> Color.Unspecified
}
}
}
fun copyLogText(context: Context, logMessage: String) {
val clipboardManager =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboardManager.setPrimaryClip(
ClipData.newPlainText(
context.getString(R.string.application_log_copy_label),
logMessage
)
)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
Toast.makeText(
context,
context.getString(R.string.text_copied),
Toast.LENGTH_SHORT
).show()
}
}
@Composable
fun SelectLogLevelDialog(
onDismissRequest: () -> Unit,
onSelect: (LogPriority) -> Unit,
initialValue: LogPriority
) {
SelectDialog(
title = stringResource(R.string.application_logs_level_dialog_name),
onDismissRequest = onDismissRequest,
onSelect = { onSelect(it) },
initialValue = initialValue,
toLabel = {
// i'm lazy
it.name.lowercase().replaceFirstChar { c -> c.uppercaseChar() }
},
optionsCount = LogPriority.FATAL downTo LogPriority.VERBOSE
)
}
@Composable
fun LogCard(logLine: LogLine, modifier: Modifier = Modifier) {
val context = LocalContext.current
val haptics = LocalHapticFeedback.current
Column(
modifier = modifier
.padding(8.dp)
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
copyLogText(context, logLine.asSimpleString)
}
)
},
) {
Text(
"[${logLine.priority.toChar()}] ${logLine.formattedTime}",
style = Typography.labelSmall,
color = getLogColor(logLine.priority),
fontFamily = FontFamily.Monospace
)
Text(
"[${logLine.tag}]: ${logLine.messageTrimmed}",
fontFamily = FontFamily.Monospace,
style = Typography.bodyMedium
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ApplicationLogsScreen(
onBackPressedDispatcher: OnBackPressedDispatcher?,
logViewModel: LogViewModel = viewModel()
) {
val logLines = logViewModel.lineState
val isLoading by logViewModel.isLoading.collectAsState()
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
val listState = rememberLazyListState()
var showMoreOptions by remember { mutableStateOf(false) }
val saveLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/plain")) { uri ->
if (uri != null) {
coroutineScope.launch {
val logs = logViewModel.getLogData()
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.bufferedWriter().use { writer ->
writer.write(logs)
}
}
Toast.makeText(context, R.string.application_logs_export_completed, Toast.LENGTH_SHORT)
.show()
}
}
}
LaunchedEffect(logLines.size) {
listState.scrollToItem(logLines.size)
}
var showLogLevelSelect by remember { mutableStateOf(false) }
if (showLogLevelSelect) {
SelectLogLevelDialog(
onDismissRequest = { showLogLevelSelect = false },
onSelect = {
logViewModel.logLevel = it
showLogLevelSelect = false
},
initialValue = logViewModel.logLevel
)
}
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
Column {
TopAppBar(
navigationIcon = {
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = stringResource(R.string.back_icon_alt)
)
}
},
actions = {
IconButton(onClick = {
coroutineScope.launch {
val data = logViewModel.getLogData()
val sendIntent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_TEXT, data)
type = "text/plain"
}
val shareIntent = Intent.createChooser(sendIntent, null)
context.startActivity(shareIntent)
}
}, enabled = !logLines.isEmpty()) {
Icon(
Icons.Filled.Share,
contentDescription = stringResource(R.string.application_logs_share)
)
}
IconButton(onClick = { showMoreOptions = !showMoreOptions }) {
Icon(
Icons.Filled.MoreVert,
contentDescription = stringResource(R.string.application_logs_more)
)
}
DropdownMenu(
expanded = showMoreOptions,
onDismissRequest = { showMoreOptions = false }
) {
DropdownMenuItem(
leadingIcon = {
Icon(
painterResource(R.drawable.icon_bug_report),
contentDescription = null,
)
},
trailingIcon = {
if (logViewModel.filterCrashes) {
Icon(
Icons.Filled.Check,
contentDescription = stringResource(R.string.application_logs_crash_only_enabled_alt)
)
}
},
text = {
Text(stringResource(R.string.application_logs_crash_only))
}, onClick = {
logViewModel.toggleCrashBuffer()
showMoreOptions = false
}
)
DropdownMenuItem(
leadingIcon = {
Icon(
painterResource(R.drawable.icon_filter_list),
contentDescription = null,
)
},
text = {
Text(stringResource(R.string.application_logs_level))
},
onClick = {
showLogLevelSelect = true
showMoreOptions = false
}
)
DropdownMenuItem(
leadingIcon = {
Icon(
painterResource(R.drawable.icon_save),
contentDescription = null,
)
},
text = {
Text(stringResource(R.string.application_logs_export))
},
onClick = {
saveLauncher.launch(context.getString(R.string.application_logs_default_filename))
showMoreOptions = false
},
enabled = !logLines.isEmpty()
)
DropdownMenuItem(
leadingIcon = {
Icon(
painterResource(R.drawable.icon_delete),
contentDescription = null
)
},
text = {
Text(stringResource(R.string.application_logs_delete))
}, onClick = {
logViewModel.clearLogs()
showMoreOptions = false
}
)
}
},
title = {
Text(
stringResource(R.string.title_activity_application_logs),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
scrollBehavior = scrollBehavior,
)
if (isLoading) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
}
}
}
) { innerPadding ->
LazyColumn(
Modifier
.padding(innerPadding)
.fillMaxWidth(),
verticalArrangement = Arrangement.SpaceEvenly,
state = listState
) {
if (!isLoading && logLines.isEmpty()) {
item {
Text(
stringResource(R.string.application_logs_no_logs),
textAlign = TextAlign.Center,
style = Typography.bodyLarge,
modifier = Modifier.fillMaxSize()
)
}
}
items(
logLines
) { line ->
LogCard(line)
Divider()
}
}
}
}
@Preview(showSystemUi = true)
@Composable
fun LogsPreview() {
GeodeLauncherTheme {
ApplicationLogsScreen(null)
}
}
@@ -1,5 +1,6 @@
package com.geode.launcher package com.geode.launcher
import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Intent import android.content.Intent
@@ -7,6 +8,7 @@ import android.content.IntentFilter
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Environment import android.os.Environment
@@ -22,8 +24,8 @@ import com.customRobTop.BaseRobTopActivity
import com.customRobTop.JniToCpp import com.customRobTop.JniToCpp
import com.geode.launcher.utils.Constants import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.DownloadUtils import com.geode.launcher.utils.DownloadUtils
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.GeodeUtils import com.geode.launcher.utils.GeodeUtils
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils import com.geode.launcher.utils.PreferenceUtils
import org.cocos2dx.lib.Cocos2dxEditText import org.cocos2dx.lib.Cocos2dxEditText
import org.cocos2dx.lib.Cocos2dxGLSurfaceView import org.cocos2dx.lib.Cocos2dxGLSurfaceView
@@ -44,10 +46,10 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
private var mReceiver: BroadcastReceiver? = null private var mReceiver: BroadcastReceiver? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setupUIState() setupUIState()
super.onCreate(savedInstanceState)
// return back to main if Geometry Dash isn't found // return back to main if Geometry Dash isn't found
if (!LaunchUtils.isGeometryDashInstalled(packageManager)) { if (!LaunchUtils.isGeometryDashInstalled(packageManager)) {
returnToMain() returnToMain()
@@ -57,20 +59,27 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
try { try {
tryLoadGame() tryLoadGame()
} catch (e: UnsatisfiedLinkError) { } catch (e: UnsatisfiedLinkError) {
e.printStackTrace() Log.e("GeodeLauncher", "Library linkage failure", e)
// generates helpful information for use in debugging library load failures // generates helpful information for use in debugging library load failures
val gdPackageInfo = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0) val gdPackageInfo = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
val failureReason = LaunchUtils.diagnoseLoadErrors(this, gdPackageInfo) val abiMismatch = LaunchUtils.detectAbiMismatch(this, gdPackageInfo, e)
val is64bit = LaunchUtils.is64bit
val errorMessage = when {
abiMismatch && is64bit -> getString(R.string.load_failed_abi_error_need_32bit_description)
abiMismatch -> getString(R.string.load_failed_abi_error_need_64bit_description)
else -> getString(R.string.load_failed_link_error_description)
}
returnToMain( returnToMain(
getString(R.string.load_failed_link_error), getString(R.string.load_failed_link_error),
getString(R.string.load_failed_link_error_description, failureReason.name) errorMessage
) )
return return
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() Log.e("GeodeLauncher", "Uncaught error during game load", e)
returnToMain( returnToMain(
getString(R.string.load_failed_generic_error), getString(R.string.load_failed_generic_error),
@@ -99,15 +108,6 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
setupRedirection(gdPackageInfo) setupRedirection(gdPackageInfo)
// request read and write permissions
requestPermissions(
arrayOf(
android.Manifest.permission.READ_EXTERNAL_STORAGE,
android.Manifest.permission.WRITE_EXTERNAL_STORAGE
),
0
)
Cocos2dxHelper.init(this, this) Cocos2dxHelper.init(this, this)
GeodeUtils.setContext(this) GeodeUtils.setContext(this)
@@ -122,8 +122,25 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
setupPostLibraryLoad(gdPackageInfo) setupPostLibraryLoad(gdPackageInfo)
if (!loadGeodeLibrary()) { try {
loadGeodeLibrary()
} catch (e: UnsatisfiedLinkError) {
handleGeodeException(e)
} catch (e: Exception) {
handleGeodeException(e)
}
}
private fun handleGeodeException(e: Throwable) {
e.printStackTrace()
val ignoreFailure = PreferenceUtils.get(this)
.getBoolean(PreferenceUtils.Key.IGNORE_LOAD_FAILURE)
if (ignoreFailure) {
Log.w("GeodeLauncher", "could not load Geode object!") Log.w("GeodeLauncher", "could not load Geode object!")
} else {
throw e
} }
} }
@@ -131,7 +148,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
try { try {
LaunchUtils.addAssetsFromPackage(assets, packageInfo) LaunchUtils.addAssetsFromPackage(assets, packageInfo)
} catch (e: NoSuchMethodException) { } catch (e: NoSuchMethodException) {
e.printStackTrace() Log.e("GeodeLauncher", "Failed to add asset redirection", e)
} }
try { try {
@@ -143,7 +160,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
LauncherFix.setOriginalDataPath(Constants.GJ_DATA_DIR) LauncherFix.setOriginalDataPath(Constants.GJ_DATA_DIR)
LauncherFix.setDataPath(saveDir.path) LauncherFix.setDataPath(saveDir.path)
} catch (e: UnsatisfiedLinkError) { } catch (e: UnsatisfiedLinkError) {
e.printStackTrace() Log.e("GeodeLauncher", "Failed to load LauncherFix", e)
} }
} }
@@ -195,26 +212,26 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
} }
@SuppressLint("UnsafeDynamicallyLoadedCode") @SuppressLint("UnsafeDynamicallyLoadedCode")
private fun loadGeodeLibrary(): Boolean { private fun loadGeodeLibrary() {
// Load Geode if exists // Load Geode if exists
// bundling the object with the application allows for nicer backtraces // bundling the object with the application allows for nicer backtraces
try { try {
// put libgeode.so in jniLibs/armeabi-v7a to get this // put libgeode.so in jniLibs/armeabi-v7a to get this
System.loadLibrary("geode") System.loadLibrary("geode")
return true return
} catch (e: UnsatisfiedLinkError) { } catch (e: UnsatisfiedLinkError) {
// but users may prefer it stored with data // but users may prefer it stored with data
val geodeFilename = LaunchUtils.geodeFilename val geodeFilename = LaunchUtils.geodeFilename
val geodePath = File(filesDir.path, "launcher/$geodeFilename") val geodePath = File(filesDir.path, "launcher/$geodeFilename")
if (geodePath.exists()) { if (geodePath.exists()) {
System.load(geodePath.path) System.load(geodePath.path)
return true return
} }
// you know zmx i have 0 clue what this does so im // you know zmx i have 0 clue what this does so im
// just gonna like copy the binary from external // just gonna like copy the binary from external
// also i get 20 million permission denied errors // also i get 20 million permission denied errors
val externalGeodePath = LaunchUtils.getInstalledGeodePath(this) ?: return false val externalGeodePath = LaunchUtils.getInstalledGeodePath(this)!!
val copiedPath = File(filesDir.path, "copied") val copiedPath = File(filesDir.path, "copied")
if (copiedPath.exists()) { if (copiedPath.exists()) {
@@ -231,17 +248,14 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
) )
if (copiedGeodePath.exists()) { if (copiedGeodePath.exists()) {
try {
println("Loading Geode from ${externalGeodePath.name}") println("Loading Geode from ${externalGeodePath.name}")
System.load(copiedGeodePath.path) System.load(copiedGeodePath.path)
} catch (e: UnsatisfiedLinkError) { return
e.printStackTrace()
}
}
} }
} }
return false throw e
}
} }
private fun createView(): FrameLayout { private fun createView(): FrameLayout {
@@ -281,13 +295,24 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
private fun hideSystemUi() { private fun hideSystemUi() {
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
WindowCompat.getInsetsController(window, window.decorView).apply { WindowCompat.getInsetsController(window, window.decorView).apply {
hide(WindowInsetsCompat.Type.systemBars())
systemBarsBehavior = systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
hide(WindowInsetsCompat.Type.systemBars())
} }
} }
override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
super.onWindowFocusChanged(hasWindowFocus)
mHasWindowFocus = hasWindowFocus
if (hasWindowFocus && !mIsOnPause) {
resumeGame()
}
hideSystemUi()
}
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
unregisterReceivers() unregisterReceivers()
@@ -315,6 +340,15 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
} }
} }
override fun onRestart() {
super.onRestart()
mIsOnPause = false
BaseRobTopActivity.isPaused = false
if (!this.mIsRunning) {
resumeGame()
}
}
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
mIsOnPause = true mIsOnPause = true
@@ -343,14 +377,6 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
} }
} }
override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
super.onWindowFocusChanged(hasWindowFocus)
mHasWindowFocus = hasWindowFocus
if (hasWindowFocus && !mIsOnPause) {
resumeGame()
}
}
override fun runOnGLThread(runnable: Runnable) { override fun runOnGLThread(runnable: Runnable) {
mGLSurfaceView?.queueEvent(runnable) mGLSurfaceView?.queueEvent(runnable)
} }
@@ -6,12 +6,16 @@ import android.os.Bundle
import android.text.format.Formatter.formatShortFileSize import android.text.format.Formatter.formatShortFileSize
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.* import androidx.compose.material3.*
@@ -25,7 +29,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -34,7 +42,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.geode.launcher.api.ReleaseViewModel import com.geode.launcher.updater.ReleaseViewModel
import com.geode.launcher.ui.theme.GeodeLauncherTheme import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme import com.geode.launcher.ui.theme.Theme
@@ -42,14 +50,19 @@ import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.utils.Constants import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.LaunchUtils import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils import com.geode.launcher.utils.PreferenceUtils
import com.geode.launcher.utils.ReleaseManager import com.geode.launcher.updater.ReleaseManager
import com.geode.launcher.utils.GeodeUtils
import com.geode.launcher.utils.useCountdownTimer import com.geode.launcher.utils.useCountdownTimer
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import java.net.ConnectException import java.net.ConnectException
import java.net.UnknownHostException import java.net.UnknownHostException
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val gdInstalled = LaunchUtils.isGeometryDashInstalled(packageManager) val gdInstalled = LaunchUtils.isGeometryDashInstalled(packageManager)
@@ -70,7 +83,8 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
) { ) {
MainScreen(gdInstalled, geodeInstalled) val loadFailed = !returnMessage.isNullOrEmpty() && !returnTitle.isNullOrEmpty()
MainScreen(gdInstalled, geodeInstalled, loadFailed)
if (!returnMessage.isNullOrEmpty() && !returnTitle.isNullOrEmpty()) { if (!returnMessage.isNullOrEmpty() && !returnTitle.isNullOrEmpty()) {
LoadFailedDialog(returnTitle, returnMessage) LoadFailedDialog(returnTitle, returnMessage)
@@ -89,6 +103,43 @@ class MainActivity : ComponentActivity() {
} }
} }
@Composable
fun LauncherUpdateIndicator(modifier: Modifier = Modifier, openTo: String, onDismiss: () -> Unit) {
val uriHandler = LocalUriHandler.current
ElevatedCard(modifier) {
Column(
// buttons add enough padding already, lower to compensate
modifier = Modifier.padding(
top = 20.dp,
start = 12.dp,
end = 12.dp,
bottom = 8.dp
),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
stringResource(R.string.launcher_update_available),
modifier = Modifier.padding(horizontal = 10.dp)
)
Row(modifier = Modifier.align(Alignment.End)) {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.launcher_update_dismiss))
}
Spacer(Modifier.size(4.dp))
TextButton(onClick = { uriHandler.openUri(openTo) }) {
Text(stringResource(R.string.launcher_download))
}
}
}
}
}
@Composable @Composable
fun UpdateProgressIndicator( fun UpdateProgressIndicator(
message: String, message: String,
@@ -146,6 +197,11 @@ fun UpdateMessageIndicator(
releaseViewModel.runReleaseCheck(true) releaseViewModel.runReleaseCheck(true)
}, },
) { ) {
Icon(
Icons.Filled.Refresh,
contentDescription = stringResource(R.string.launcher_retry_icon_alt)
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.release_fetch_button_retry)) Text(stringResource(R.string.release_fetch_button_retry))
} }
} }
@@ -178,17 +234,21 @@ fun LoadFailedDialog(returnTitle: String, returnMessage: String) {
} }
@Composable @Composable
fun UpdateWarning() { fun UpdateWarning(inSafeMode: Boolean = false, onDismiss: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val packageManager = context.packageManager val packageManager = context.packageManager
val gdVersionCode = remember { LaunchUtils.getGeometryDashVersionCode(packageManager) } val gdVersionCode = remember { LaunchUtils.getGeometryDashVersionCode(packageManager) }
val gdVersionString = remember { LaunchUtils.getGeometryDashVersionString(packageManager) } val gdVersionString = remember { LaunchUtils.getGeometryDashVersionString(packageManager) }
var showUpdateWarning by remember { mutableStateOf(true) } var lastDismissedVersion by PreferenceUtils.useLongPreference(
preferenceKey = PreferenceUtils.Key.DISMISSED_GJ_UPDATE
)
if (gdVersionCode != Constants.SUPPORTED_VERSION_CODE) { val canDismissRelease = gdVersionCode >= Constants.SUPPORTED_VERSION_CODE
if (showUpdateWarning) { val shouldDismiss = canDismissRelease && gdVersionCode == lastDismissedVersion
if (gdVersionCode != Constants.SUPPORTED_VERSION_CODE && !shouldDismiss) {
AlertDialog( AlertDialog(
icon = { icon = {
Icon( Icon(
@@ -200,34 +260,65 @@ fun UpdateWarning() {
Text(stringResource(R.string.launcher_unsupported_version_title)) Text(stringResource(R.string.launcher_unsupported_version_title))
}, },
text = { text = {
val message = if (gdVersionCode >= Constants.SUPPORTED_VERSION_CODE) {
R.string.launcher_unsupported_version_description
} else {
R.string.launcher_outdated_version_description
}
Text(stringResource( Text(stringResource(
R.string.launcher_unsupported_version_description, message, gdVersionString, Constants.SUPPORTED_VERSION_STRING
gdVersionString, Constants.SUPPORTED_VERSION_STRING
)) ))
}, },
confirmButton = { confirmButton = {
TextButton(onClick = { TextButton(onClick = {
showUpdateWarning = false onDismiss()
onLaunch(context) lastDismissedVersion = gdVersionCode
onLaunch(context, inSafeMode)
}) { }) {
Text(stringResource(R.string.message_box_accept)) Text(stringResource(R.string.message_box_accept))
} }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showUpdateWarning = false }) { TextButton(onClick = { onDismiss() }) {
Text(stringResource(R.string.message_box_cancel)) Text(stringResource(R.string.message_box_cancel))
} }
}, },
onDismissRequest = { showUpdateWarning = false } onDismissRequest = { onDismiss() }
) )
}
} else { } else {
LaunchedEffect(gdVersionCode) { LaunchedEffect(gdVersionCode) {
onLaunch(context) onLaunch(context, inSafeMode)
} }
} }
} }
@Composable
fun SafeModeDialog(onDismiss: () -> Unit, onLaunch: () -> Unit) {
AlertDialog(
icon = {
Icon(
Icons.Filled.Warning,
contentDescription = stringResource(R.string.launcher_warning_icon_alt)
)
},
title = { Text(stringResource(R.string.safe_mode_enable_title)) },
text = { Text(stringResource(R.string.safe_mode_enable_description)) },
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.message_box_cancel))
}
},
confirmButton = {
TextButton(onClick = onLaunch) {
Text(stringResource(R.string.message_box_continue))
}
},
onDismissRequest = onDismiss
)
}
@Composable @Composable
fun UpdateCard(releaseViewModel: ReleaseViewModel, modifier: Modifier = Modifier) { fun UpdateCard(releaseViewModel: ReleaseViewModel, modifier: Modifier = Modifier) {
val context = LocalContext.current val context = LocalContext.current
@@ -312,9 +403,31 @@ fun UpdateCard(releaseViewModel: ReleaseViewModel, modifier: Modifier = Modifier
} }
} }
} }
val nextUpdate by releaseViewModel.nextLauncherUpdate.collectAsState()
val nextUpdateValue = nextUpdate
if (!releaseViewModel.isInUpdate && nextUpdateValue != null) {
val updateUrl = nextUpdateValue.getLauncherDownload()?.browserDownloadUrl
?: nextUpdateValue.htmlUrl
LauncherUpdateIndicator(
modifier = modifier,
openTo = updateUrl,
onDismiss = {
releaseViewModel.dismissLauncherUpdate()
}
)
}
} }
fun onLaunch(context: Context) { fun onLaunch(context: Context, safeMode: Boolean = false) {
if (safeMode) {
GeodeUtils.setAdditionalLaunchArguments(GeodeUtils.ARGUMENT_SAFE_MODE)
} else {
GeodeUtils.clearLaunchArguments()
}
val launchIntent = Intent(context, GeometryDashActivity::class.java) val launchIntent = Intent(context, GeometryDashActivity::class.java)
launchIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK launchIntent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
@@ -330,6 +443,7 @@ fun onSettings(context: Context) {
fun MainScreen( fun MainScreen(
gdInstalled: Boolean = true, gdInstalled: Boolean = true,
geodePreinstalled: Boolean = true, geodePreinstalled: Boolean = true,
disableAutomaticLaunch: Boolean = false,
releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory) releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
) { ) {
val context = LocalContext.current val context = LocalContext.current
@@ -347,6 +461,8 @@ fun MainScreen(
val geodeInstalled = geodePreinstalled || geodeJustInstalled val geodeInstalled = geodePreinstalled || geodeJustInstalled
var beginLaunch by remember { mutableStateOf(false) } var beginLaunch by remember { mutableStateOf(false) }
var showSafeModeDialog by remember { mutableStateOf(false) }
var launchInSafeMode by remember { mutableStateOf(false) }
LaunchedEffect(shouldUpdate) { LaunchedEffect(shouldUpdate) {
if (shouldUpdate && !releaseViewModel.hasPerformedCheck) { if (shouldUpdate && !releaseViewModel.hasPerformedCheck) {
@@ -375,7 +491,7 @@ fun MainScreen(
) )
if (gdInstalled && geodeInstalled) { if (gdInstalled && geodeInstalled) {
if (shouldAutomaticallyLaunch.value && !releaseViewModel.isInUpdate) { if (shouldAutomaticallyLaunch.value && !releaseViewModel.isInUpdate && !disableAutomaticLaunch) {
val countdownTimer = useCountdownTimer( val countdownTimer = useCountdownTimer(
time = 3000, time = 3000,
onCountdownFinish = { onCountdownFinish = {
@@ -400,10 +516,42 @@ fun MainScreen(
} }
} }
// compose apis don't provide a good way of adding long press to a button
val interactionSource = remember { MutableInteractionSource() }
val viewConfiguration = LocalViewConfiguration.current
val haptics = LocalHapticFeedback.current
LaunchedEffect(interactionSource) {
interactionSource.interactions.collectLatest { interaction ->
when (interaction) {
is PressInteraction.Press -> {
launchInSafeMode = false
delay(viewConfiguration.longPressTimeoutMillis)
// perform a second delay to make the action more obvious
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
delay(viewConfiguration.longPressTimeoutMillis)
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
showSafeModeDialog = true
}
is PressInteraction.Release -> {
if (!showSafeModeDialog) {
beginLaunch = true
}
}
}
}
}
Row { Row {
Button( Button(
onClick = { beginLaunch = true }, onClick = { },
enabled = !releaseViewModel.isInUpdate enabled = !releaseViewModel.isInUpdate,
interactionSource = interactionSource
) { ) {
Icon( Icon(
Icons.Filled.PlayArrow, Icons.Filled.PlayArrow,
@@ -463,12 +611,30 @@ fun MainScreen(
UpdateCard( UpdateCard(
releaseViewModel, releaseViewModel,
modifier = Modifier.padding(12.dp) modifier = Modifier
.padding(12.dp)
) )
} }
if (beginLaunch) { if (beginLaunch) {
UpdateWarning() UpdateWarning(launchInSafeMode) {
beginLaunch = false
launchInSafeMode = false
}
}
if (showSafeModeDialog) {
SafeModeDialog(
onDismiss = {
showSafeModeDialog = false
},
onLaunch = {
launchInSafeMode = true
beginLaunch = true
showSafeModeDialog = false
}
)
} }
} }
@@ -13,6 +13,7 @@ import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedDispatcher import androidx.activity.OnBackPressedDispatcher
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
@@ -20,8 +21,6 @@ import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
@@ -31,11 +30,9 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -44,9 +41,8 @@ import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.geode.launcher.api.ReleaseViewModel import com.geode.launcher.updater.ReleaseViewModel
import com.geode.launcher.ui.theme.GeodeLauncherTheme import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme import com.geode.launcher.ui.theme.Theme
@@ -58,7 +54,9 @@ import java.net.UnknownHostException
class SettingsActivity : ComponentActivity() { class SettingsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME) val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
val theme = Theme.fromInt(themeOption) val theme = Theme.fromInt(themeOption)
@@ -81,16 +79,6 @@ class SettingsActivity : ComponentActivity() {
} }
} }
/*
fun onExportSaveData(context: Context) {
Toast.makeText(
context,
"This function is not implemented yet!",
Toast.LENGTH_SHORT
).show()
}
*/
fun onOpenFolder(context: Context) { fun onOpenFolder(context: Context) {
val file = LaunchUtils.getBaseDirectory(context) val file = LaunchUtils.getBaseDirectory(context)
val clipboardManager = val clipboardManager =
@@ -104,7 +92,7 @@ fun onOpenFolder(context: Context) {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
Toast.makeText( Toast.makeText(
context, context,
context.getString(R.string.export_folder_copied), context.getString(R.string.text_copied),
Toast.LENGTH_SHORT Toast.LENGTH_SHORT
).show() ).show()
} }
@@ -224,6 +212,11 @@ fun updateTheme(context: Context, theme: Int) {
} }
} }
fun onOpenLogs(context: Context) {
val launchIntent = Intent(context, ApplicationLogsActivity::class.java)
context.startActivity(launchIntent)
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
@@ -267,7 +260,7 @@ fun SettingsScreen(
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) { IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
Icon( Icon(
imageVector = Icons.Filled.ArrowBack, imageVector = Icons.Filled.ArrowBack,
contentDescription = context.getString(R.string.settings_back_icon_alt) contentDescription = context.getString(R.string.back_icon_alt)
) )
} }
}, },
@@ -302,20 +295,11 @@ fun SettingsScreen(
title = stringResource(R.string.preference_black_background_name), title = stringResource(R.string.preference_black_background_name),
preferenceKey = PreferenceUtils.Key.BLACK_BACKGROUND preferenceKey = PreferenceUtils.Key.BLACK_BACKGROUND
) )
SettingsCard(
title = context.getString(R.string.preference_load_testing_name),
preferenceKey = PreferenceUtils.Key.LOAD_TESTING,
)
SettingsCard( SettingsCard(
title = context.getString(R.string.preference_load_automatically_name), title = context.getString(R.string.preference_load_automatically_name),
description = context.getString(R.string.preference_load_automatically_description), description = context.getString(R.string.preference_load_automatically_description),
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY, preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY,
) )
OptionsButton(
title = context.getString(R.string.preferences_copy_external_button),
description = LaunchUtils.getBaseDirectory(context).path,
onClick = { onOpenFolder(context) }
)
OptionsButton( OptionsButton(
title = stringResource(R.string.preferences_open_file_manager), title = stringResource(R.string.preferences_open_file_manager),
onClick = { onOpenFileManager(context) } onClick = { onOpenFileManager(context) }
@@ -356,251 +340,36 @@ fun SettingsScreen(
UpdateIndicator(snackbarHostState, updateStatus) UpdateIndicator(snackbarHostState, updateStatus)
} }
} }
/*
OptionsGroup("Data") { OptionsGroup(stringResource(R.string.preference_category_developer)) {
SettingsStringCard(
title = stringResource(R.string.preference_launch_arguments_name),
dialogTitle = stringResource(R.string.preference_launch_arguments_set_title),
preferenceKey = PreferenceUtils.Key.LAUNCH_ARGUMENTS,
filterInput = { it.filter { c ->
// if only there was a better way to define this!
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*(){}<>[]?:;'\"~`-_+=\\| ".contains(c)
}}
)
OptionsButton( OptionsButton(
title = "Export save data", title = context.getString(R.string.preferences_copy_external_button),
onClick = { onExportSaveData(context) } description = LaunchUtils.getBaseDirectory(context).path,
onClick = { onOpenFolder(context) }
)
OptionsButton(
title = stringResource(R.string.preferences_view_logs),
onClick = { onOpenLogs(context) }
) )
} }
*/
}
}
)
}
fun toggleSetting(context: Context, preferenceKey: PreferenceUtils.Key): Boolean {
val preferences = PreferenceUtils.get(context)
return preferences.toggleBoolean(preferenceKey)
}
fun getSetting(context: Context, preferenceKey: PreferenceUtils.Key): Boolean {
val preferences = PreferenceUtils.get(context)
return preferences.getBoolean(preferenceKey)
}
@Composable
fun OptionsGroup(title: String, content: @Composable () -> Unit) {
Column {
Text( Text(
title, stringResource(R.string.preference_launcher_version, BuildConfig.VERSION_NAME),
style = Typography.titleMedium, style = Typography.labelSmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
)
content()
}
}
@Composable
fun SettingsSelectCard(
title: String,
dialogTitle: String,
maxVal: Int,
preferenceKey: PreferenceUtils.Key,
toLabel: @Composable (Int) -> String,
extraSelectBehavior: ((Int) -> Unit)? = null
) {
val preferenceValue by PreferenceUtils.useIntPreference(preferenceKey)
var showDialog by remember { mutableStateOf(false) }
OptionsCard(
title = { OptionsTitle(title = title) },
modifier = Modifier
.clickable(
onClick = {
showDialog = true
},
role = Role.Button
)
) {
Text(toLabel(preferenceValue))
}
if (showDialog) {
val context = LocalContext.current
SelectDialog(
title = dialogTitle,
onDismissRequest = {
showDialog = false
},
onSelect = { selected ->
showDialog = false
PreferenceUtils.get(context)
.setInt(preferenceKey, selected)
extraSelectBehavior?.invoke(selected)
},
initialValue = preferenceValue,
toLabel = toLabel,
optionsCount = maxVal
)
}
}
@Composable
fun SelectDialog(
title: String,
onDismissRequest: () -> Unit,
onSelect: (Int) -> Unit,
initialValue: Int,
toLabel: @Composable (Int) -> String,
optionsCount: Int,
) {
var selectedValue by remember { mutableIntStateOf(initialValue) }
Dialog(onDismissRequest = onDismissRequest) {
Card(
shape = RoundedCornerShape(16.dp)
) {
// styling a dialog is actually a little hard if you're doing what i'm doing
// maybe there's a better way to make these padding values...
Column {
Text(
title,
style = Typography.titleLarge,
modifier = Modifier.padding(
start = 28.dp,
top = 24.dp,
bottom = 12.dp
)
)
// do not give the row or column padding!! it messes up the selection effect
(0..optionsCount).forEach { id ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(
onClick = { selectedValue = id },
role = Role.RadioButton
)
.padding(horizontal = 12.dp)
) {
RadioButton(
selected = selectedValue == id,
onClick = { selectedValue = id }
)
Text(
toLabel(id),
style = Typography.bodyMedium
) )
} }
} }
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(
bottom = 16.dp,
end = 16.dp,
top = 4.dp
) )
) {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
TextButton(onClick = { onSelect(selectedValue) }) {
Text(stringResource(R.string.message_box_accept))
}
}
}
}
}
}
@Composable
fun OptionsButton(title: String, description: String? = null, onClick: () -> Unit) {
OptionsCard(
title = {
OptionsTitle(
title = title,
description = description
)
},
modifier = Modifier
.clickable(onClick = onClick, role = Role.Button)
) { }
}
@Composable
fun SettingsCard(title: String, description: String? = null, preferenceKey: PreferenceUtils.Key) {
val context = LocalContext.current
val settingEnabled = remember {
mutableStateOf(getSetting(context, preferenceKey))
}
OptionsCard(
title = {
OptionsTitle(
Modifier.fillMaxWidth(0.75f),
title = title,
description = description
)
},
modifier = Modifier.toggleable(
value = settingEnabled.value,
onValueChange = { settingEnabled.value = toggleSetting(context, preferenceKey) },
role = Role.Switch,
)
) {
Switch(checked = settingEnabled.value, onCheckedChange = null)
}
}
@Composable
fun OptionsTitle(modifier: Modifier = Modifier, title: String, description: String? = null) {
Column(
modifier,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(title)
if (!description.isNullOrEmpty()) {
Text(
description,
style = Typography.labelMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
@Composable
fun OptionsCard(modifier: Modifier = Modifier, title: @Composable () -> Unit, content: @Composable () -> Unit) {
Row(
modifier
.fillMaxWidth()
.height(64.dp)
.padding(horizontal = 16.dp),
Arrangement.SpaceBetween,
Alignment.CenterVertically,
) {
title()
content()
}
}
@Preview(showBackground = true)
@Composable
fun OptionsCardPreview() {
GeodeLauncherTheme {
OptionsGroup(title = "Preview Group") {
SettingsCard(
title = "Load files from /test",
description = "Very long testing description goes here. It is incredibly long, it should wrap onto a new line.",
preferenceKey = PreferenceUtils.Key.LOAD_TESTING
)
SettingsCard(
title = "Testing option 2",
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY
)
}
}
} }
@Preview(showSystemUi = true) @Preview(showSystemUi = true)
@@ -0,0 +1,366 @@
package com.geode.launcher
import android.content.Context
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.utils.PreferenceUtils
fun toggleSetting(context: Context, preferenceKey: PreferenceUtils.Key): Boolean {
val preferences = PreferenceUtils.get(context)
return preferences.toggleBoolean(preferenceKey)
}
fun getSetting(context: Context, preferenceKey: PreferenceUtils.Key): Boolean {
val preferences = PreferenceUtils.get(context)
return preferences.getBoolean(preferenceKey)
}
@Composable
fun OptionsGroup(title: String, content: @Composable () -> Unit) {
Column {
Text(
title,
style = Typography.titleMedium,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
content()
}
}
@Composable
fun SettingsSelectCard(
title: String,
dialogTitle: String,
maxVal: Int,
preferenceKey: PreferenceUtils.Key,
toLabel: @Composable (Int) -> String,
extraSelectBehavior: ((Int) -> Unit)? = null
) {
val preferenceValue by PreferenceUtils.useIntPreference(preferenceKey)
var showDialog by remember { mutableStateOf(false) }
OptionsCard(
title = { OptionsTitle(title = title) },
modifier = Modifier
.clickable(
onClick = {
showDialog = true
},
role = Role.Button
)
) {
Text(toLabel(preferenceValue))
}
if (showDialog) {
val context = LocalContext.current
SelectDialog(
title = dialogTitle,
onDismissRequest = {
showDialog = false
},
onSelect = { selected ->
showDialog = false
PreferenceUtils.get(context)
.setInt(preferenceKey, selected)
extraSelectBehavior?.invoke(selected)
},
initialValue = preferenceValue,
toLabel = toLabel,
optionsCount = 0..maxVal
)
}
}
@Composable
fun SettingsStringCard(
title: String,
dialogTitle: String,
preferenceKey: PreferenceUtils.Key,
filterInput: ((String) -> String)? = null
) {
var preferenceValue by PreferenceUtils.useStringPreference(preferenceKey)
var showDialog by remember { mutableStateOf(false) }
OptionsCard(
title = {
OptionsTitle(title = title, description = preferenceValue)
},
modifier = Modifier
.clickable(
onClick = {
showDialog = true
},
role = Role.Button
)
) { }
if (showDialog) {
StringDialog(
title = dialogTitle,
onDismissRequest = { showDialog = false },
onSelect = {
preferenceValue = it
showDialog = false
},
initialValue = preferenceValue ?: "",
filterInput = filterInput
)
}
}
@Composable
fun StringDialog(
title: String,
onDismissRequest: () -> Unit,
onSelect: (String) -> Unit,
initialValue: String,
filterInput: ((String) -> String)? = null
) {
var enteredValue by remember { mutableStateOf(initialValue) }
AlertDialog(
onDismissRequest = { onDismissRequest() },
title = {
Text(title)
},
text = {
OutlinedTextField(
value = enteredValue,
onValueChange = {
enteredValue = filterInput?.invoke(it) ?: it
},
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
autoCorrect = false
),
trailingIcon = {
if (enteredValue.isNotEmpty()) {
IconButton(onClick = { enteredValue = "" }) {
Icon(
Icons.Filled.Clear,
contentDescription = stringResource(R.string.preference_text_clear)
)
}
}
}
)
},
confirmButton = {
TextButton(onClick = { onSelect(enteredValue) }) {
Text(stringResource(R.string.message_box_accept))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
)
}
@Composable
fun <T> SelectDialog(
title: String,
onDismissRequest: () -> Unit,
onSelect: (T) -> Unit,
initialValue: T,
toLabel: @Composable (T) -> String,
optionsCount: Iterable<T>,
) {
var selectedValue by remember { mutableStateOf(initialValue) }
Dialog(onDismissRequest = onDismissRequest) {
Card(
shape = RoundedCornerShape(16.dp)
) {
// styling a dialog is actually a little hard if you're doing what i'm doing
// maybe there's a better way to make these padding values...
Column {
Text(
title,
style = Typography.titleLarge,
modifier = Modifier.padding(
start = 28.dp,
top = 24.dp,
bottom = 12.dp
)
)
// do not give the row or column padding!! it messes up the selection effect
optionsCount.forEach { id ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(
onClick = { selectedValue = id },
role = Role.RadioButton
)
.padding(horizontal = 12.dp)
) {
RadioButton(
selected = selectedValue == id,
onClick = { selectedValue = id }
)
Text(
toLabel(id),
style = Typography.bodyMedium
)
}
}
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(
bottom = 16.dp,
end = 16.dp,
top = 4.dp
)
) {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
TextButton(onClick = { onSelect(selectedValue) }) {
Text(stringResource(R.string.message_box_accept))
}
}
}
}
}
}
@Composable
fun OptionsButton(title: String, description: String? = null, onClick: () -> Unit) {
OptionsCard(
title = {
OptionsTitle(
title = title,
description = description
)
},
modifier = Modifier
.clickable(onClick = onClick, role = Role.Button)
) { }
}
@Composable
fun SettingsCard(title: String, description: String? = null, preferenceKey: PreferenceUtils.Key) {
val context = LocalContext.current
val settingEnabled = remember {
mutableStateOf(getSetting(context, preferenceKey))
}
OptionsCard(
title = {
OptionsTitle(
Modifier.fillMaxWidth(0.75f),
title = title,
description = description
)
},
modifier = Modifier.toggleable(
value = settingEnabled.value,
onValueChange = { settingEnabled.value = toggleSetting(context, preferenceKey) },
role = Role.Switch,
)
) {
Switch(checked = settingEnabled.value, onCheckedChange = null)
}
}
@Composable
fun OptionsTitle(modifier: Modifier = Modifier, title: String, description: String? = null) {
Column(
modifier,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(title)
if (!description.isNullOrEmpty()) {
Text(
description,
style = Typography.labelMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
@Composable
fun OptionsCard(modifier: Modifier = Modifier, title: @Composable () -> Unit, content: @Composable () -> Unit) {
Row(
modifier
.fillMaxWidth()
.height(64.dp)
.padding(horizontal = 16.dp),
Arrangement.SpaceBetween,
Alignment.CenterVertically,
) {
title()
content()
}
}
@Preview(showBackground = true)
@Composable
fun OptionsCardPreview() {
GeodeLauncherTheme {
OptionsGroup(title = "Preview Group") {
SettingsCard(
title = "Load files from /test",
description = "Very long testing description goes here. It is incredibly long, it should wrap onto a new line.",
preferenceKey = PreferenceUtils.Key.LOAD_TESTING
)
SettingsCard(
title = "Testing option 2",
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY
)
}
}
}
@@ -3,11 +3,13 @@ package com.geode.launcher
import android.database.Cursor import android.database.Cursor
import android.database.MatrixCursor import android.database.MatrixCursor
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.CancellationSignal import android.os.CancellationSignal
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.DocumentsProvider import android.provider.DocumentsProvider
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi
import com.geode.launcher.utils.LaunchUtils import com.geode.launcher.utils.LaunchUtils
import java.io.File import java.io.File
@@ -150,7 +152,7 @@ class UserDirectoryProvider : DocumentsProvider() {
notifyFileChange(file) notifyFileChange(file)
} }
override fun renameDocument(documentId: String, displayName: String): String? { override fun renameDocument(documentId: String, displayName: String): String {
val file = getFileForDocumentId(documentId) val file = getFileForDocumentId(documentId)
val dest = findFileNameForNewFile(File(file.parentFile, displayName)) val dest = findFileNameForNewFile(File(file.parentFile, displayName))
file.renameTo(dest) file.renameTo(dest)
@@ -203,4 +205,19 @@ class UserDirectoryProvider : DocumentsProvider() {
val uri = getDocumentUri(parentDocumentId) val uri = getDocumentUri(parentDocumentId)
context!!.contentResolver.notifyChange(uri, null) context!!.contentResolver.notifyChange(uri, null)
} }
@RequiresApi(Build.VERSION_CODES.O)
override fun findDocumentPath(
parentDocumentId: String?,
childDocumentId: String
): DocumentsContract.Path {
if (!parentDocumentId.isNullOrEmpty()) {
// not implementing this for now...
return super.findDocumentPath(parentDocumentId, childDocumentId)
}
val nonRootPath = listOf(ROOT) + childDocumentId.removePrefix(ROOT).split("/")
return DocumentsContract.Path(ROOT, nonRootPath)
}
} }
@@ -0,0 +1,36 @@
package com.geode.launcher.activityresult
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import androidx.activity.result.contract.ActivityResultContract
open class GeodeOpenFileActivityResult : ActivityResultContract<GeodeOpenFileActivityResult.OpenFileParams, Uri?>() {
class OpenFileParams(val extraMimes: Array<String>, val defaultPath: Uri?) {}
override fun createIntent(context: Context, input: OpenFileParams): Intent {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.putExtra(Intent.EXTRA_MIME_TYPES, input.extraMimes)
.setType("*/*")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (input.defaultPath != null) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, input.defaultPath)
}
}
return intent
}
final override fun getSynchronousResult(
context: Context,
input: OpenFileParams
): SynchronousResult<Uri?>? = null
final override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
return intent.takeIf { resultCode == Activity.RESULT_OK }?.data
}
}
@@ -0,0 +1,55 @@
package com.geode.launcher.activityresult
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import androidx.activity.result.contract.ActivityResultContract
open class GeodeOpenFilesActivityResult : ActivityResultContract<GeodeOpenFilesActivityResult.OpenFileParams, List<@JvmSuppressWildcards Uri>>() {
class OpenFileParams(val extraMimes: Array<String>, val defaultPath: Uri?) {}
override fun createIntent(context: Context, input: OpenFileParams): Intent {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
.putExtra(Intent.EXTRA_MIME_TYPES, input.extraMimes)
.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
.setType("*/*")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (input.defaultPath != null) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, input.defaultPath)
}
}
return intent
}
final override fun getSynchronousResult(
context: Context,
input: OpenFileParams
): SynchronousResult<List<@JvmSuppressWildcards Uri>>? = null
final override fun parseResult(resultCode: Int, intent: Intent?): List<Uri> {
// Gracefully stolen from ActivityResultContracts.Intent.getclipDataUris
val clipData = intent.takeIf {
resultCode == Activity.RESULT_OK
}?.clipData
val resultSet = LinkedHashSet<Uri>()
intent?.data?.let { data ->
resultSet.add(data)
}
if (clipData == null && resultSet.isEmpty()) {
return emptyList()
} else if (clipData != null) {
for (i in 0 until clipData.itemCount) {
val uri = clipData.getItemAt(i).uri
if (uri != null) {
resultSet.add(uri)
}
}
}
return ArrayList(resultSet)
}
}
@@ -0,0 +1,34 @@
package com.geode.launcher.activityresult
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import androidx.activity.result.contract.ActivityResultContract
open class GeodeSaveFileActivityResult : ActivityResultContract<GeodeSaveFileActivityResult.SaveFileParams, Uri?>() {
class SaveFileParams(val mimeType: String?, val defaultPath: Uri?) {}
override fun createIntent(context: Context, input: SaveFileParams): Intent {
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
.setType(input.mimeType ?: "*/*")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (input.defaultPath != null) {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, input.defaultPath)
}
}
return intent
}
final override fun getSynchronousResult(
context: Context,
input: SaveFileParams
): SynchronousResult<Uri?>? = null
final override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
return intent.takeIf { resultCode == Activity.RESULT_OK }?.data
}
}
@@ -0,0 +1,214 @@
package com.geode.launcher.log
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaInstant
import kotlinx.datetime.toLocalDateTime
import okio.BufferedSource
import java.io.IOException
fun BufferedSource.readCChar(): Char {
return this.readUtf8CodePoint().toChar()
}
fun BufferedSource.readCString(): String {
val buffer = StringBuilder()
var lastByte = this.readCChar()
while (lastByte != '\u0000') {
buffer.append(lastByte)
lastByte = this.readCChar()
}
return buffer.toString()
}
data class ProcessInformation(val processId: Int, val threadId: Int, val processUid: Int)
enum class LogPriority {
UNKNOWN, DEFAULT, VERBOSE, DEBUG, INFO, WARN, ERROR, FATAL, SILENT;
class LogPriorityIterator(
start: LogPriority,
private val end: LogPriority,
private val step: Int
) : Iterator<LogPriority> {
private var hasNext: Boolean = if (step > 0) start <= end else start >= end
private var next = if (hasNext) start else end
override fun hasNext() = hasNext
override fun next(): LogPriority {
val current = next
if (current == end) {
if (!hasNext) {
throw NoSuchElementException()
}
hasNext = false
} else {
next = LogPriority.fromInt(next.toInt() + step)
}
return current
}
}
class LogPriorityProgression(
override val start: LogPriority,
override val endInclusive: LogPriority,
private val step: Int = 1
) : Iterable<LogPriority>, ClosedRange<LogPriority> {
override fun iterator(): Iterator<LogPriority> {
return LogPriorityIterator(start, endInclusive, step)
}
}
infix fun downTo(to: LogPriority) = LogPriorityProgression(this, to, -1)
operator fun rangeTo(to: LogPriority) = LogPriorityProgression(this, to)
companion object {
fun fromByte(byte: Byte): LogPriority = fromInt(byte.toInt())
fun fromInt(int: Int) = when (int) {
0x1 -> DEFAULT
0x2 -> VERBOSE
0x3 -> DEBUG
0x4 -> INFO
0x5 -> WARN
0x6 -> ERROR
0x7 -> FATAL
0x8 -> SILENT
else -> UNKNOWN
}
}
fun toInt() = when (this) {
DEFAULT -> 0x1
VERBOSE -> 0x2
DEBUG -> 0x3
INFO -> 0x4
WARN -> 0x5
ERROR -> 0x6
FATAL -> 0x7
SILENT -> 0x8
else -> 0x0
}
fun toChar(): Char {
return when (this) {
VERBOSE -> 'V'
DEBUG -> 'D'
INFO -> 'I'
WARN -> 'W'
ERROR -> 'E'
FATAL -> 'F'
SILENT -> 'S'
else -> '?'
}
}
}
/**
* Represents a log entry from logcat.
*/
data class LogLine(
val process: ProcessInformation,
val time: Instant,
val logId: Int,
val priority: LogPriority,
val tag: String,
val message: String
) {
companion object {
private enum class EntryVersion {
V3, V4
}
private fun headerSizeToVersion(size: UShort) = when (size.toInt()) {
0x18 -> EntryVersion.V3
0x1c -> EntryVersion.V4
else -> throw IOException("LogLine::fromInputStream: unknown format for (headerSize = $size)")
}
fun fromBufferedSource(source: BufferedSource): LogLine {
/*
// from android <liblog/include/log/log_read.h>
// there are multiple logger entry formats
// use the header_size to determine the one you have
struct logger_entry_v3 {
uint16_t len; // length of the payload
uint16_t hdr_size; // sizeof(struct logger_entry_v3)
int32_t pid; // generating process's pid
int32_t tid; // generating process's tid
int32_t sec; // seconds since Epoch
int32_t nsec; // nanoseconds
uint32_t lid; // log id of the payload
}
struct logger_entry_v4 {
uint16_t len; // length of the payload
uint16_t hdr_size; // sizeof(struct logger_entry_v4 = 28)
int32_t pid; // generating process's pid
uint32_t tid; // generating process's tid
uint32_t sec; // seconds since Epoch
uint32_t nsec; // nanoseconds
uint32_t lid; // log id of the payload, bottom 4 bits currently
uint32_t uid; // generating process's uid
};
*/
/* val payloadLength = */ source.readShortLe()
val headerSize = source.readShortLe().toUShort()
val entryVersion = headerSizeToVersion(headerSize)
val pid = source.readIntLe()
val tid = source.readIntLe()
val sec = source.readIntLe().toLong()
val nSec = source.readIntLe()
val lid = source.readIntLe()
val uid = if (entryVersion == EntryVersion.V4)
source.readIntLe() else 0
val processInformation = ProcessInformation(pid, tid, uid)
val time = Instant.fromEpochSeconds(sec, nSec)
val priorityByte = source.readByte()
val priority = LogPriority.fromByte(priorityByte)
val tag = source.readCString()
val message = source.readCString()
return LogLine(
process = processInformation,
priority = priority,
time = time,
logId = lid,
tag = tag,
message = message
)
}
fun showException(exception: Exception) = LogLine(
process = ProcessInformation(0, 0, 0),
time = Clock.System.now(),
logId = 0,
priority = LogPriority.FATAL,
tag = "GeodeLauncher",
message = "Failed to parse log entry with ${exception.stackTraceToString()}"
)
}
val identifier = time.toJavaInstant()
val messageTrimmed by lazy { this.message.trim() }
val formattedTime by lazy { this.time.toLocalDateTime(TimeZone.currentSystemDefault()) }
val asSimpleString by lazy {
"$formattedTime [${this.priority.toChar()}/${this.tag}]: ${this.messageTrimmed}"
}
}
@@ -0,0 +1,150 @@
package com.geode.launcher.log
import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import okio.buffer
import okio.source
import java.io.EOFException
import java.io.IOException
/**
* ViewModel to manage log output from logcat.
* Currently, it loads the list of logs at init with no ability to read additional logs.
*/
class LogViewModel: ViewModel() {
// the original version of this code did support streaming btw
// the states were way too annoying to manage across multiple threads
// yay!
private val _lineState = mutableStateListOf<LogLine>()
val lineState = _lineState
private val _isLoading = MutableStateFlow(false)
val isLoading = _isLoading.asStateFlow()
private var failedToLoad = false
private var logJob: Job? = null
var filterCrashes = false
private set
// skip debug/verbose lines by default
var logLevel = LogPriority.INFO
set(value) {
field = value
_lineState.clear()
loadLogs()
}
fun toggleCrashBuffer() {
filterCrashes = !filterCrashes
_lineState.clear()
loadLogs()
}
private suspend fun logOutput(): List<LogLine> {
val logLines = ArrayList<LogLine>()
// -B = binary format, -d = dump logs
val logCommand = if (filterCrashes) "logcat -b crash -B -d"
else "logcat -B -d"
val logProcess = try {
Runtime.getRuntime().exec(logCommand)
} catch (ioe: IOException) {
ioe.printStackTrace()
logLines += LogLine.showException(ioe)
failedToLoad = true
return logLines
}
val logSource = logProcess.inputStream.source().buffer()
try {
coroutineScope {
// this runs until the stream is exhausted
while (true) {
val line = LogLine.fromBufferedSource(logSource)
if (line.priority >= logLevel) {
logLines += line
}
yield()
}
}
} catch (_: EOFException) {
// ignore, end of file reached
} catch (e: Exception) {
e.printStackTrace()
logLines += LogLine.showException(e)
// technically it maybe didn't completely fail...
failedToLoad = true
}
return logLines
}
fun clearLogs() {
logJob?.cancel()
viewModelScope.launch(Dispatchers.IO) {
// -c = clear
Runtime.getRuntime().exec("logcat -c")
.waitFor()
}
_lineState.clear()
}
private fun dumpLogcatText(): String {
val logCommand = if (filterCrashes) "logcat -b crash -d"
else "logcat -d"
val logProcess = try {
Runtime.getRuntime().exec(logCommand)
} catch (ioe: IOException) {
ioe.printStackTrace()
return ioe.stackTraceToString()
}
return logProcess.inputStream.bufferedReader().readText()
}
fun getLogData(): String = if (failedToLoad) {
dumpLogcatText()
} else {
_lineState.joinToString("\n") { it.asSimpleString }
}
private fun loadLogs() {
_isLoading.value = true
logJob?.cancel()
logJob = viewModelScope.launch(Dispatchers.IO) {
val lines = logOutput()
_lineState.addAll(lines)
_isLoading.value = false
logJob = null
}
}
init {
loadLogs()
}
}
@@ -79,24 +79,6 @@ fun GeodeLauncherTheme(
else -> LightColorScheme else -> LightColorScheme
} }
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
with ((view.context as Activity).window) {
WindowCompat.setDecorFitsSystemWindows(this, false)
statusBarColor = Color.Transparent.toArgb()
navigationBarColor = Color.Transparent.toArgb()
WindowCompat.getInsetsController(this, view).apply {
isAppearanceLightStatusBars = theme == Theme.LIGHT
isAppearanceLightNavigationBars = theme == Theme.LIGHT
}
}
}
}
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = Typography, typography = Typography,
@@ -1,4 +1,4 @@
package com.geode.launcher.api package com.geode.launcher.updater
import com.geode.launcher.utils.LaunchUtils import com.geode.launcher.utils.LaunchUtils
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
@@ -23,7 +23,8 @@ class Release(
val tagName: String, val tagName: String,
val createdAt: Instant, val createdAt: Instant,
val publishedAt: Instant, val publishedAt: Instant,
val assets: List<Asset> val assets: List<Asset>,
val htmlUrl: String
) { ) {
fun getDescription(): String { fun getDescription(): String {
if (tagName == "nightly") { if (tagName == "nightly") {
@@ -42,7 +43,7 @@ class Release(
return createdAt.epochSeconds return createdAt.epochSeconds
} }
fun getAndroidDownload(): Asset? { fun getGeodeDownload(): Asset? {
// try to find an asset that matches the architecture first // try to find an asset that matches the architecture first
val platform = LaunchUtils.platformName val platform = LaunchUtils.platformName
@@ -51,4 +52,22 @@ class Release(
it.name.endsWith(releaseSuffix) it.name.endsWith(releaseSuffix)
} }
} }
fun getLauncherDownload(): Asset? {
val platform = LaunchUtils.platformName
val use32BitPlatform = platform == "android32"
return assets.find { asset ->
/* you know it's good when you pull out the truth table
* u32bp | contains | found
* 1 | 1 | 1
* 0 | 1 | 0
* 1 | 0 | 0
* 0 | 0 | 1
* surprise! it's an xnor
*/
(asset.name.contains("android32")) == use32BitPlatform
}
}
} }
@@ -1,9 +1,11 @@
package com.geode.launcher.utils package com.geode.launcher.updater
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import com.geode.launcher.api.Release import com.geode.launcher.BuildConfig
import com.geode.launcher.api.ReleaseRepository import com.geode.launcher.utils.DownloadUtils
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
@@ -15,6 +17,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.Cache import okhttp3.Cache
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okio.buffer
import okio.source
import java.io.File import java.io.File
import java.io.InterruptedIOException import java.io.InterruptedIOException
@@ -30,7 +34,7 @@ class ReleaseManager private constructor(
private lateinit var managerInstance: ReleaseManager private lateinit var managerInstance: ReleaseManager
fun get(context: Context): ReleaseManager { fun get(context: Context): ReleaseManager {
if (!::managerInstance.isInitialized) { if (!Companion::managerInstance.isInitialized) {
val applicationContext = context.applicationContext val applicationContext = context.applicationContext
val httpClient = OkHttpClient.Builder() val httpClient = OkHttpClient.Builder()
@@ -74,6 +78,9 @@ class ReleaseManager private constructor(
val isInUpdate: Boolean val isInUpdate: Boolean
get() = _uiState.value !is ReleaseManagerState.Failure && _uiState.value !is ReleaseManagerState.Finished get() = _uiState.value !is ReleaseManagerState.Failure && _uiState.value !is ReleaseManagerState.Finished
private val _availableLauncherUpdate = MutableStateFlow<Release?>(null)
val availableLauncherUpdate = _availableLauncherUpdate.asStateFlow()
private fun sendError(e: Exception) { private fun sendError(e: Exception) {
_uiState.value = ReleaseManagerState.Failure(e) _uiState.value = ReleaseManagerState.Failure(e)
@@ -84,21 +91,24 @@ class ReleaseManager private constructor(
} }
} }
fun dismissLauncherUpdate() {
val update = availableLauncherUpdate.value ?: return
val sharedPreferences = PreferenceUtils.get(applicationContext)
sharedPreferences.setString(PreferenceUtils.Key.LAST_DISMISSED_UPDATE, update.tagName)
_availableLauncherUpdate.value = null
}
private suspend fun getLatestRelease(): Release? { private suspend fun getLatestRelease(): Release? {
val sharedPreferences = PreferenceUtils.get(applicationContext) val sharedPreferences = PreferenceUtils.get(applicationContext)
val useNightly = sharedPreferences.getBoolean(PreferenceUtils.Key.RELEASE_CHANNEL) val useNightly = sharedPreferences.getBoolean(PreferenceUtils.Key.RELEASE_CHANNEL)
val latestRelease = if (useNightly) { return releaseRepository.getLatestGeodeRelease(useNightly)
releaseRepository.getLatestNightlyRelease()
} else {
releaseRepository.getLatestRelease()
}
return latestRelease
} }
private suspend fun performUpdate(release: Release) { private suspend fun performUpdate(release: Release) {
val releaseAsset = release.getAndroidDownload() val releaseAsset = release.getGeodeDownload()
if (releaseAsset == null) { if (releaseAsset == null) {
val noAssetException = Exception("missing Android download") val noAssetException = Exception("missing Android download")
_uiState.value = ReleaseManagerState.Failure(noAssetException) _uiState.value = ReleaseManagerState.Failure(noAssetException)
@@ -110,7 +120,10 @@ class ReleaseManager private constructor(
_uiState.value = ReleaseManagerState.InDownload(0, releaseAsset.size.toLong()) _uiState.value = ReleaseManagerState.InDownload(0, releaseAsset.size.toLong())
try { try {
val fileStream = DownloadUtils.downloadStream(httpClient, releaseAsset.browserDownloadUrl) { progress, outOf -> val fileStream = DownloadUtils.downloadStream(
httpClient,
releaseAsset.browserDownloadUrl
) { progress, outOf ->
_uiState.value = ReleaseManagerState.InDownload(progress, outOf) _uiState.value = ReleaseManagerState.InDownload(progress, outOf)
} }
@@ -145,13 +158,30 @@ class ReleaseManager private constructor(
} }
val sharedPreferences = PreferenceUtils.get(applicationContext) val sharedPreferences = PreferenceUtils.get(applicationContext)
val originalFileHash = sharedPreferences.getString(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED)
?: return false
val fileLastModified = sharedPreferences.getLong(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED) val currentFileHash = computeFileHash(geodeFile)
if (fileLastModified == 0L) {
return false return originalFileHash != currentFileHash
} }
return fileLastModified != geodeFile.lastModified() private fun checkLauncherUpdate(launcherUpdate: Release) {
if (BuildConfig.DEBUG) {
println("ignoring launcher update check - in debug mode!")
return
}
val sharedPreferences = PreferenceUtils.get(applicationContext)
val lastDismissedVersion = sharedPreferences.getString(PreferenceUtils.Key.LAST_DISMISSED_UPDATE)
if (!lastDismissedVersion.isNullOrEmpty() && launcherUpdate.tagName == lastDismissedVersion) {
return
}
if (launcherUpdate.tagName != BuildConfig.VERSION_NAME) {
_availableLauncherUpdate.value = launcherUpdate
}
} }
private suspend fun checkForNewRelease(allowOverwriting: Boolean = false) { private suspend fun checkForNewRelease(allowOverwriting: Boolean = false) {
@@ -162,6 +192,17 @@ class ReleaseManager private constructor(
return return
} }
val launcherRelease = try {
releaseRepository.getLatestLauncherRelease()
} catch (e: Exception) {
sendError(e)
return
}
if (launcherRelease != null) {
checkLauncherUpdate(launcherRelease)
}
if (release == null) { if (release == null) {
_uiState.value = ReleaseManagerState.Finished() _uiState.value = ReleaseManagerState.Finished()
return return
@@ -176,7 +217,7 @@ class ReleaseManager private constructor(
val geodeFile = getGeodeOutputPath() val geodeFile = getGeodeOutputPath()
// check if an update is needed // check if an update is needed
if (latestVersion <= currentVersion && geodeFile.exists()) { if (latestVersion == currentVersion && geodeFile.exists()) {
_uiState.value = ReleaseManagerState.Finished() _uiState.value = ReleaseManagerState.Finished()
return return
} }
@@ -203,13 +244,16 @@ class ReleaseManager private constructor(
release.getDescriptor() release.getDescriptor()
) )
// store hash as modified time is unreliable (don't know why)
val outputFile = getGeodeOutputPath() val outputFile = getGeodeOutputPath()
sharedPreferences.setLong( val fileHash = computeFileHash(outputFile)
PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED,
outputFile.lastModified() sharedPreferences.setString(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED, fileHash)
)
} }
private fun computeFileHash(file: File): String =
file.source().buffer().readByteString().md5().hex()
private fun getGeodeOutputPath(): File { private fun getGeodeOutputPath(): File {
val geodeName = LaunchUtils.geodeFilename val geodeName = LaunchUtils.geodeFilename
val geodeDirectory = LaunchUtils.getBaseDirectory(applicationContext) val geodeDirectory = LaunchUtils.getBaseDirectory(applicationContext)
@@ -1,4 +1,4 @@
package com.geode.launcher.api package com.geode.launcher.updater
import com.geode.launcher.utils.DownloadUtils.executeCoroutine import com.geode.launcher.utils.DownloadUtils.executeCoroutine
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
@@ -12,20 +12,24 @@ import java.net.URL
class ReleaseRepository(private val httpClient: OkHttpClient) { class ReleaseRepository(private val httpClient: OkHttpClient) {
companion object { companion object {
private const val GITHUB_API_BASE = "https://api.github.com/repos/geode-sdk/geode" private const val GITHUB_API_BASE = "https://api.github.com"
private const val GITHUB_API_HEADER = "X-GitHub-Api-Version" private const val GITHUB_API_HEADER = "X-GitHub-Api-Version"
private const val GITHUB_API_VERSION = "2022-11-28" private const val GITHUB_API_VERSION = "2022-11-28"
} }
suspend fun getLatestNightlyRelease(): Release? { suspend fun getLatestLauncherRelease(): Release? {
val nightlyPath = "$GITHUB_API_BASE/releases/tags/nightly" val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/android-launcher/releases/latest"
val url = URL(nightlyPath)
val url = URL(releasePath)
return getReleaseByUrl(url) return getReleaseByUrl(url)
} }
suspend fun getLatestRelease(): Release? { suspend fun getLatestGeodeRelease(isNightly: Boolean = false): Release? {
val releasePath = "$GITHUB_API_BASE/releases/latest" val geodeBaseUrl = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases"
val releasePath = if (isNightly) "$geodeBaseUrl/tags/nightly"
else "$geodeBaseUrl/latest"
val url = URL(releasePath) val url = URL(releasePath)
return getReleaseByUrl(url) return getReleaseByUrl(url)
@@ -1,4 +1,4 @@
package com.geode.launcher.api package com.geode.launcher.updater
import android.app.Application import android.app.Application
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@@ -7,7 +7,7 @@ import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.AP
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.geode.launcher.utils.ReleaseManager import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -53,6 +53,8 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
val isInUpdate val isInUpdate
get() = ReleaseManager.get(application).isInUpdate get() = ReleaseManager.get(application).isInUpdate
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdate
var hasPerformedCheck = false var hasPerformedCheck = false
private set private set
@@ -78,7 +80,7 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
fun useGlobalCheckState() { fun useGlobalCheckState() {
hasPerformedCheck = true hasPerformedCheck = true
viewModelScope.launch { viewModelScope.launch(Dispatchers.IO) {
val releaseFlow = ReleaseManager.get(application) val releaseFlow = ReleaseManager.get(application)
.uiState .uiState
@@ -89,11 +91,17 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
fun runReleaseCheck(isManual: Boolean = false) { fun runReleaseCheck(isManual: Boolean = false) {
hasPerformedCheck = true hasPerformedCheck = true
viewModelScope.launch { viewModelScope.launch(Dispatchers.IO) {
val releaseFlow = ReleaseManager.get(application) val releaseFlow = ReleaseManager.get(application)
.checkForUpdates(isManual) .checkForUpdates(isManual)
syncUiState(releaseFlow) syncUiState(releaseFlow)
} }
} }
fun dismissLauncherUpdate() {
viewModelScope.launch(Dispatchers.IO) {
ReleaseManager.get(application).dismissLauncherUpdate()
}
}
} }
@@ -13,8 +13,8 @@ object Constants {
@SuppressLint("SdCardPath") @SuppressLint("SdCardPath")
const val GJ_DATA_DIR = "/data/data/${PACKAGE_NAME}" const val GJ_DATA_DIR = "/data/data/${PACKAGE_NAME}"
const val SUPPORTED_VERSION_CODE = 37L const val SUPPORTED_VERSION_CODE = 38L
const val SUPPORTED_VERSION_STRING = "2.2.13" const val SUPPORTED_VERSION_STRING = "2.205"
const val LAUNCHER_KEY_RETURN_TITLE = "return_title" const val LAUNCHER_KEY_RETURN_TITLE = "return_title"
const val LAUNCHER_KEY_RETURN_MESSAGE = "return_message" const val LAUNCHER_KEY_RETURN_MESSAGE = "return_message"
@@ -116,19 +116,13 @@ object DownloadUtils {
fun copyFile(inputStream: InputStream, outputStream: OutputStream) { fun copyFile(inputStream: InputStream, outputStream: OutputStream) {
// gotta love copying // gotta love copying
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
FileUtils.copy(inputStream, outputStream) inputStream.use { input -> outputStream.use { output ->
FileUtils.copy(input, output)
}}
} else { } else {
inputStream.use { input -> inputStream.use { input -> outputStream.use { output ->
outputStream.use { output -> input.copyTo(output)
val buffer = ByteArray(4 * 1024) }}
while (true) {
val byteCount = input.read(buffer)
if (byteCount < 0) break
output.write(buffer, 0, byteCount)
}
output.flush()
}
}
} }
} }
} }
@@ -0,0 +1,196 @@
package com.geode.launcher.utils
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.Environment
import android.provider.DocumentsContract
import android.provider.MediaStore
import java.io.File
class FileUtils {
// copied from https://stackoverflow.com/questions/17546101/get-real-path-for-uri-android
// i am actually very lazy to move this to a separate class
// hello zmx i moved it
companion object {
fun getRealPathFromURI(context: Context, uri: Uri): String? {
when {
// DocumentProvider
DocumentsContract.isDocumentUri(context, uri) -> {
when {
// ExternalStorageProvider
isExternalStorageDocument(uri) -> {
return handleExternalStorageDocument(context, uri)
}
isDownloadsDocument(uri) -> {
return handleDownloadsDocument(context, uri)
}
isMediaDocument(uri) -> {
return handleMediaDocument(context, uri)
}
// TODO: add the geode provider
}
}
"content".equals(uri.scheme, ignoreCase = true) -> {
// Return the remote address
if (isGooglePhotosUri(uri)) return uri.lastPathSegment
// This happened with a directory
if (isExternalStorageDocument(uri)) {
return handleExternalStorageDocument(context, uri)
}
return getDataColumn(context, uri, null, null)
}
"file".equals(uri.scheme, ignoreCase = true) -> {
return uri.path
}
}
return null
}
private fun handleMediaDocument(context: Context, uri: Uri): String? {
val docId = DocumentsContract.getDocumentId(uri)
val split = docId.split(":").toTypedArray()
val type = split[0]
var contentUri: Uri? = null
when (type) {
"image" -> {
contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
}
"video" -> {
contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
}
"audio" -> {
contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
}
}
val selection = "_id=?"
val selectionArgs = arrayOf(split[1])
return getDataColumn(
context,
contentUri,
selection,
selectionArgs
)
}
private fun handleDownloadsDocument(context: Context, uri: Uri): String? {
val fileName = getFilePath(context, uri)
if (fileName != null) {
return Environment.getExternalStorageDirectory().toString() + "/Download/" + fileName
}
var id = DocumentsContract.getDocumentId(uri)
if (id.startsWith("raw:")) {
id = id.replaceFirst("raw:".toRegex(), "")
val file = File(id)
if (file.exists()) return id
}
val contentUri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), java.lang.Long.valueOf(id))
return getDataColumn(context, contentUri, null, null)
}
private fun handleExternalStorageDocument(context: Context, uri: Uri): String {
val docId: String = if (DocumentsContract.isDocumentUri(context, uri)) {
DocumentsContract.getDocumentId(uri)
} else {
// please please please PLEASE let this be the only other case
DocumentsContract.getTreeDocumentId(uri)
}
val split = docId.split(":").toTypedArray()
val type = split[0]
// This is for checking Main Memory
return if ("primary".equals(type, ignoreCase = true)) {
if (split.size > 1) {
Environment.getExternalStorageDirectory().toString() + "/" + split[1]
} else {
Environment.getExternalStorageDirectory().toString() + "/"
}
// This is for checking SD Card
} else {
"storage" + "/" + docId.replace(":", "/")
}
}
private fun getFilePath(context: Context, uri: Uri?): String? {
var cursor: Cursor? = null
val projection = arrayOf(
MediaStore.MediaColumns.DISPLAY_NAME
)
try {
if (uri == null) return null
cursor = context.contentResolver.query(
uri, projection, null, null,
null
)
if (cursor != null && cursor.moveToFirst()) {
val index = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)
val data = cursor.getString(index)
cursor.close()
return data
}
} catch (e: Exception) {
cursor?.close()
} finally {
cursor?.close()
}
return null
}
private fun getDataColumn(context: Context, uri: Uri?, selection: String?,
selectionArgs: Array<String>?): String? {
var cursor: Cursor? = null
val column = "_data"
val projection = arrayOf(
column
)
try {
if (uri == null) return null
cursor = context.contentResolver.query(
uri, projection, selection, selectionArgs,
null
)
if (cursor != null && cursor.moveToFirst()) {
val index = cursor.getColumnIndexOrThrow(column)
val data = cursor.getString(index)
cursor.close()
return data
}
} catch (e: Exception) {
cursor?.close()
}
return null
}
/**
* @param uri The Uri to check.
* @return Whether the Uri authority is ExternalStorageProvider.
*/
private fun isExternalStorageDocument(uri: Uri): Boolean {
return "com.android.externalstorage.documents" == uri.authority
}
/**
* @param uri The Uri to check.
* @return Whether the Uri authority is DownloadsProvider.
*/
private fun isDownloadsDocument(uri: Uri): Boolean {
return "com.android.providers.downloads.documents" == uri.authority
}
/**
* @param uri The Uri to check.
* @return Whether the Uri authority is MediaProvider.
*/
private fun isMediaDocument(uri: Uri): Boolean {
return "com.android.providers.media.documents" == uri.authority
}
/**
* @param uri The Uri to check.
* @return Whether the Uri authority is Google Photos.
*/
private fun isGooglePhotosUri(uri: Uri): Boolean {
return "com.google.android.apps.photos.content" == uri.authority
}
}
}
@@ -1,62 +1,77 @@
package com.geode.launcher.utils package com.geode.launcher.utils
import android.app.Activity import android.Manifest
import android.content.ActivityNotFoundException
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.ContentUris
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.database.Cursor import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.MediaStore import android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION
import android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI
import android.provider.OpenableColumns
import android.util.Log import android.util.Log
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.FileProvider import androidx.core.content.ContextCompat
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.geode.launcher.BuildConfig
import com.geode.launcher.R
import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.activityresult.GeodeOpenFileActivityResult
import com.geode.launcher.activityresult.GeodeOpenFilesActivityResult
import com.geode.launcher.activityresult.GeodeSaveFileActivityResult
import java.io.File import java.io.File
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.net.URI
import kotlin.system.exitProcess import kotlin.system.exitProcess
@Suppress("unused") @Suppress("unused", "KotlinJniMissingFunction")
object GeodeUtils { object GeodeUtils {
private lateinit var activity: WeakReference<AppCompatActivity> private lateinit var activity: WeakReference<AppCompatActivity>
private lateinit var fileResultLauncher: ActivityResultLauncher<Intent> private lateinit var openFileResultLauncher: ActivityResultLauncher<GeodeOpenFileActivityResult.OpenFileParams>
private lateinit var filesResultLauncher: ActivityResultLauncher<Intent> private lateinit var openDirectoryResultLauncher: ActivityResultLauncher<Uri?>
private lateinit var openFilesResultLauncher: ActivityResultLauncher<GeodeOpenFilesActivityResult.OpenFileParams>
private lateinit var saveFileResultLauncher: ActivityResultLauncher<GeodeSaveFileActivityResult.SaveFileParams>
private lateinit var requestPermissionLauncher: ActivityResultLauncher<String>
private lateinit var internalRequestPermissionsLauncher: ActivityResultLauncher<Array<String>>
private lateinit var internalRequestAllFilesLauncher: ActivityResultLauncher<Intent>
private var afterRequestPermissions: (() -> Unit)? = null
private var afterRequestPermissionsFailure: (() -> Unit)? = null
fun setContext(activity: AppCompatActivity) { fun setContext(activity: AppCompatActivity) {
this.activity = WeakReference(activity) this.activity = WeakReference(activity)
fileResultLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> openFileResultLauncher = activity.registerForActivityResult(GeodeOpenFileActivityResult()) { uri ->
if (result.resultCode == Activity.RESULT_OK) {
val data = result.data
if (data != null) {
val uri = data.data
if (uri != null) { if (uri != null) {
val path = getRealPathFromURI(activity, uri) val path = FileUtils.getRealPathFromURI(activity, uri)
if (path != null) { if (path != null) {
selectFileCallback(path) selectFileCallback(path)
return@registerForActivityResult return@registerForActivityResult
} }
} }
failedCallback()
} }
openDirectoryResultLauncher = activity.registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) {
if (it != null) {
val path = FileUtils.getRealPathFromURI(activity, it)
if (path != null)
selectFileCallback(path)
return@registerForActivityResult
} }
failedCallback() failedCallback()
} }
filesResultLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> openFilesResultLauncher = activity.registerForActivityResult(GeodeOpenFilesActivityResult()) { result ->
if (result.resultCode == Activity.RESULT_OK) { if (result.isEmpty()) {
val data = result.data failedCallback()
if (data != null) { return@registerForActivityResult
val clipData = data.clipData }
if (clipData != null) { val paths: Array<String> = Array(result.size) {"n = $it"}
val paths = Array(clipData.itemCount) { "" } for (i in result.indices) {
for (i in 0 until clipData.itemCount) { val path = FileUtils.getRealPathFromURI(activity, result[i])
val path = getRealPathFromURI(activity, clipData.getItemAt(i).uri)
if (path != null) { if (path != null) {
paths[i] = path paths[i] = path
} }
@@ -64,24 +79,56 @@ object GeodeUtils {
selectFilesCallback(paths) selectFilesCallback(paths)
return@registerForActivityResult return@registerForActivityResult
} }
saveFileResultLauncher = activity.registerForActivityResult(GeodeSaveFileActivityResult()) { uri ->
if (uri != null) {
val path = FileUtils.getRealPathFromURI(activity, uri)
if (path != null) {
selectFileCallback(path)
return@registerForActivityResult
} }
} }
failedCallback() failedCallback()
} }
requestPermissionLauncher = activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
permissionCallback(isGranted)
}
internalRequestPermissionsLauncher = activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
val isGranted = permissions.values.all { it }
if (isGranted) {
afterRequestPermissions?.invoke()
} else {
Toast.makeText(activity, R.string.missing_permissions, Toast.LENGTH_SHORT)
.show()
afterRequestPermissionsFailure?.invoke()
}
}
// only necessary on newer android versions
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.S_V2) {
internalRequestAllFilesLauncher = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { _ ->
if (Environment.isExternalStorageManager()) {
afterRequestPermissions?.invoke()
} else {
Toast.makeText(activity, R.string.missing_permissions, Toast.LENGTH_SHORT)
.show()
afterRequestPermissionsFailure?.invoke()
}
}
}
} }
@JvmStatic @JvmStatic
fun getLogcatCrashBuffer(): String { fun getLogcatCrashBuffer(): String {
try { return try {
val logcatProcess = Runtime.getRuntime().exec("logcat -v brief -b crash -d") val logcatProcess = Runtime.getRuntime().exec("logcat -v brief -b crash -d")
val logcatOutput = logcatProcess.inputStream.bufferedReader().readText() logcatProcess.inputStream.bufferedReader().readText()
} catch (e: Exception) {
return logcatOutput
}
catch (e: Exception) {
Log.e("Geode", "Failed to get logcat crash buffer", e) Log.e("Geode", "Failed to get logcat crash buffer", e)
return "" ""
} }
} }
@@ -111,125 +158,202 @@ object GeodeUtils {
activity.get()?.run { activity.get()?.run {
packageManager.getLaunchIntentForPackage(packageName)?.also { packageManager.getLaunchIntentForPackage(packageName)?.also {
val mainIntent = Intent.makeRestartActivityTask(it.component) val mainIntent = Intent.makeRestartActivityTask(it.component)
mainIntent.putExtra("restarted", true); mainIntent.putExtra("restarted", true)
startActivity(mainIntent) startActivity(mainIntent)
exitProcess(0) exitProcess(0)
} }
} }
} }
// TODO As of now this is unused
@JvmStatic @JvmStatic
fun openFolder(path: String): Boolean { fun openFolder(path: String): Boolean {
activity.get()?.run { val context = activity.get()!!
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.addCategory(Intent.CATEGORY_OPENABLE)
DocumentFile.fromFile(File(path)).also {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, it.uri)
}
intent.setType("*/*")
if (intent.resolveActivity(packageManager) != null) { val pathFile = File(path)
startActivity(intent) val baseDirectory = LaunchUtils.getBaseDirectory(context)
return true val isInternalPath = pathFile.startsWith(baseDirectory)
val intent = if (isInternalPath) {
// TODO: figure out how to get this to point to the path it should be pointing at
// (the best i got was pointing at a file)
// val relativePath = pathFile.relativeTo(baseDirectory)
Intent(Intent.ACTION_VIEW).apply {
data = DocumentsContract.buildRootUri(
"${context.packageName}.user", UserDirectoryProvider.ROOT
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
)
} }
} } else {
return false Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addCategory(Intent.CATEGORY_OPENABLE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val documentFile = DocumentFile.fromFile(File(path))
putExtra(DocumentsContract.EXTRA_INITIAL_URI, documentFile.uri)
} }
external fun selectFileCallback(path: String) type = "*/*"
}
}
external fun selectFilesCallback(paths: Array<String>) return try {
context.startActivity(intent)
true
} catch (_: ActivityNotFoundException) {
false
}
}
external fun failedCallback() private external fun selectFileCallback(path: String)
private external fun selectFilesCallback(paths: Array<String>)
private external fun failedCallback()
private fun checkForFilePermissions(onSuccess: () -> Unit, onFailure: () -> Unit) {
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.S_V2) {
if (Environment.isExternalStorageManager()) {
onSuccess()
} else {
val intent = Intent(
ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.fromParts("package", BuildConfig.APPLICATION_ID, null)
)
internalRequestAllFilesLauncher.launch(intent)
afterRequestPermissions = onSuccess
afterRequestPermissionsFailure = onFailure
}
} else {
val permissions = listOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE
)
val context = activity.get()!!
val needsPermissions = permissions.filter {
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
}
if (needsPermissions.isNotEmpty()) {
internalRequestPermissionsLauncher.launch(needsPermissions.toTypedArray())
afterRequestPermissions = onSuccess
afterRequestPermissionsFailure = onFailure
} else {
onSuccess()
}
}
}
@JvmStatic @JvmStatic
fun selectFile(path: String): Boolean { fun selectFile(path: String): Boolean {
activity.get()?.run { var uri: Uri?
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.addCategory(Intent.CATEGORY_OPENABLE)
DocumentFile.fromFile(File(path)).also { DocumentFile.fromFile(File(path)).also {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, it.uri) uri = it.uri
} }
intent.setType("*/*")
if (intent.resolveActivity(packageManager) != null) { return try {
print("Geode Selectfile") checkForFilePermissions(
fileResultLauncher.launch(intent) onSuccess = {
print("Geode launched ") openFileResultLauncher.launch(GeodeOpenFileActivityResult.OpenFileParams(arrayOf("*/*"), uri))
return true },
onFailure = { failedCallback() }
)
true
} catch (e: ActivityNotFoundException) {
false
} }
} }
return false
}
@JvmStatic @JvmStatic
fun selectFiles(path: String): Boolean { fun selectFiles(path: String): Boolean {
activity.get()?.run { var uri: Uri?
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.addCategory(Intent.CATEGORY_OPENABLE)
DocumentFile.fromFile(File(path)).also { DocumentFile.fromFile(File(path)).also {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, it.uri) uri = it.uri
} }
intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
intent.setType("*/*")
if (intent.resolveActivity(packageManager) != null) { return try {
filesResultLauncher.launch(intent) checkForFilePermissions(
return true onSuccess = {
openFilesResultLauncher.launch(GeodeOpenFilesActivityResult.OpenFileParams(arrayOf("*/*"), uri))
},
onFailure = { failedCallback() }
)
true
} catch (e: ActivityNotFoundException) {
false
} }
} }
return false
}
@JvmStatic @JvmStatic
fun selectFolder(path: String): Boolean { fun selectFolder(path: String): Boolean {
activity.get()?.run { var uri: Uri?
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.addCategory(Intent.CATEGORY_OPENABLE)
DocumentFile.fromFile(File(path)).also { DocumentFile.fromFile(File(path)).also {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, it.uri) uri = it.uri
} }
intent.setType("*/*")
if (intent.resolveActivity(packageManager) != null) { return try {
fileResultLauncher.launch(intent) checkForFilePermissions(
return true onSuccess = {
openDirectoryResultLauncher.launch(uri)
},
onFailure = { failedCallback() }
)
true
} catch (e: ActivityNotFoundException) {
false
} }
}
return false
} }
@JvmStatic @JvmStatic
fun createFile(path: String): Boolean { fun createFile(path: String): Boolean {
activity.get()?.run { var uri: Uri?
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.addCategory(Intent.CATEGORY_OPENABLE)
DocumentFile.fromFile(File(path)).also { DocumentFile.fromFile(File(path)).also {
intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, it.uri) uri = it.uri
} }
intent.setType("*/*")
if (intent.resolveActivity(packageManager) != null) { return try {
fileResultLauncher.launch(intent) checkForFilePermissions(
return true onSuccess = {
saveFileResultLauncher.launch(GeodeSaveFileActivityResult.SaveFileParams(null, uri))
},
onFailure = { failedCallback() }
)
true
} catch (e: ActivityNotFoundException) {
false
} }
} }
return false
}
@JvmStatic @JvmStatic
fun getBaseDirectory(): String { fun getBaseDirectory(): String {
val activity = activity.get()!! val activity = activity.get()!!
return LaunchUtils.getBaseDirectory(activity).path return LaunchUtils.getBaseDirectory(activity).canonicalPath
}
@JvmStatic
fun getInternalDirectory(): String {
val activity = activity.get()!!
return activity.filesDir.canonicalPath
} }
private val gameVersionMap = mapOf( private val gameVersionMap = mapOf(
37L to "2.200" 37L to "2.200",
38L to "2.205"
) )
@JvmStatic @JvmStatic
@@ -243,155 +367,83 @@ object GeodeUtils {
return "" return ""
} }
// copied from https://stackoverflow.com/questions/17546101/get-real-path-for-uri-android
// i am actually very lazy to move this to a separate class
fun getRealPathFromURI(context: Context, uri: Uri): String? {
when {
// DocumentProvider
DocumentsContract.isDocumentUri(context, uri) -> {
when {
// ExternalStorageProvider
isExternalStorageDocument(uri) -> {
val docId = DocumentsContract.getDocumentId(uri)
val split = docId.split(":").toTypedArray()
val type = split[0]
// This is for checking Main Memory
return if ("primary".equals(type, ignoreCase = true)) {
if (split.size > 1) {
Environment.getExternalStorageDirectory().toString() + "/" + split[1]
} else {
Environment.getExternalStorageDirectory().toString() + "/"
}
// This is for checking SD Card
} else {
"storage" + "/" + docId.replace(":", "/")
}
}
isDownloadsDocument(uri) -> {
val fileName = getFilePath(context, uri)
if (fileName != null) {
return Environment.getExternalStorageDirectory().toString() + "/Download/" + fileName
}
var id = DocumentsContract.getDocumentId(uri)
if (id.startsWith("raw:")) {
id = id.replaceFirst("raw:".toRegex(), "")
val file = File(id)
if (file.exists()) return id
}
val contentUri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), java.lang.Long.valueOf(id))
return getDataColumn(context, contentUri, null, null)
}
isMediaDocument(uri) -> {
val docId = DocumentsContract.getDocumentId(uri)
val split = docId.split(":").toTypedArray()
val type = split[0]
var contentUri: Uri? = null
when (type) {
"image" -> {
contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
}
"video" -> {
contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
}
"audio" -> {
contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
}
}
val selection = "_id=?"
val selectionArgs = arrayOf(split[1])
return getDataColumn(context, contentUri, selection, selectionArgs)
}
// TODO: add the geode provider
}
}
"content".equals(uri.scheme, ignoreCase = true) -> {
// Return the remote address
if (isGooglePhotosUri(uri)) return uri.lastPathSegment
return getDataColumn(context, uri, null, null)
}
"file".equals(uri.scheme, ignoreCase = true) -> {
return uri.path
}
}
return null
}
fun getDataColumn(context: Context, uri: Uri?, selection: String?,
selectionArgs: Array<String>?): String? {
var cursor: Cursor? = null
val column = "_data"
val projection = arrayOf(
column
)
try {
if (uri == null) return null
cursor = context.contentResolver.query(uri, projection, selection, selectionArgs,
null)
if (cursor != null && cursor.moveToFirst()) {
val index = cursor.getColumnIndexOrThrow(column)
return cursor.getString(index)
}
} finally {
cursor?.close()
}
return null
}
fun getFilePath(context: Context, uri: Uri?): String? {
var cursor: Cursor? = null
val projection = arrayOf(
MediaStore.MediaColumns.DISPLAY_NAME
)
try {
if (uri == null) return null
cursor = context.contentResolver.query(uri, projection, null, null,
null)
if (cursor != null && cursor.moveToFirst()) {
val index = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME)
return cursor.getString(index)
}
} finally {
cursor?.close()
}
return null
}
/**
* @param uri The Uri to check.
* @return Whether the Uri authority is ExternalStorageProvider.
*/
fun isExternalStorageDocument(uri: Uri): Boolean {
return "com.android.externalstorage.documents" == uri.authority
}
/**
* @param uri The Uri to check.
* @return Whether the Uri authority is DownloadsProvider.
*/
fun isDownloadsDocument(uri: Uri): Boolean {
return "com.android.providers.downloads.documents" == uri.authority
}
/**
* @param uri The Uri to check.
* @return Whether the Uri authority is MediaProvider.
*/
fun isMediaDocument(uri: Uri): Boolean {
return "com.android.providers.media.documents" == uri.authority
}
/**
* @param uri The Uri to check.
* @return Whether the Uri authority is Google Photos.
*/
fun isGooglePhotosUri(uri: Uri): Boolean {
return "com.google.android.apps.photos.content" == uri.authority
}
fun isGeodeUri(uri: Uri): Boolean { fun isGeodeUri(uri: Uri): Boolean {
return "com.geode.launcher.user" == uri.authority return "com.geode.launcher.user" == uri.authority
} }
private const val INTERNAL_PERMISSION_PREFIX = "geode.permission_internal"
private const val MANAGE_ALL_FILES = "${INTERNAL_PERMISSION_PREFIX}.MANAGE_ALL_FILES"
@JvmStatic
fun getPermissionStatus(permission: String): Boolean {
val context = activity.get() ?: return false
return when (permission) {
MANAGE_ALL_FILES -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S_V2) {
Environment.isExternalStorageManager()
} else {
val permissions = listOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE
)
return permissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
}
else -> ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
}
}
@JvmStatic
fun requestPermission(permission: String) {
if (permission == MANAGE_ALL_FILES) {
// this function handles already having perms for us
checkForFilePermissions(onSuccess = {
permissionCallback(true)
}, onFailure = {
permissionCallback(false)
})
return
}
if (getPermissionStatus(permission)) {
permissionCallback(true)
return
}
try {
requestPermissionLauncher.launch(permission)
} catch (e: ActivityNotFoundException) {
permissionCallback(false)
}
}
private external fun permissionCallback(granted: Boolean)
const val ARGUMENT_SAFE_MODE = "--geode:safe-mode"
private var additionalLaunchArguments = arrayListOf<String>()
fun setAdditionalLaunchArguments(vararg args: String) {
additionalLaunchArguments.addAll(args)
}
fun clearLaunchArguments() = setAdditionalLaunchArguments()
@JvmStatic
fun getLaunchArguments(): String? {
activity.get()?.apply {
val preferences = PreferenceUtils.get(this)
val userArgs = preferences.getString(PreferenceUtils.Key.LAUNCH_ARGUMENTS)
val args = if (!userArgs.isNullOrEmpty()) {
listOf(userArgs) + additionalLaunchArguments
} else additionalLaunchArguments
return args.joinToString(" ")
}
return null
}
} }
@@ -11,10 +11,6 @@ import android.util.Log
import java.io.File import java.io.File
object LaunchUtils { object LaunchUtils {
enum class FailureReason {
NOT_FOUND, ABI_MISMATCH, UNKNOWN
}
fun isGeometryDashInstalled(packageManager: PackageManager): Boolean { fun isGeometryDashInstalled(packageManager: PackageManager): Boolean {
return try { return try {
packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0) packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0)
@@ -40,7 +36,7 @@ object LaunchUtils {
return game.versionName return game.versionName
} }
fun diagnoseLoadErrors(context: Context, packageInfo: PackageInfo): FailureReason { fun detectAbiMismatch(context: Context, packageInfo: PackageInfo, loadException: Error): Boolean {
val abi = applicationArchitecture val abi = applicationArchitecture
val isExtracted = val isExtracted =
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
@@ -50,33 +46,42 @@ object LaunchUtils {
"Geometry Dash metadata:\nSplit sources: $isSplit\nExtracted libraries: $isExtracted\nLauncher architecture: $abi" "Geometry Dash metadata:\nSplit sources: $isSplit\nExtracted libraries: $isExtracted\nLauncher architecture: $abi"
Log.i("GeodeLauncher", metadata) Log.i("GeodeLauncher", metadata)
// easiest check! these messages are hardcoded in bionic/linker/linker_phdr.cpp
if (loadException.message?.contains("32-bit instead of 64-bit") == true) {
return true
}
if (loadException.message?.contains("64-bit instead of 32-bit") == true) {
return true
}
// determine if it can find gd libraries for the opposite architecture // determine if it can find gd libraries for the opposite architecture
val gdBinaryName = "lib${Constants.COCOS_LIB_NAME}.so" val gdBinaryName = "lib${Constants.COCOS_LIB_NAME}.so"
val oppositeArchitecture = if (abi == "arm64-v8a") "armeabi-v7a" else "arm64-v8a" val oppositeArchitecture = if (abi == "arm64-v8a") "armeabi-v7a" else "arm64-v8a"
if (packageInfo.applicationInfo.nativeLibraryDir.contains(oppositeArchitecture)) { // detect issues using native library directory (only works on extracted libraries)
return FailureReason.ABI_MISMATCH val nativeLibraryDir = packageInfo.applicationInfo.nativeLibraryDir
if (nativeLibraryDir.contains(oppositeArchitecture)) {
return true
}
// android has some odd behavior where 32bit libraries get installed to the arm directory instead of armeabi-v7a
// detect it, ig
if (oppositeArchitecture == "armeabi-v7a" && nativeLibraryDir.contains("/lib/arm/")) {
return true
} }
if (!isExtracted) { if (!isExtracted) {
try { try {
context.assets.openNonAssetFd("lib/$oppositeArchitecture/$gdBinaryName") context.assets.openNonAssetFd("lib/$oppositeArchitecture/$gdBinaryName")
return FailureReason.ABI_MISMATCH return true
} catch (_: Exception) { } catch (_: Exception) {
// this is good, actually! // this is good, actually!
} }
} }
// try fetching for its own libraries (only for uncompressed libraries) return false
if (!isExtracted) {
try {
context.assets.openNonAssetFd("lib/$abi/$gdBinaryName")
} catch (_: Exception) {
return FailureReason.NOT_FOUND
}
}
return FailureReason.UNKNOWN
} }
// supposedly CPU_ABI returns the current arch for the running application // supposedly CPU_ABI returns the current arch for the running application
@@ -84,8 +89,9 @@ object LaunchUtils {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
val applicationArchitecture: String = Build.CPU_ABI val applicationArchitecture: String = Build.CPU_ABI
val platformName: String = if (applicationArchitecture == "arm64-v8a") val is64bit = applicationArchitecture == "arm64-v8a"
"android64" else "android32"
val platformName: String = if (is64bit) "android64" else "android32"
val geodeFilename: String = "Geode.$platformName.so" val geodeFilename: String = "Geode.$platformName.so"
@@ -13,6 +13,7 @@ import androidx.core.content.edit
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import com.geode.launcher.BuildConfig
/** /**
* Extension object for SharedPreferences to add better key safety and default values. * Extension object for SharedPreferences to add better key safety and default values.
@@ -23,36 +24,69 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
@Composable @Composable
fun useBooleanPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<Boolean> { fun useBooleanPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<Boolean> {
return usePreference(preferenceKey, lifecycleOwner) { p, k -> return usePreference(
p.getBoolean(k) preferenceKey,
} lifecycleOwner,
preferenceGet = { p, k -> p.getBoolean(k) },
preferenceSet = { p, k, v -> p.setBoolean(k, v) }
)
} }
@Composable @Composable
fun useStringPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<String?> { fun useStringPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<String?> {
return usePreference(preferenceKey, lifecycleOwner) { p, k -> return usePreference(
p.getString(k) preferenceKey,
} lifecycleOwner,
preferenceGet = { p, k -> p.getString(k) },
preferenceSet = { p, k, v -> p.setString(k, v) }
)
} }
@Composable @Composable
fun useIntPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<Int> { fun useIntPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<Int> {
return usePreference(preferenceKey, lifecycleOwner) { p, k -> return usePreference(
p.getInt(k) preferenceKey,
} lifecycleOwner,
preferenceGet = { p, k -> p.getInt(k) },
preferenceSet = { p, k, v -> p.setInt(k, v) }
)
} }
@Composable @Composable
private fun <T> usePreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, preferenceGet: (PreferenceUtils, Key) -> T): MutableState<T> { fun useLongPreference(preferenceKey: Key, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current): MutableState<Long> {
return usePreference(
preferenceKey,
lifecycleOwner,
preferenceGet = { p, k -> p.getLong(k) },
preferenceSet = { p, k, v -> p.setLong(k, v) }
)
}
@Composable
private fun <T> usePreference(
preferenceKey: Key,
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
preferenceGet: (PreferenceUtils, Key) -> T,
preferenceSet: (PreferenceUtils, Key, T) -> Unit
): MutableState<T> {
val context = LocalContext.current val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences(FILE_KEY, Context.MODE_PRIVATE) val sharedPreferences = context.getSharedPreferences(FILE_KEY, Context.MODE_PRIVATE)
val preferences = get(sharedPreferences) val preferences = get(sharedPreferences)
val preferenceValue = remember { val preferenceValue = remember {
mutableStateOf( val state = mutableStateOf(preferenceGet(preferences, preferenceKey))
preferenceGet(preferences, preferenceKey) object : MutableState<T> by state {
) override var value: T
get() = state.value
set(value) {
// ignore setting the same thing (messes up default preferences)
if (state.value == value) { return }
state.value = value
preferenceSet(preferences, preferenceKey, value)
}
}
} }
val listener = SharedPreferences.OnSharedPreferenceChangeListener { val listener = SharedPreferences.OnSharedPreferenceChangeListener {
@@ -98,11 +132,16 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
THEME, THEME,
BLACK_BACKGROUND, BLACK_BACKGROUND,
CURRENT_RELEASE_MODIFIED, CURRENT_RELEASE_MODIFIED,
LAST_DISMISSED_UPDATE,
DISMISSED_GJ_UPDATE,
LAUNCH_ARGUMENTS,
IGNORE_LOAD_FAILURE
} }
private fun defaultValueForBooleanKey(key: Key): Boolean { private fun defaultValueForBooleanKey(key: Key): Boolean {
return when (key) { return when (key) {
Key.UPDATE_AUTOMATICALLY -> true Key.UPDATE_AUTOMATICALLY -> true
Key.RELEASE_CHANNEL -> BuildConfig.DEBUG
else -> false else -> false
} }
} }
@@ -117,7 +156,11 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
Key.CURRENT_VERSION_TIMESTAMP -> "PreferenceCurrentVersionDescriptor" Key.CURRENT_VERSION_TIMESTAMP -> "PreferenceCurrentVersionDescriptor"
Key.THEME -> "PreferenceTheme" Key.THEME -> "PreferenceTheme"
Key.BLACK_BACKGROUND -> "PreferenceBlackBackground" Key.BLACK_BACKGROUND -> "PreferenceBlackBackground"
Key.CURRENT_RELEASE_MODIFIED -> "PreferenceReleaseModified" Key.CURRENT_RELEASE_MODIFIED -> "PreferenceReleaseModifiedHash"
Key.LAST_DISMISSED_UPDATE -> "PreferenceLastDismissedUpdate"
Key.DISMISSED_GJ_UPDATE -> "PreferenceDismissedGJUpdate"
Key.LAUNCH_ARGUMENTS -> "PreferenceLaunchArguments"
Key.IGNORE_LOAD_FAILURE -> "PreferenceIgnoreLoadFailure"
} }
} }
@@ -128,6 +171,13 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
return sharedPreferences.getBoolean(keyName, defaultValue) return sharedPreferences.getBoolean(keyName, defaultValue)
} }
fun setBoolean(key: Key, value: Boolean) {
val keyName = keyToName(key)
sharedPreferences.edit {
putBoolean(keyName, value)
}
}
fun toggleBoolean(key: Key): Boolean { fun toggleBoolean(key: Key): Boolean {
val currentValue = getBoolean(key) val currentValue = getBoolean(key)
val keyName = keyToName(key) val keyName = keyToName(key)
@@ -144,7 +194,7 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
return sharedPreferences.getString(keyName, null) return sharedPreferences.getString(keyName, null)
} }
fun setString(key: Key, value: String) { fun setString(key: Key, value: String?) {
val keyName = keyToName(key) val keyName = keyToName(key)
sharedPreferences.edit { sharedPreferences.edit {
putString(keyName, value) putString(keyName, value)
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,760Q546,760 593,713Q640,666 640,600L640,440Q640,374 593,327Q546,280 480,280Q414,280 367,327Q320,374 320,440L320,600Q320,666 367,713Q414,760 480,760ZM400,640L560,640L560,560L400,560L400,640ZM400,480L560,480L560,400L400,400L400,480ZM480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520ZM480,840Q415,840 359.5,808Q304,776 272,720L160,720L160,640L244,640Q241,620 240.5,600Q240,580 240,560L160,560L160,480L240,480Q240,460 240.5,440Q241,420 244,400L160,400L160,320L272,320Q286,297 303.5,277Q321,257 344,242L280,176L336,120L422,206Q450,197 479,197Q508,197 536,206L624,120L680,176L614,242Q637,257 655.5,276.5Q674,296 688,320L800,320L800,400L716,400Q719,420 719.5,440Q720,460 720,480L800,480L800,560L720,560Q720,580 719.5,600Q719,620 716,640L800,640L800,720L688,720Q656,776 600.5,808Q545,840 480,840Z"/>
</vector>
+10
View File
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M400,720L400,640L560,640L560,720L400,720ZM240,520L240,440L720,440L720,520L240,520ZM120,320L120,240L840,240L840,320L120,320Z"/>
</vector>
+10
View File
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M840,280L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840Q167,840 143.5,816.5Q120,793 120,760L120,200Q120,167 143.5,143.5Q167,120 200,120L680,120L840,280ZM760,314L646,200L200,200Q200,200 200,200Q200,200 200,200L200,760Q200,760 200,760Q200,760 200,760L760,760Q760,760 760,760Q760,760 760,760L760,314ZM480,720Q530,720 565,685Q600,650 600,600Q600,550 565,515Q530,480 480,480Q430,480 395,515Q360,550 360,600Q360,650 395,685Q430,720 480,720ZM240,400L600,400L600,240L240,240L240,400ZM200,314L200,760Q200,760 200,760Q200,760 200,760L200,760Q200,760 200,760Q200,760 200,760L200,200Q200,200 200,200Q200,200 200,200L200,200L200,314Z"/>
</vector>
+42 -7
View File
@@ -12,6 +12,7 @@
<string name="launcher_launch_icon_alt">play icon</string> <string name="launcher_launch_icon_alt">play icon</string>
<string name="launcher_download_icon_alt">download icon</string> <string name="launcher_download_icon_alt">download icon</string>
<string name="launcher_settings_icon_alt">settings icon</string> <string name="launcher_settings_icon_alt">settings icon</string>
<string name="launcher_retry_icon_alt">retry icon</string>
<string name="geode_download_title">Geode is not installed.</string> <string name="geode_download_title">Geode is not installed.</string>
<string name="geode_download_description">Geode is downloading. Please wait.</string> <string name="geode_download_description">Geode is downloading. Please wait.</string>
<plurals name="automatically_load_countdown"> <plurals name="automatically_load_countdown">
@@ -21,9 +22,16 @@
<string name="launcher_warning_icon_alt">warning</string> <string name="launcher_warning_icon_alt">warning</string>
<string name="launcher_error_icon_alt">error</string> <string name="launcher_error_icon_alt">error</string>
<string name="launcher_unsupported_version_title">Unsupported version</string> <string name="launcher_unsupported_version_title">Unsupported version</string>
<string name="launcher_unsupported_version_description"> <string name="launcher_outdated_version_description">
The current version of Geometry Dash is not compatible with the version supported by the launcher (%2$s).\nLaunch will continue, but crashes may occur! The current version of Geometry Dash is not compatible with the version supported by the launcher (%2$s). You must update Geometry Dash to fix this.\nLaunch will continue, but crashes may occur!
</string> </string>
<string name="launcher_unsupported_version_description">
The current version of Geometry Dash is not compatible with the version supported by the launcher. A launcher update is required to fix this.\nLaunch will continue, but crashes may occur!
</string>
<string name="launcher_update_available">A new launcher update is available!</string>
<string name="launcher_update_dismiss">Dismiss</string>
<string name="safe_mode_enable_title">Enable safe mode?</string>
<string name="safe_mode_enable_description">Safe mode disables loading of all mods, allowing one to remove problematic mods or diagnose crashes. If you\'re not experiencing issues with Geode, you should probably not be using safe mode.</string>
<!-- Updater strings --> <!-- Updater strings -->
<string name="release_fetch_failed">Update failed.\n%1$s</string> <string name="release_fetch_failed">Update failed.\n%1$s</string>
@@ -40,18 +48,22 @@
<!-- GeometryDashActivity strings --> <!-- GeometryDashActivity strings -->
<string name="load_failed_link_error">Failed to link libraries</string> <string name="load_failed_link_error">Failed to link libraries</string>
<string name="load_failed_link_error_description">Game libraries could not be found. This may occur if the architecture of the launcher and Geometry Dash do not match, or from a system bug.\nReported issue: %1$s.\nCheck application logs for more details.</string> <string name="load_failed_link_error_description">Game libraries could not be found. This may occur if the architecture or version of the launcher and Geometry Dash do not match, or from a system bug.\nCheck application logs for more details.</string>
<string name="load_failed_abi_error_need_64bit_description">The architecture of the launcher and Geometry Dash do not match. Please reinstall Geometry Dash from the Play Store or use the 64-bit version of the launcher to resolve this issue.\nCheck application logs for more details.</string>
<string name="load_failed_abi_error_need_32bit_description">The architecture of the launcher and Geometry Dash do not match. Please reinstall Geometry Dash from the Play Store or use the 32-bit version of the launcher to resolve this issue.\nCheck application logs for more details.</string>
<string name="load_failed_generic_error">Load failed</string> <string name="load_failed_generic_error">Load failed</string>
<string name="load_failed_generic_error_description">Uncaught error during game load: `%1$s`.\nCheck application logs for more details. Please report this issue!</string> <string name="load_failed_generic_error_description">Uncaught error during game load: `%1$s`.\nCheck application logs for more details.</string>
<string name="message_box_cancel">Cancel</string> <string name="message_box_cancel">Cancel</string>
<string name="message_box_accept">OK</string> <string name="message_box_accept">OK</string>
<string name="message_box_continue">Continue</string>
<string name="missing_permissions">Missing required permissions for this action!</string>
<!-- SettingsActivity strings --> <!-- SettingsActivity strings -->
<string name="title_activity_settings">Settings</string> <string name="title_activity_settings">Settings</string>
<string name="settings_back_icon_alt">back arrow icon</string> <string name="back_icon_alt">back</string>
<string name="export_folder_tag">Launcher External Folder</string> <string name="export_folder_tag">Launcher External Folder</string>
<string name="export_folder_copied">Copied!</string> <string name="text_copied">Copied to clipboard.</string>
<string name="preference_category_testing">General</string> <string name="preference_category_testing">General</string>
<string name="preference_load_testing_name">Load files from /test</string> <string name="preference_load_testing_name">Load files from /test</string>
@@ -59,6 +71,7 @@
<string name="preference_load_automatically_description">Launches the game after a short delay.</string> <string name="preference_load_automatically_description">Launches the game after a short delay.</string>
<string name="preferences_copy_external_button">Copy external files directory</string> <string name="preferences_copy_external_button">Copy external files directory</string>
<string name="preferences_open_file_manager">Open file manager</string> <string name="preferences_open_file_manager">Open file manager</string>
<string name="preferences_view_logs">View application logs</string>
<string name="preference_theme_name">Theme</string> <string name="preference_theme_name">Theme</string>
<string name="preference_theme_select">Select theme</string> <string name="preference_theme_select">Select theme</string>
@@ -72,11 +85,33 @@
<string name="preference_release_channel_name">Use nightly release channel</string> <string name="preference_release_channel_name">Use nightly release channel</string>
<string name="preference_check_for_updates_button">Check for updates</string> <string name="preference_check_for_updates_button">Check for updates</string>
<string name="preference_check_for_updates_description">Current version: %1$s</string> <string name="preference_check_for_updates_description">Current loader version: %1$s</string>
<string name="preference_launcher_version">Geode Launcher v%1$s</string>
<string name="preference_check_for_updates_failed">Update failed. See logs for more details.</string> <string name="preference_check_for_updates_failed">Update failed. See logs for more details.</string>
<string name="preference_check_for_updates_none_found">No updates found.</string> <string name="preference_check_for_updates_none_found">No updates found.</string>
<string name="preference_check_for_updates_success">Geode has been updated!</string> <string name="preference_check_for_updates_success">Geode has been updated!</string>
<string name="preference_check_for_updates_downloading">Downloading update…</string> <string name="preference_check_for_updates_downloading">Downloading update…</string>
<string name="preference_check_for_updates_no_internet">No internet found.</string> <string name="preference_check_for_updates_no_internet">No internet found.</string>
<string name="preference_check_for_updates_already_updating">An update is already in progress!</string> <string name="preference_check_for_updates_already_updating">An update is already in progress!</string>
<string name="preference_category_developer">Developer</string>
<string name="preference_launch_arguments_name">Launch arguments</string>
<string name="preference_launch_arguments_set_title">Set launch arguments</string>
<string name="preference_ignore_load_failure_name">Ignore loader errors</string>
<string name="preference_text_clear">Clear</string>
<!-- ApplicationLogsActivity strings -->
<string name="title_activity_application_logs">Application Logs</string>
<string name="application_logs_no_logs">No logs available.</string>
<string name="application_logs_delete">Clear All</string>
<string name="application_logs_share">Share</string>
<string name="application_log_copy_label">Log Message</string>
<string name="application_logs_more">More</string>
<string name="application_logs_export">Save To</string>
<string name="application_logs_level">Set Log Level</string>
<string name="application_logs_level_dialog_name">Set minimum log level</string>
<string name="application_logs_export_completed">Log export complete.</string>
<string name="application_logs_default_filename" translatable="false">launcher_logcat.txt</string>
<string name="application_logs_crash_only">Show Crash Buffer</string>
<string name="application_logs_crash_only_enabled_alt">Enabled</string>
</resources> </resources>
-16
View File
@@ -1,16 +0,0 @@
buildscript {
ext {
compose_version = '1.5.4'
compose_compiler = '1.5.7'
}
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '8.2.1' apply false
id 'com.android.library' version '8.2.1' apply false
id 'org.jetbrains.kotlin.android' version '1.9.21' apply false
id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.21' apply false
}
tasks.register('clean', Delete) {
delete rootProject.buildDir
}
+16
View File
@@ -0,0 +1,16 @@
buildscript {
val composeBOM by extra("2023.10.01")
val composeCompiler by extra("1.5.8")
}
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id("com.android.application") version "8.2.2" apply false
id("com.android.library") version "8.2.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.22" apply false
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+2
View File
@@ -21,3 +21,5 @@ kotlin.code.style=official
# resources declared in the library itself and none from the library's dependencies, # resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library # thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
# nice optimization
org.gradle.configuration-cache=true
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
Vendored
+7 -7
View File
@@ -145,7 +145,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #( case $MAX_FD in #(
max*) max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045 # shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) || MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit" warn "Could not query maximum file descriptor limit"
esac esac
@@ -153,7 +153,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
'' | soft) :;; #( '' | soft) :;; #(
*) *)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045 # shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" || ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD" warn "Could not set maximum file descriptor limit to $MAX_FD"
esac esac
@@ -202,11 +202,11 @@ fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command; # Collect all arguments for the java command:
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# shell script including quotes and variable substitutions, so put them in # and any embedded shellness will be escaped.
# double quotes to make sure that they get re-expanded; and # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# * put everything else in single quotes, so that it's not re-expanded. # treated as '${Hostname}' itself on the command line.
set -- \ set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+4 -1
View File
@@ -5,6 +5,8 @@ pluginManagement {
mavenCentral() mavenCentral()
} }
} }
@Suppress("UnstableApiUsage")
dependencyResolutionManagement { dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { repositories {
@@ -12,5 +14,6 @@ dependencyResolutionManagement {
mavenCentral() mavenCentral()
} }
} }
rootProject.name = "launcher" rootProject.name = "launcher"
include ':app' include(":app")