├── .github └── workflows │ ├── build.yml │ └── update-site.yml ├── .gitignore ├── LICENSE.txt ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ └── main │ ├── AndroidManifest.xml │ ├── cpp │ ├── CMakeLists.txt │ └── launcher-fix.cpp │ ├── ic_launcher-playstore.png │ ├── java │ ├── com │ │ ├── customRobTop │ │ │ ├── BaseRobTopActivity.kt │ │ │ └── JniToCpp.kt │ │ └── geode │ │ │ └── launcher │ │ │ ├── AltMainActivity.kt │ │ │ ├── GeometryDashActivity.kt │ │ │ ├── LauncherFix.kt │ │ │ ├── MainActivity.kt │ │ │ ├── UserDirectoryProvider.kt │ │ │ ├── activityresult │ │ │ ├── GeodeOpenFileActivityResult.kt │ │ │ ├── GeodeOpenFilesActivityResult.kt │ │ │ └── GeodeSaveFileActivityResult.kt │ │ │ ├── log │ │ │ ├── LogLine.kt │ │ │ └── LogViewModel.kt │ │ │ ├── main │ │ │ ├── Components.kt │ │ │ ├── ErrorComponents.kt │ │ │ ├── LaunchComponents.kt │ │ │ ├── LaunchNotification.kt │ │ │ ├── LaunchViewModel.kt │ │ │ └── UpdateComponents.kt │ │ │ ├── preferences │ │ │ ├── ApplicationLogsActivity.kt │ │ │ ├── DeveloperSettingsActivity.kt │ │ │ ├── SettingsActivity.kt │ │ │ └── SettingsComponents.kt │ │ │ ├── ui │ │ │ └── theme │ │ │ │ ├── Color.kt │ │ │ │ ├── Theme.kt │ │ │ │ └── Type.kt │ │ │ ├── updater │ │ │ ├── Release.kt │ │ │ ├── ReleaseManager.kt │ │ │ ├── ReleaseRepository.kt │ │ │ └── ReleaseViewModel.kt │ │ │ └── utils │ │ │ ├── Components.kt │ │ │ ├── Constants.kt │ │ │ ├── ConstrainedFrameLayout.kt │ │ │ ├── DownloadUtils.kt │ │ │ ├── FileUtils.kt │ │ │ ├── GamePackageUtils.kt │ │ │ ├── GeodeUtils.kt │ │ │ ├── LaunchUtils.kt │ │ │ ├── PreferenceUtils.kt │ │ │ ├── ProfileManager.kt │ │ │ └── useCountdownTimer.kt │ └── org │ │ ├── cocos2dx │ │ └── lib │ │ │ ├── Cocos2dxAccelerometer.kt │ │ │ ├── Cocos2dxActivity.kt │ │ │ ├── Cocos2dxBitmap.kt │ │ │ ├── Cocos2dxEditText.kt │ │ │ ├── Cocos2dxGLSurfaceView.kt │ │ │ ├── Cocos2dxHelper.kt │ │ │ ├── Cocos2dxRenderer.kt │ │ │ ├── Cocos2dxTextInputWrapper.kt │ │ │ └── Cocos2dxTypefaces.kt │ │ └── fmod │ │ ├── AudioDevice.kt │ │ ├── FMOD.kt │ │ └── MediaCodec.kt │ ├── jniLibs │ └── .keep │ └── res │ ├── drawable-v24 │ ├── ic_launcher_foreground.xml │ └── ic_launcher_monochrome.xml │ ├── drawable │ ├── geode_logo.xml │ ├── geode_monochrome.xml │ ├── google_play_badge.png │ ├── icon_bug_report.xml │ ├── icon_content_copy.xml │ ├── icon_data_object.xml │ ├── icon_delete.xml │ ├── icon_description.xml │ ├── icon_download.xml │ ├── icon_error.xml │ ├── icon_filter_list.xml │ ├── icon_link.xml │ ├── icon_person.xml │ ├── icon_person_add.xml │ ├── icon_question_mark.xml │ ├── icon_remove.xml │ ├── icon_resume.xml │ ├── icon_save.xml │ └── icon_undo.xml │ ├── mipmap-anydpi-v26 │ └── ic_launcher.xml │ ├── mipmap-hdpi │ ├── ic_launcher.png │ └── ic_launcher_foreground.png │ ├── mipmap-mdpi │ ├── ic_launcher.png │ └── ic_launcher_foreground.png │ ├── mipmap-xhdpi │ ├── ic_launcher.png │ └── ic_launcher_foreground.png │ ├── mipmap-xxhdpi │ ├── ic_launcher.png │ └── ic_launcher_foreground.png │ ├── mipmap-xxxhdpi │ ├── ic_launcher.png │ └── ic_launcher_foreground.png │ ├── values-v31 │ └── themes.xml │ ├── values │ ├── colors.xml │ ├── ic_launcher_background.xml │ ├── strings.xml │ └── themes.xml │ └── xml │ ├── backup_rules.xml │ ├── data_extraction_rules.xml │ ├── game_mode_config.xml │ └── provider_paths.xml ├── build.gradle.kts ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build launcher 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: '*' 7 | 8 | jobs: 9 | build: 10 | strategy: 11 | matrix: 12 | config: 13 | - name: Release 14 | lower: release 15 | 16 | - name: Debug 17 | lower: debug 18 | 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - name: Setup Java JDK 25 | uses: actions/setup-java@v4 26 | with: 27 | java-version: 21 28 | distribution: temurin 29 | cache: gradle 30 | 31 | - name: Generate keystore file 32 | run: echo ${{secrets.KEYSTORE_FILE}} | base64 -d > ~/release.keystore 33 | 34 | - name: Fix gradle permissions 35 | run: chmod +x ./gradlew 36 | 37 | - name: Build project 38 | run: | 39 | ./gradlew app:assemble${{ matrix.config.name }} \ 40 | -Pandroid.injected.signing.store.file="$HOME/release.keystore" \ 41 | -Pandroid.injected.signing.store.password="${{secrets.KEYSTORE_PASSWORD}}" \ 42 | -Pandroid.injected.signing.key.alias="${{secrets.KEY_ALIAS}}" \ 43 | -Pandroid.injected.signing.key.password="${{secrets.KEY_PASSWORD}}" 44 | 45 | - name: Rename universal apk 46 | id: rename_apk_universal 47 | run: | 48 | SHORT_SHA=${GITHUB_SHA::7} 49 | APK_PATH=${{github.workspace}}/app/build/outputs/apk/${{ matrix.config.lower }}/app-universal-${{ matrix.config.lower }}.apk 50 | OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-$SHORT_SHA.apk 51 | mv $APK_PATH $OUT_PATH 52 | echo "path=$OUT_PATH" >> $GITHUB_OUTPUT 53 | 54 | - name: Rename apk 55 | id: rename_apk 56 | run: | 57 | SHORT_SHA=${GITHUB_SHA::7} 58 | APK_PATH=${{github.workspace}}/app/build/outputs/apk/${{ matrix.config.lower }}/app-armeabi-v7a-${{ matrix.config.lower }}.apk 59 | OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-android32-$SHORT_SHA.apk 60 | mv $APK_PATH $OUT_PATH 61 | echo "path=$OUT_PATH" >> $GITHUB_OUTPUT 62 | 63 | - name: Upload development build 64 | uses: actions/upload-artifact@v4 65 | with: 66 | name: geode-launcher-${{ matrix.config.lower }} 67 | path: | 68 | ${{ steps.rename_apk_universal.outputs.path }} 69 | ${{ steps.rename_apk.outputs.path }} 70 | -------------------------------------------------------------------------------- /.github/workflows/update-site.yml: -------------------------------------------------------------------------------- 1 | name: Update Website 2 | 3 | on: 4 | # release: 5 | # types: 6 | # - released 7 | 8 | workflow_dispatch: 9 | 10 | jobs: 11 | trigger: 12 | name: Trigger site build 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/github-script@v7 16 | with: 17 | github-token: ${{ secrets.GEODE_BOT_PUSH_BIN_TOKEN }} 18 | script: | 19 | await github.rest.actions.createWorkflowDispatch({ 20 | owner: 'geode-sdk', 21 | repo: 'website', 22 | workflow_id: 'build.yml', 23 | ref: 'main' 24 | }) 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/* 5 | .DS_Store 6 | /build 7 | /captures 8 | .externalNativeBuild 9 | .cxx 10 | local.properties 11 | app/release/ 12 | .vscode/ 13 | .kotlin/ -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Boost Software License - Version 1.0 - August 17th, 2003 2 | 3 | Permission is hereby granted, free of charge, to any person or organization 4 | obtaining a copy of the software and accompanying documentation covered by 5 | this license (the "Software") to use, reproduce, display, distribute, 6 | execute, and transmit the Software, and to prepare derivative works of the 7 | Software, and to permit third-parties to whom the Software is furnished to 8 | do so, all subject to the following: 9 | 10 | The copyright notices in the Software and this entire statement, including 11 | the above license grant, this restriction and the following disclaimer, 12 | must be included in all copies of the Software, in whole or in part, and 13 | all derivative works of the Software, unless such copies or derivative 14 | works are solely in the form of machine-executable object code generated by 15 | a source language processor. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT 20 | SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE 21 | FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, 22 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 23 | DEALINGS IN THE SOFTWARE. 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Geode Android Launcher 2 | 3 | Launches a vanilla copy of Geometry Dash with the Geode loader added. 4 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | val composeBOM: String by rootProject.extra 2 | 3 | kotlin { 4 | compilerOptions { 5 | extraWarnings.set(true) 6 | } 7 | } 8 | 9 | plugins { 10 | id("com.android.application") 11 | id("org.jetbrains.kotlin.android") 12 | id("org.jetbrains.kotlin.plugin.serialization") 13 | id("org.jetbrains.kotlin.plugin.compose") 14 | } 15 | 16 | android { 17 | compileSdk = 36 18 | 19 | defaultConfig { 20 | applicationId = "com.geode.launcher" 21 | minSdk = 23 22 | targetSdk = 35 23 | versionCode = 22 24 | versionName = "1.6.1" 25 | 26 | vectorDrawables { 27 | useSupportLibrary = true 28 | } 29 | 30 | @Suppress("UnstableApiUsage") 31 | externalNativeBuild { 32 | cmake { 33 | arguments("-DUSE_TULIPHOOK:BOOL=OFF", "-DANDROID_STL=c++_shared", "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES:BOOL=OFF") 34 | } 35 | } 36 | 37 | //noinspection ChromeOsAbiSupport (not my fault) 38 | ndk.abiFilters += listOf("arm64-v8a", "armeabi-v7a") 39 | } 40 | 41 | splits { 42 | abi { 43 | isEnable = true 44 | reset() 45 | 46 | //noinspection ChromeOsAbiSupport. i'm sorry! 47 | include("arm64-v8a", "armeabi-v7a") 48 | 49 | isUniversalApk = true 50 | } 51 | } 52 | 53 | buildTypes { 54 | release { 55 | isMinifyEnabled = true 56 | isShrinkResources = true 57 | 58 | proguardFiles( 59 | getDefaultProguardFile("proguard-android-optimize.txt"), 60 | "proguard-rules.pro" 61 | ) 62 | } 63 | } 64 | compileOptions { 65 | // enables a polyfill for java Instant on api levels < 26 (used for updater) 66 | isCoreLibraryDesugaringEnabled = true 67 | 68 | sourceCompatibility = JavaVersion.VERSION_1_8 69 | targetCompatibility = JavaVersion.VERSION_1_8 70 | } 71 | kotlinOptions { 72 | jvmTarget = "1.8" 73 | } 74 | buildFeatures { 75 | compose = true 76 | buildConfig = true 77 | } 78 | packaging { 79 | resources { 80 | excludes += "/META-INF/{AL2.0,LGPL2.1}" 81 | } 82 | } 83 | externalNativeBuild { 84 | cmake { 85 | version = "3.25.0+" 86 | path = file("src/main/cpp/CMakeLists.txt") 87 | } 88 | } 89 | namespace = "com.geode.launcher" 90 | ndkVersion = "28.1.13356709" 91 | } 92 | 93 | dependencies { 94 | implementation (platform("androidx.compose:compose-bom:$composeBOM")) 95 | implementation ("androidx.core:core-ktx:1.16.0") 96 | implementation ("androidx.compose.ui:ui") 97 | implementation ("androidx.compose.material3:material3") 98 | implementation ("androidx.compose.ui:ui-tooling-preview") 99 | implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") 100 | implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") 101 | implementation ("androidx.activity:activity-compose:1.10.1") 102 | implementation ("androidx.activity:activity-ktx:1.10.1") 103 | implementation ("androidx.appcompat:appcompat:1.7.0") 104 | implementation ("androidx.documentfile:documentfile:1.0.1") 105 | implementation ("com.squareup.okio:okio:3.11.0") 106 | implementation ("com.squareup.okhttp3:okhttp:4.12.0") 107 | implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1") 108 | implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.8.1") 109 | implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2") 110 | implementation ("com.mikepenz:multiplatform-markdown-renderer-android:0.33.0") 111 | implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.33.0") 112 | implementation ("androidx.browser:browser:1.8.0") 113 | debugImplementation ("androidx.compose.ui:ui-tooling") 114 | coreLibraryDesugaring ("com.android.tools:desugar_jdk_libs:2.1.5") 115 | } 116 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add wildcard keep rules to all of the library code in use 2 | -keep class org.fmod.** { *; } 3 | -keep class org.cocos2dx.lib.** { *; } 4 | -keep class com.customRobTop.** { *; } 5 | 6 | # note: if you're going to add more rules, consider the @Keep annotation 7 | # this should really only be kept to non-custom code 8 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 29 | 32 | 33 | 45 | 49 | 54 | 59 | 64 | 67 | 68 | 76 | 79 | 80 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 97 | 98 | 99 | 100 | 101 | 106 | 109 | 110 | 111 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /app/src/main/cpp/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | cmake_minimum_required(VERSION 3.18.1) 2 | 3 | project(launcherfix) 4 | 5 | add_library(launcherfix SHARED launcher-fix.cpp) 6 | 7 | target_compile_features(launcherfix PUBLIC cxx_std_20) 8 | set_target_properties(launcherfix PROPERTIES CXX_EXTENSIONS OFF) 9 | 10 | include(FetchContent) 11 | 12 | option(USE_TULIPHOOK "enables choice of hooking library" OFF) 13 | if (USE_TULIPHOOK) 14 | FetchContent_Declare(TulipHook 15 | GIT_REPOSITORY https://github.com/geode-sdk/TulipHook.git 16 | GIT_TAG 9aa1f95091ebac18c657d57b755f59d00fe6f1f8 17 | ) 18 | 19 | set(TULIP_LINK_SOURCE ON CACHE INTERNAL "") 20 | FetchContent_MakeAvailable(TulipHook) 21 | 22 | target_compile_definitions(launcherfix PRIVATE -DUSE_TULIPHOOK=1) 23 | 24 | target_link_libraries(launcherfix PRIVATE TulipHook TulipHookInclude) 25 | else() 26 | FetchContent_Declare(dobby 27 | # this is the last version that builds on android at all 28 | GIT_REPOSITORY https://github.com/jmpews/Dobby 29 | GIT_TAG 0932d69c320e786672361ab53825ba8f4245e9d3 30 | ) 31 | 32 | FetchContent_GetProperties(dobby) 33 | if(NOT dobby_POPULATED) 34 | FetchContent_Populate(dobby) 35 | 36 | # fixes Dobby build 37 | # add_compile_definitions(typeof=__typeof__) 38 | set(DOBBY_DEBUG OFF CACHE INTERNAL "" FORCE) 39 | set(DOBBY_GENERATE_SHARED OFF CACHE INTERNAL "" FORCE) 40 | 41 | add_subdirectory("${dobby_SOURCE_DIR}" ${dobby_BINARY_DIR} EXCLUDE_FROM_ALL) 42 | target_include_directories(launcherfix PRIVATE ${dobby_SOURCE_DIR}/include) 43 | target_link_libraries(launcherfix PRIVATE dobby) 44 | endif() 45 | endif() 46 | 47 | target_link_libraries(launcherfix PRIVATE log) 48 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geode-sdk/android-launcher/09bb28d66ffd0a60b6d5b7f3fcf647b0368134d0/app/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /app/src/main/java/com/customRobTop/BaseRobTopActivity.kt: -------------------------------------------------------------------------------- 1 | package com.customRobTop 2 | 3 | import android.annotation.SuppressLint 4 | import android.app.Activity 5 | import android.content.* 6 | import android.net.ConnectivityManager 7 | import android.net.NetworkCapabilities 8 | import android.net.Uri 9 | import android.os.Build 10 | import android.provider.Settings 11 | import android.util.Log 12 | import android.widget.Toast 13 | import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.closeIMEKeyboard 14 | import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.openIMEKeyboard 15 | import java.lang.ref.WeakReference 16 | import java.util.* 17 | 18 | 19 | @Suppress("unused", "UNUSED_PARAMETER") 20 | object BaseRobTopActivity { 21 | private var isLoaded = false 22 | var blockBackButton = false 23 | private var keyboardActive = false 24 | 25 | var isPaused = false 26 | 27 | lateinit var me: WeakReference 28 | private var shouldResumeSound = true 29 | 30 | fun setCurrentActivity(currentActivity: Activity) { 31 | me = WeakReference(currentActivity) 32 | } 33 | 34 | @SuppressLint("HardwareIds") 35 | @JvmStatic 36 | fun getUserID(): String { 37 | // this is how RobTop does it in 2.2, based on the meltdown leaks 38 | val androidId = Settings.Secure.getString(me.get()?.contentResolver, Settings.Secure.ANDROID_ID) 39 | return if ("9774d56d682e549c" != androidId) { 40 | UUID.nameUUIDFromBytes(androidId.toByteArray()).toString() 41 | } else return UUID.randomUUID().toString() 42 | } 43 | 44 | @JvmStatic 45 | fun isNetworkAvailable(): Boolean { 46 | val connectivityManager = me.get()?.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager 47 | 48 | val activeNetwork = connectivityManager.activeNetwork 49 | val networkCapabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false 50 | 51 | if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { 52 | return true 53 | } 54 | 55 | if (networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) { 56 | return true 57 | } 58 | 59 | return false 60 | } 61 | 62 | @JvmStatic 63 | fun openURL(url: String) { 64 | Log.d("MAIN", "Open URL") 65 | me.get()?.runOnUiThread { 66 | try { 67 | me.get()?.startActivity( 68 | Intent( 69 | "android.intent.action.VIEW", 70 | Uri.parse(url) 71 | ) 72 | ) 73 | } catch (e: ActivityNotFoundException) { 74 | Toast.makeText( 75 | me.get(), 76 | "No activity found to open this URL.", 77 | Toast.LENGTH_SHORT, 78 | ).show() 79 | } 80 | } 81 | } 82 | 83 | @JvmStatic 84 | fun sendMail(subject: String, body: String, to: String) { 85 | me.get()?.runOnUiThread { 86 | val i = Intent("android.intent.action.SEND") 87 | i.type = "message/rfc822" 88 | i.putExtra("android.intent.extra.EMAIL", arrayOf(to)) 89 | i.putExtra("android.intent.extra.SUBJECT", subject) 90 | i.putExtra("android.intent.extra.TEXT", body) 91 | try { 92 | me.get()?.startActivity(Intent.createChooser(i, "Send mail...")) 93 | } catch (e: ActivityNotFoundException) { 94 | Toast.makeText( 95 | me.get(), 96 | "There are no email clients installed.", 97 | Toast.LENGTH_SHORT, 98 | ).show() 99 | } 100 | } 101 | } 102 | 103 | @JvmStatic 104 | fun shouldResumeSound(): Boolean { 105 | return shouldResumeSound 106 | } 107 | 108 | @JvmStatic 109 | fun setKeyboardState(value: Boolean) { 110 | keyboardActive = value 111 | } 112 | 113 | @JvmStatic 114 | fun onToggleKeyboard() { 115 | me.get()?.runOnUiThread { 116 | if (keyboardActive) { 117 | openIMEKeyboard() 118 | } else { 119 | closeIMEKeyboard() 120 | } 121 | } 122 | } 123 | 124 | @JvmStatic 125 | fun loadingFinished() { 126 | isLoaded = true 127 | } 128 | 129 | @JvmStatic 130 | fun getDeviceRefreshRate(): Float { 131 | val display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 132 | me.get()?.display 133 | } else { 134 | @Suppress("DEPRECATION") 135 | me.get()?.windowManager?.defaultDisplay 136 | } 137 | 138 | return display!!.refreshRate 139 | } 140 | 141 | // Everyplay doesn't even exist anymore lol 142 | @JvmStatic 143 | fun setupEveryplay() {} 144 | 145 | @JvmStatic 146 | fun isEveryplaySupported(): Boolean { 147 | return false 148 | } 149 | 150 | @JvmStatic 151 | fun isRecordingSupported(): Boolean { 152 | return false 153 | } 154 | 155 | @JvmStatic 156 | fun isRecordingPaused() : Boolean { 157 | return false 158 | } 159 | 160 | @JvmStatic 161 | fun isRecording() : Boolean { 162 | return false 163 | } 164 | 165 | @JvmStatic 166 | fun startRecording() {} 167 | 168 | @JvmStatic 169 | fun stopRecording() {} 170 | 171 | @JvmStatic 172 | fun resumeRecording() {} 173 | 174 | @JvmStatic 175 | fun pauseRecording() {} 176 | 177 | @JvmStatic 178 | fun playLastRecording() {} 179 | 180 | @JvmStatic 181 | fun showEveryplay() {} 182 | 183 | @JvmStatic 184 | fun setEveryplayMetadata(levelID: String, levelName: String) {} 185 | 186 | @JvmStatic 187 | fun onNativePause() {} 188 | 189 | @JvmStatic 190 | fun onNativeResume() {} 191 | 192 | // Google Play Games methods 193 | // for some reason the button is hidden in game 194 | @JvmStatic 195 | fun gameServicesIsSignedIn(): Boolean { 196 | return false 197 | } 198 | 199 | @JvmStatic 200 | fun gameServicesSignIn() { } 201 | 202 | @JvmStatic 203 | fun gameServicesSignOut() { } 204 | 205 | @JvmStatic 206 | fun unlockAchievement(id: String) {} 207 | 208 | @JvmStatic 209 | fun showAchievements() {} 210 | 211 | // advertisements stuff, useless for full version 212 | @JvmStatic 213 | fun enableBanner() {} 214 | 215 | @JvmStatic 216 | fun disableBanner() {} 217 | 218 | @JvmStatic 219 | fun showInterstitial() {} 220 | 221 | @JvmStatic 222 | fun cacheInterstitial() {} 223 | 224 | @JvmStatic 225 | fun hasCachedInterstitial(): Boolean { 226 | return false 227 | } 228 | 229 | @JvmStatic 230 | fun showRewardedVideo() {} 231 | 232 | @JvmStatic 233 | fun cacheRewardedVideo() {} 234 | 235 | @JvmStatic 236 | fun hasCachedRewardedVideo(): Boolean { 237 | return false 238 | } 239 | 240 | @JvmStatic 241 | fun queueRefreshBanner() {} 242 | 243 | @JvmStatic 244 | fun enableBannerNoRefresh() {} 245 | 246 | // for in game rate buttons, may implement in the future depending on how lazy I am 247 | // (requires Play Store library) 248 | @JvmStatic 249 | fun tryShowRateDialog(appName: String) {} 250 | 251 | @JvmStatic 252 | fun openAppPage() {} 253 | 254 | @JvmStatic 255 | @Deprecated( 256 | message = "This method is not found on newer versions of the game.", 257 | level = DeprecationLevel.HIDDEN 258 | ) 259 | fun logEvent(event: String) {} 260 | } -------------------------------------------------------------------------------- /app/src/main/java/com/customRobTop/JniToCpp.kt: -------------------------------------------------------------------------------- 1 | package com.customRobTop 2 | 3 | @Suppress("unused", "KotlinJniMissingFunction") 4 | object JniToCpp { 5 | @JvmStatic 6 | external fun didCacheInterstitial(str: String?) 7 | 8 | @JvmStatic 9 | external fun didClickInterstitial() 10 | 11 | @JvmStatic 12 | external fun didCloseInterstitial() 13 | 14 | @JvmStatic 15 | external fun didDismissInterstitial() 16 | 17 | @JvmStatic 18 | external fun everyplayRecordingStopped() 19 | 20 | @JvmStatic 21 | external fun googlePlaySignedIn() 22 | 23 | @JvmStatic 24 | external fun hideLoadingCircle() 25 | 26 | @JvmStatic 27 | external fun itemPurchased(str: String) 28 | 29 | @JvmStatic 30 | external fun itemRefunded(str: String) 31 | 32 | @JvmStatic 33 | external fun promoImageDownloaded() 34 | 35 | @JvmStatic 36 | external fun resumeSound() 37 | 38 | @JvmStatic 39 | external fun rewardedVideoAdFinished(i: Int) 40 | 41 | @JvmStatic 42 | external fun rewardedVideoAdHidden() 43 | 44 | @JvmStatic 45 | external fun setupHSSAssets(str: String?, str2: String?) 46 | 47 | @JvmStatic 48 | external fun showInterstitialFailed() 49 | 50 | @JvmStatic 51 | external fun userDidAttemptToRateApp() 52 | 53 | @JvmStatic 54 | external fun videoAdHidden() 55 | 56 | @JvmStatic 57 | external fun videoAdShowed() 58 | } -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/LauncherFix.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher 2 | 3 | import androidx.annotation.Keep 4 | import com.geode.launcher.utils.Constants 5 | 6 | @Keep 7 | object LauncherFix { 8 | fun loadLibrary() { 9 | System.loadLibrary(Constants.LAUNCHER_FIX_LIB_NAME) 10 | } 11 | 12 | external fun setDataPath(dataPath: String) 13 | 14 | external fun setOriginalDataPath(dataPath: String) 15 | 16 | external fun performExceptionsRenaming() 17 | 18 | external fun enableCustomSymbolList(symbolsPath: String) 19 | } -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/UserDirectoryProvider.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher 2 | 3 | import android.database.Cursor 4 | import android.database.MatrixCursor 5 | import android.net.Uri 6 | import android.os.Build 7 | import android.os.CancellationSignal 8 | import android.os.ParcelFileDescriptor 9 | import android.provider.DocumentsContract 10 | import android.provider.DocumentsProvider 11 | import android.webkit.MimeTypeMap 12 | import androidx.annotation.RequiresApi 13 | import com.geode.launcher.utils.LaunchUtils 14 | import java.io.File 15 | 16 | private val DEFAULT_ROOT_PROJECTION: Array = arrayOf( 17 | DocumentsContract.Root.COLUMN_ROOT_ID, 18 | DocumentsContract.Root.COLUMN_FLAGS, 19 | DocumentsContract.Root.COLUMN_ICON, 20 | DocumentsContract.Root.COLUMN_TITLE, 21 | DocumentsContract.Root.COLUMN_DOCUMENT_ID, 22 | ) 23 | 24 | private val DEFAULT_DOCUMENT_PROJECTION: Array = arrayOf( 25 | DocumentsContract.Document.COLUMN_DOCUMENT_ID, 26 | DocumentsContract.Document.COLUMN_MIME_TYPE, 27 | DocumentsContract.Document.COLUMN_DISPLAY_NAME, 28 | DocumentsContract.Document.COLUMN_LAST_MODIFIED, 29 | DocumentsContract.Document.COLUMN_FLAGS, 30 | DocumentsContract.Document.COLUMN_SIZE 31 | ) 32 | 33 | // a lot of this code is pulled from 34 | class UserDirectoryProvider : DocumentsProvider() { 35 | companion object { 36 | internal const val ROOT = "root" 37 | } 38 | 39 | private lateinit var rootDir: File 40 | 41 | override fun onCreate(): Boolean { 42 | val context = context ?: return false 43 | rootDir = LaunchUtils.getBaseDirectory(context, true) 44 | 45 | return true 46 | } 47 | 48 | override fun queryRoots(projection: Array?): Cursor { 49 | val result = MatrixCursor(projection ?: DEFAULT_ROOT_PROJECTION) 50 | 51 | // assert context is nonnull 52 | val context = context ?: return result 53 | 54 | result.newRow().apply { 55 | add(DocumentsContract.Root.COLUMN_ROOT_ID, ROOT) 56 | add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, ROOT) 57 | add( 58 | DocumentsContract.Root.COLUMN_FLAGS, 59 | DocumentsContract.Root.FLAG_SUPPORTS_CREATE or 60 | DocumentsContract.Root.FLAG_SUPPORTS_RECENTS or 61 | DocumentsContract.Root.FLAG_SUPPORTS_SEARCH 62 | ) 63 | add(DocumentsContract.Root.COLUMN_TITLE, context.getString(R.string.app_name)) 64 | add(DocumentsContract.Root.COLUMN_ICON, R.drawable.geode_logo) 65 | } 66 | 67 | return result 68 | } 69 | 70 | override fun queryDocument(documentId: String?, projection: Array?): Cursor { 71 | return MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION).apply { 72 | val file = getFileForDocumentId(documentId ?: ROOT) 73 | appendDocument(this, file) 74 | } 75 | } 76 | 77 | override fun queryChildDocuments( 78 | parentDocumentId: String?, 79 | projection: Array?, 80 | sortOrder: String? 81 | ): Cursor { 82 | return MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION).apply { 83 | val parent = getFileForDocumentId(parentDocumentId ?: ROOT) 84 | parent.listFiles()?.forEach { file -> appendDocument(this, file) } 85 | 86 | context?.let { 87 | setNotificationUri(it.contentResolver, getDocumentUri(parentDocumentId)) 88 | } 89 | } 90 | } 91 | 92 | override fun openDocument( 93 | documentId: String?, 94 | mode: String?, 95 | signal: CancellationSignal? 96 | ): ParcelFileDescriptor { 97 | val file = getFileForDocumentId(documentId ?: ROOT) 98 | return ParcelFileDescriptor.open(file, ParcelFileDescriptor.parseMode(mode)) 99 | } 100 | 101 | private fun appendDocument(cursor: MatrixCursor, file: File) { 102 | var flags = 0 103 | if (file.canWrite()) { 104 | flags = if (file.isDirectory) { 105 | DocumentsContract.Document.FLAG_DIR_SUPPORTS_CREATE 106 | } else { 107 | DocumentsContract.Document.FLAG_SUPPORTS_WRITE 108 | } 109 | flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_DELETE 110 | flags = flags or DocumentsContract.Document.FLAG_SUPPORTS_RENAME 111 | // The system will handle copy + move for us 112 | } 113 | 114 | val name = if (file == rootDir) { 115 | context!!.getString(R.string.app_name) 116 | } else { 117 | file.name 118 | } 119 | cursor.newRow().apply { 120 | add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, getDocumentIdForFile(file)) 121 | add(DocumentsContract.Document.COLUMN_MIME_TYPE, typeForFile(file)) 122 | add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, name) 123 | add(DocumentsContract.Document.COLUMN_LAST_MODIFIED, file.lastModified()) 124 | add(DocumentsContract.Document.COLUMN_FLAGS, flags) 125 | add(DocumentsContract.Document.COLUMN_SIZE, file.length()) 126 | if (file == rootDir) { 127 | add(DocumentsContract.Document.COLUMN_ICON, R.drawable.geode_logo) 128 | } 129 | } 130 | } 131 | 132 | override fun createDocument( 133 | parentDocumentId: String, 134 | mimeType: String, 135 | displayName: String 136 | ): String { 137 | val folder = getFileForDocumentId(parentDocumentId) 138 | val file = findFileNameForNewFile(File(folder, displayName)) 139 | if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) { 140 | file.mkdirs() 141 | } else { 142 | file.createNewFile() 143 | } 144 | 145 | notifyFileChange(file) 146 | return getDocumentIdForFile(file) 147 | } 148 | 149 | override fun deleteDocument(documentId: String) { 150 | val file = getFileForDocumentId(documentId) 151 | file.deleteRecursively() 152 | notifyFileChange(file) 153 | } 154 | 155 | override fun renameDocument(documentId: String, displayName: String): String { 156 | val file = getFileForDocumentId(documentId) 157 | val dest = findFileNameForNewFile(File(file.parentFile, displayName)) 158 | file.renameTo(dest) 159 | 160 | notifyFileChange(file) 161 | return getDocumentIdForFile(dest) 162 | } 163 | 164 | private fun typeForFile(file: File): String { 165 | if (file.isDirectory) { 166 | return DocumentsContract.Document.MIME_TYPE_DIR 167 | } 168 | 169 | return MimeTypeMap.getSingleton() 170 | .getMimeTypeFromExtension(file.extension) ?: "application/octet-stream" 171 | } 172 | 173 | private fun getFileForDocumentId(documentId: String): File { 174 | return File(rootDir, documentId.removePrefix(ROOT)) 175 | } 176 | 177 | private fun getDocumentIdForFile(file: File): String { 178 | return ROOT + file.toRelativeString(rootDir) 179 | } 180 | 181 | private fun findFileNameForNewFile(file: File): File { 182 | var unusedFile = file 183 | var i = 1 184 | while (unusedFile.exists()) { 185 | val pathWithoutExtension = unusedFile.absolutePath.substringBeforeLast('.') 186 | val extension = unusedFile.absolutePath.substringAfterLast('.') 187 | unusedFile = File("$pathWithoutExtension.$i.$extension") 188 | i++ 189 | } 190 | return unusedFile 191 | } 192 | 193 | private fun getDocumentUri(parentDocumentId: String?): Uri { 194 | return DocumentsContract.buildChildDocumentsUri( 195 | "${context!!.packageName}.user", 196 | parentDocumentId 197 | ) 198 | } 199 | 200 | private fun notifyFileChange(file: File) { 201 | notifyChange(getDocumentIdForFile(file.parentFile!!)) 202 | } 203 | 204 | private fun notifyChange(parentDocumentId: String?) { 205 | val uri = getDocumentUri(parentDocumentId) 206 | context!!.contentResolver.notifyChange(uri, null) 207 | } 208 | 209 | @RequiresApi(Build.VERSION_CODES.O) 210 | override fun findDocumentPath( 211 | parentDocumentId: String?, 212 | childDocumentId: String 213 | ): DocumentsContract.Path { 214 | if (!parentDocumentId.isNullOrEmpty()) { 215 | // not implementing this for now... 216 | return super.findDocumentPath(parentDocumentId, childDocumentId) 217 | } 218 | 219 | val nonRootPath = listOf(ROOT) + childDocumentId.removePrefix(ROOT).split("/") 220 | 221 | return DocumentsContract.Path(ROOT, nonRootPath) 222 | } 223 | } -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/activityresult/GeodeOpenFileActivityResult.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher.activityresult 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.net.Uri 7 | import android.os.Build 8 | import android.provider.DocumentsContract 9 | import androidx.activity.result.contract.ActivityResultContract 10 | 11 | open class GeodeOpenFileActivityResult : ActivityResultContract() { 12 | class OpenFileParams(val extraMimes: Array, val defaultPath: Uri?) {} 13 | override fun createIntent(context: Context, input: OpenFileParams): Intent { 14 | val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) 15 | .addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) 16 | .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) 17 | .putExtra(Intent.EXTRA_MIME_TYPES, input.extraMimes) 18 | .setType("*/*") 19 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 20 | if (input.defaultPath != null) { 21 | intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, input.defaultPath) 22 | } 23 | } 24 | return intent 25 | } 26 | 27 | final override fun getSynchronousResult( 28 | context: Context, 29 | input: OpenFileParams 30 | ): SynchronousResult? = null 31 | 32 | final override fun parseResult(resultCode: Int, intent: Intent?): Uri? { 33 | return intent.takeIf { resultCode == Activity.RESULT_OK }?.data 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/activityresult/GeodeOpenFilesActivityResult.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher.activityresult 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.net.Uri 7 | import android.os.Build 8 | import android.provider.DocumentsContract 9 | import androidx.activity.result.contract.ActivityResultContract 10 | 11 | open class GeodeOpenFilesActivityResult : ActivityResultContract>() { 12 | class OpenFileParams(val extraMimes: Array, val defaultPath: Uri?) {} 13 | override fun createIntent(context: Context, input: OpenFileParams): Intent { 14 | val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) 15 | .putExtra(Intent.EXTRA_MIME_TYPES, input.extraMimes) 16 | .addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) 17 | .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) 18 | .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) 19 | .setType("*/*") 20 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 21 | if (input.defaultPath != null) { 22 | intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, input.defaultPath) 23 | } 24 | } 25 | return intent 26 | } 27 | 28 | final override fun getSynchronousResult( 29 | context: Context, 30 | input: OpenFileParams 31 | ): SynchronousResult>? = null 32 | 33 | final override fun parseResult(resultCode: Int, intent: Intent?): List { 34 | // Gracefully stolen from ActivityResultContracts.Intent.getclipDataUris 35 | val clipData = intent.takeIf { 36 | resultCode == Activity.RESULT_OK 37 | }?.clipData 38 | val resultSet = LinkedHashSet() 39 | intent?.data?.let { data -> 40 | resultSet.add(data) 41 | } 42 | if (clipData == null && resultSet.isEmpty()) { 43 | return emptyList() 44 | } else if (clipData != null) { 45 | for (i in 0 until clipData.itemCount) { 46 | val uri = clipData.getItemAt(i).uri 47 | if (uri != null) { 48 | resultSet.add(uri) 49 | } 50 | } 51 | } 52 | return ArrayList(resultSet) 53 | } 54 | } 55 | 56 | -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/activityresult/GeodeSaveFileActivityResult.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher.activityresult 2 | 3 | import android.app.Activity 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.net.Uri 7 | import android.os.Build 8 | import android.provider.DocumentsContract 9 | import androidx.activity.result.contract.ActivityResultContract 10 | import androidx.documentfile.provider.DocumentFile 11 | import java.io.File 12 | 13 | open class GeodeSaveFileActivityResult : ActivityResultContract() { 14 | class SaveFileParams(val mimeType: String?, val initialPath: File?) 15 | override fun createIntent(context: Context, input: SaveFileParams): Intent { 16 | val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) 17 | .addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) 18 | .setType(input.mimeType ?: "*/*") 19 | 20 | if (input.initialPath != null) { 21 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 22 | val uri = DocumentFile.fromFile(input.initialPath).uri 23 | intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, uri) 24 | } 25 | 26 | intent.putExtra(Intent.EXTRA_TITLE, input.initialPath.name) 27 | } 28 | 29 | return intent 30 | } 31 | 32 | final override fun getSynchronousResult( 33 | context: Context, 34 | input: SaveFileParams 35 | ): SynchronousResult? = null 36 | 37 | final override fun parseResult(resultCode: Int, intent: Intent?): Uri? { 38 | return intent.takeIf { resultCode == Activity.RESULT_OK }?.data 39 | } 40 | } 41 | 42 | -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/log/LogLine.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher.log 2 | 3 | import kotlinx.datetime.Clock 4 | import kotlinx.datetime.Instant 5 | import kotlinx.datetime.TimeZone 6 | import kotlinx.datetime.toJavaInstant 7 | import kotlinx.datetime.toLocalDateTime 8 | import okio.Buffer 9 | import okio.BufferedSource 10 | import java.io.IOException 11 | 12 | fun BufferedSource.readCChar(): Char { 13 | return this.readUtf8CodePoint().toChar() 14 | } 15 | 16 | fun BufferedSource.readCString(): String { 17 | val buffer = StringBuilder() 18 | 19 | var lastByte = this.readCChar() 20 | while (lastByte != '\u0000') { 21 | buffer.append(lastByte) 22 | lastByte = this.readCChar() 23 | } 24 | 25 | return buffer.toString() 26 | } 27 | 28 | data class ProcessInformation(val processId: Int, val threadId: Int, val processUid: Int) 29 | 30 | enum class LogPriority { 31 | UNKNOWN, DEFAULT, VERBOSE, DEBUG, INFO, WARN, ERROR, FATAL, SILENT; 32 | 33 | class LogPriorityIterator( 34 | start: LogPriority, 35 | private val end: LogPriority, 36 | private val step: Int 37 | ) : Iterator { 38 | private var hasNext: Boolean = if (step > 0) start <= end else start >= end 39 | private var next = if (hasNext) start else end 40 | 41 | override fun hasNext() = hasNext 42 | 43 | override fun next(): LogPriority { 44 | val current = next 45 | if (current == end) { 46 | if (!hasNext) { 47 | throw NoSuchElementException() 48 | } 49 | 50 | hasNext = false 51 | } else { 52 | next = LogPriority.fromInt(next.toInt() + step) 53 | } 54 | 55 | return current 56 | } 57 | } 58 | 59 | class LogPriorityProgression( 60 | override val start: LogPriority, 61 | override val endInclusive: LogPriority, 62 | private val step: Int = 1 63 | ) : Iterable, ClosedRange { 64 | override fun iterator(): Iterator { 65 | return LogPriorityIterator(start, endInclusive, step) 66 | } 67 | } 68 | 69 | infix fun downTo(to: LogPriority) = LogPriorityProgression(this, to, -1) 70 | 71 | operator fun rangeTo(to: LogPriority) = LogPriorityProgression(this, to) 72 | 73 | companion object { 74 | fun fromByte(byte: Byte): LogPriority = fromInt(byte.toInt()) 75 | 76 | fun fromInt(int: Int) = when (int) { 77 | 0x1 -> DEFAULT 78 | 0x2 -> VERBOSE 79 | 0x3 -> DEBUG 80 | 0x4 -> INFO 81 | 0x5 -> WARN 82 | 0x6 -> ERROR 83 | 0x7 -> FATAL 84 | 0x8 -> SILENT 85 | else -> UNKNOWN 86 | } 87 | } 88 | 89 | fun toInt() = when (this) { 90 | DEFAULT -> 0x1 91 | VERBOSE -> 0x2 92 | DEBUG -> 0x3 93 | INFO -> 0x4 94 | WARN -> 0x5 95 | ERROR -> 0x6 96 | FATAL -> 0x7 97 | SILENT -> 0x8 98 | else -> 0x0 99 | } 100 | 101 | fun toChar(): Char { 102 | return when (this) { 103 | VERBOSE -> 'V' 104 | DEBUG -> 'D' 105 | INFO -> 'I' 106 | WARN -> 'W' 107 | ERROR -> 'E' 108 | FATAL -> 'F' 109 | SILENT -> 'S' 110 | else -> '?' 111 | } 112 | } 113 | } 114 | 115 | /** 116 | * Represents a log entry from logcat. 117 | */ 118 | data class LogLine( 119 | val process: ProcessInformation, 120 | val time: Instant, 121 | val logId: Int, 122 | val priority: LogPriority, 123 | val tag: String, 124 | val message: String 125 | ) { 126 | companion object { 127 | private enum class EntryVersion { 128 | V3, V4 129 | } 130 | 131 | private fun headerSizeToVersion(size: UShort) = when (size.toInt()) { 132 | 0x18 -> EntryVersion.V3 133 | 0x1c -> EntryVersion.V4 134 | else -> throw IOException("LogLine::fromInputStream: unknown format for (headerSize = $size)") 135 | } 136 | 137 | fun fromBufferedSource(source: BufferedSource): LogLine { 138 | /* 139 | // from android 140 | // there are multiple logger entry formats 141 | // use the header_size to determine the one you have 142 | 143 | struct logger_entry_v3 { 144 | uint16_t len; // length of the payload 145 | uint16_t hdr_size; // sizeof(struct logger_entry_v3) 146 | int32_t pid; // generating process's pid 147 | int32_t tid; // generating process's tid 148 | int32_t sec; // seconds since Epoch 149 | int32_t nsec; // nanoseconds 150 | uint32_t lid; // log id of the payload 151 | } 152 | 153 | struct logger_entry_v4 { 154 | uint16_t len; // length of the payload 155 | uint16_t hdr_size; // sizeof(struct logger_entry_v4 = 28) 156 | int32_t pid; // generating process's pid 157 | uint32_t tid; // generating process's tid 158 | uint32_t sec; // seconds since Epoch 159 | uint32_t nsec; // nanoseconds 160 | uint32_t lid; // log id of the payload, bottom 4 bits currently 161 | uint32_t uid; // generating process's uid 162 | }; 163 | */ 164 | 165 | val payloadLength = source.readShortLe().toLong() 166 | val headerSize = source.readShortLe().toUShort() 167 | 168 | val entryVersion = headerSizeToVersion(headerSize) 169 | 170 | val pid = source.readIntLe() 171 | val tid = source.readIntLe() 172 | val sec = source.readIntLe().toLong() 173 | val nSec = source.readIntLe() 174 | val lid = source.readIntLe() 175 | val uid = if (entryVersion == EntryVersion.V4) 176 | source.readIntLe() else 0 177 | 178 | val processInformation = ProcessInformation(pid, tid, uid) 179 | val time = Instant.fromEpochSeconds(sec, nSec) 180 | 181 | // the payload is split into three parts 182 | // initial priority byte -> null terminated tag -> non null terminated message 183 | 184 | val packetBuffer = Buffer() 185 | source.readFully(packetBuffer, payloadLength) 186 | 187 | val priorityByte = packetBuffer.readByte() 188 | val priority = LogPriority.fromByte(priorityByte) 189 | 190 | val tag = packetBuffer.readCString() 191 | val message = packetBuffer.readUtf8() 192 | 193 | return LogLine( 194 | process = processInformation, 195 | priority = priority, 196 | time = time, 197 | logId = lid, 198 | tag = tag, 199 | message = message 200 | ) 201 | } 202 | 203 | fun showException(exception: Exception) = LogLine( 204 | process = ProcessInformation(0, 0, 0), 205 | time = Clock.System.now(), 206 | logId = 0, 207 | priority = LogPriority.FATAL, 208 | tag = "GeodeLauncher", 209 | message = "Failed to parse log entry with ${exception.stackTraceToString()}" 210 | ) 211 | } 212 | 213 | val identifier = time.toJavaInstant() 214 | 215 | val messageTrimmed by lazy { 216 | this.message.trim { it <= ' ' } 217 | } 218 | 219 | val formattedTime by lazy { this.time.toLocalDateTime(TimeZone.currentSystemDefault()) } 220 | val asSimpleString by lazy { 221 | "$formattedTime [${this.priority.toChar()}/${this.tag}]: ${this.messageTrimmed}" 222 | } 223 | } -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/log/LogViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher.log 2 | 3 | import androidx.compose.runtime.mutableStateListOf 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import kotlinx.coroutines.Dispatchers 7 | import kotlinx.coroutines.Job 8 | import kotlinx.coroutines.coroutineScope 9 | import kotlinx.coroutines.flow.MutableStateFlow 10 | import kotlinx.coroutines.flow.asStateFlow 11 | import kotlinx.coroutines.launch 12 | import kotlinx.coroutines.withContext 13 | import kotlinx.coroutines.yield 14 | import okio.Buffer 15 | import okio.buffer 16 | import okio.source 17 | import java.io.EOFException 18 | import java.io.IOException 19 | 20 | /** 21 | * ViewModel to manage log output from logcat. 22 | * Currently, it loads the list of logs at init with no ability to read additional logs. 23 | */ 24 | class LogViewModel: ViewModel() { 25 | // the original version of this code did support streaming btw 26 | // the states were way too annoying to manage across multiple threads 27 | // yay! 28 | 29 | private val _lineState = mutableStateListOf() 30 | val lineState = _lineState 31 | 32 | private val _isLoading = MutableStateFlow(false) 33 | val isLoading = _isLoading.asStateFlow() 34 | 35 | private var failedToLoad = false 36 | 37 | private var logJob: Job? = null 38 | 39 | var filterCrashes = false 40 | private set 41 | 42 | // skip debug/verbose lines by default 43 | var logLevel = LogPriority.INFO 44 | set(value) { 45 | field = value 46 | 47 | _lineState.clear() 48 | loadLogs() 49 | } 50 | 51 | fun toggleCrashBuffer() { 52 | filterCrashes = !filterCrashes 53 | 54 | _lineState.clear() 55 | loadLogs() 56 | } 57 | 58 | private suspend fun logOutput(): List { 59 | val logLines = ArrayList() 60 | 61 | // -B = binary format, -d = dump logs 62 | val logCommand = if (filterCrashes) "logcat -b crash -B -d" 63 | else "logcat -B -d" 64 | 65 | val logProcess = try { 66 | withContext(Dispatchers.IO) { 67 | Runtime.getRuntime().exec(logCommand) 68 | } 69 | } catch (ioe: IOException) { 70 | ioe.printStackTrace() 71 | logLines += LogLine.showException(ioe) 72 | 73 | failedToLoad = true 74 | 75 | return logLines 76 | } 77 | 78 | // read entire log into a buffer so no logs are added to the buffer during processing 79 | val logBuffer = Buffer() 80 | logProcess.inputStream.source().buffer().readAll(logBuffer) 81 | 82 | try { 83 | coroutineScope { 84 | // this runs until the stream is exhausted 85 | while (true) { 86 | val line = LogLine.fromBufferedSource(logBuffer) 87 | 88 | if (line.priority >= logLevel) { 89 | logLines += line 90 | } 91 | 92 | yield() 93 | } 94 | } 95 | } catch (_: EOFException) { 96 | // ignore, end of file reached 97 | } catch (e: Exception) { 98 | e.printStackTrace() 99 | logLines += LogLine.showException(e) 100 | 101 | // technically it maybe didn't completely fail... 102 | failedToLoad = true 103 | } 104 | 105 | return logLines 106 | } 107 | 108 | fun clearLogs() { 109 | logJob?.cancel() 110 | 111 | viewModelScope.launch(Dispatchers.IO) { 112 | // -c = clear 113 | Runtime.getRuntime().exec("logcat -c") 114 | .waitFor() 115 | } 116 | 117 | _lineState.clear() 118 | } 119 | 120 | private fun dumpLogcatText(): String { 121 | val logCommand = if (filterCrashes) "logcat -b crash -d" 122 | else "logcat -d" 123 | 124 | val logProcess = try { 125 | Runtime.getRuntime().exec(logCommand) 126 | } catch (ioe: IOException) { 127 | ioe.printStackTrace() 128 | return ioe.stackTraceToString() 129 | } 130 | 131 | return logProcess.inputStream.bufferedReader().readText() 132 | } 133 | 134 | fun getLogData(): String = if (failedToLoad) { 135 | dumpLogcatText() 136 | } else { 137 | _lineState.joinToString("\n") { it.asSimpleString } 138 | } 139 | 140 | private fun loadLogs() { 141 | _isLoading.value = true 142 | 143 | logJob?.cancel() 144 | logJob = viewModelScope.launch(Dispatchers.IO) { 145 | val lines = logOutput() 146 | _lineState.addAll(lines) 147 | _isLoading.value = false 148 | 149 | logJob = null 150 | } 151 | } 152 | 153 | init { 154 | loadLogs() 155 | } 156 | } -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/main/Components.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher.main 2 | 3 | import android.content.ActivityNotFoundException 4 | import android.content.Context 5 | import android.content.Intent 6 | import android.net.Uri 7 | import android.widget.Toast 8 | import androidx.compose.foundation.Image 9 | import androidx.compose.foundation.clickable 10 | import androidx.compose.foundation.layout.Arrangement 11 | import androidx.compose.foundation.layout.Column 12 | import androidx.compose.foundation.layout.Row 13 | import androidx.compose.foundation.layout.Spacer 14 | import androidx.compose.foundation.layout.padding 15 | import androidx.compose.foundation.layout.size 16 | import androidx.compose.foundation.layout.sizeIn 17 | import androidx.compose.foundation.layout.width 18 | import androidx.compose.foundation.shape.AbsoluteRoundedCornerShape 19 | import androidx.compose.material3.ElevatedCard 20 | import androidx.compose.runtime.Composable 21 | import androidx.compose.ui.Alignment 22 | import androidx.compose.ui.Modifier 23 | import androidx.compose.ui.draw.clip 24 | import androidx.compose.ui.platform.LocalContext 25 | import androidx.compose.ui.res.painterResource 26 | import androidx.compose.ui.res.stringResource 27 | import androidx.compose.ui.unit.dp 28 | import com.geode.launcher.R 29 | import com.geode.launcher.utils.Constants 30 | 31 | fun onDownloadGame(context: Context) { 32 | val appUrl = "https://play.google.com/store/apps/details?id=${Constants.PACKAGE_NAME}" 33 | try { 34 | val intent = Intent(Intent.ACTION_VIEW).apply { 35 | data = Uri.parse(appUrl) 36 | setPackage("com.android.vending") 37 | } 38 | context.startActivity(intent) 39 | } catch (_: ActivityNotFoundException) { 40 | Toast.makeText(context, R.string.no_activity_found, Toast.LENGTH_SHORT).show() 41 | } 42 | } 43 | 44 | @Composable 45 | fun GooglePlayBadge(modifier: Modifier = Modifier) { 46 | val context = LocalContext.current 47 | 48 | Image( 49 | painter = painterResource(id = R.drawable.google_play_badge), 50 | contentDescription = stringResource(R.string.launcher_download_game), 51 | modifier = modifier 52 | .width(196.dp) 53 | .clip(AbsoluteRoundedCornerShape(4.dp)) 54 | .clickable { onDownloadGame(context) } 55 | ) 56 | } 57 | 58 | @Composable 59 | fun StatusIndicator( 60 | icon: @Composable () -> Unit, 61 | text: @Composable () -> Unit, 62 | onClick: () -> Unit, 63 | modifier: Modifier = Modifier 64 | ) { 65 | ElevatedCard(modifier = modifier, onClick = onClick) { 66 | Row(modifier = Modifier.padding(8.dp)) { 67 | icon() 68 | Spacer(Modifier.size(8.dp)) 69 | text() 70 | } 71 | } 72 | } 73 | 74 | @Composable 75 | fun InlineDialog(headline: @Composable () -> Unit, body: @Composable () -> Unit, modifier: Modifier = Modifier) { 76 | ElevatedCard(modifier = modifier) { 77 | Column(modifier = Modifier.padding(18.dp).sizeIn(maxWidth = 512.dp)) { 78 | Row( 79 | verticalAlignment = Alignment.CenterVertically, 80 | horizontalArrangement = Arrangement.spacedBy(8.dp), 81 | modifier = Modifier.padding(bottom = 8.dp) 82 | ) { 83 | headline() 84 | } 85 | 86 | body() 87 | } 88 | } 89 | } -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/main/LaunchNotification.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher.main 2 | 3 | import android.content.Context 4 | import androidx.compose.animation.AnimatedVisibility 5 | import androidx.compose.animation.core.MutableTransitionState 6 | import androidx.compose.animation.expandHorizontally 7 | import androidx.compose.animation.fadeIn 8 | import androidx.compose.animation.fadeOut 9 | import androidx.compose.animation.shrinkHorizontally 10 | import androidx.compose.foundation.layout.Column 11 | import androidx.compose.foundation.layout.IntrinsicSize 12 | import androidx.compose.foundation.layout.displayCutoutPadding 13 | import androidx.compose.foundation.layout.padding 14 | import androidx.compose.foundation.layout.size 15 | import androidx.compose.foundation.layout.width 16 | import androidx.compose.material.icons.Icons 17 | import androidx.compose.material.icons.filled.Info 18 | import androidx.compose.material.icons.filled.Warning 19 | import androidx.compose.material3.Icon 20 | import androidx.compose.material3.ListItem 21 | import androidx.compose.material3.LocalContentColor 22 | import androidx.compose.material3.MaterialTheme 23 | import androidx.compose.material3.OutlinedCard 24 | import androidx.compose.material3.Text 25 | import androidx.compose.runtime.Composable 26 | import androidx.compose.runtime.CompositionLocalProvider 27 | import androidx.compose.runtime.LaunchedEffect 28 | import androidx.compose.runtime.getValue 29 | import androidx.compose.runtime.mutableStateOf 30 | import androidx.compose.runtime.remember 31 | import androidx.compose.runtime.setValue 32 | import androidx.compose.ui.Modifier 33 | import androidx.compose.ui.platform.LocalContext 34 | import androidx.compose.ui.res.painterResource 35 | import androidx.compose.ui.res.stringResource 36 | import androidx.compose.ui.unit.dp 37 | import com.geode.launcher.R 38 | import com.geode.launcher.ui.theme.GeodeLauncherTheme 39 | import com.geode.launcher.ui.theme.LocalTheme 40 | import com.geode.launcher.ui.theme.Theme 41 | import com.geode.launcher.updater.ReleaseManager 42 | import com.geode.launcher.utils.Constants 43 | import com.geode.launcher.utils.GamePackageUtils 44 | import com.geode.launcher.utils.PreferenceUtils 45 | import kotlinx.coroutines.delay 46 | 47 | enum class LaunchNotificationType { 48 | GEODE_UPDATED, LAUNCHER_UPDATE_AVAILABLE, UPDATE_FAILED, UNSUPPORTED_VERSION; 49 | } 50 | 51 | fun determineDisplayedCards(context: Context): List { 52 | val cards = mutableListOf() 53 | 54 | if (GamePackageUtils.getGameVersionCode(context.packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) { 55 | cards.add(LaunchNotificationType.UNSUPPORTED_VERSION) 56 | } 57 | 58 | val releaseManager = ReleaseManager.get(context) 59 | val loadAutomatically = PreferenceUtils.get(context).getBoolean(PreferenceUtils.Key.LOAD_AUTOMATICALLY) 60 | 61 | val availableUpdate = releaseManager.availableLauncherUpdate.value 62 | if (loadAutomatically && availableUpdate != null) { 63 | cards.add(LaunchNotificationType.LAUNCHER_UPDATE_AVAILABLE) 64 | } 65 | 66 | val releaseState = releaseManager.uiState.value 67 | 68 | if (releaseState is ReleaseManager.ReleaseManagerState.Finished && releaseState.hasUpdated) { 69 | cards.add(LaunchNotificationType.GEODE_UPDATED) 70 | } 71 | 72 | if (releaseState is ReleaseManager.ReleaseManagerState.Failure) { 73 | cards.add(LaunchNotificationType.UPDATE_FAILED) 74 | } 75 | 76 | return cards 77 | } 78 | 79 | @Composable 80 | fun NotificationCardFromType(type: LaunchNotificationType) { 81 | when (type) { 82 | LaunchNotificationType.LAUNCHER_UPDATE_AVAILABLE -> { 83 | var showInfoDialog by remember { mutableStateOf(false) } 84 | 85 | if (showInfoDialog) { 86 | LauncherUpdateInformation { 87 | showInfoDialog = false 88 | } 89 | } 90 | 91 | AnimatedNotificationCard( 92 | displayLength = 5000L, 93 | onClick = { 94 | showInfoDialog = true 95 | } 96 | ) { 97 | LauncherUpdateContent() 98 | } 99 | } 100 | LaunchNotificationType.UPDATE_FAILED -> { 101 | AnimatedNotificationCard { 102 | UpdateFailedContent() 103 | } 104 | } 105 | LaunchNotificationType.UNSUPPORTED_VERSION -> { 106 | AnimatedNotificationCard { 107 | OutdatedVersionContent() 108 | } 109 | } 110 | else -> {} 111 | } 112 | } 113 | 114 | @Composable 115 | fun LaunchNotification() { 116 | val context = LocalContext.current 117 | 118 | val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME) 119 | val theme = Theme.fromInt(themeOption) 120 | 121 | val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND) 122 | 123 | val cards = determineDisplayedCards(context) 124 | 125 | CompositionLocalProvider(LocalTheme provides theme) { 126 | GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) { 127 | // surface is not in use, so this is unfortunately not provided 128 | CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { 129 | Column( 130 | modifier = Modifier.displayCutoutPadding() 131 | ) { 132 | cards.forEach { 133 | NotificationCardFromType(it) 134 | } 135 | } 136 | } 137 | } 138 | } 139 | } 140 | 141 | @Composable 142 | fun AnimatedNotificationCard(modifier: Modifier = Modifier, visibilityDelay: Long = 0L, displayLength: Long = 3000L, onClick: (() -> Unit)? = null, contents: @Composable () -> Unit) { 143 | val state = remember { 144 | MutableTransitionState(false).apply { 145 | targetState = visibilityDelay <= 0 146 | } 147 | } 148 | 149 | LaunchedEffect(true) { 150 | if (visibilityDelay > 0) { 151 | delay(visibilityDelay) 152 | state.targetState = true 153 | } 154 | 155 | delay(displayLength) 156 | state.targetState = false 157 | } 158 | 159 | AnimatedVisibility( 160 | visibleState = state, 161 | enter = expandHorizontally() + fadeIn(), 162 | exit = shrinkHorizontally() + fadeOut(), 163 | modifier = modifier 164 | ) { 165 | CardView(onClick, modifier = Modifier.padding(8.dp)) { 166 | contents() 167 | } 168 | } 169 | } 170 | 171 | @Composable 172 | fun CardView(onClick: (() -> Unit)?, modifier: Modifier = Modifier, contents: @Composable () -> Unit) { 173 | // val surfaceColor = MaterialTheme.colorScheme.background.copy(alpha = 0.75f) 174 | 175 | if (onClick != null) { 176 | OutlinedCard( 177 | onClick = onClick, 178 | modifier = modifier 179 | ) { 180 | contents() 181 | } 182 | } else { 183 | OutlinedCard( 184 | modifier = modifier 185 | ) { 186 | contents() 187 | } 188 | } 189 | } 190 | 191 | @Composable 192 | fun LauncherUpdateContent(modifier: Modifier = Modifier) { 193 | ListItem( 194 | headlineContent = { 195 | Text(text = stringResource(id = R.string.launcher_update_available)) 196 | }, 197 | supportingContent = { 198 | Text(text = stringResource(id = R.string.launcher_notification_update_cta)) 199 | }, 200 | leadingContent = { 201 | Icon( 202 | Icons.Filled.Info, 203 | contentDescription = null, 204 | ) 205 | }, 206 | modifier = modifier.width(IntrinsicSize.Max) 207 | ) 208 | } 209 | 210 | @Composable 211 | fun UpdateNotificationContent(modifier: Modifier = Modifier) { 212 | ListItem( 213 | headlineContent = { 214 | Text(text = stringResource(id = R.string.launcher_notification_update_success)) 215 | }, 216 | leadingContent = { 217 | Icon( 218 | painterResource(R.drawable.geode_monochrome), 219 | contentDescription = null, 220 | modifier = Modifier.size(24.dp) 221 | ) 222 | }, 223 | modifier = modifier.width(IntrinsicSize.Max) 224 | ) 225 | } 226 | 227 | @Composable 228 | fun OutdatedVersionContent(modifier: Modifier = Modifier) { 229 | ListItem( 230 | headlineContent = { 231 | Text(text = stringResource(id = R.string.launcher_notification_compatibility)) 232 | }, 233 | leadingContent = { 234 | Icon( 235 | Icons.Filled.Warning, 236 | contentDescription = null, 237 | ) 238 | }, 239 | supportingContent = { 240 | Text(text = stringResource(id = R.string.launcher_notification_compatibility_description)) 241 | }, 242 | modifier = modifier.width(IntrinsicSize.Max) 243 | ) 244 | } 245 | 246 | @Composable 247 | fun UpdateFailedContent(modifier: Modifier = Modifier) { 248 | ListItem( 249 | headlineContent = { 250 | Text(text = stringResource(id = R.string.launcher_notification_update_failed)) 251 | }, 252 | leadingContent = { 253 | Icon( 254 | Icons.Filled.Warning, 255 | contentDescription = null, 256 | ) 257 | }, 258 | modifier = modifier.width(IntrinsicSize.Max) 259 | ) 260 | } -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/main/LaunchViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher.main 2 | 3 | import android.app.Application 4 | import android.os.CountDownTimer 5 | import androidx.lifecycle.ViewModel 6 | import androidx.lifecycle.ViewModelProvider 7 | import androidx.lifecycle.viewModelScope 8 | import androidx.lifecycle.viewmodel.initializer 9 | import androidx.lifecycle.viewmodel.viewModelFactory 10 | import com.geode.launcher.updater.ReleaseManager 11 | import com.geode.launcher.utils.Constants 12 | import com.geode.launcher.utils.GamePackageUtils 13 | import com.geode.launcher.utils.LaunchUtils 14 | import com.geode.launcher.utils.PreferenceUtils 15 | import kotlinx.coroutines.flow.MutableStateFlow 16 | import kotlinx.coroutines.flow.asStateFlow 17 | import kotlinx.coroutines.flow.map 18 | import kotlinx.coroutines.flow.takeWhile 19 | import kotlinx.coroutines.launch 20 | 21 | private const val COOLDOWN_LENGTH_MS = 3000L 22 | 23 | class LaunchViewModel(private val application: Application): ViewModel() { 24 | companion object { 25 | val Factory: ViewModelProvider.Factory = viewModelFactory { 26 | initializer { 27 | val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as Application 28 | 29 | LaunchViewModel( 30 | application = application 31 | ) 32 | } 33 | } 34 | } 35 | 36 | private var readyTimerPassed = false 37 | private var readyTimer: CountDownTimer? = null 38 | 39 | private fun initReadyTimer() { 40 | readyTimer = object : CountDownTimer(COOLDOWN_LENGTH_MS, 1000) { 41 | override fun onTick(millisUntilFinished: Long) { 42 | // no tick necessary 43 | } 44 | 45 | override fun onFinish() { 46 | readyTimerPassed = true 47 | 48 | if (_uiState.value is LaunchUIState.Working) { 49 | viewModelScope.launch { 50 | preReadyCheck() 51 | } 52 | } 53 | } 54 | }.start() 55 | } 56 | 57 | enum class LaunchCancelReason { 58 | MANUAL, AUTOMATIC, LAST_LAUNCH_CRASHED, GEODE_NOT_FOUND, GAME_MISSING, GAME_OUTDATED; 59 | 60 | fun allowsRetry() = when (this) { 61 | MANUAL, 62 | AUTOMATIC, 63 | LAST_LAUNCH_CRASHED, 64 | GEODE_NOT_FOUND -> true 65 | GAME_MISSING, 66 | GAME_OUTDATED -> false 67 | } 68 | 69 | fun isGameInstallIssue() = when (this) { 70 | GAME_OUTDATED, 71 | GAME_MISSING -> true 72 | else -> false 73 | } 74 | } 75 | 76 | sealed class LaunchUIState { 77 | data object Initial : LaunchUIState() 78 | data object UpdateCheck : LaunchUIState() 79 | data class Updating(val downloaded: Long, val outOf: Long?) : LaunchUIState() 80 | data class Cancelled(val reason: LaunchCancelReason, val inProgress: Boolean = false) : LaunchUIState() 81 | data object Working : LaunchUIState() 82 | data object Ready : LaunchUIState() 83 | } 84 | 85 | private val _uiState = MutableStateFlow(LaunchUIState.Initial) 86 | val uiState = _uiState.asStateFlow() 87 | 88 | val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdate 89 | 90 | var loadFailure: LoadFailureInfo? = null 91 | private var hasCheckedForUpdates = false 92 | private var isCancelling = false 93 | private var hasManuallyStarted = false 94 | 95 | private suspend fun determineUpdateStatus() { 96 | ReleaseManager.get(application).uiState.takeWhile { 97 | it is ReleaseManager.ReleaseManagerState.InUpdateCheck || it is ReleaseManager.ReleaseManagerState.InDownload 98 | }.map { when (it) { 99 | is ReleaseManager.ReleaseManagerState.InUpdateCheck -> LaunchUIState.UpdateCheck 100 | is ReleaseManager.ReleaseManagerState.InDownload -> LaunchUIState.Updating(it.downloaded, it.outOf) 101 | else -> LaunchUIState.UpdateCheck 102 | } 103 | }.collect { 104 | _uiState.emit(it) 105 | } 106 | 107 | // flow terminates once update check is finished 108 | hasCheckedForUpdates = true 109 | preReadyCheck() 110 | } 111 | 112 | fun currentCrashInfo(): LoadFailureInfo? { 113 | val currentState = _uiState.value 114 | if (currentState is LaunchUIState.Cancelled && currentState.reason == LaunchCancelReason.LAST_LAUNCH_CRASHED) { 115 | return loadFailure 116 | } 117 | 118 | return null 119 | } 120 | 121 | fun clearCrashInfo() { 122 | val currentState = _uiState.value 123 | if (currentState is LaunchUIState.Cancelled && currentState.reason == LaunchCancelReason.LAST_LAUNCH_CRASHED) { 124 | loadFailure = null 125 | } 126 | } 127 | 128 | private suspend fun preReadyCheck() { 129 | if (isCancelling) { 130 | // don't let the game start if cancelled 131 | return 132 | } 133 | 134 | val packageManager = application.packageManager 135 | 136 | if (!GamePackageUtils.isGameInstalled(packageManager)) { 137 | return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING)) 138 | } 139 | 140 | if (ReleaseManager.get(application).isInUpdate) { 141 | viewModelScope.launch { 142 | determineUpdateStatus() 143 | } 144 | return 145 | } 146 | 147 | if (loadFailure != null) { 148 | // last launch crashed, so cancel 149 | return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.LAST_LAUNCH_CRASHED)) 150 | } 151 | 152 | if (GamePackageUtils.getGameVersionCode(packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN) { 153 | return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_OUTDATED)) 154 | } 155 | 156 | if (!LaunchUtils.isGeodeInstalled(application)) { 157 | return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GEODE_NOT_FOUND)) 158 | } 159 | 160 | val loadAutomatically = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.LOAD_AUTOMATICALLY) 161 | if (!loadAutomatically && !hasManuallyStarted) { 162 | return cancelLaunch(true) 163 | } 164 | 165 | if (!readyTimerPassed && !hasManuallyStarted) { 166 | return _uiState.emit(LaunchUIState.Working) 167 | } 168 | 169 | _uiState.emit(LaunchUIState.Ready) 170 | } 171 | 172 | suspend fun beginLaunchFlow(isRestart: Boolean = false) { 173 | if (isRestart) { 174 | hasManuallyStarted = true 175 | } 176 | 177 | if (_uiState.value !is LaunchUIState.Initial && _uiState.value !is LaunchUIState.Cancelled) { 178 | return 179 | } 180 | 181 | if (_uiState.value is LaunchUIState.Cancelled && !isRestart) { 182 | return 183 | } 184 | 185 | isCancelling = false 186 | 187 | if (!GamePackageUtils.isGameInstalled(application.packageManager)) { 188 | return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING)) 189 | } 190 | 191 | if (readyTimer == null) { 192 | initReadyTimer() 193 | } 194 | 195 | val hasGeode = LaunchUtils.isGeodeInstalled(application) 196 | val shouldUpdate = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY) 197 | if ((shouldUpdate && !hasCheckedForUpdates) || !hasGeode) { 198 | ReleaseManager.get(application).checkForUpdates(false) 199 | } 200 | 201 | preReadyCheck() 202 | } 203 | 204 | suspend fun cancelLaunch(isAutomatic: Boolean = false) { 205 | // no need to double cancel 206 | if (_uiState.value is LaunchUIState.Cancelled) { 207 | return 208 | } 209 | 210 | val reason = if (isAutomatic) LaunchCancelReason.AUTOMATIC else LaunchCancelReason.MANUAL 211 | 212 | isCancelling = true 213 | _uiState.emit(LaunchUIState.Cancelled(reason, true)) 214 | 215 | val releaseManager = ReleaseManager.get(application) 216 | if (releaseManager.isInUpdate) { 217 | releaseManager.cancelUpdate() 218 | } 219 | 220 | _uiState.emit(LaunchUIState.Cancelled(reason, false)) 221 | } 222 | 223 | override fun onCleared() { 224 | super.onCleared() 225 | 226 | readyTimer?.cancel() 227 | } 228 | } -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/preferences/DeveloperSettingsActivity.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher.preferences 2 | 3 | import android.os.Build 4 | import android.os.Bundle 5 | import androidx.activity.ComponentActivity 6 | import androidx.activity.OnBackPressedDispatcher 7 | import androidx.activity.compose.setContent 8 | import androidx.activity.enableEdgeToEdge 9 | import androidx.compose.foundation.layout.Arrangement 10 | import androidx.compose.foundation.layout.Column 11 | import androidx.compose.foundation.layout.fillMaxSize 12 | import androidx.compose.foundation.layout.fillMaxWidth 13 | import androidx.compose.foundation.layout.padding 14 | import androidx.compose.foundation.rememberScrollState 15 | import androidx.compose.foundation.verticalScroll 16 | import androidx.compose.material.icons.Icons 17 | import androidx.compose.material.icons.automirrored.filled.ArrowBack 18 | import androidx.compose.material.icons.filled.Info 19 | import androidx.compose.material3.ElevatedCard 20 | import androidx.compose.material3.ExperimentalMaterial3Api 21 | import androidx.compose.material3.HorizontalDivider 22 | import androidx.compose.material3.Icon 23 | import androidx.compose.material3.IconButton 24 | import androidx.compose.material3.MaterialTheme 25 | import androidx.compose.material3.Scaffold 26 | import androidx.compose.material3.Surface 27 | import androidx.compose.material3.Text 28 | import androidx.compose.material3.TopAppBar 29 | import androidx.compose.material3.TopAppBarDefaults 30 | import androidx.compose.runtime.Composable 31 | import androidx.compose.runtime.CompositionLocalProvider 32 | import androidx.compose.runtime.getValue 33 | import androidx.compose.ui.Modifier 34 | import androidx.compose.ui.input.nestedscroll.nestedScroll 35 | import androidx.compose.ui.res.stringResource 36 | import androidx.compose.ui.text.style.TextOverflow 37 | import androidx.compose.ui.unit.dp 38 | import com.geode.launcher.R 39 | import com.geode.launcher.ui.theme.GeodeLauncherTheme 40 | import com.geode.launcher.ui.theme.LocalTheme 41 | import com.geode.launcher.ui.theme.Theme 42 | import com.geode.launcher.utils.LabelledText 43 | import com.geode.launcher.utils.PreferenceUtils 44 | 45 | class DeveloperSettingsActivity : ComponentActivity() { 46 | override fun onCreate(savedInstanceState: Bundle?) { 47 | enableEdgeToEdge() 48 | 49 | super.onCreate(savedInstanceState) 50 | setContent { 51 | val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME) 52 | val theme = Theme.fromInt(themeOption) 53 | 54 | val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND) 55 | 56 | CompositionLocalProvider(LocalTheme provides theme) { 57 | GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) { 58 | Surface( 59 | modifier = Modifier.fillMaxSize(), 60 | color = MaterialTheme.colorScheme.background 61 | ) { 62 | DeveloperSettingsScreen(onBackPressedDispatcher) 63 | } 64 | } 65 | } 66 | } 67 | } 68 | } 69 | 70 | @OptIn(ExperimentalMaterial3Api::class) 71 | @Composable 72 | fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) { 73 | val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() 74 | 75 | Scaffold( 76 | modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), 77 | topBar = { 78 | Column { 79 | TopAppBar( 80 | navigationIcon = { 81 | IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) { 82 | Icon( 83 | imageVector = Icons.AutoMirrored.Filled.ArrowBack, 84 | contentDescription = stringResource(R.string.back_icon_alt) 85 | ) 86 | } 87 | }, 88 | title = { 89 | Text( 90 | stringResource(R.string.title_activity_developer_settings), 91 | maxLines = 1, 92 | overflow = TextOverflow.Ellipsis 93 | ) 94 | }, 95 | scrollBehavior = scrollBehavior, 96 | ) 97 | } 98 | } 99 | ) { innerPadding -> 100 | Column( 101 | Modifier 102 | .padding(innerPadding) 103 | .fillMaxWidth() 104 | .verticalScroll(rememberScrollState()), 105 | verticalArrangement = Arrangement.spacedBy(8.dp) 106 | ) { 107 | 108 | val developerMode by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE) 109 | 110 | SettingsCard( 111 | title = stringResource(R.string.preference_developer_mode), 112 | preferenceKey = PreferenceUtils.Key.DEVELOPER_MODE, 113 | ) 114 | 115 | InlineText( 116 | stringResource(R.string.preference_developer_mode_about) 117 | ) 118 | 119 | HorizontalDivider() 120 | 121 | if (developerMode) { 122 | OptionsGroup(title = stringResource(R.string.preference_category_gameplay)) { 123 | SettingsStringCard( 124 | title = stringResource(R.string.preference_launch_arguments_name), 125 | dialogTitle = stringResource(R.string.preference_launch_arguments_set_title), 126 | preferenceKey = PreferenceUtils.Key.LAUNCH_ARGUMENTS, 127 | filterInput = { it.filter { c -> 128 | // if only there was a better way to define this! 129 | "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*(){}<>[]?:;'\"~`-_+=\\| ".contains(c) 130 | }} 131 | ) 132 | 133 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 134 | SettingsCard( 135 | title = stringResource(R.string.preference_force_hrr), 136 | preferenceKey = PreferenceUtils.Key.FORCE_HRR, 137 | ) 138 | } 139 | /* 140 | SettingsCard( 141 | title = stringResource(R.string.preference_override_exceptions_name), 142 | description = stringResource(R.string.preference_override_exceptions_description), 143 | preferenceKey = PreferenceUtils.Key.CUSTOM_SYMBOL_LIST 144 | ) 145 | */ 146 | } 147 | 148 | OptionsGroup(title = stringResource(R.string.preference_category_updater)) { 149 | SettingsCard( 150 | title = stringResource(R.string.preference_use_index_update_api), 151 | preferenceKey = PreferenceUtils.Key.USE_INDEX_API 152 | ) 153 | 154 | SettingsSelectCard( 155 | title = stringResource(R.string.preference_release_channel_tag_name), 156 | dialogTitle = stringResource(R.string.preference_release_channel_select), 157 | preferenceKey = PreferenceUtils.Key.RELEASE_CHANNEL_TAG, 158 | options = linkedMapOf( 159 | 0 to stringResource(R.string.preference_release_channel_stable), 160 | 1 to stringResource(R.string.preference_release_channel_beta), 161 | 2 to stringResource(R.string.preference_release_channel_nightly), 162 | ) 163 | ) 164 | 165 | ElevatedCard(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) { 166 | LabelledText( 167 | label = stringResource(R.string.preference_developer_update_notice), 168 | icon = { 169 | Icon(Icons.Filled.Info, null) 170 | }, 171 | modifier = Modifier.padding(8.dp) 172 | ) 173 | } 174 | } 175 | 176 | OptionsGroup(title = stringResource(R.string.preference_category_testing)) { 177 | SettingsCard( 178 | title = stringResource(R.string.preference_enable_redesign), 179 | preferenceKey = PreferenceUtils.Key.ENABLE_REDESIGN, 180 | ) 181 | } 182 | 183 | OptionsGroup(title = stringResource(R.string.preference_profiles)) { 184 | ProfileCreateCard() 185 | ProfileSelectCard() 186 | } 187 | } 188 | } 189 | } 190 | } -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple80 = Color(0xFFD0BCFF) 6 | val PurpleGrey80 = Color(0xFFCCC2DC) 7 | val Pink80 = Color(0xFFEFB8C8) 8 | 9 | val Purple40 = Color(0xFF6650a4) 10 | val PurpleGrey40 = Color(0xFF625b71) 11 | val Pink40 = Color(0xFF7D5260) -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher.ui.theme 2 | 3 | import android.app.Activity 4 | import android.os.Build 5 | import androidx.compose.foundation.isSystemInDarkTheme 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.darkColorScheme 8 | import androidx.compose.material3.dynamicDarkColorScheme 9 | import androidx.compose.material3.dynamicLightColorScheme 10 | import androidx.compose.material3.lightColorScheme 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.runtime.SideEffect 13 | import androidx.compose.runtime.compositionLocalOf 14 | import androidx.compose.ui.graphics.Color 15 | import androidx.compose.ui.graphics.toArgb 16 | import androidx.compose.ui.platform.LocalContext 17 | import androidx.compose.ui.platform.LocalView 18 | import androidx.core.view.ViewCompat 19 | import androidx.core.view.WindowCompat 20 | 21 | private val DarkColorScheme = darkColorScheme( 22 | primary = Purple80, 23 | secondary = PurpleGrey80, 24 | tertiary = Pink80 25 | ) 26 | 27 | private val LightColorScheme = lightColorScheme( 28 | primary = Purple40, 29 | secondary = PurpleGrey40, 30 | tertiary = Pink40 31 | 32 | /* Other default colors to override 33 | background = Color(0xFFFFFBFE), 34 | surface = Color(0xFFFFFBFE), 35 | onPrimary = Color.White, 36 | onSecondary = Color.White, 37 | onTertiary = Color.White, 38 | onBackground = Color(0xFF1C1B1F), 39 | onSurface = Color(0xFF1C1B1F), 40 | */ 41 | ) 42 | 43 | enum class Theme { 44 | LIGHT, DARK; 45 | 46 | companion object { 47 | @Composable 48 | fun fromInt(value: Int) = when (value) { 49 | 1 -> LIGHT 50 | 2 -> DARK 51 | else -> if (isSystemInDarkTheme()) DARK else LIGHT 52 | } 53 | } 54 | } 55 | 56 | val LocalTheme = compositionLocalOf { Theme.LIGHT } 57 | 58 | @Composable 59 | fun GeodeLauncherTheme( 60 | theme: Theme = Theme.fromInt(0), 61 | // Dynamic color is available on Android 12+ 62 | dynamicColor: Boolean = true, 63 | blackBackground: Boolean = false, 64 | content: @Composable () -> Unit 65 | ) { 66 | val colorScheme = when { 67 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 68 | val context = LocalContext.current 69 | when { 70 | theme == Theme.DARK && blackBackground -> 71 | dynamicDarkColorScheme(context).copy(surface = Color.Black, background = Color.Black) 72 | theme == Theme.DARK -> dynamicDarkColorScheme(context) 73 | else -> dynamicLightColorScheme(context) 74 | } 75 | } 76 | theme == Theme.DARK && blackBackground -> 77 | DarkColorScheme.copy(surface = Color.Black, background = Color.Black) 78 | theme == Theme.DARK -> DarkColorScheme 79 | else -> LightColorScheme 80 | } 81 | 82 | MaterialTheme( 83 | colorScheme = colorScheme, 84 | typography = Typography, 85 | content = content 86 | ) 87 | } -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | lineHeight = 24.sp, 16 | letterSpacing = 0.5.sp 17 | ), 18 | titleMedium = TextStyle( 19 | fontFamily = FontFamily.Default, 20 | fontWeight = FontWeight.SemiBold, 21 | fontSize = 18.sp, 22 | letterSpacing = 0.5.sp 23 | ) 24 | /* Other default text styles to override 25 | titleLarge = TextStyle( 26 | fontFamily = FontFamily.Default, 27 | fontWeight = FontWeight.Normal, 28 | fontSize = 22.sp, 29 | lineHeight = 28.sp, 30 | letterSpacing = 0.sp 31 | ), 32 | labelSmall = TextStyle( 33 | fontFamily = FontFamily.Default, 34 | fontWeight = FontWeight.Medium, 35 | fontSize = 11.sp, 36 | lineHeight = 16.sp, 37 | letterSpacing = 0.5.sp 38 | ) 39 | */ 40 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/updater/Release.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher.updater 2 | 3 | import com.geode.launcher.utils.LaunchUtils 4 | import kotlinx.datetime.Instant 5 | import kotlinx.serialization.Serializable 6 | 7 | @Serializable 8 | data class Asset( 9 | val url: String, 10 | val id: Int, 11 | val name: String, 12 | val size: Int, 13 | val createdAt: Instant, 14 | val updatedAt: Instant, 15 | val browserDownloadUrl: String, 16 | ) 17 | 18 | @Serializable 19 | data class Release( 20 | val url: String, 21 | val id: Int, 22 | val targetCommitish: String, 23 | val tagName: String, 24 | val createdAt: Instant, 25 | val publishedAt: Instant, 26 | val assets: List, 27 | val htmlUrl: String, 28 | val body: String?, 29 | val name: String?, 30 | ) 31 | 32 | @Serializable 33 | data class LoaderVersion( 34 | val tag: String, 35 | val version: String, 36 | val createdAt: Instant, 37 | val commitHash: String, 38 | val prerelease: Boolean 39 | ) 40 | 41 | @Serializable 42 | data class LoaderPayload( 43 | val payload: T?, 44 | val error: String 45 | ) 46 | 47 | data class DownloadableAsset(val url: String, val filename: String, val size: Long? = null) 48 | 49 | abstract class Downloadable { 50 | abstract fun getDescription(): String 51 | abstract fun getDescriptor(): Long 52 | abstract fun getDownload(): DownloadableAsset? 53 | } 54 | 55 | class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadable() { 56 | override fun getDescription(): String { 57 | if (release.tagName == "nightly") { 58 | // get the commit from the assets 59 | // otherwise, a separate request is needed to get the hash (ew) 60 | val asset = release.assets.first() 61 | val commit = asset.name.substring(6..12) 62 | 63 | return "nightly-$commit" 64 | } 65 | 66 | return release.tagName 67 | } 68 | 69 | override fun getDescriptor(): Long { 70 | return release.createdAt.epochSeconds 71 | } 72 | 73 | private fun getGitHubDownload(): Asset? { 74 | // try to find an asset that matches the architecture first 75 | val platform = LaunchUtils.platformName 76 | 77 | val releaseSuffix = "$platform.zip" 78 | return release.assets.find { 79 | it.name.endsWith(releaseSuffix) 80 | } 81 | } 82 | 83 | override fun getDownload(): DownloadableAsset? { 84 | val download = getGitHubDownload() ?: return null 85 | return DownloadableAsset( 86 | url = download.browserDownloadUrl, 87 | filename = download.name, 88 | size = download.size.toLong() 89 | ) 90 | } 91 | } 92 | 93 | class DownloadableLauncherRelease(val release: Release) : Downloadable() { 94 | override fun getDescription(): String { 95 | return release.tagName 96 | } 97 | 98 | override fun getDescriptor(): Long { 99 | return release.createdAt.epochSeconds 100 | } 101 | 102 | private fun getGitHubDownload(): Asset? { 103 | val platform = LaunchUtils.platformName 104 | val use32BitPlatform = platform == "android32" 105 | 106 | return release.assets.find { asset -> 107 | /* you know it's good when you pull out the truth table 108 | * u32bp | contains | found 109 | * 1 | 1 | 1 110 | * 0 | 1 | 0 111 | * 1 | 0 | 0 112 | * 0 | 0 | 1 113 | * surprise! it's an xnor 114 | */ 115 | 116 | (asset.name.contains("android32")) == use32BitPlatform 117 | } 118 | } 119 | 120 | override fun getDownload(): DownloadableAsset? { 121 | val download = getGitHubDownload() ?: return null 122 | return DownloadableAsset( 123 | url = download.browserDownloadUrl, 124 | filename = download.name, 125 | size = download.size.toLong() 126 | ) 127 | } 128 | } 129 | 130 | class DownloadableLoaderRelease(private val version: LoaderVersion) : Downloadable() { 131 | override fun getDescription(): String { 132 | return version.tag 133 | } 134 | 135 | override fun getDescriptor(): Long { 136 | return version.createdAt.epochSeconds 137 | } 138 | 139 | override fun getDownload(): DownloadableAsset? { 140 | val filename = "geode-${version.tag}-${LaunchUtils.platformName}.zip" 141 | return DownloadableAsset( 142 | url = "https://github.com/geode-sdk/geode/releases/download/${version.tag}/$filename", 143 | filename = filename 144 | ) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/updater/ReleaseRepository.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher.updater 2 | 3 | import com.geode.launcher.utils.DownloadUtils.executeCoroutine 4 | import kotlinx.datetime.Clock 5 | import kotlinx.datetime.DateTimeUnit 6 | import kotlinx.datetime.Instant 7 | import kotlinx.datetime.until 8 | import kotlinx.serialization.ExperimentalSerializationApi 9 | import kotlinx.serialization.json.Json 10 | import kotlinx.serialization.json.JsonNamingStrategy 11 | import kotlinx.serialization.json.okio.decodeFromBufferedSource 12 | import okhttp3.HttpUrl.Companion.toHttpUrlOrNull 13 | import okhttp3.OkHttpClient 14 | import okhttp3.Request 15 | import java.io.IOException 16 | import java.net.URL 17 | 18 | class ReleaseRepository(private val httpClient: OkHttpClient) { 19 | companion object { 20 | private const val GITHUB_API_BASE = "https://api.github.com" 21 | private const val GITHUB_API_HEADER = "X-GitHub-Api-Version" 22 | private const val GITHUB_API_VERSION = "2022-11-28" 23 | 24 | private const val GITHUB_RATELIMIT_REMAINING = "x-ratelimit-remaining" 25 | private const val GITHUB_RATELIMIT_RESET = "x-ratelimit-reset" 26 | 27 | private const val GEODE_API_BASE = "https://api.geode-sdk.org/v1" 28 | } 29 | 30 | suspend fun getLatestLauncherRelease(): DownloadableLauncherRelease? { 31 | val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/android-launcher/releases/latest" 32 | 33 | val url = URL(releasePath) 34 | 35 | return getReleaseByUrl(url)?.let(::DownloadableLauncherRelease) 36 | } 37 | 38 | suspend fun getLatestGeodeRelease(): DownloadableGitHubLoaderRelease? { 39 | val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases/latest" 40 | val url = URL(releasePath) 41 | 42 | return getReleaseByUrl(url)?.let(::DownloadableGitHubLoaderRelease) 43 | } 44 | 45 | suspend fun getReleaseByTag(tag: String): DownloadableGitHubLoaderRelease? { 46 | val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases/tags/$tag" 47 | val url = URL(releasePath) 48 | 49 | return getReleaseByUrl(url)?.let(::DownloadableGitHubLoaderRelease) 50 | } 51 | 52 | @OptIn(ExperimentalSerializationApi::class) 53 | suspend fun getLatestGeodePreRelease(): DownloadableGitHubLoaderRelease? { 54 | val releasesUrl = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases?per_page=2" 55 | val url = URL(releasesUrl) 56 | 57 | val request = Request.Builder() 58 | .url(url) 59 | .addHeader("Accept", "application/json") 60 | .addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION) 61 | .build() 62 | 63 | val call = httpClient.newCall(request) 64 | val response = call.executeCoroutine() 65 | 66 | return when (response.code) { 67 | 200 -> { 68 | val format = Json { 69 | namingStrategy = JsonNamingStrategy.SnakeCase 70 | ignoreUnknownKeys = true 71 | } 72 | 73 | val release = format.decodeFromBufferedSource>( 74 | response.body!!.source() 75 | ) 76 | 77 | (release.find { it.tagName != "nightly" }) 78 | ?.let(::DownloadableGitHubLoaderRelease) 79 | } 80 | 403 -> { 81 | // determine if the error code is a ratelimit 82 | // (github docs say it sends 429 too, but haven't seen that) 83 | 84 | val limitRemaining = response.headers.get(GITHUB_RATELIMIT_REMAINING)?.toInt() 85 | val limitReset = response.headers.get(GITHUB_RATELIMIT_RESET)?.toLong() 86 | 87 | if (limitRemaining == 0 && limitReset != null) { 88 | // handle ratelimit with a custom error 89 | // there's also a retry-after header but again, haven't seen 90 | val resetTime = Instant.fromEpochSeconds(limitReset, 0L) 91 | val msg = generateRateLimitMessage(resetTime) 92 | 93 | throw IOException(msg) 94 | } 95 | 96 | throw IOException("response 403: ${response.body!!.string()}") 97 | } 98 | 404 -> { 99 | null 100 | } 101 | else -> { 102 | throw IOException("unknown response ${response.code}") 103 | } 104 | } 105 | } 106 | 107 | private fun generateRateLimitMessage(resetTime: Instant): String { 108 | val currentTime = Clock.System.now() 109 | val resetDelay = currentTime.until(resetTime, DateTimeUnit.SECOND) 110 | 111 | val formattedWait = "${resetDelay / 60}m" 112 | 113 | return "api ratelimit reached, try again in ${formattedWait}" 114 | } 115 | 116 | @OptIn(ExperimentalSerializationApi::class) 117 | private suspend fun getReleaseByUrl(url: URL): Release? { 118 | val request = Request.Builder() 119 | .url(url) 120 | .addHeader("Accept", "application/json") 121 | .addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION) 122 | .build() 123 | 124 | val call = httpClient.newCall(request) 125 | val response = call.executeCoroutine() 126 | 127 | return when (response.code) { 128 | 200 -> { 129 | val format = Json { 130 | namingStrategy = JsonNamingStrategy.SnakeCase 131 | ignoreUnknownKeys = true 132 | } 133 | 134 | val release = format.decodeFromBufferedSource( 135 | response.body!!.source() 136 | ) 137 | 138 | release 139 | } 140 | 403 -> { 141 | // determine if the error code is a ratelimit 142 | // (github docs say it sends 429 too, but haven't seen that) 143 | 144 | val limitRemaining = response.headers[GITHUB_RATELIMIT_REMAINING]?.toInt() 145 | val limitReset = response.headers[GITHUB_RATELIMIT_RESET]?.toLong() 146 | 147 | if (limitRemaining == 0 && limitReset != null) { 148 | // handle ratelimit with a custom error 149 | // there's also a retry-after header but again, haven't seen 150 | val resetTime = Instant.fromEpochSeconds(limitReset, 0L) 151 | val msg = generateRateLimitMessage(resetTime) 152 | 153 | throw IOException(msg) 154 | } 155 | 156 | throw IOException("response 403: ${response.body!!.string()}") 157 | } 158 | 404 -> { 159 | null 160 | } 161 | else -> { 162 | throw IOException("unknown response ${response.code}") 163 | } 164 | } 165 | } 166 | 167 | @OptIn(ExperimentalSerializationApi::class) 168 | suspend fun getLatestIndexRelease(gd: Long, prerelease: Boolean = false): DownloadableLoaderRelease? { 169 | val url = "$GEODE_API_BASE/loader/versions/latest".toHttpUrlOrNull()!! 170 | .newBuilder() 171 | .addQueryParameter("gd", gd.toString()) 172 | .addQueryParameter("platform", "android") 173 | .addQueryParameter("prerelease", prerelease.toString()) 174 | .build() 175 | 176 | val request = Request.Builder() 177 | .url(url) 178 | .addHeader("Accept", "application/json") 179 | .build() 180 | 181 | val call = httpClient.newCall(request) 182 | val response = call.executeCoroutine() 183 | 184 | return when (response.code) { 185 | 200 -> { 186 | val format = Json { 187 | namingStrategy = JsonNamingStrategy.SnakeCase 188 | ignoreUnknownKeys = true 189 | } 190 | 191 | val body = format.decodeFromBufferedSource>( 192 | response.body!!.source() 193 | ) 194 | 195 | val release = body.payload ?: throw IOException("index error: ${body.error}") 196 | 197 | DownloadableLoaderRelease(release) 198 | } 199 | 404 -> { 200 | null 201 | } 202 | else -> { 203 | throw IOException("unknown response ${response.code}") 204 | } 205 | } 206 | } 207 | } -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/updater/ReleaseViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher.updater 2 | 3 | import android.app.Application 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.ViewModelProvider 6 | import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY 7 | import androidx.lifecycle.viewModelScope 8 | import androidx.lifecycle.viewmodel.initializer 9 | import androidx.lifecycle.viewmodel.viewModelFactory 10 | import kotlinx.coroutines.Dispatchers 11 | import kotlinx.coroutines.flow.MutableStateFlow 12 | import kotlinx.coroutines.flow.StateFlow 13 | import kotlinx.coroutines.flow.asStateFlow 14 | import kotlinx.coroutines.flow.map 15 | import kotlinx.coroutines.launch 16 | 17 | class ReleaseViewModel(private val application: Application): ViewModel() { 18 | companion object { 19 | val Factory: ViewModelProvider.Factory = viewModelFactory { 20 | initializer { 21 | val application = this[APPLICATION_KEY] as Application 22 | 23 | ReleaseViewModel( 24 | application = application 25 | ) 26 | } 27 | } 28 | } 29 | 30 | sealed class ReleaseUIState { 31 | data object InUpdateCheck : ReleaseUIState() 32 | data class Failure(val exception: Exception) : ReleaseUIState() 33 | data class InDownload(val downloaded: Long, val outOf: Long?) : ReleaseUIState() 34 | data class Finished(val hasUpdated: Boolean = false) : ReleaseUIState() 35 | data class Cancelled(val isCancelling: Boolean = false) : ReleaseUIState() 36 | 37 | companion object { 38 | fun managerStateToUI(state: ReleaseManager.ReleaseManagerState): ReleaseUIState { 39 | return when (state) { 40 | is ReleaseManager.ReleaseManagerState.InUpdateCheck -> InUpdateCheck 41 | is ReleaseManager.ReleaseManagerState.Failure -> Failure(state.exception) 42 | is ReleaseManager.ReleaseManagerState.InDownload -> 43 | InDownload(state.downloaded, state.outOf) 44 | is ReleaseManager.ReleaseManagerState.Finished -> Finished(state.hasUpdated) 45 | } 46 | } 47 | } 48 | } 49 | 50 | private val _uiState = MutableStateFlow(ReleaseUIState.Finished()) 51 | val uiState = _uiState.asStateFlow() 52 | 53 | val isInUpdate 54 | get() = ReleaseManager.get(application).isInUpdate 55 | 56 | val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdate 57 | 58 | var hasPerformedCheck = false 59 | private set 60 | 61 | fun cancelUpdate() { 62 | viewModelScope.launch { 63 | _uiState.value = ReleaseUIState.Cancelled(true) 64 | 65 | ReleaseManager.get(application).cancelUpdate() 66 | 67 | _uiState.value = ReleaseUIState.Cancelled() 68 | } 69 | } 70 | 71 | private suspend fun syncUiState( 72 | flow: StateFlow 73 | ) { 74 | flow.map(ReleaseUIState::managerStateToUI).collect { 75 | // send the mapped state to the ui 76 | _uiState.value = it 77 | } 78 | } 79 | 80 | fun useGlobalCheckState() { 81 | hasPerformedCheck = true 82 | 83 | viewModelScope.launch(Dispatchers.IO) { 84 | val releaseFlow = ReleaseManager.get(application) 85 | .uiState 86 | 87 | syncUiState(releaseFlow) 88 | } 89 | } 90 | 91 | fun runReleaseCheck(isManual: Boolean = false) { 92 | hasPerformedCheck = true 93 | 94 | viewModelScope.launch(Dispatchers.IO) { 95 | val releaseFlow = ReleaseManager.get(application) 96 | .checkForUpdates(isManual) 97 | 98 | syncUiState(releaseFlow) 99 | } 100 | } 101 | } -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/utils/Components.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher.utils 2 | 3 | import androidx.compose.foundation.layout.Spacer 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.text.InlineTextContent 6 | import androidx.compose.foundation.text.appendInlineContent 7 | import androidx.compose.material3.Text 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.text.Placeholder 11 | import androidx.compose.ui.text.PlaceholderVerticalAlign 12 | import androidx.compose.ui.text.buildAnnotatedString 13 | import androidx.compose.ui.unit.sp 14 | 15 | @Composable 16 | fun LabelledText(label: String, icon: @Composable (() -> Unit)? = null, modifier: Modifier = Modifier) { 17 | if (icon != null) { 18 | val iconId = "inlineIcon" 19 | val paddingId = "paddingIcon" 20 | 21 | val labelText = buildAnnotatedString { 22 | appendInlineContent(iconId, "[x]") 23 | appendInlineContent(paddingId, " ") 24 | append(label) 25 | } 26 | 27 | val inlineContent = mapOf( 28 | Pair(iconId, InlineTextContent( 29 | Placeholder( 30 | width = 24.sp, 31 | height = 24.sp, 32 | placeholderVerticalAlign = PlaceholderVerticalAlign.TextCenter 33 | ) 34 | ) { 35 | icon() 36 | }), 37 | Pair(paddingId, InlineTextContent(Placeholder(width = 6.sp, height = 24.sp, placeholderVerticalAlign = PlaceholderVerticalAlign.Center)) { 38 | Spacer(modifier = Modifier.fillMaxWidth()) 39 | }) 40 | ) 41 | 42 | Text(text = labelText, inlineContent = inlineContent, modifier = modifier) 43 | } else { 44 | Text(label, modifier = modifier) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/utils/Constants.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher.utils 2 | 3 | import android.annotation.SuppressLint 4 | 5 | object Constants { 6 | const val PACKAGE_NAME = "com.robtopx.geometryjump" 7 | 8 | const val COCOS_LIB_NAME = "cocos2dcpp" 9 | const val FMOD_LIB_NAME = "fmod" 10 | const val LAUNCHER_FIX_LIB_NAME = "launcherfix" 11 | 12 | // this value is hardcoded into GD 13 | @SuppressLint("SdCardPath") 14 | const val GJ_DATA_DIR = "/data/data/${PACKAGE_NAME}" 15 | 16 | // anything below this version code is blocked from opening the game 17 | const val SUPPORTED_VERSION_CODE_MIN = 37L 18 | 19 | // anything below this version code shows a warning on the main menu 20 | const val SUPPORTED_VERSION_CODE_MIN_WARNING = 40L 21 | 22 | // anything above this version code will show a warning when starting the game 23 | const val SUPPORTED_VERSION_CODE = 40L 24 | 25 | const val SUPPORTED_VERSION_STRING = "2.2.143" 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/utils/ConstrainedFrameLayout.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher.utils 2 | 3 | import android.content.Context 4 | import android.widget.FrameLayout 5 | import kotlin.math.ceil 6 | import kotlin.math.floor 7 | import kotlin.math.min 8 | 9 | class ConstrainedFrameLayout(context: Context) : FrameLayout(context) { 10 | // avoid running the aspect ratio fix when we don't need it 11 | var aspectRatio: Float? = null 12 | var zoom: Float? = null 13 | var fitZoom: Boolean = false 14 | 15 | override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { 16 | var paddingX = 0.0f 17 | var paddingY = 0.0f 18 | 19 | val currentZoom = zoom 20 | if (currentZoom != null) { 21 | val width = MeasureSpec.getSize(widthMeasureSpec) 22 | val height = MeasureSpec.getSize(heightMeasureSpec) 23 | if (width == 0 || height == 0) { 24 | super.onMeasure(widthMeasureSpec, heightMeasureSpec) 25 | return 26 | } 27 | 28 | val calculatedWidth = width * currentZoom 29 | val calculatedHeight = height * currentZoom 30 | 31 | paddingX = (width - calculatedWidth)/2 32 | paddingY = (height - calculatedHeight)/2 33 | } 34 | 35 | val currentAspectRatio = aspectRatio 36 | if (currentAspectRatio != null) { 37 | val width = MeasureSpec.getSize(widthMeasureSpec) 38 | val height = MeasureSpec.getSize(heightMeasureSpec) 39 | if (width == 0 || height == 0) { 40 | super.onMeasure(widthMeasureSpec, heightMeasureSpec) 41 | return 42 | } 43 | 44 | val calculatedWidth = (height - paddingY * 2) * currentAspectRatio 45 | val padding = (width - calculatedWidth) / 2 46 | 47 | paddingX = padding 48 | } 49 | 50 | if (paddingX > 0.0 || paddingY > 0.0) { 51 | if (fitZoom) { 52 | val width = MeasureSpec.getSize(widthMeasureSpec) 53 | val height = MeasureSpec.getSize(heightMeasureSpec) 54 | 55 | val calculatedHeight = (height - paddingY * 2) 56 | val calculatedWidth = (width - paddingX * 2) 57 | 58 | val zoomFactor = min(height / calculatedHeight, width / calculatedWidth) 59 | 60 | scaleX = zoomFactor 61 | scaleY = zoomFactor 62 | } 63 | 64 | setPadding( 65 | ceil(paddingX).toInt(), 66 | ceil(paddingY).toInt(), 67 | floor(paddingX).toInt(), 68 | floor(paddingY).toInt() 69 | ) 70 | } 71 | 72 | super.onMeasure( 73 | widthMeasureSpec, 74 | heightMeasureSpec 75 | ) 76 | } 77 | } -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/utils/DownloadUtils.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher.utils 2 | 3 | import android.os.Build 4 | import android.os.FileUtils 5 | import kotlinx.coroutines.runInterruptible 6 | import kotlinx.coroutines.suspendCancellableCoroutine 7 | import okhttp3.Call 8 | import okhttp3.Callback 9 | import okhttp3.MediaType 10 | import okhttp3.OkHttpClient 11 | import okhttp3.Request 12 | import okhttp3.Response 13 | import okhttp3.ResponseBody 14 | import okio.Buffer 15 | import okio.BufferedSource 16 | import okio.ForwardingSource 17 | import okio.Source 18 | import okio.buffer 19 | import java.io.IOException 20 | import java.io.InputStream 21 | import java.io.OutputStream 22 | import java.util.concurrent.TimeUnit 23 | import java.util.zip.ZipInputStream 24 | import kotlin.coroutines.resume 25 | import kotlin.coroutines.resumeWithException 26 | 27 | 28 | typealias ProgressCallback = (progress: Long, outOf: Long) -> Unit 29 | 30 | object DownloadUtils { 31 | suspend fun downloadStream( 32 | httpClient: OkHttpClient, 33 | url: String, 34 | onProgress: ProgressCallback? = null 35 | ): InputStream { 36 | val request = Request.Builder() 37 | .url(url) 38 | .build() 39 | 40 | // build a new client using the same pool as the old client 41 | // (more efficient) 42 | val progressClientBuilder = httpClient.newBuilder() 43 | .cache(null) 44 | // disable timeout 45 | .readTimeout(0, TimeUnit.SECONDS) 46 | 47 | // add progress listener 48 | if (onProgress != null) { 49 | progressClientBuilder.addNetworkInterceptor { chain -> 50 | val originalResponse = chain.proceed(chain.request()) 51 | originalResponse.newBuilder() 52 | .body(ProgressResponseBody( 53 | originalResponse.body!!, onProgress 54 | )) 55 | .build() 56 | } 57 | } 58 | 59 | val progressClient = progressClientBuilder.build() 60 | 61 | val call = progressClient.newCall(request) 62 | val response = call.executeCoroutine() 63 | 64 | return response.body!!.byteStream() 65 | } 66 | 67 | suspend fun Call.executeCoroutine(): Response { 68 | return suspendCancellableCoroutine { continuation -> 69 | this.enqueue(object : Callback { 70 | override fun onFailure(call: Call, e: IOException) { 71 | if (continuation.isCancelled) { 72 | return 73 | } 74 | 75 | continuation.resumeWithException(e) 76 | } 77 | 78 | override fun onResponse(call: Call, response: Response) { 79 | continuation.resume(response) 80 | } 81 | }) 82 | 83 | continuation.invokeOnCancellation { 84 | this.cancel() 85 | } 86 | } 87 | } 88 | 89 | suspend fun extractFileFromZipStream(inputStream: InputStream, outputStream: OutputStream, zipPath: String) { 90 | // note to self: ZipInputStreams are a little silly 91 | // (runInterruptible allows it to cancel, otherwise it waits for the stream to finish) 92 | runInterruptible { 93 | inputStream.use { 94 | outputStream.use { 95 | // nice indentation 96 | val zip = ZipInputStream(inputStream) 97 | zip.use { 98 | var entry = it.nextEntry 99 | while (entry != null) { 100 | if (entry.name == zipPath) { 101 | it.copyTo(outputStream) 102 | return@runInterruptible 103 | } 104 | 105 | entry = it.nextEntry 106 | } 107 | 108 | // no matching entries found, throw exception 109 | throw IOException("no file found for $zipPath") 110 | } 111 | } 112 | } 113 | } 114 | } 115 | 116 | fun copyFile(inputStream: InputStream, outputStream: OutputStream) { 117 | // gotta love copying 118 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 119 | inputStream.use { input -> outputStream.use { output -> 120 | FileUtils.copy(input, output) 121 | }} 122 | } else { 123 | inputStream.use { input -> outputStream.use { output -> 124 | input.copyTo(output) 125 | }} 126 | } 127 | } 128 | } 129 | 130 | // based on 131 | // lazily ported to kotlin 132 | private class ProgressResponseBody ( 133 | private val responseBody: ResponseBody, 134 | private val progressCallback: ProgressCallback 135 | ) : ResponseBody() { 136 | private val bufferedSource: BufferedSource by lazy { 137 | source(responseBody.source()).buffer() 138 | } 139 | 140 | override fun contentType(): MediaType? { 141 | return responseBody.contentType() 142 | } 143 | 144 | override fun contentLength(): Long { 145 | return responseBody.contentLength() 146 | } 147 | 148 | override fun source(): BufferedSource { 149 | return bufferedSource 150 | } 151 | 152 | private fun source(source: Source): Source { 153 | return object : ForwardingSource(source) { 154 | var totalBytesRead = 0L 155 | 156 | override fun read(sink: Buffer, byteCount: Long): Long { 157 | val bytesRead = super.read(sink, byteCount) 158 | totalBytesRead += if (bytesRead != -1L) bytesRead else 0 159 | 160 | progressCallback( 161 | totalBytesRead, 162 | responseBody.contentLength() 163 | ) 164 | 165 | return bytesRead 166 | } 167 | } 168 | } 169 | } -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/utils/FileUtils.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher.utils 2 | 3 | import android.content.ContentUris 4 | import android.content.Context 5 | import android.database.Cursor 6 | import android.net.Uri 7 | import android.os.Environment 8 | import android.provider.DocumentsContract 9 | import android.provider.MediaStore 10 | import java.io.File 11 | 12 | class FileUtils { 13 | // copied from https://stackoverflow.com/questions/17546101/get-real-path-for-uri-android 14 | // i am actually very lazy to move this to a separate class 15 | // hello zmx i moved it 16 | companion object { 17 | fun getRealPathFromURI(context: Context, uri: Uri): String? { 18 | when { 19 | // DocumentProvider 20 | DocumentsContract.isDocumentUri(context, uri) -> { 21 | when { 22 | // ExternalStorageProvider 23 | isExternalStorageDocument(uri) -> { 24 | return handleExternalStorageDocument(context, uri) 25 | } 26 | isDownloadsDocument(uri) -> { 27 | return handleDownloadsDocument(context, uri) 28 | } 29 | isMediaDocument(uri) -> { 30 | return handleMediaDocument(context, uri) 31 | } 32 | 33 | // TODO: add the geode provider 34 | } 35 | } 36 | "content".equals(uri.scheme, ignoreCase = true) -> { 37 | // Return the remote address 38 | if (isGooglePhotosUri(uri)) return uri.lastPathSegment 39 | // This happened with a directory 40 | if (isExternalStorageDocument(uri)) { 41 | return handleExternalStorageDocument(context, uri) 42 | } 43 | return getDataColumn(context, uri, null, null) 44 | } 45 | "file".equals(uri.scheme, ignoreCase = true) -> { 46 | return uri.path 47 | } 48 | } 49 | return null 50 | } 51 | 52 | private fun handleMediaDocument(context: Context, uri: Uri): String? { 53 | val docId = DocumentsContract.getDocumentId(uri) 54 | val split = docId.split(":").toTypedArray() 55 | val type = split[0] 56 | var contentUri: Uri? = null 57 | when (type) { 58 | "image" -> { 59 | contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI 60 | } 61 | "video" -> { 62 | contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI 63 | } 64 | "audio" -> { 65 | contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI 66 | } 67 | } 68 | val selection = "_id=?" 69 | val selectionArgs = arrayOf(split[1]) 70 | return getDataColumn( 71 | context, 72 | contentUri, 73 | selection, 74 | selectionArgs 75 | ) 76 | } 77 | 78 | private fun handleDownloadsDocument(context: Context, uri: Uri): String? { 79 | val fileName = getFilePath(context, uri) 80 | if (fileName != null) { 81 | return Environment.getExternalStorageDirectory().toString() + "/Download/" + fileName 82 | } 83 | var id = DocumentsContract.getDocumentId(uri) 84 | if (id.startsWith("raw:")) { 85 | id = id.replaceFirst("raw:".toRegex(), "") 86 | val file = File(id) 87 | if (file.exists()) return id 88 | } 89 | val contentUri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), java.lang.Long.valueOf(id)) 90 | return getDataColumn(context, contentUri, null, null) 91 | } 92 | 93 | private fun handleExternalStorageDocument(context: Context, uri: Uri): String { 94 | val docId: String = if (DocumentsContract.isDocumentUri(context, uri)) { 95 | DocumentsContract.getDocumentId(uri) 96 | } else { 97 | // please please please PLEASE let this be the only other case 98 | DocumentsContract.getTreeDocumentId(uri) 99 | } 100 | val split = docId.split(":").toTypedArray() 101 | val type = split[0] 102 | // This is for checking Main Memory 103 | return if ("primary".equals(type, ignoreCase = true)) { 104 | if (split.size > 1) { 105 | Environment.getExternalStorageDirectory().toString() + "/" + split[1] 106 | } else { 107 | Environment.getExternalStorageDirectory().toString() + "/" 108 | } 109 | // This is for checking SD Card 110 | } else { 111 | "storage" + "/" + docId.replace(":", "/") 112 | } 113 | } 114 | private fun getFilePath(context: Context, uri: Uri?): String? { 115 | var cursor: Cursor? = null 116 | val projection = arrayOf( 117 | MediaStore.MediaColumns.DISPLAY_NAME 118 | ) 119 | try { 120 | if (uri == null) return null 121 | cursor = context.contentResolver.query( 122 | uri, projection, null, null, 123 | null 124 | ) 125 | if (cursor != null && cursor.moveToFirst()) { 126 | val index = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DISPLAY_NAME) 127 | val data = cursor.getString(index) 128 | cursor.close() 129 | return data 130 | } 131 | } catch (e: Exception) { 132 | cursor?.close() 133 | } finally { 134 | cursor?.close() 135 | } 136 | return null 137 | } 138 | 139 | private fun getDataColumn(context: Context, uri: Uri?, selection: String?, 140 | selectionArgs: Array?): String? { 141 | var cursor: Cursor? = null 142 | val column = "_data" 143 | val projection = arrayOf( 144 | column 145 | ) 146 | try { 147 | if (uri == null) return null 148 | cursor = context.contentResolver.query( 149 | uri, projection, selection, selectionArgs, 150 | null 151 | ) 152 | if (cursor != null && cursor.moveToFirst()) { 153 | val index = cursor.getColumnIndexOrThrow(column) 154 | val data = cursor.getString(index) 155 | cursor.close() 156 | return data 157 | } 158 | } catch (e: Exception) { 159 | cursor?.close() 160 | } 161 | return null 162 | } 163 | 164 | /** 165 | * @param uri The Uri to check. 166 | * @return Whether the Uri authority is ExternalStorageProvider. 167 | */ 168 | private fun isExternalStorageDocument(uri: Uri): Boolean { 169 | return "com.android.externalstorage.documents" == uri.authority 170 | } 171 | 172 | /** 173 | * @param uri The Uri to check. 174 | * @return Whether the Uri authority is DownloadsProvider. 175 | */ 176 | private fun isDownloadsDocument(uri: Uri): Boolean { 177 | return "com.android.providers.downloads.documents" == uri.authority 178 | } 179 | 180 | /** 181 | * @param uri The Uri to check. 182 | * @return Whether the Uri authority is MediaProvider. 183 | */ 184 | private fun isMediaDocument(uri: Uri): Boolean { 185 | return "com.android.providers.media.documents" == uri.authority 186 | } 187 | 188 | /** 189 | * @param uri The Uri to check. 190 | * @return Whether the Uri authority is Google Photos. 191 | */ 192 | private fun isGooglePhotosUri(uri: Uri): Boolean { 193 | return "com.google.android.apps.photos.content" == uri.authority 194 | } 195 | } 196 | } -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/utils/GamePackageUtils.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher.utils 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.Context 5 | import android.content.pm.ApplicationInfo 6 | import android.content.pm.PackageInfo 7 | import android.content.pm.PackageManager 8 | import android.content.res.AssetManager 9 | import android.os.Build 10 | import android.util.Log 11 | import okio.ByteString.Companion.toByteString 12 | 13 | object GamePackageUtils { 14 | fun isGameInstalled(packageManager: PackageManager): Boolean { 15 | return try { 16 | packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0) 17 | true 18 | } catch (e: PackageManager.NameNotFoundException) { 19 | false 20 | } 21 | } 22 | 23 | fun showDownloadBadge(packageManager: PackageManager) = 24 | !isGameInstalled(packageManager) || !identifyGameLegitimacy(packageManager) 25 | 26 | fun getGameVersionCode(packageManager: PackageManager): Long { 27 | val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0) 28 | 29 | return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { 30 | game.longVersionCode 31 | } else { 32 | @Suppress("DEPRECATION") 33 | game.versionCode.toLong() 34 | } 35 | } 36 | 37 | fun getGameVersionCodeOrNull(packageManager: PackageManager): Long? { 38 | return try { 39 | getGameVersionCode(packageManager) 40 | } catch (e: PackageManager.NameNotFoundException) { 41 | null 42 | } 43 | } 44 | 45 | fun getGameVersionString(packageManager: PackageManager): String { 46 | val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, 0) 47 | return game.versionName ?: "0.000" 48 | } 49 | 50 | private val gameVersionMap = mapOf( 51 | 37L to "2.200", 52 | 38L to "2.205", 53 | 39L to "2.206", 54 | 40L to "2.2074" // ok 55 | ) 56 | 57 | /** 58 | * Gets the version name that more closely aligns to the Windows/macOS releases 59 | */ 60 | fun getUnifiedVersionName(packageManager: PackageManager): String { 61 | val versionCode = getGameVersionCode(packageManager) 62 | return gameVersionMap[versionCode] ?: getGameVersionString(packageManager) 63 | } 64 | 65 | fun detectAbiMismatch(context: Context, packageInfo: PackageInfo, loadException: Error): Boolean { 66 | val abi = LaunchUtils.applicationArchitecture 67 | val isExtracted = 68 | packageInfo.applicationInfo!!.flags and ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS == ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS 69 | val isSplit = (packageInfo.applicationInfo?.splitSourceDirs?.size ?: 0) > 1 70 | 71 | val metadata = 72 | "Geometry Dash metadata:\nSplit sources: $isSplit\nExtracted libraries: $isExtracted\nLauncher architecture: $abi" 73 | Log.i("GeodeLauncher", metadata) 74 | 75 | // easiest check! these messages are hardcoded in bionic/linker/linker_phdr.cpp 76 | if (loadException.message?.contains("32-bit instead of 64-bit") == true) { 77 | return true 78 | } 79 | 80 | if (loadException.message?.contains("64-bit instead of 32-bit") == true) { 81 | return true 82 | } 83 | 84 | // determine if it can find gd libraries for the opposite architecture 85 | val gdBinaryName = "lib${Constants.COCOS_LIB_NAME}.so" 86 | val oppositeArchitecture = if (abi == "arm64-v8a") "armeabi-v7a" else "arm64-v8a" 87 | 88 | // detect issues using native library directory (only works on extracted libraries) 89 | val nativeLibraryDir = packageInfo.applicationInfo!!.nativeLibraryDir 90 | 91 | if (nativeLibraryDir.contains(oppositeArchitecture)) { 92 | return true 93 | } 94 | 95 | // android has some odd behavior where 32bit libraries get installed to the arm directory instead of armeabi-v7a 96 | // detect it, ig 97 | if (oppositeArchitecture == "armeabi-v7a" && nativeLibraryDir.contains("/lib/arm/")) { 98 | return true 99 | } 100 | 101 | if (!isExtracted) { 102 | try { 103 | context.assets.openNonAssetFd("lib/$oppositeArchitecture/$gdBinaryName") 104 | return true 105 | } catch (_: Exception) { 106 | // this is good, actually! 107 | } 108 | } 109 | 110 | return false 111 | } 112 | 113 | @SuppressLint("DiscouragedPrivateApi") 114 | fun addAssetsFromPackage(assetManager: AssetManager, packageInfo: PackageInfo) { 115 | // this method is officially marked as deprecated but it is the only method we are allowed to reflect 116 | // (the source recommends replacing with AssetManager.setApkAssets(ApkAssets[], boolean) lol) 117 | val clazz = assetManager.javaClass 118 | val aspMethod = clazz.getDeclaredMethod("addAssetPath", String::class.java) 119 | 120 | aspMethod.invoke(assetManager, packageInfo.applicationInfo?.sourceDir) 121 | packageInfo.applicationInfo?.splitSourceDirs?.forEach { 122 | aspMethod.invoke(assetManager, it) 123 | } 124 | } 125 | 126 | private const val GAME_CERTIFICATE_HASH = "f5e7d8284d72c461a5f022d0cf755df101c3bb1e69cbe241bc1aef2cc5610a43" 127 | private const val AMAZON_CERTIFICATE_HASH = "1f228081e4d66e006887d4ba0f3d40e96a80c758b84231f11cd5c1c9aef6048f" 128 | 129 | private fun validateCertificate(certificate: ByteArray): GameSource = when (val hash = certificate.toByteString().sha256().hex()) { 130 | GAME_CERTIFICATE_HASH -> GameSource.GOOGLE 131 | AMAZON_CERTIFICATE_HASH -> GameSource.AMAZON 132 | else -> { 133 | println("Encountered certificate hash: $hash") 134 | GameSource.UNKNOWN 135 | } 136 | } 137 | 138 | enum class GameSource { 139 | UNKNOWN, GOOGLE, AMAZON 140 | } 141 | 142 | fun identifyGameSource(packageManager: PackageManager): GameSource { 143 | @Suppress("DEPRECATION") 144 | val certificates = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { 145 | val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, PackageManager.GET_SIGNING_CERTIFICATES) 146 | val signingInfo = game.signingInfo 147 | 148 | signingInfo?.signingCertificateHistory ?: return GameSource.UNKNOWN 149 | } else { 150 | val game = packageManager.getPackageInfo(Constants.PACKAGE_NAME, PackageManager.GET_SIGNATURES) 151 | game.signatures 152 | } 153 | 154 | return certificates?.map { 155 | validateCertificate(it.toByteArray()) 156 | }?.firstOrNull { 157 | it != GameSource.UNKNOWN 158 | } ?: GameSource.UNKNOWN 159 | } 160 | 161 | fun identifyGameLegitimacy(packageManager: PackageManager) = 162 | identifyGameSource(packageManager) != GameSource.UNKNOWN 163 | } -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/utils/LaunchUtils.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher.utils 2 | 3 | import android.content.Context 4 | import android.os.Build 5 | import java.io.File 6 | import java.io.Serializable 7 | 8 | object LaunchUtils { 9 | // supposedly CPU_ABI returns the current arch for the running application 10 | // despite being deprecated, this is also one of the few ways to get this information 11 | @Suppress("DEPRECATION") 12 | val applicationArchitecture: String = Build.CPU_ABI 13 | 14 | val is64bit = applicationArchitecture == "arm64-v8a" 15 | 16 | val platformName: String = if (is64bit) "android64" else "android32" 17 | 18 | val geodeFilename: String = "Geode.$platformName.so" 19 | 20 | fun getInstalledGeodePath(context: Context): File? { 21 | val geodeName = geodeFilename 22 | 23 | val internalGeodePath = File(context.filesDir, "launcher/$geodeName") 24 | if (internalGeodePath.exists()) { 25 | return internalGeodePath 26 | } 27 | 28 | val externalGeodeDir = getBaseDirectory(context) 29 | 30 | val updateGeodePath = File(externalGeodeDir, "launcher/$geodeName") 31 | if (updateGeodePath.exists()) { 32 | return updateGeodePath 33 | } 34 | 35 | val externalGeodePath = File(externalGeodeDir, geodeName) 36 | if (externalGeodePath.exists()) { 37 | return externalGeodePath 38 | } 39 | 40 | return null 41 | } 42 | 43 | fun isGeodeInstalled(context: Context): Boolean { 44 | return getInstalledGeodePath(context) != null 45 | } 46 | 47 | /** 48 | * Returns the directory that Geode/the game should base itself off of. 49 | */ 50 | fun getBaseDirectory(context: Context, ignoreProfile: Boolean = false): File { 51 | // deprecated, but seems to be the best choice of directory (i forced mat to test it) 52 | // also, is getting the first item the correct choice here?? what do they mean 53 | @Suppress("DEPRECATION") 54 | val dir = context.externalMediaDirs.first() 55 | 56 | // prevent having resources added to system gallery 57 | // accessing this file every time the directory is read may be a little wasteful... 58 | val noMediaPath = File(dir, ".nomedia") 59 | noMediaPath.createNewFile() 60 | 61 | val currentProfile = ProfileManager.get(context).getCurrentProfile() 62 | if (currentProfile != null && !ignoreProfile) { 63 | val profile = File(dir, "profiles/$currentProfile/") 64 | profile.mkdirs() 65 | 66 | return profile 67 | } 68 | 69 | return dir 70 | } 71 | 72 | fun getSaveDirectory(context: Context): File { 73 | return File(getBaseDirectory(context), "save") 74 | } 75 | 76 | private const val CRASH_INDICATOR_NAME = "lastSessionDidCrash" 77 | 78 | private fun getCrashDirectory(context: Context): File { 79 | val base = getBaseDirectory(context) 80 | return File(base, "game/geode/crashlogs/") 81 | } 82 | 83 | fun getLastCrash(context: Context): File? { 84 | val crashDirectory = getCrashDirectory(context) 85 | if (!crashDirectory.exists()) { 86 | return null 87 | } 88 | 89 | val children = crashDirectory.listFiles { 90 | // ignore indicator files (including old file) 91 | _, name -> name != CRASH_INDICATOR_NAME && name != "last-pid" 92 | } 93 | 94 | return children?.maxByOrNull { it.lastModified() } 95 | } 96 | 97 | fun lastSessionCrashed(context: Context): Boolean { 98 | val base = getCrashDirectory(context) 99 | val crashIndicatorFile = File(base, CRASH_INDICATOR_NAME) 100 | 101 | return crashIndicatorFile.exists() 102 | } 103 | 104 | enum class LauncherError : Serializable { 105 | LINKER_NEEDS_64BIT, 106 | LINKER_NEEDS_32BIT, 107 | LINKER_FAILED_STL, 108 | LINKER_FAILED, 109 | GENERIC, 110 | CRASHED; 111 | 112 | fun isAbiFailure(): Boolean { 113 | return this == LINKER_NEEDS_32BIT || this == LINKER_NEEDS_64BIT 114 | } 115 | } 116 | 117 | const val LAUNCHER_KEY_RETURN_ERROR = "return_error" 118 | const val LAUNCHER_KEY_RETURN_MESSAGE = "return_message" 119 | const val LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE = "return_extended_message" 120 | } -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/utils/ProfileManager.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher.utils 2 | 3 | import android.content.Context 4 | import android.content.SharedPreferences 5 | import androidx.core.content.edit 6 | import kotlinx.coroutines.flow.MutableStateFlow 7 | import kotlinx.coroutines.flow.asStateFlow 8 | import kotlinx.serialization.Serializable 9 | import kotlinx.serialization.json.Json 10 | 11 | private const val FILE_KEY = "GeodeLauncherPreferencesFileKey" 12 | private const val PROFILES_FILE_KEY = "GeodeLauncherProfiles" 13 | 14 | private const val PROFILE_LIST_SAVE_KEY = "profiles" 15 | private const val PROFILE_CURRENT_SAVE_KEY = "currentProfile" 16 | 17 | @Serializable 18 | data class Profile(val path: String, val name: String) 19 | 20 | class ProfileManager { 21 | private val applicationContext: Context 22 | private val preferences: SharedPreferences 23 | 24 | companion object { 25 | private lateinit var managerInstance: ProfileManager 26 | 27 | fun get(context: Context): ProfileManager { 28 | if (!Companion::managerInstance.isInitialized) { 29 | val applicationContext = context.applicationContext 30 | 31 | managerInstance = ProfileManager(applicationContext) 32 | } 33 | 34 | return managerInstance 35 | } 36 | } 37 | 38 | constructor(applicationContext: Context) { 39 | this.applicationContext = applicationContext 40 | this.preferences = applicationContext.getSharedPreferences(PROFILES_FILE_KEY, Context.MODE_PRIVATE) 41 | 42 | _storedProfiles.value = getProfiles() 43 | } 44 | 45 | private val _storedProfiles = MutableStateFlow>(emptyList()) 46 | val storedProfiles = _storedProfiles.asStateFlow() 47 | 48 | fun getCurrentProfile(): String? { 49 | return preferences.getString(PROFILE_CURRENT_SAVE_KEY, null) 50 | .takeUnless { it.isNullOrEmpty() } 51 | } 52 | 53 | fun getCurrentFileKey(): String { 54 | val currentProfile = getCurrentProfile() 55 | ?: return FILE_KEY 56 | 57 | return "${FILE_KEY}_${currentProfile}" 58 | } 59 | 60 | fun getProfile(path: String): Profile? { 61 | val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) 62 | return profilesSet?.firstNotNullOfOrNull { 63 | Json.decodeFromString(it).takeIf { it.path == path } 64 | } 65 | } 66 | 67 | /** 68 | * Store a profile into the save, removing any duplicate profiles beforehand 69 | */ 70 | fun storeProfile(profile: Profile) { 71 | val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) ?: emptySet() 72 | 73 | val removedList = profilesSet.filterTo(LinkedHashSet()) { 74 | val saveProfile = Json.decodeFromString(it) 75 | saveProfile.path != profile.path 76 | } 77 | removedList.add(Json.encodeToString(profile)) 78 | 79 | preferences.edit { 80 | putStringSet(PROFILE_LIST_SAVE_KEY, removedList) 81 | } 82 | 83 | _storedProfiles.value = getProfiles() 84 | } 85 | 86 | fun deleteProfile(path: String) { 87 | val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) ?: emptySet() 88 | 89 | val removedList = profilesSet.filterTo(LinkedHashSet()) { 90 | val saveProfile = Json.decodeFromString(it) 91 | saveProfile.path != path 92 | } 93 | 94 | preferences.edit { 95 | putStringSet(PROFILE_LIST_SAVE_KEY, removedList) 96 | } 97 | 98 | _storedProfiles.value = getProfiles() 99 | } 100 | 101 | fun deleteProfiles(paths: List) { 102 | val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) ?: emptySet() 103 | 104 | val removedList = profilesSet.filterTo(LinkedHashSet()) { 105 | val saveProfile = Json.decodeFromString(it) 106 | !paths.contains(saveProfile.path) 107 | } 108 | 109 | preferences.edit(commit = true) { 110 | putStringSet(PROFILE_LIST_SAVE_KEY, removedList) 111 | } 112 | 113 | _storedProfiles.value = getProfiles() 114 | } 115 | 116 | fun setCurrentProfile(profile: String?) { 117 | preferences.edit(commit = true) { 118 | if (profile.isNullOrEmpty()) { 119 | remove(PROFILE_CURRENT_SAVE_KEY) 120 | } else { 121 | putString(PROFILE_CURRENT_SAVE_KEY, profile) 122 | } 123 | } 124 | } 125 | 126 | fun getProfiles(): List { 127 | val profilesSet = preferences.getStringSet(PROFILE_LIST_SAVE_KEY, null) ?: emptySet() 128 | 129 | return profilesSet.map { 130 | Json.decodeFromString(it) 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /app/src/main/java/com/geode/launcher/utils/useCountdownTimer.kt: -------------------------------------------------------------------------------- 1 | package com.geode.launcher.utils 2 | 3 | import androidx.compose.runtime.* 4 | import androidx.lifecycle.compose.LocalLifecycleOwner 5 | import androidx.lifecycle.Lifecycle 6 | import androidx.lifecycle.LifecycleEventObserver 7 | import androidx.lifecycle.LifecycleOwner 8 | import kotlinx.coroutines.delay 9 | 10 | const val MS_TO_SEC = 1000L 11 | 12 | @Composable 13 | fun useCountdownTimer( 14 | lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, 15 | time: Long, 16 | onCountdownFinish: () -> Unit 17 | ): Long { 18 | var millisUntilFinished by remember { 19 | mutableLongStateOf(time) 20 | } 21 | 22 | var shouldBeCounting by remember { 23 | mutableStateOf(true) 24 | } 25 | 26 | LaunchedEffect(shouldBeCounting, millisUntilFinished) { 27 | if (!shouldBeCounting) { 28 | return@LaunchedEffect 29 | } 30 | 31 | if (millisUntilFinished > 0) { 32 | delay(MS_TO_SEC) 33 | millisUntilFinished -= MS_TO_SEC 34 | } else { 35 | onCountdownFinish() 36 | } 37 | } 38 | 39 | DisposableEffect(lifecycleOwner) { 40 | val observer = LifecycleEventObserver { _, event -> 41 | when (event) { 42 | Lifecycle.Event.ON_START -> { 43 | shouldBeCounting = true 44 | millisUntilFinished = time 45 | } 46 | Lifecycle.Event.ON_PAUSE, 47 | Lifecycle.Event.ON_STOP -> { 48 | shouldBeCounting = false 49 | } 50 | else -> {} 51 | } 52 | } 53 | 54 | lifecycleOwner.lifecycle.addObserver(observer) 55 | 56 | onDispose { 57 | lifecycleOwner.lifecycle.removeObserver(observer) 58 | } 59 | } 60 | 61 | return millisUntilFinished / MS_TO_SEC 62 | } -------------------------------------------------------------------------------- /app/src/main/java/org/cocos2dx/lib/Cocos2dxAccelerometer.kt: -------------------------------------------------------------------------------- 1 | /**************************************************************************** 2 | Copyright (c) 2010-2011 cocos2d-x.org 3 | 4 | http://www.cocos2d-x.org 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | ****************************************************************************/ 24 | package org.cocos2dx.lib 25 | 26 | import android.content.Context 27 | import android.content.res.Configuration 28 | import android.hardware.Sensor 29 | import android.hardware.SensorEvent 30 | import android.hardware.SensorEventListener 31 | import android.hardware.SensorManager 32 | import android.os.Build 33 | import android.view.Surface 34 | import android.view.WindowManager 35 | import java.lang.ref.WeakReference 36 | import kotlin.math.roundToInt 37 | 38 | class Cocos2dxAccelerometer : SensorEventListener { 39 | companion object { 40 | @JvmStatic 41 | external fun onSensorChanged(pX: Float, pY: Float, pZ: Float, pTimestamp: Long) 42 | } 43 | 44 | constructor(context: Context) { 45 | mContext = WeakReference(context) 46 | mSensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager 47 | mAccelerometer = mSensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) 48 | val display = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 49 | context.display 50 | } else { 51 | @Suppress("DEPRECATION") 52 | (context.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay 53 | } 54 | this.mNaturalOrientation = display.rotation 55 | } 56 | 57 | private val mContext: WeakReference 58 | private val mSensorManager: SensorManager 59 | private val mAccelerometer: Sensor? 60 | private val mNaturalOrientation: Int 61 | 62 | fun enable() { 63 | this.mSensorManager.registerListener(this, this.mAccelerometer, SensorManager.SENSOR_DELAY_GAME) 64 | } 65 | 66 | fun setInterval(interval: Float) { 67 | this.mSensorManager.registerListener(this, this.mAccelerometer, (interval * 100000).roundToInt()) 68 | } 69 | 70 | fun disable() { 71 | this.mSensorManager.unregisterListener(this) 72 | } 73 | 74 | override fun onSensorChanged(pSensorEvent: SensorEvent) { 75 | if (pSensorEvent.sensor.type != Sensor.TYPE_ACCELEROMETER) { 76 | return 77 | } 78 | 79 | var (x, y, z) = pSensorEvent.values 80 | 81 | /* 82 | * Because the axes are not swapped when the device's screen orientation 83 | * changes. So we should swap it here. In tablets such as Motorola Xoom, 84 | * the default orientation is landscape, so should consider this. 85 | */ 86 | val orientation = this.mContext.get()!!.resources.configuration.orientation 87 | 88 | if ((orientation == Configuration.ORIENTATION_LANDSCAPE) && (this.mNaturalOrientation != Surface.ROTATION_0)) { 89 | val tmp = x 90 | x = -y 91 | y = tmp 92 | } else if ((orientation == Configuration.ORIENTATION_PORTRAIT) && (this.mNaturalOrientation != Surface.ROTATION_0)) { 93 | val tmp = x 94 | x = y 95 | y = -tmp 96 | } 97 | 98 | Cocos2dxGLSurfaceView.queueAccelerometer(x, y, z, pSensorEvent.timestamp) 99 | } 100 | 101 | override fun onAccuracyChanged(pSensor: Sensor, pAccuracy: Int) {} 102 | } -------------------------------------------------------------------------------- /app/src/main/java/org/cocos2dx/lib/Cocos2dxActivity.kt: -------------------------------------------------------------------------------- 1 | package org.cocos2dx.lib 2 | 3 | import com.customRobTop.BaseRobTopActivity 4 | 5 | @Suppress("unused") 6 | object Cocos2dxActivity { 7 | @JvmStatic 8 | fun openURL(url: String) { 9 | BaseRobTopActivity.openURL(url) 10 | } 11 | } -------------------------------------------------------------------------------- /app/src/main/java/org/cocos2dx/lib/Cocos2dxEditText.kt: -------------------------------------------------------------------------------- 1 | package org.cocos2dx.lib 2 | 3 | import android.content.Context 4 | import android.view.KeyEvent 5 | import androidx.appcompat.widget.AppCompatEditText 6 | 7 | class Cocos2dxEditText(context: Context) : AppCompatEditText(context) { 8 | var cocos2dxGLSurfaceView: Cocos2dxGLSurfaceView? = null 9 | 10 | override fun onKeyDown(keyCode: Int, keyEvent: KeyEvent): Boolean { 11 | super.onKeyDown(keyCode, keyEvent) 12 | if (keyCode != KeyEvent.KEYCODE_BACK) { 13 | return true 14 | } 15 | cocos2dxGLSurfaceView?.requestFocus() 16 | return true 17 | } 18 | } -------------------------------------------------------------------------------- /app/src/main/java/org/cocos2dx/lib/Cocos2dxHelper.kt: -------------------------------------------------------------------------------- 1 | package org.cocos2dx.lib 2 | 3 | import android.content.Context 4 | import android.content.pm.ApplicationInfo 5 | import android.content.res.AssetManager 6 | import android.os.Process 7 | import com.customRobTop.BaseRobTopActivity 8 | import com.geode.launcher.utils.LaunchUtils 9 | 10 | 11 | @Suppress("unused", "KotlinJniMissingFunction") 12 | object Cocos2dxHelper { 13 | // private val sCocos2dMusic: Cocos2dxMusic? = null 14 | // private val sCocos2dSound: Cocos2dxSound? = null 15 | private var sAssetManager: AssetManager? = null 16 | private var sCocos2dxAccelerometer: Cocos2dxAccelerometer? = null 17 | private var sAccelerometerEnabled = false 18 | private var packageName: String? = null 19 | private var fileDirectory: String? = null 20 | 21 | // private var sContext: Context? = null 22 | private var cocos2dxHelperListener: Cocos2dxHelperListener? = null 23 | 24 | @JvmStatic 25 | external fun nativeSetApkPath(apkPath: String) 26 | 27 | @JvmStatic 28 | private external fun nativeSetEditTextDialogResult(bytes: ByteArray) 29 | 30 | @JvmStatic 31 | fun getCocos2dxWritablePath(): String? { 32 | return fileDirectory 33 | } 34 | 35 | @JvmStatic 36 | fun terminateProcess() { 37 | Process.killProcess(Process.myPid()) 38 | } 39 | 40 | @JvmStatic 41 | fun getAssetManager(): AssetManager? { 42 | return sAssetManager 43 | } 44 | 45 | @JvmStatic 46 | fun enableAccelerometer() { 47 | sAccelerometerEnabled = true 48 | sCocos2dxAccelerometer?.enable() 49 | } 50 | 51 | @JvmStatic 52 | fun setAccelerometerInterval(interval: Float) { 53 | sCocos2dxAccelerometer?.setInterval(interval) 54 | } 55 | 56 | @JvmStatic 57 | fun disableAccelerometer() { 58 | sAccelerometerEnabled = false 59 | sCocos2dxAccelerometer?.disable() 60 | } 61 | 62 | fun onResume() { 63 | if (sAccelerometerEnabled) { 64 | sCocos2dxAccelerometer?.enable() 65 | } 66 | } 67 | 68 | fun onPause() { 69 | if (sAccelerometerEnabled) { 70 | sCocos2dxAccelerometer?.disable() 71 | } 72 | } 73 | 74 | @JvmStatic 75 | fun getDPI(): Int { 76 | return BaseRobTopActivity.me.get()?.resources?.configuration?.densityDpi ?: -1 77 | } 78 | 79 | @JvmStatic 80 | fun showDialog(title: String, message: String) { 81 | cocos2dxHelperListener?.showDialog(title, message) 82 | } 83 | 84 | fun init(context: Context, cocos2dxHelperListener: Cocos2dxHelperListener) { 85 | val applicationInfo: ApplicationInfo = context.applicationInfo 86 | // sContext = pContext 87 | this.cocos2dxHelperListener = cocos2dxHelperListener 88 | packageName = applicationInfo.packageName 89 | fileDirectory = LaunchUtils.getSaveDirectory(context).absolutePath 90 | sCocos2dxAccelerometer = Cocos2dxAccelerometer(context) 91 | // Cocos2dxHelper.sCocos2dMusic = Cocos2dxMusic(pContext) 92 | // var simultaneousStreams: Int = Cocos2dxSound.MAX_SIMULTANEOUS_STREAMS_DEFAULT 93 | // if (Cocos2dxHelper.getDeviceModel().indexOf("GT-I9100") !== -1) { 94 | // simultaneousStreams = Cocos2dxSound.MAX_SIMULTANEOUS_STREAMS_I9100 95 | // } 96 | // Cocos2dxHelper.sCocos2dSound = Cocos2dxSound(pContext, simultaneousStreams) 97 | sAssetManager = context.assets 98 | Cocos2dxBitmap.setContext(context) 99 | // Cocos2dxETCLoader.setContext(pContext) 100 | } 101 | 102 | interface Cocos2dxHelperListener { 103 | fun runOnGLThread(runnable: Runnable) 104 | 105 | fun showDialog(title: String, message: String) 106 | 107 | fun showEditTextDialog( 108 | title: String, 109 | message: String, 110 | inputMode: Int, 111 | inputFlag: Int, 112 | returnType: Int, 113 | maxLength: Int 114 | ) 115 | } 116 | } -------------------------------------------------------------------------------- /app/src/main/java/org/cocos2dx/lib/Cocos2dxRenderer.kt: -------------------------------------------------------------------------------- 1 | package org.cocos2dx.lib 2 | 3 | import android.opengl.GLSurfaceView 4 | import android.os.Build 5 | import javax.microedition.khronos.egl.EGLConfig 6 | import javax.microedition.khronos.opengles.GL10 7 | 8 | private const val NANOSECONDS_PER_SECOND = 1000000000L 9 | private const val NANOSECONDS_PER_MICROSECOND = 1000000L 10 | 11 | @Suppress("unused", "KotlinJniMissingFunction") 12 | class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceView.Renderer { 13 | companion object { 14 | @JvmStatic 15 | fun setAnimationInterval(@Suppress("UNUSED_PARAMETER") animationInterval: Double) { 16 | // this function is useless for gd 17 | } 18 | 19 | @JvmStatic 20 | private external fun nativeDeleteBackward() 21 | 22 | @JvmStatic 23 | private external fun nativeGetContentText(): String 24 | 25 | @JvmStatic 26 | private external fun nativeInsertText(text: String) 27 | 28 | @JvmStatic 29 | private external fun nativeKeyDown(keyCode: Int): Boolean 30 | 31 | @JvmStatic 32 | private external fun nativeOnPause() 33 | 34 | @JvmStatic 35 | private external fun nativeOnResume() 36 | 37 | @JvmStatic 38 | private external fun nativeRender() 39 | 40 | @JvmStatic 41 | private external fun nativeTextClosed() 42 | 43 | @JvmStatic 44 | private external fun nativeTouchesBegin(id: Int, x: Float, y: Float) 45 | 46 | @JvmStatic 47 | private external fun nativeTouchesCancel( 48 | ids: IntArray, 49 | xs: FloatArray, 50 | ys: FloatArray 51 | ) 52 | 53 | @JvmStatic 54 | private external fun nativeTouchesEnd(id: Int, x: Float, y: Float) 55 | 56 | @JvmStatic 57 | private external fun nativeTouchesMove(ids: IntArray, xs: FloatArray, ys: FloatArray) 58 | 59 | @JvmStatic 60 | private external fun nativeInit(width: Int, height: Int) 61 | } 62 | 63 | private var lastTickInNanoSeconds: Long = 0 64 | private var screenWidth = 0 65 | private var screenHeight = 0 66 | var sendResizeEvents = false 67 | var setFrameRate = false 68 | private var mAnimationInterval: Long? = null 69 | 70 | fun limitFrameRate(rate: Int) { 71 | mAnimationInterval = NANOSECONDS_PER_SECOND/rate 72 | } 73 | 74 | fun setScreenWidthAndHeight(surfaceWidth: Int, surfaceHeight: Int) { 75 | screenWidth = surfaceWidth 76 | screenHeight = surfaceHeight 77 | } 78 | 79 | override fun onSurfaceCreated(gl10: GL10?, eglConfig: EGLConfig?) { 80 | nativeInit(screenWidth, screenHeight) 81 | lastTickInNanoSeconds = System.nanoTime() 82 | } 83 | 84 | override fun onSurfaceChanged(gl10: GL10?, width: Int, height: Int) { 85 | if (setFrameRate && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 86 | handler.updateRefreshRate() 87 | } 88 | 89 | println("renderer surfaceChanged: ${width}x${height}@${handler.display.refreshRate}fps") 90 | } 91 | 92 | override fun onDrawFrame(gl: GL10?) { 93 | val animationInterval = mAnimationInterval 94 | if (animationInterval == null) { 95 | nativeRender() 96 | } else { 97 | nativeRender() 98 | 99 | val now = System.nanoTime() 100 | val interval = now - this.lastTickInNanoSeconds 101 | 102 | if (interval < animationInterval) { 103 | try { 104 | Thread.sleep((animationInterval - interval) / NANOSECONDS_PER_MICROSECOND) 105 | } catch (e: Exception) {} 106 | } 107 | 108 | lastTickInNanoSeconds = System.nanoTime() 109 | } 110 | } 111 | 112 | fun handleActionDown(id: Int, x: Float, y: Float) { 113 | nativeTouchesBegin(id, x, y) 114 | } 115 | 116 | fun handleActionUp(id: Int, x: Float, y: Float) { 117 | nativeTouchesEnd(id, x, y) 118 | } 119 | 120 | fun handleActionCancel(ids: IntArray, xs: FloatArray, ys: FloatArray) { 121 | nativeTouchesCancel(ids, xs, ys) 122 | } 123 | 124 | fun handleActionMove(ids: IntArray, xs: FloatArray, ys: FloatArray) { 125 | nativeTouchesMove(ids, xs, ys) 126 | } 127 | 128 | fun handleKeyDown(keyCode: Int) { 129 | nativeKeyDown(keyCode) 130 | } 131 | 132 | fun handleOnPause() { 133 | nativeOnPause() 134 | } 135 | 136 | fun handleOnResume() { 137 | nativeOnResume() 138 | } 139 | 140 | fun handleInsertText(text: String) { 141 | nativeInsertText(text) 142 | } 143 | 144 | fun handleDeleteBackward() { 145 | nativeDeleteBackward() 146 | } 147 | 148 | fun handleTextClosed() { 149 | nativeTextClosed() 150 | } 151 | 152 | fun getContentText(): String { 153 | return nativeGetContentText() 154 | } 155 | } -------------------------------------------------------------------------------- /app/src/main/java/org/cocos2dx/lib/Cocos2dxTextInputWrapper.kt: -------------------------------------------------------------------------------- 1 | package org.cocos2dx.lib 2 | 3 | import android.content.Context 4 | import android.text.TextWatcher 5 | import android.widget.TextView.OnEditorActionListener 6 | import android.text.Editable 7 | import android.view.KeyEvent 8 | import android.view.inputmethod.InputMethodManager 9 | import android.widget.TextView 10 | 11 | class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurfaceView) : TextWatcher, OnEditorActionListener { 12 | private var originText: String? = null 13 | private var text: String? = null 14 | 15 | private val isFullScreenEdit: Boolean 16 | get() = (cocos2dxGLSurfaceView.cocos2dxEditText?.context?.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).isFullscreenMode 17 | 18 | fun setOriginText(originText: String?) { 19 | this.originText = originText 20 | } 21 | 22 | override fun afterTextChanged(s: Editable) { 23 | if (!isFullScreenEdit) { 24 | var nModified = s.length - text!!.length 25 | if (nModified > 0) { 26 | cocos2dxGLSurfaceView.insertText( 27 | s.subSequence(text!!.length, s.length).toString() 28 | ) 29 | } else { 30 | while (nModified < 0) { 31 | cocos2dxGLSurfaceView.deleteBackward() 32 | nModified++ 33 | } 34 | } 35 | text = s.toString() 36 | } 37 | } 38 | 39 | override fun beforeTextChanged( 40 | charSequence: CharSequence, 41 | start: Int, 42 | count: Int, 43 | after: Int 44 | ) { 45 | text = charSequence.toString() 46 | } 47 | 48 | override fun onTextChanged(pCharSequence: CharSequence, start: Int, before: Int, count: Int) {} 49 | override fun onEditorAction(textView: TextView, actionID: Int, keyEvent: KeyEvent?): Boolean { 50 | if (cocos2dxGLSurfaceView.cocos2dxEditText == textView && isFullScreenEdit) { 51 | for (i in originText!!.length downTo 1) { 52 | cocos2dxGLSurfaceView.deleteBackward() 53 | } 54 | var text = textView.text.toString() 55 | if (text.compareTo("") == 0) { 56 | text = "\n" 57 | } 58 | if (10 != text[text.length - 1].code) { 59 | text += 10.toChar() 60 | } 61 | cocos2dxGLSurfaceView.insertText(text) 62 | } 63 | if (actionID != 6) { 64 | return false 65 | } 66 | cocos2dxGLSurfaceView.requestFocus() 67 | Cocos2dxGLSurfaceView.closeIMEKeyboard() 68 | return false 69 | } 70 | } -------------------------------------------------------------------------------- /app/src/main/java/org/cocos2dx/lib/Cocos2dxTypefaces.kt: -------------------------------------------------------------------------------- 1 | package org.cocos2dx.lib 2 | 3 | import android.content.Context 4 | import android.graphics.Typeface 5 | import kotlin.jvm.Synchronized 6 | import java.util.HashMap 7 | 8 | object Cocos2dxTypefaces { 9 | private val typefaceCache = HashMap() 10 | 11 | @Synchronized 12 | operator fun get(pContext: Context, pAssetName: String): Typeface? { 13 | val typeface: Typeface? 14 | val typeface2: Typeface 15 | 16 | synchronized(Cocos2dxTypefaces::class.java) { 17 | if (!typefaceCache.containsKey(pAssetName)) { 18 | typeface2 = if (pAssetName.startsWith("/")) { 19 | Typeface.createFromFile(pAssetName) 20 | } else { 21 | Typeface.createFromAsset(pContext.assets, pAssetName) 22 | } 23 | typefaceCache[pAssetName] = typeface2 24 | } 25 | typeface = typefaceCache[pAssetName] 26 | } 27 | return typeface 28 | } 29 | } -------------------------------------------------------------------------------- /app/src/main/java/org/fmod/AudioDevice.kt: -------------------------------------------------------------------------------- 1 | package org.fmod 2 | 3 | import android.media.AudioAttributes 4 | import android.media.AudioFormat 5 | import android.media.AudioManager 6 | import android.media.AudioTrack 7 | import android.os.Build 8 | import android.util.Log 9 | 10 | class AudioDevice { 11 | private var mTrack: AudioTrack? = null 12 | private fun fetchChannelConfigFromCount(speakerCount: Int): Int { 13 | return when (speakerCount) { 14 | 1 -> AudioFormat.CHANNEL_OUT_MONO 15 | 2 -> AudioFormat.CHANNEL_OUT_STEREO 16 | 6 -> AudioFormat.CHANNEL_OUT_5POINT1 17 | 8 -> AudioFormat.CHANNEL_OUT_7POINT1_SURROUND 18 | else -> AudioFormat.CHANNEL_INVALID 19 | } 20 | } 21 | 22 | fun init(channelCount: Int, sampleRateInHz: Int, sampleSize: Int, sampleCount: Int): Boolean { 23 | val channelConfig = fetchChannelConfigFromCount(channelCount) 24 | val minBufferSize = AudioTrack.getMinBufferSize( 25 | sampleRateInHz, 26 | channelConfig, 27 | AudioFormat.ENCODING_PCM_16BIT 28 | ) 29 | 30 | if (minBufferSize < 0) { 31 | Log.w( 32 | "fmod", 33 | "AudioDevice::init : Couldn't query minimum buffer size, possibly unsupported sample rate or channel count" 34 | ) 35 | } else { 36 | Log.i("fmod", "AudioDevice::init : Min buffer size: $minBufferSize bytes") 37 | } 38 | 39 | val realBufferSize = sampleSize * sampleCount * channelCount * 2 40 | val bufferSize = minBufferSize.coerceAtLeast(realBufferSize) 41 | Log.i("fmod", "AudioDevice::init : Actual buffer size: $bufferSize bytes") 42 | return try { 43 | val attributes = AudioAttributes.Builder() 44 | .setUsage(AudioAttributes.USAGE_GAME) 45 | .build() 46 | 47 | val format = AudioFormat.Builder() 48 | .setChannelMask(channelConfig) 49 | .setSampleRate(sampleRateInHz) 50 | .setEncoding(AudioFormat.ENCODING_PCM_16BIT) 51 | .build() 52 | 53 | val audioTrackBuilder = AudioTrack.Builder() 54 | .setAudioAttributes(attributes) 55 | .setAudioFormat(format) 56 | .setBufferSizeInBytes(bufferSize) 57 | .setTransferMode(AudioTrack.MODE_STREAM) 58 | 59 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 60 | audioTrackBuilder.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY) 61 | } 62 | 63 | val audioTrack = audioTrackBuilder.build() 64 | 65 | mTrack = audioTrack 66 | try { 67 | audioTrack.play() 68 | true 69 | } catch (unused: IllegalStateException) { 70 | Log.e("fmod", "AudioDevice::init : AudioTrack play caused IllegalStateException") 71 | mTrack?.release() 72 | mTrack = null 73 | false 74 | } 75 | } catch (_: IllegalArgumentException) { 76 | Log.e("fmod", "AudioDevice::init : AudioTrack creation caused IllegalArgumentException") 77 | false 78 | } 79 | } 80 | 81 | fun close() { 82 | try { 83 | mTrack?.stop() 84 | } catch (unused: IllegalStateException) { 85 | Log.e("fmod", "AudioDevice::init : AudioTrack stop caused IllegalStateException") 86 | } 87 | mTrack?.release() 88 | mTrack = null 89 | } 90 | 91 | fun write(sArr: ShortArray, size: Int) { 92 | mTrack?.write(sArr, 0, size) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /app/src/main/java/org/fmod/FMOD.kt: -------------------------------------------------------------------------------- 1 | package org.fmod 2 | 3 | import android.annotation.SuppressLint 4 | import android.content.BroadcastReceiver 5 | import android.content.Context 6 | import android.content.Intent 7 | import android.content.IntentFilter 8 | import android.content.pm.PackageManager 9 | import android.content.res.AssetManager 10 | import android.media.AudioDeviceInfo 11 | import android.media.AudioManager 12 | import android.net.Uri 13 | import android.os.Build 14 | import android.util.Log 15 | import java.io.FileNotFoundException 16 | 17 | // i never agreed to your licenses! take that 18 | @SuppressLint("StaticFieldLeak") 19 | object FMOD { 20 | private var gContext: Context? = null 21 | private val gPluginBroadcastReceiver = PluginBroadcastReceiver() 22 | 23 | external fun OutputAAudioHeadphonesChanged() 24 | 25 | @JvmStatic 26 | fun init(context: Context?) { 27 | gContext = context 28 | gContext?.registerReceiver( 29 | gPluginBroadcastReceiver, 30 | IntentFilter(Intent.ACTION_HEADSET_PLUG) 31 | ) 32 | } 33 | 34 | @JvmStatic 35 | fun close() { 36 | val context = gContext 37 | context?.unregisterReceiver(gPluginBroadcastReceiver) 38 | gContext = null 39 | } 40 | 41 | @JvmStatic 42 | fun checkInit(): Boolean { 43 | return gContext != null 44 | } 45 | 46 | @JvmStatic 47 | fun getAssetManager(): AssetManager? { 48 | val context = gContext 49 | return context?.assets 50 | } 51 | 52 | @JvmStatic 53 | fun supportsLowLatency(): Boolean { 54 | val outputBlockSize = getOutputBlockSize() 55 | val lowLatencyFlag = lowLatencyFlag() 56 | val proAudioFlag = proAudioFlag() 57 | val z = outputBlockSize in 1..1024 58 | val isBluetoothOn = isBluetoothOn() 59 | Log.i( 60 | "fmod", 61 | "FMOD::supportsLowLatency : Low latency = $lowLatencyFlag, Pro Audio = $proAudioFlag, Bluetooth On = $isBluetoothOn, Acceptable Block Size = $z ($outputBlockSize)" 62 | ) 63 | return z && lowLatencyFlag && !isBluetoothOn 64 | } 65 | 66 | @JvmStatic 67 | fun lowLatencyFlag(): Boolean { 68 | val context = gContext ?: return false 69 | return context.packageManager.hasSystemFeature(PackageManager.FEATURE_AUDIO_LOW_LATENCY) 70 | } 71 | 72 | @JvmStatic 73 | fun proAudioFlag(): Boolean { 74 | val context = gContext ?: return false 75 | return context.packageManager.hasSystemFeature(PackageManager.FEATURE_AUDIO_PRO) 76 | } 77 | 78 | @JvmStatic 79 | fun supportsAAudio(): Boolean { 80 | return Build.VERSION.SDK_INT >= 27 81 | } 82 | 83 | @JvmStatic 84 | fun getOutputSampleRate(): Int { 85 | val context = gContext ?: return 0 86 | val audioService = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager 87 | val property = audioService.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE) 88 | 89 | return property?.toInt() ?: 0 90 | } 91 | 92 | @JvmStatic 93 | fun getOutputBlockSize(): Int { 94 | val context = gContext ?: return 0 95 | val audioService = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager 96 | val property = audioService.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER) 97 | 98 | return property?.toInt() ?: 0 99 | } 100 | 101 | @JvmStatic 102 | fun isBluetoothOn(): Boolean { 103 | val context = gContext ?: return false 104 | val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager 105 | 106 | val devices = audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS) 107 | return devices.any { 108 | it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || it.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO 109 | } 110 | } 111 | 112 | @JvmStatic 113 | fun fileDescriptorFromUri(str: String?): Int { 114 | gContext?.apply { 115 | try { 116 | contentResolver.openFileDescriptor(Uri.parse(str), "r")?.apply { 117 | return detachFd() 118 | } 119 | } catch (_: FileNotFoundException) { } 120 | } 121 | 122 | return -1 123 | } 124 | 125 | internal class PluginBroadcastReceiver : BroadcastReceiver() { 126 | override fun onReceive(context: Context, intent: Intent) { 127 | OutputAAudioHeadphonesChanged() 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /app/src/main/java/org/fmod/MediaCodec.kt: -------------------------------------------------------------------------------- 1 | package org.fmod 2 | 3 | import android.media.MediaCodec 4 | import android.media.MediaDataSource 5 | import android.media.MediaExtractor 6 | import android.media.MediaCodec.BufferInfo 7 | import android.media.MediaFormat 8 | import android.os.Build 9 | import android.util.Log 10 | import java.io.IOException 11 | import java.nio.ByteBuffer 12 | 13 | class MediaCodec { 14 | // these variables require getX functions for ndk access 15 | var channelCount = 0 16 | private set 17 | 18 | var length = 0L 19 | private set 20 | 21 | var sampleRate = 0 22 | private set 23 | 24 | private var mCodecPtr = 0L 25 | private var mCurrentOutputBufferIndex = -1 26 | private var mDecoder: MediaCodec? = null 27 | private var mExtractor: MediaExtractor? = null 28 | private var mInputBuffers: Array? = null 29 | private var mInputFinished = false 30 | private var mOutputBuffers: Array? = null 31 | private var mOutputFinished = false 32 | 33 | fun init(codecPtr: Long): Boolean { 34 | mCodecPtr = codecPtr 35 | 36 | try { 37 | val mediaExtractor = MediaExtractor() 38 | mExtractor = mediaExtractor 39 | mediaExtractor.setDataSource(object : MediaDataSource() { 40 | override fun close() {} 41 | override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int { 42 | return fmodReadAt(mCodecPtr, position, buffer, offset, size) 43 | } 44 | 45 | override fun getSize(): Long { 46 | return fmodGetSize(mCodecPtr) 47 | } 48 | }) 49 | } catch (e: IOException) { 50 | Log.w("fmod", "MediaCodec::init : $e") 51 | return false 52 | } 53 | 54 | val extractor = mExtractor!! 55 | 56 | val trackCount = extractor.trackCount 57 | var track = 0 58 | 59 | while (track < trackCount) { 60 | val trackFormat = extractor.getTrackFormat(track) 61 | val string = trackFormat.getString(MediaFormat.KEY_MIME) 62 | Log.d( 63 | "fmod", 64 | "MediaCodec::init : Format $track / $trackCount -- $trackFormat" 65 | ) 66 | if (string == MediaFormat.MIMETYPE_AUDIO_AAC) { 67 | return try { 68 | val decoder = MediaCodec.createDecoderByType(string) 69 | extractor.selectTrack(track) 70 | decoder.configure(trackFormat, null, null, 0) 71 | decoder.start() 72 | mInputBuffers = decoder.inputBuffers 73 | mOutputBuffers = decoder.outputBuffers 74 | mDecoder = decoder 75 | 76 | val encoderDelay = 77 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && trackFormat.containsKey(MediaFormat.KEY_ENCODER_DELAY)) 78 | trackFormat.getInteger(MediaFormat.KEY_ENCODER_DELAY) else 0 79 | 80 | val encoderPadding = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && trackFormat.containsKey(MediaFormat.KEY_ENCODER_PADDING)) 81 | trackFormat.getInteger(MediaFormat.KEY_ENCODER_PADDING) else 0 82 | 83 | val trackDuration = trackFormat.getLong(MediaFormat.KEY_DURATION) 84 | channelCount = trackFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT) 85 | val trackSampleRate = trackFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE) 86 | sampleRate = trackSampleRate 87 | length = 88 | (((trackDuration * trackSampleRate.toLong() + 999999) / 1000000).toInt() - encoderDelay - encoderPadding).toLong() 89 | 90 | true 91 | } catch (e: IOException) { 92 | Log.e("fmod", "MediaCodec::init : $e") 93 | false 94 | } 95 | } else { 96 | track++ 97 | } 98 | } 99 | return false 100 | } 101 | 102 | fun release() { 103 | mDecoder?.stop() 104 | mDecoder?.release() 105 | mDecoder = null 106 | 107 | mExtractor?.release() 108 | mExtractor = null 109 | } 110 | 111 | fun read(bArr: ByteArray, i: Int): Int { 112 | var dequeueInputBuffer = 0 113 | val i2 = 114 | if (!mInputFinished || !mOutputFinished || mCurrentOutputBufferIndex != -1) 0 else -1 115 | while (!mInputFinished && mDecoder!!.dequeueInputBuffer(0).also { 116 | dequeueInputBuffer = it 117 | } >= 0) { 118 | val readSampleData = mExtractor!!.readSampleData(mInputBuffers!![dequeueInputBuffer], 0) 119 | if (readSampleData >= 0) { 120 | mDecoder!!.queueInputBuffer( 121 | dequeueInputBuffer, 122 | 0, 123 | readSampleData, 124 | mExtractor!!.sampleTime, 125 | 0 126 | ) 127 | mExtractor!!.advance() 128 | } else { 129 | mDecoder!!.queueInputBuffer(dequeueInputBuffer, 0, 0, 0, 4) 130 | mInputFinished = true 131 | } 132 | } 133 | if (!mOutputFinished && mCurrentOutputBufferIndex == -1) { 134 | val bufferInfo = BufferInfo() 135 | val dequeueOutputBuffer = mDecoder!!.dequeueOutputBuffer(bufferInfo, 10000) 136 | if (dequeueOutputBuffer >= 0) { 137 | mCurrentOutputBufferIndex = dequeueOutputBuffer 138 | mOutputBuffers!![dequeueOutputBuffer].limit(bufferInfo.size) 139 | mOutputBuffers!![dequeueOutputBuffer].position(bufferInfo.offset) 140 | } else if (dequeueOutputBuffer == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { 141 | mOutputBuffers = mDecoder!!.outputBuffers 142 | } else if (dequeueOutputBuffer == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { 143 | Log.d( 144 | "fmod", 145 | "MediaCodec::read : MediaCodec::dequeueOutputBuffer returned MediaCodec.INFO_OUTPUT_FORMAT_CHANGED " + mDecoder!!.outputFormat 146 | ) 147 | } else if (dequeueOutputBuffer == MediaCodec.INFO_TRY_AGAIN_LATER) { 148 | Log.d( 149 | "fmod", 150 | "MediaCodec::read : MediaCodec::dequeueOutputBuffer returned MediaCodec.INFO_TRY_AGAIN_LATER." 151 | ) 152 | } else { 153 | Log.w( 154 | "fmod", 155 | "MediaCodec::read : MediaCodec::dequeueOutputBuffer returned $dequeueOutputBuffer" 156 | ) 157 | } 158 | if (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0) { 159 | mOutputFinished = true 160 | } 161 | } 162 | val i3 = mCurrentOutputBufferIndex 163 | if (i3 == -1) { 164 | return i2 165 | } 166 | val byteBuffer = mOutputBuffers!![i3] 167 | val min = byteBuffer.remaining().coerceAtMost(i) 168 | byteBuffer[bArr, 0, min] 169 | if (!byteBuffer.hasRemaining()) { 170 | byteBuffer.clear() 171 | mDecoder!!.releaseOutputBuffer(mCurrentOutputBufferIndex, false) 172 | mCurrentOutputBufferIndex = -1 173 | } 174 | return min 175 | } 176 | 177 | fun seek(i: Int) { 178 | val i2 = mCurrentOutputBufferIndex 179 | if (i2 != -1) { 180 | mOutputBuffers!![i2].clear() 181 | mCurrentOutputBufferIndex = -1 182 | } 183 | mInputFinished = false 184 | mOutputFinished = false 185 | mDecoder!!.flush() 186 | val j = i.toLong() 187 | mExtractor!!.seekTo(j * 1000000 / sampleRate.toLong(), MediaExtractor.SEEK_TO_PREVIOUS_SYNC) 188 | val sampleTime = (mExtractor!!.sampleTime * sampleRate.toLong() + 999999) / 1000000 189 | var i3 = ((j - sampleTime) * channelCount.toLong() * 2).toInt() 190 | if (i3 < 0) { 191 | Log.w( 192 | "fmod", 193 | "MediaCodec::seek : Seek to $i resulted in position $sampleTime" 194 | ) 195 | return 196 | } 197 | val bArr = ByteArray(1024) 198 | while (i3 > 0) { 199 | i3 -= read(bArr, 1024.coerceAtMost(i3)) 200 | } 201 | } 202 | 203 | companion object { 204 | @JvmStatic 205 | external fun fmodGetSize(codecPtr: Long): Long 206 | 207 | @JvmStatic 208 | external fun fmodReadAt(codecPtr: Long, position: Long, buffer: ByteArray?, offset: Int, size: Int): Int 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /app/src/main/jniLibs/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geode-sdk/android-launcher/09bb28d66ffd0a60b6d5b7f3fcf647b0368134d0/app/src/main/jniLibs/.keep -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 11 | 14 | 15 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 33 | 34 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_monochrome.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/geode_logo.xml: -------------------------------------------------------------------------------- 1 | 7 | 9 | 10 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 26 | 29 | 30 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 48 | 49 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/geode_monochrome.xml: -------------------------------------------------------------------------------- 1 | 8 | 12 | 13 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/google_play_badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geode-sdk/android-launcher/09bb28d66ffd0a60b6d5b7f3fcf647b0368134d0/app/src/main/res/drawable/google_play_badge.png -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_bug_report.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_content_copy.xml: -------------------------------------------------------------------------------- 1 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_data_object.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_delete.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_description.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_download.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_error.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_filter_list.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_link.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_person.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_person_add.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_question_mark.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_remove.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_resume.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_save.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/icon_undo.xml: -------------------------------------------------------------------------------- 1 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geode-sdk/android-launcher/09bb28d66ffd0a60b6d5b7f3fcf647b0368134d0/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geode-sdk/android-launcher/09bb28d66ffd0a60b6d5b7f3fcf647b0368134d0/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geode-sdk/android-launcher/09bb28d66ffd0a60b6d5b7f3fcf647b0368134d0/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geode-sdk/android-launcher/09bb28d66ffd0a60b6d5b7f3fcf647b0368134d0/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geode-sdk/android-launcher/09bb28d66ffd0a60b6d5b7f3fcf647b0368134d0/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geode-sdk/android-launcher/09bb28d66ffd0a60b6d5b7f3fcf647b0368134d0/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geode-sdk/android-launcher/09bb28d66ffd0a60b6d5b7f3fcf647b0368134d0/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geode-sdk/android-launcher/09bb28d66ffd0a60b6d5b7f3fcf647b0368134d0/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geode-sdk/android-launcher/09bb28d66ffd0a60b6d5b7f3fcf647b0368134d0/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geode-sdk/android-launcher/09bb28d66ffd0a60b6d5b7f3fcf647b0368134d0/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/values-v31/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #282828 4 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 13 |