Compare commits

..
28 Commits
Author SHA1 Message Date
Chloe 1658072b1a have directoryprovider follow the selected theme 2026-10-18 18:53:16 -05:00
Chloe b6e88b20cb go green 2026-10-18 18:43:44 -05:00
Chloe f4af50b2cb cleanup keyboard event handler 2026-10-18 16:47:02 -05:00
Chloe 00c47f4c70 add edges to the custom icons 2026-10-18 00:26:08 -05:00
Chloe 11649a236a fix launcher update shadow clipping 2026-10-17 22:15:36 -05:00
Chloe ff712196cc technically 2026-10-17 17:57:34 -05:00
Chloe 6c57f3f261 fix keyboard insets attempting to use new apis 2026-10-17 17:54:11 -05:00
Chloe 626f8d00d1 Merge branch 'orchid-pruning' 2026-10-17 17:49:47 -05:00
Chloe 45ecc71749 update dependencies 2026-10-17 17:49:39 -05:00
Chloe 3d60bd3d53 do textinputwrapper changes 2026-10-17 02:51:06 -05:00
Chloe e40af4c49b implement new file pickers 2026-10-17 02:51:06 -05:00
Chloe e0a31694af i accidentally defaulted it to true 2026-10-17 02:51:05 -05:00
Chloe c8994c2484 add restart with safe mode util 2026-10-17 02:51:05 -05:00
Chloe 35c8428f08 i accidentally defaulted it to true 2026-10-16 17:52:38 -05:00
Chloe bca761f26a add restart with safe mode util 2026-10-16 17:48:35 -05:00
Chloe 79c06ca629 ime and input changes 2026-10-15 22:16:23 -05:00
Chloe 3194dff7ff Bundle Geode with Play Store build (#88)
* add gradle task to download geode binaries

* disable automatic update on play build

* get download url from release api

* fix incorrect priorities between external library and prebundled

* bundle geode resources
this also fixes the release api fetch as i never tested it until now, oops

* add message for loading from files dir

* raise minimum gd version with bundled geode

* clear old resources prior to copying
2026-09-07 17:19:12 -07:00
Chloe 9e86bbe355 idk where this import went 2026-09-07 00:45:13 -07:00
Chloe eef9160970 figure out how to update (almost) everything 2026-09-07 00:40:21 -07:00
Chloe 3b4951def1 implement new release api features (#86)
* implement new release api features

* make hash nullable
2026-08-29 17:06:45 -07:00
Chloe ef44d48ed0 light cleanup of cocos2dxbitmap while i'm at it 2026-08-01 22:56:53 -07:00
Chloe aae2aa2290 fix incorrect behavior in splitString 2026-08-01 22:54:42 -07:00
Oleksandr Nemesh 0a62082519 fix app icon not getting switched on google play (#84) 2026-08-01 04:47:45 +03:00
prevter 758d5ad432 remove some unused permissions
Note: I've tested it a bit and didn't find any problems. This change was required by Google Play to proceed with review
2026-07-05 21:53:55 +03:00
prevter b3a776ce7c fix openFolder util crashing 2026-07-05 21:52:25 +03:00
prevter 72f74fa084 more google play changes 2026-07-01 11:46:54 +03:00
Oleksandr Nemesh 0dba4f0606 add google play support (#82) 2026-06-26 14:28:12 -07:00
Chloe 2016aa4e90 android 17 fix 2026-06-23 19:04:08 -07:00
45 changed files with 1678 additions and 515 deletions

No files matched your search

+55 -8
View File
@@ -12,9 +12,15 @@ jobs:
config:
- name: Release
lower: release
flavor: standard
- name: Debug
lower: debug
flavor: standard
- name: Release
lower: release
flavor: googlePlay
runs-on: ubuntu-latest
@@ -34,9 +40,19 @@ jobs:
- name: Fix gradle permissions
run: chmod +x ./gradlew
- name: Build project
- name: Build project (standard)
if: ${{ matrix.config.flavor == 'standard' }}
run: |
./gradlew app:assemble${{ matrix.config.name }} \
./gradlew app:assembleStandard${{ matrix.config.name }} \
-Pandroid.injected.signing.store.file="$HOME/release.keystore" \
-Pandroid.injected.signing.store.password="${{secrets.KEYSTORE_PASSWORD}}" \
-Pandroid.injected.signing.key.alias="${{secrets.KEY_ALIAS}}" \
-Pandroid.injected.signing.key.password="${{secrets.KEY_PASSWORD}}"
- name: Build project (Google Play)
if: ${{ matrix.config.flavor == 'googlePlay' }}
run: |
./gradlew app:bundleGooglePlayRelease -PdisableAbiSplits \
-Pandroid.injected.signing.store.file="$HOME/release.keystore" \
-Pandroid.injected.signing.store.password="${{secrets.KEYSTORE_PASSWORD}}" \
-Pandroid.injected.signing.key.alias="${{secrets.KEY_ALIAS}}" \
@@ -44,35 +60,57 @@ jobs:
- name: Rename universal apk
id: rename_apk_universal
if: ${{ matrix.config.flavor == 'standard' }}
run: |
SHORT_SHA=${GITHUB_SHA::7}
APK_PATH=${{github.workspace}}/app/build/outputs/apk/${{ matrix.config.lower }}/app-universal-${{ matrix.config.lower }}.apk
APK_PATH=${{github.workspace}}/app/build/outputs/apk/standard/${{ matrix.config.lower }}/app-standard-universal-${{ matrix.config.lower }}.apk
OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-$SHORT_SHA.apk
mv $APK_PATH $OUT_PATH
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
- name: Rename apk
id: rename_apk
if: ${{ matrix.config.flavor == 'standard' }}
run: |
SHORT_SHA=${GITHUB_SHA::7}
APK_PATH=${{github.workspace}}/app/build/outputs/apk/${{ matrix.config.lower }}/app-armeabi-v7a-${{ matrix.config.lower }}.apk
APK_PATH=${{github.workspace}}/app/build/outputs/apk/standard/${{ matrix.config.lower }}/app-standard-armeabi-v7a-${{ matrix.config.lower }}.apk
OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-android32-$SHORT_SHA.apk
mv $APK_PATH $OUT_PATH
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
- name: Rename mappings
id: rename_mappings
if: ${{ matrix.config.lower == 'release' }}
if: ${{ matrix.config.flavor == 'standard' && matrix.config.lower == 'release' }}
run: |
SHORT_SHA=${GITHUB_SHA::7}
MAPPING_PATH=${{github.workspace}}/app/build/outputs/mapping/${{ matrix.config.lower }}/mapping.txt
MAPPING_PATH=${{github.workspace}}/app/build/outputs/mapping/standardRelease/mapping.txt
OUT_PATH=${{github.workspace}}/mapping-${{ matrix.config.lower }}-$SHORT_SHA.txt
mv $MAPPING_PATH $OUT_PATH
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
- name: Rename AAB
id: rename_aab
if: ${{ matrix.config.flavor == 'googlePlay' }}
run: |
SHORT_SHA=${GITHUB_SHA::7}
AAB_PATH=${{github.workspace}}/app/build/outputs/bundle/googlePlayRelease/app-googlePlay-release.aab
OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-$SHORT_SHA.aab
mv $AAB_PATH $OUT_PATH
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
- name: Rename AAB mappings
id: rename_aab_mappings
if: ${{ matrix.config.flavor == 'googlePlay' }}
run: |
SHORT_SHA=${GITHUB_SHA::7}
MAPPING_PATH=${{github.workspace}}/app/build/outputs/mapping/googlePlayRelease/mapping.txt
OUT_PATH=${{github.workspace}}/mapping-${{ matrix.config.lower }}-$SHORT_SHA.txt
mv $MAPPING_PATH $OUT_PATH
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
- name: Upload release build
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: ${{ matrix.config.lower == 'release' }}
if: ${{ matrix.config.flavor == 'standard' && matrix.config.lower == 'release' }}
with:
name: geode-launcher-${{ matrix.config.lower }}
path: |
@@ -82,9 +120,18 @@ jobs:
- name: Upload debug build
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: ${{ matrix.config.lower == 'debug' }}
if: ${{ matrix.config.flavor == 'standard' && matrix.config.lower == 'debug' }}
with:
name: geode-launcher-${{ matrix.config.lower }}
path: |
${{ steps.rename_apk_universal.outputs.path }}
${{ steps.rename_apk.outputs.path }}
- name: Upload Google Play build
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: ${{ matrix.config.flavor == 'googlePlay' }}
with:
name: geode-launcher-google-play
path: |
${{ steps.rename_aab.outputs.path }}
${{ steps.rename_aab_mappings.outputs.path }}
+282 -22
View File
@@ -1,4 +1,7 @@
val composeBOM: String by rootProject.extra
import groovy.json.JsonSlurper
import java.net.URI
import java.security.MessageDigest
import java.util.zip.ZipInputStream
kotlin {
compilerOptions {
@@ -12,15 +15,241 @@ plugins {
id("org.jetbrains.kotlin.plugin.compose")
}
val currentGeodeVersion = "5.11.2"
abstract class DownloadVersionMetadataTask : DefaultTask() {
@get:Input
abstract val geodeVersion: Property<String>
@get:OutputFile
abstract val targetFile: RegularFileProperty
@TaskAction
fun execute() {
val downloadGeodeVersion = geodeVersion.get()
val destinationFile = targetFile.get().asFile
val releaseUrl = "https://api.geode-sdk.org/v1/loader/versions/$downloadGeodeVersion"
URI.create(releaseUrl).toURL().openStream().use { response ->
destinationFile.outputStream().use { destination ->
response.copyTo(destination)
}
}
}
}
abstract class DownloadGeodeFileTask : DefaultTask() {
@get:Input
abstract val platform: Property<String>
@get:InputFile
abstract val metadata: RegularFileProperty
private fun downloadAndVerifyFile(filename: String, url: String, hash: String?): File {
logger.info("Downloading $filename from $url")
val tempFile = File(temporaryDir, filename)
URI.create(url).toURL().openStream().use { response ->
tempFile.outputStream().use { output ->
response.copyTo(output)
}
}
if (hash != null) {
val downloadedHash = MessageDigest.getInstance("SHA-256")
.digest(tempFile.readBytes())
.toHexString()
if (downloadedHash != hash) {
throw GradleException("Hash check failed for file $filename, expected $hash but found $downloadedHash.")
}
}
return tempFile
}
fun downloadZip(fallbackUrl: String?): File {
val downloadPlatform = platform.get()
val metadataFile = metadata.get().asFile
val json = JsonSlurper().parse(metadataFile) as Map<*, *>
val payload = json["payload"] as? Map<*, *>
val downloads = payload?.get("downloads") as? Map<*, *>
val platformInfo = downloads?.get(downloadPlatform) as? Map<*, *>
val downloadUrl = platformInfo?.get("url") as? String ?: fallbackUrl
if (downloadUrl == null) {
throw GradleException("Failed to fetch download url for platform $platform.")
}
val expectedHash = platformInfo?.get("hash") as? String
return downloadAndVerifyFile("$downloadPlatform.zip", downloadUrl, expectedHash)
}
}
abstract class DownloadAndUnzipGeodeTask : DownloadGeodeFileTask() {
@get:Input
abstract val geodeVersion: Property<String>
@get:OutputFile
abstract val targetFile: RegularFileProperty
@TaskAction
fun execute() {
val downloadGeodeVersion = geodeVersion.get()
val downloadPlatform = platform.get()
val destinationFile = targetFile.get().asFile
val tempOutput = this.downloadZip("https://github.com/geode-sdk/geode/releases/download/v$downloadGeodeVersion/geode-v$downloadGeodeVersion-$downloadPlatform.zip")
val expectedFile = "Geode.$downloadPlatform.so"
ZipInputStream(tempOutput.inputStream()).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
if (entry.name == expectedFile) {
destinationFile.outputStream().use {
zip.copyTo(it)
}
return@execute
}
entry = zip.nextEntry
}
throw GradleException("Could not find Geode release.")
}
}
}
abstract class DownloadAndUnzipGeodeResourcesTask : DownloadGeodeFileTask() {
init {
platform.set("resources")
}
@get:Input
abstract val geodeVersion: Property<String>
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun execute() {
val downloadGeodeVersion = geodeVersion.get()
val tempOutput = this.downloadZip("https://github.com/geode-sdk/geode/releases/download/v$downloadGeodeVersion/resources.zip")
val baseOutput = outputDir.get().asFile
if (baseOutput.exists()) baseOutput.deleteRecursively()
val output = File(baseOutput, "geode.loader")
output.mkdirs()
val canonicalOutput = output.canonicalPath
ZipInputStream(tempOutput.inputStream()).use { zipStream ->
var entry = zipStream.nextEntry
while (entry != null) {
val destination = File(output, entry.name)
if (!destination.canonicalPath.startsWith(canonicalOutput)) {
throw GradleException("attempted copy to outside of output directory: ${entry.name}")
}
if (destination.isDirectory) {
destination.mkdirs()
} else {
destination.parentFile?.mkdirs()
destination.outputStream().use { destinationStream ->
zipStream.copyTo(destinationStream)
}
}
entry = zipStream.nextEntry
}
}
}
}
val fetchGeodeMetadataTask = tasks.register<DownloadVersionMetadataTask>("fetchGeodeMetadata") {
description = "Fetches and saves a Geode version's metadata"
inputs.property("geodeVersion", currentGeodeVersion)
geodeVersion = currentGeodeVersion
targetFile = temporaryDir.resolve("metadata.json")
}
val downloadGeode32Task = tasks.register<DownloadAndUnzipGeodeTask>("downloadGeode32Binary") {
description = "Downloads the 32bit Geode release archive"
inputs.property("geodeVersion", currentGeodeVersion)
geodeVersion = currentGeodeVersion
platform = "android32"
metadata = fetchGeodeMetadataTask.flatMap { it.targetFile }
targetFile = temporaryDir.resolve("libgeode.so")
}
val downloadGeode64Task = tasks.register<DownloadAndUnzipGeodeTask>("downloadGeode64Binary") {
description = "Downloads the 64bit Geode release archive"
inputs.property("geodeVersion", currentGeodeVersion)
geodeVersion = currentGeodeVersion
platform = "android64"
metadata = fetchGeodeMetadataTask.flatMap { it.targetFile }
targetFile = temporaryDir.resolve("libgeode.so")
}
val downloadGeodeResourcesTask = tasks.register<DownloadAndUnzipGeodeResourcesTask>("downloadGeodeResources") {
description = "Downloads Geode resources"
inputs.property("geodeVersion", currentGeodeVersion)
geodeVersion = currentGeodeVersion
metadata = fetchGeodeMetadataTask.flatMap { it.targetFile }
outputDir = layout.buildDirectory.dir("geode-resources")
}
abstract class MergeGeodeFilesTask : DefaultTask() {
@get:InputFile
abstract val binary32: RegularFileProperty
@get:InputFile
abstract val binary64: RegularFileProperty
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun execute() {
val out = outputDir.get().asFile
out.deleteRecursively()
out.mkdirs()
binary32.get().asFile.copyTo(outputDir.file("armeabi-v7a/libgeode.so").get().asFile)
binary64.get().asFile.copyTo(outputDir.file("arm64-v8a/libgeode.so").get().asFile)
}
}
val fullDownloadTask = tasks.register<MergeGeodeFilesTask>("downloadGeodeFull") {
description = "Downloads both Geode releases"
binary32 = downloadGeode32Task.flatMap { it.targetFile }
binary64 = downloadGeode64Task.flatMap { it.targetFile }
outputDir = layout.buildDirectory.dir("geode-prebuilt")
}
android {
compileSdk = 36
compileSdk = 37
defaultConfig {
applicationId = "com.geode.launcher"
minSdk = 23
targetSdk = 36
versionCode = 29
versionName = "1.8.0"
targetSdk = 37
versionCode = 31
versionName = "1.9.0"
vectorDrawables {
useSupportLibrary = true
@@ -39,7 +268,7 @@ android {
splits {
abi {
isEnable = true
isEnable = !project.hasProperty("disableAbiSplits")
reset()
//noinspection ChromeOsAbiSupport. i'm sorry!
@@ -49,6 +278,24 @@ android {
}
}
flavorDimensions += "distribution"
productFlavors {
create("standard") {
dimension = "distribution"
isDefault = true
buildConfigField("boolean", "GOOGLE_PLAY_BUILD", "false")
buildConfigField("String", "PREBUNDLED_GEODE", "null")
}
create("googlePlay") {
dimension = "distribution"
applicationId = "com.geode.launcher.play"
buildConfigField("boolean", "GOOGLE_PLAY_BUILD", "true")
buildConfigField("String", "PREBUNDLED_GEODE", "\"$currentGeodeVersion\"")
}
}
buildTypes {
release {
isMinifyEnabled = true
@@ -64,8 +311,8 @@ android {
// enables a polyfill for java Instant on api levels < 26 (used for updater)
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
@@ -83,32 +330,45 @@ android {
}
}
namespace = "com.geode.launcher"
ndkVersion = "29.0.14206865"
ndkVersion = "30.0.16248370"
}
androidComponents {
onVariants(selector().withFlavor("distribution" to "googlePlay")) { variant ->
variant.sources.jniLibs?.addGeneratedSourceDirectory(
fullDownloadTask,
MergeGeodeFilesTask::outputDir
)
variant.sources.assets?.addGeneratedSourceDirectory(
downloadGeodeResourcesTask,
DownloadAndUnzipGeodeResourcesTask::outputDir
)
}
}
dependencies {
implementation (platform("androidx.compose:compose-bom:$composeBOM"))
implementation ("androidx.core:core-ktx:1.18.0")
implementation (platform("androidx.compose:compose-bom:2026.09.00"))
implementation ("androidx.core:core-ktx:1.19.1")
implementation ("androidx.compose.ui:ui")
implementation ("androidx.compose.material3:material3")
implementation ("androidx.compose.ui:ui-tooling-preview")
implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")
implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0")
implementation ("androidx.activity:activity-compose:1.13.0")
implementation ("androidx.activity:activity-ktx:1.13.0")
implementation ("androidx.appcompat:appcompat:1.7.1")
implementation ("androidx.appcompat:appcompat:1.8.0")
implementation ("androidx.documentfile:documentfile:1.1.0")
implementation ("com.squareup.okio:okio:3.17.0")
implementation ("com.squareup.okhttp3:okhttp:5.3.2")
implementation ("com.squareup.okhttp3:okhttp-coroutines:5.3.2")
implementation ("com.squareup.okio:okio:3.18.2")
implementation ("com.squareup.okhttp3:okhttp:5.5.0")
implementation ("com.squareup.okhttp3:okhttp-coroutines:5.5.0")
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0")
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.10.0")
implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1")
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.11.0")
implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
implementation ("com.mikepenz:multiplatform-markdown-renderer-android:0.39.2")
implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.39.2")
implementation ("com.mikepenz:multiplatform-markdown-renderer-android:0.46.0")
implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.46.0")
implementation ("androidx.browser:browser:1.10.0")
debugImplementation ("androidx.compose.ui:ui-tooling")
coreLibraryDesugaring ("com.android.tools:desugar_jdk_libs:2.1.5")
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
tools:node="remove" />
</manifest>
+23 -5
View File
@@ -17,8 +17,6 @@
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.VIBRATE" />
@@ -127,7 +125,7 @@
</activity>
<activity
android:name=".GeometryDashActivity"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard"
android:configChanges="fontScale|layoutDirection|density|smallestScreenSize|screenSize|uiMode|screenLayout|orientation|navigation|keyboardHidden|keyboard|locale"
android:exported="true"
android:hardwareAccelerated="true"
android:screenOrientation="landscape"
@@ -235,10 +233,30 @@
<data android:scheme="geode-launcher" android:host="main" />
</intent-filter>
</activity-alias>
<activity-alias
android:name=".MainActivityGreen"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_green"
android:label="@string/app_name"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="geode-launcher" android:host="main" />
</intent-filter>
</activity-alias>
<provider
android:name=".UserDirectoryProvider"
android:authorities="com.geode.launcher.user"
android:authorities="${applicationId}.user"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
@@ -248,7 +266,7 @@
</provider>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="com.geode.launcher.fileprovider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
@@ -2,33 +2,106 @@ package com.customRobTop
import android.annotation.SuppressLint
import android.app.Activity
import android.content.*
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import android.provider.Settings
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.net.toUri
import com.customRobTop.contracts.ImportFileActivityContract
import com.customRobTop.contracts.ShareFileActivityContract
import com.geode.launcher.R
import com.geode.launcher.utils.DownloadUtils
import com.geode.launcher.utils.LaunchUtils
import org.cocos2dx.lib.Cocos2dxGLSurfaceView
import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.closeIMEKeyboard
import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.openIMEKeyboard
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.io.OutputStream
import java.lang.ref.WeakReference
import java.util.*
import java.util.UUID
@Suppress("unused", "UNUSED_PARAMETER")
object BaseRobTopActivity {
const val KEYBOARD_INPUT_MULTILINE = 1
const val KEYBOARD_INPUT_NUMERIC = 4
const val KEYBOARD_INPUT_PASSWORD = 2
const val LEVEL_EXTENSION: String = ".gdlvl"
private var isLoaded = false
var blockBackButton = false
private var keyboardActive = false
var curKeyboardInputFlags = 0
var isPaused = false
lateinit var me: WeakReference<Activity>
private var shouldResumeSound = true
fun setCurrentActivity(currentActivity: Activity) {
private var pendingExportPaths = emptyList<String>()
private lateinit var importFileActivityContract: ActivityResultLauncher<Unit>
private lateinit var shareFileActivityContract: ActivityResultLauncher<String>
private lateinit var shareFolderActivityContract: ActivityResultLauncher<Uri?>
fun setCurrentActivity(currentActivity: AppCompatActivity) {
me = WeakReference(currentActivity)
importFileActivityContract = currentActivity.registerForActivityResult(ImportFileActivityContract()) { uris ->
val filePaths = copyPickedFiles(uris)
Cocos2dxGLSurfaceView.cocos2dxGLSurfaceView.queueEvent {
JniToCpp.filesPicked(filePaths.toTypedArray())
}
}
shareFileActivityContract = currentActivity.registerForActivityResult(ShareFileActivityContract()) { uri ->
val currentlyPending = pendingExportPaths
pendingExportPaths = emptyList()
val exportPath = currentlyPending.firstOrNull()
if (exportPath == null || uri == null) {
Cocos2dxGLSurfaceView.cocos2dxGLSurfaceView.queueEvent {
JniToCpp.fileShareFinished(false)
}
return@registerForActivityResult
}
val result = saveToDocument(exportPath, uri)
Cocos2dxGLSurfaceView.cocos2dxGLSurfaceView.queueEvent {
JniToCpp.fileShareFinished(result)
}
}
shareFolderActivityContract = currentActivity.registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
val currentlyPending = pendingExportPaths
pendingExportPaths = emptyList()
if (uri == null) {
Cocos2dxGLSurfaceView.cocos2dxGLSurfaceView.queueEvent {
JniToCpp.fileShareFinished(false)
}
return@registerForActivityResult
}
val result = saveToFolder(currentlyPending, uri) > 0
Cocos2dxGLSurfaceView.cocos2dxGLSurfaceView.queueEvent {
JniToCpp.fileShareFinished(result)
}
}
}
@SuppressLint("HardwareIds")
@@ -87,6 +160,18 @@ object BaseRobTopActivity {
}
}
@JvmStatic
fun getClipboardString(): String {
me.get()?.run {
val manager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = manager.primaryClip
if (clip != null && clip.itemCount > 0) {
return clip.getItemAt(0).coerceToText(this).toString()
}
}
return ""
}
@JvmStatic
fun sendMail(subject: String, body: String, to: String) {
me.get()?.runOnUiThread {
@@ -264,4 +349,164 @@ object BaseRobTopActivity {
level = DeprecationLevel.HIDDEN
)
fun logEvent(event: String) {}
@JvmStatic
fun setKeyboardInputFlags(flags: Int) {
curKeyboardInputFlags = flags
}
@JvmStatic
fun pickImportFiles() {
try {
importFileActivityContract.launch(Unit)
} catch (_: ActivityNotFoundException) {
me.get()?.apply {
runOnUiThread {
Toast.makeText(
this,
R.string.no_activity_found,
Toast.LENGTH_SHORT,
).show()
}
}
}
}
private fun copyPickedFiles(uris: List<Uri>): List<String> {
val me = me.get() ?: return emptyList()
val importsDir = File(LaunchUtils.getSaveDirectory(me), "imports")
importsDir.mkdirs()
return uris.mapIndexedNotNull { index, uri ->
val displayName = displayName(uri)
?: "import_$index$LEVEL_EXTENSION"
if (!displayName.endsWith(LEVEL_EXTENSION)) {
return@mapIndexedNotNull null
}
var outputFile = File(importsDir, displayName)
var counter = 2
while (outputFile.exists()) {
outputFile = File(importsDir, "${counter}_$displayName")
counter++
}
try {
me.contentResolver.openInputStream(uri)?.use { input ->
outputFile.outputStream().use { output ->
DownloadUtils.copyFile(input, output)
}
outputFile.absolutePath
}
} catch (_: Exception) {
outputFile.delete()
null
}
}
}
private fun displayName(uri: Uri): String? {
me.get()?.contentResolver?.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursorQuery ->
if (cursorQuery.moveToFirst()) {
val displayName = cursorQuery.getString(0)
if (displayName.isNotEmpty()) {
return File(displayName).name
}
}
}
return null
}
@JvmStatic
fun shareFiles(files: Array<String>) {
pendingExportPaths = files.toList()
try {
if (files.size == 1) {
shareFileActivityContract.launch(files.first())
return
} else if (files.size > 1) {
shareFolderActivityContract.launch(null)
return
}
} catch (_: ActivityNotFoundException) {
me.get()?.apply {
runOnUiThread {
Toast.makeText(
this,
R.string.no_activity_found,
Toast.LENGTH_SHORT,
).show()
}
}
Cocos2dxGLSurfaceView.cocos2dxGLSurfaceView.queueEvent {
JniToCpp.fileShareFinished(false)
}
}
}
private fun saveToFolder(exportPaths: List<String>, treeUri: Uri): Int {
val uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri))
val me = me.get() ?: return 0
return exportPaths.count { path ->
val filename = File(path).name
val documentUri = DocumentsContract.createDocument(me.contentResolver, uri, "application/octet-stream", filename)
?: return@count false
saveToDocument(path, documentUri)
}
}
private fun saveToDocument(exportPath: String, uri: Uri): Boolean {
try {
FileInputStream(exportPath).use { inputStream ->
openOutput(uri).use { outputStream ->
if (outputStream != null) {
DownloadUtils.copyFile(inputStream, outputStream)
}
}
}
ensureLevelExtension(uri)
return true
} catch (_: IOException) {
return false
}
}
private fun openOutput(uri: Uri): OutputStream? {
val me = me.get() ?: return null
return try {
me.contentResolver.openOutputStream(uri, "wt")
} catch (_: IOException) {
me.contentResolver.openOutputStream(uri)
}
}
private fun ensureLevelExtension(uri: Uri) {
val displayName = displayName(uri)
if (displayName == null || displayName.endsWith(LEVEL_EXTENSION)) {
return
}
val me = me.get() ?: return
val extensionIdx = displayName.lastIndexOf(".gdlvl.")
val substring = if (extensionIdx < 0) {
displayName + LEVEL_EXTENSION
} else {
displayName.substring(0, extensionIdx + 6)
}
try {
DocumentsContract.renameDocument(me.contentResolver, uri, substring)
} catch (_: IOException) {
}
}
}
@@ -55,4 +55,16 @@ object JniToCpp {
@JvmStatic
external fun videoAdShowed()
@JvmStatic
external fun filesPicked(files: Array<String>)
@JvmStatic
external fun fileShareFinished(success: Boolean)
@JvmStatic
external fun textInputCaretToEnd()
@JvmStatic
external fun textInputDone()
}
@@ -0,0 +1,40 @@
package com.customRobTop.contracts
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.activity.result.contract.ActivityResultContract
class ImportFileActivityContract : ActivityResultContract<Unit, List<Uri>>() {
override fun createIntent(
context: Context,
input: Unit
): Intent {
return Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
}
}
override fun parseResult(
resultCode: Int,
intent: Intent?
): List<Uri> {
if (resultCode != Activity.RESULT_OK || intent == null) {
return emptyList()
}
val clipData = intent.clipData
return if (clipData != null) {
List(clipData.itemCount) { i -> clipData.getItemAt(i).uri }
} else {
val intentData = intent.data
if (intentData != null)
listOf(intentData)
else
emptyList()
}
}
}
@@ -0,0 +1,40 @@
package com.customRobTop.contracts
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.DocumentsContract
import androidx.activity.result.contract.ActivityResultContract
import java.io.File
class ShareFileActivityContract : ActivityResultContract<String, Uri?>() {
override fun createIntent(
context: Context,
input: String
): Intent {
return Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/octet-stream"
putExtra(Intent.EXTRA_TITLE, File(input).name)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
putExtra(DocumentsContract.EXTRA_INITIAL_URI,
DocumentsContract.buildDocumentUri("com.android.externalstorage.documents", "primary:Download")
)
}
}
}
override fun parseResult(
resultCode: Int,
intent: Intent?
): Uri? {
if (resultCode != Activity.RESULT_OK || intent == null) {
return null
}
return intent.data
}
}
@@ -171,7 +171,8 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
tryLoadLibrary(gdPackageInfo, Constants.FMOD_LIB_NAME)
tryLoadLibrary(gdPackageInfo, Constants.COCOS_LIB_NAME)
if (GamePackageUtils.getGameVersionCode(packageManager) >= 39L) {
val gameVersionCode = GamePackageUtils.getGameVersionCode(packageManager)
if (gameVersionCode >= 39L) {
// this fix requires geode v3, which is 2.206+
// there is a short period in which 2.206 users will still have geode v2, but whatever. ig
LauncherFix.enableExceptionsRenaming()
@@ -182,6 +183,9 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
loadInternalMods()
val baseView = createView()
if (gameVersionCode >= 42L) {
mGLSurfaceView?.sendNewCocosEvents = true
}
if (!GeodeUtils.handleSafeArea && Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
val frameLayoutParams = ViewGroup.LayoutParams(
@@ -205,12 +209,22 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
left = 0, top = 0, right = 0, bottom = 0
)
}
Cocos2dxGLSurfaceView.onKeyboardInsets(insets)
WindowInsets.CONSUMED
}
frameLayout.addView(baseView)
setContentView(frameLayout)
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
baseView.setOnApplyWindowInsetsListener { view, insets ->
Cocos2dxGLSurfaceView.onKeyboardInsets(insets)
return@setOnApplyWindowInsetsListener view.onApplyWindowInsets(insets)
}
}
setContentView(baseView)
}
@@ -379,53 +393,73 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
return "$sourceDir!/lib/$architecture/"
}
@SuppressLint("UnsafeDynamicallyLoadedCode")
private fun loadGeodeLibrary() {
// Load Geode if exists
// bundling the object with the application allows for nicer backtraces
try {
// put libgeode.so in jniLibs/armeabi-v7a to get this
System.loadLibrary("geode")
return
} catch (e: UnsatisfiedLinkError) {
// but users may prefer it stored with data
val geodeFilename = LaunchUtils.geodeFilename
val geodePath = File(filesDir.path, "launcher/$geodeFilename")
if (geodePath.exists()) {
System.load(geodePath.path)
@Suppress("SENSELESS_COMPARISON")
if (BuildConfig.PREBUNDLED_GEODE != null) {
// attempt to load an externally provided Geode first
if (!loadExternalGeode()) {
copyGeodeResources()
System.loadLibrary("geode")
}
} else {
// Load Geode if exists
// bundling the object with the application allows for nicer backtraces
try {
// put libgeode.so in jniLibs/armeabi-v7a to get this
System.loadLibrary("geode")
return
}
// you know zmx i have 0 clue what this does so im
// just gonna like copy the binary from external
// also i get 20 million permission denied errors
val externalGeodePath = LaunchUtils.getInstalledGeodePath(this)!!
val copiedPath = File(filesDir.path, "copied")
if (copiedPath.exists()) {
copiedPath.deleteRecursively()
}
copiedPath.mkdir()
val copiedGeodePath = File(copiedPath.path, "Geode.so")
if (externalGeodePath.exists()) {
DownloadUtils.copyFile(
FileInputStream(externalGeodePath),
FileOutputStream(copiedGeodePath)
)
if (copiedGeodePath.exists()) {
println("Loading Geode from ${externalGeodePath.name}")
System.load(copiedGeodePath.path)
return
} catch (e: UnsatisfiedLinkError) {
if (!loadExternalGeode()) {
throw e
}
}
throw e
}
}
@SuppressLint("UnsafeDynamicallyLoadedCode")
private fun loadExternalGeode(): Boolean {
// but users may prefer it stored with data
val geodeFilename = LaunchUtils.geodeFilename
val geodePath = File(filesDir.path, "launcher/$geodeFilename")
if (geodePath.exists()) {
println("Loading Geode from ${geodePath.path}")
System.load(geodePath.path)
return true
}
// you know zmx i have 0 clue what this does so im
// just gonna like copy the binary from external
// also i get 20 million permission denied errors
val externalGeodePath = LaunchUtils.getInstalledGeodePath(this)
?: return false
val copiedPath = File(filesDir.path, "copied")
if (copiedPath.exists()) {
copiedPath.deleteRecursively()
}
copiedPath.mkdir()
val copiedGeodePath = File(copiedPath.path, "Geode.so")
if (externalGeodePath.exists()) {
DownloadUtils.copyFile(
FileInputStream(externalGeodePath),
FileOutputStream(copiedGeodePath)
)
if (copiedGeodePath.exists()) {
// android 17 fix
copiedGeodePath.setWritable(false)
println("Loading Geode from ${externalGeodePath.name}")
System.load(copiedGeodePath.path)
return true
}
}
return false
}
private fun createView(): FrameLayout {
val frameLayoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
@@ -477,6 +511,10 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
editText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
glSurfaceView.cocos2dxEditText = editText
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
glSurfaceView.defaultFocusHighlightEnabled = false
}
return frameLayout
}
@@ -663,6 +701,56 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
}
}
private fun copyGeodeResources() {
@Suppress("SENSELESS_COMPARISON")
if (BuildConfig.PREBUNDLED_GEODE == null) {
return
}
val resourcesDirectory = LaunchUtils.getGeodeResourcesDirectory(this)
val pathBase = "geode.loader"
val versionFilename = "version"
val bundledVersion = try {
assets.open("$pathBase/$versionFilename").bufferedReader().use {
it.readText()
}.trimEnd()
} catch (_: IOException) {
return
}
val versionFile = File(resourcesDirectory, versionFilename)
val resourcesVersion = if (versionFile.exists()) {
versionFile.readText().trimEnd()
} else null
if (bundledVersion == resourcesVersion) {
println("copyGeodeResources: found $resourcesVersion, expected $bundledVersion. No work needs to be done!")
return
}
if (resourcesDirectory.exists()) {
resourcesDirectory.deleteRecursively()
}
resourcesDirectory.mkdirs()
println("copyGeodeResources: found $resourcesVersion, expected $bundledVersion. Copying assets")
val fileListing = try {
assets.list(pathBase)
} catch (_: IOException) {
return
}
fileListing?.forEach { fileName ->
val resourceOutput = File(resourcesDirectory, fileName)
val resource = assets.open("$pathBase/$fileName")
DownloadUtils.copyFile(resource, resourceOutput.outputStream())
}
}
class EGLConfigChooser : GLSurfaceView.EGLConfigChooser {
// this comes from EGL14, but is unavailable on EGL10
// also EGL14 is incompatible with EGL10. so whatever
@@ -104,13 +104,25 @@ class MainActivity : ComponentActivity() {
val gdInstalled = GamePackageUtils.isGameInstalled(packageManager)
val geodeInstalled = LaunchUtils.isGeodeInstalled(this)
if (gdInstalled && geodeInstalled && intent.getBooleanExtra("restarted", false)) {
onLaunch(this)
val restartImmediately = intent.getBooleanExtra("restarted", false)
if (gdInstalled && geodeInstalled && restartImmediately) {
val withSafeMode = intent.getBooleanExtra("safe-mode", false)
onLaunch(this, withSafeMode)
}
enableEdgeToEdge()
clearDownloadedApks(this)
// remove old Geode for the like 4 people that downloaded the Play Store build
@Suppress("SENSELESS_COMPARISON")
if (BuildConfig.PREBUNDLED_GEODE != null) {
val preferences = PreferenceUtils.get(this)
if (!preferences.getBoolean(PreferenceUtils.Key.PREBUNDLED_GEODE_MIGRATION_PERFORMED)) {
LaunchUtils.getInstalledGeodePath(this)?.delete()
preferences.setBoolean(PreferenceUtils.Key.PREBUNDLED_GEODE_MIGRATION_PERFORMED, true)
}
}
val returnMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_MESSAGE)
val returnExtendedMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE)
@@ -207,7 +219,7 @@ fun mapCancelReasonToInfo(cancelReason: LaunchViewModel.LaunchCancelReason): Lau
)
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_error),
details = stringResource(R.string.launcher_cancelled_outdated)
details = stringResource(R.string.launcher_cancelled_outdated, GamePackageUtils.getSupportedUnifiedVersion())
)
LaunchViewModel.LaunchCancelReason.AUTOMATIC,
LaunchViewModel.LaunchCancelReason.MANUAL-> LaunchStatusInfo(
@@ -256,7 +268,7 @@ fun mapLaunchStatusToInfo(state: LaunchViewModel.LaunchUIState, inSafeMode: Bool
)
}
is LaunchViewModel.LaunchUIState.Cancelled -> {
return mapCancelReasonToInfo(state.reason)
mapCancelReasonToInfo(state.reason)
}
}
}
@@ -716,6 +728,9 @@ fun GeodeUpdateIndicator(snackbarHostState: SnackbarHostState, onRetry: () -> Un
ReleaseManager.UpdateException.Reason.EXTERNAL_FILE_IN_USE -> stringResource(
R.string.release_fetch_external_in_use_short
)
ReleaseManager.UpdateException.Reason.HASH_VALIDATION_FAILED -> stringResource(
R.string.release_fetch_hash_validation_failed_short
)
else -> stringResource(R.string.release_fetch_generic_short)
}
}
@@ -825,10 +840,11 @@ fun AltMainScreen(
)
// only show launcher update in a case where a user won't see it ingame
if (launchUIState is LaunchViewModel.LaunchUIState.Cancelled) {
val currentUIState = launchUIState
if (currentUIState is LaunchViewModel.LaunchUIState.Cancelled) {
val gameVersion = remember { GamePackageUtils.getGameVersionCodeOrNull(context.packageManager) }
if (gameVersion != null && gameVersion < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
if (gameVersion != null && gameVersion < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING && currentUIState.reason != LaunchViewModel.LaunchCancelReason.GAME_OUTDATED) {
UnsupportedVersionWarning()
}
@@ -10,7 +10,10 @@ import android.provider.DocumentsContract
import android.provider.DocumentsProvider
import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi
import com.geode.launcher.utils.ApplicationIcon
import com.geode.launcher.utils.IconUtils
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
import java.io.File
private val DEFAULT_ROOT_PROJECTION: Array<String> = arrayOf(
@@ -60,8 +63,17 @@ class UserDirectoryProvider : DocumentsProvider() {
DocumentsContract.Root.FLAG_SUPPORTS_RECENTS or
DocumentsContract.Root.FLAG_SUPPORTS_SEARCH
)
add(DocumentsContract.Root.COLUMN_TITLE, context.getString(R.string.app_name))
add(DocumentsContract.Root.COLUMN_ICON, R.drawable.geode_logo)
val selectedIconKey = PreferenceUtils.get(context).getString(PreferenceUtils.Key.SELECTED_ICON)
?: "geode"
val selectedIcon = ApplicationIcon.fromId(selectedIconKey)
val iconDetails = IconUtils.getIconDetails(selectedIcon)
val nameId = if (selectedIcon == ApplicationIcon.SAPPHIRE) R.string.application_icon_sapphire else R.string.app_name
val logoId = if (selectedIcon == ApplicationIcon.GEODE) R.drawable.geode_logo else iconDetails.iconId
add(DocumentsContract.Root.COLUMN_TITLE, context.getString(nameId))
add(DocumentsContract.Root.COLUMN_ICON, logoId)
}
return result
@@ -202,7 +202,11 @@ fun onShowLogs(context: Context) {
fun ErrorInfoDescription(description: String, modifier: Modifier = Modifier) {
val context = LocalContext.current
val currentRelease = remember {
PreferenceUtils.get(context).getString(PreferenceUtils.Key.CURRENT_VERSION_TAG)
@Suppress("SENSELESS_COMPARISON")
if (BuildConfig.PREBUNDLED_GEODE != null)
"${BuildConfig.PREBUNDLED_GEODE}*"
else
PreferenceUtils.get(context).getString(PreferenceUtils.Key.CURRENT_VERSION_TAG)
}
val gameVersion = remember {
@@ -36,6 +36,7 @@ import com.geode.launcher.utils.GeodeUtils
import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlin.time.Duration.Companion.milliseconds
@Composable
@@ -142,7 +143,7 @@ fun onSettings(context: Context) {
}
@Composable
fun LongPressButton(onClick: () -> Unit, onLongPress: () -> Unit, enabled: Boolean = true, modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) {
fun LongPressButton(onClick: () -> Unit, onLongPress: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, content: @Composable RowScope.() -> Unit) {
// compose apis don't provide a good way of adding long press to a button
var isLongPress by remember { mutableStateOf(false) }
@@ -154,11 +155,11 @@ fun LongPressButton(onClick: () -> Unit, onLongPress: () -> Unit, enabled: Boole
interactionSource.interactions.collectLatest { interaction ->
when (interaction) {
is PressInteraction.Press -> {
delay(viewConfiguration.longPressTimeoutMillis)
delay(viewConfiguration.longPressTimeoutMillis.milliseconds)
// perform a second delay to make the action more obvious
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
delay(viewConfiguration.longPressTimeoutMillis)
delay(viewConfiguration.longPressTimeoutMillis.milliseconds)
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
@@ -186,6 +187,8 @@ fun LongPressButton(onClick: () -> Unit, onLongPress: () -> Unit, enabled: Boole
@Composable
fun UnsupportedVersionWarning(modifier: Modifier = Modifier) {
val gameVersion = GamePackageUtils.getSupportedUnifiedVersion()
InlineDialog(
headline = {
Icon(
@@ -201,7 +204,7 @@ fun UnsupportedVersionWarning(modifier: Modifier = Modifier) {
},
body = {
Text(
stringResource(R.string.launcher_game_update_required),
stringResource(R.string.launcher_game_update_required, gameVersion),
)
},
modifier = modifier.padding(8.dp)
@@ -39,6 +39,8 @@ import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.GeodeUtils
import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.delay
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
enum class LaunchNotificationType {
GEODE_UPDATED, LAUNCHER_UPDATE_AVAILABLE, UPDATE_FAILED, UNSUPPORTED_VERSION, FAILED_LOAD;
@@ -87,7 +89,7 @@ fun NotificationCardFromType(type: LaunchNotificationType) {
}
AnimatedNotificationCard(
displayLength = 5000L,
displayLength = 5.seconds,
onClick = {
showInfoDialog = true
}
@@ -140,15 +142,15 @@ fun LaunchNotification(cards: List<LaunchNotificationType>) {
}
@Composable
fun AnimatedNotificationCard(modifier: Modifier = Modifier, visibilityDelay: Long = 0L, displayLength: Long = 3000L, onClick: (() -> Unit)? = null, contents: @Composable () -> Unit) {
fun AnimatedNotificationCard(modifier: Modifier = Modifier, visibilityDelay: Duration = Duration.ZERO, displayLength: Duration = 3.seconds, onClick: (() -> Unit)? = null, contents: @Composable () -> Unit) {
val state = remember {
MutableTransitionState(false).apply {
targetState = visibilityDelay <= 0
targetState = visibilityDelay <= Duration.ZERO
}
}
LaunchedEffect(true) {
if (visibilityDelay > 0) {
if (visibilityDelay > Duration.ZERO) {
delay(visibilityDelay)
state.targetState = true
}
@@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.geode.launcher.BuildConfig
import com.geode.launcher.updater.ReleaseManager
import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.GamePackageUtils
@@ -182,7 +183,9 @@ class LaunchViewModel(private val application: Application): ViewModel() {
return
}
if (GamePackageUtils.getGameVersionCode(packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN) {
// play store build only comes with the one build of geode
val minVersionCode = if (BuildConfig.GOOGLE_PLAY_BUILD) Constants.SUPPORTED_VERSION_CODE else Constants.SUPPORTED_VERSION_CODE_MIN
if (GamePackageUtils.getGameVersionCode(packageManager) < minVersionCode) {
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_OUTDATED))
return
}
@@ -248,25 +251,31 @@ class LaunchViewModel(private val application: Application): ViewModel() {
initReadyTimer()
}
val releaseManager = ReleaseManager.get(application)
if (!BuildConfig.GOOGLE_PLAY_BUILD) {
val releaseManager = ReleaseManager.get(application)
val hasGeode = LaunchUtils.isGeodeInstalled(application)
val shouldUpdate = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
val hasGeode = LaunchUtils.isGeodeInstalled(application)
val shouldUpdate = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
if ((shouldUpdate && !hasCheckedForUpdates) || !hasGeode) {
releaseManager.checkForUpdates(false)
} else {
releaseManager.afterUseCachedUpdate()
}
if ((shouldUpdate && !hasCheckedForUpdates) || !hasGeode) {
releaseManager.checkForUpdates(false)
} else {
releaseManager.afterUseCachedUpdate()
}
if (isRestart) {
releaseManager.dismissedLauncherUpdate = true
if (isRestart) {
releaseManager.dismissedLauncherUpdate = true
}
}
preReadyCheck()
}
fun retryUpdate() {
if (BuildConfig.GOOGLE_PLAY_BUILD) {
return
}
ReleaseManager.get(application).checkForUpdates(true)
if (ReleaseManager.get(application).isInUpdate) {
restoreUpdateState = _uiState.value
@@ -4,7 +4,6 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Build
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -409,7 +408,8 @@ fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
}
}
ElevatedCard(modifier) {
// there's like a single pixel of the shadow that gets clipped without this...
ElevatedCard(modifier = modifier.padding(bottom = 1.dp)) {
Column(
// buttons add enough padding already, lower to compensate
modifier = Modifier.padding(
@@ -129,7 +129,7 @@ fun onSelectIcon(context: Context, selectedIcon: ApplicationIconDetails) {
val iconData = IconUtils.getIconDetails(it)
packageManager.setComponentEnabledSetting(
ComponentName(context, "${context.packageName}.${iconData.component}"),
ComponentName(context, "${context.packageName.removeSuffix(".play")}.${iconData.component}"),
if (iconData.id == selectedIcon.id)
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
else PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
@@ -40,6 +40,7 @@ import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.geode.launcher.BuildConfig
import com.geode.launcher.R
import com.geode.launcher.preferences.components.OptionsButton
import com.geode.launcher.preferences.components.OptionsGroup
@@ -204,28 +205,30 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
)
}
OptionsGroup(title = stringResource(R.string.preference_category_updater)) {
val context = LocalContext.current
if (!BuildConfig.GOOGLE_PLAY_BUILD) {
OptionsGroup(title = stringResource(R.string.preference_category_updater)) {
val context = LocalContext.current
SettingsSelectCard(
title = stringResource(R.string.preference_release_channel_tag_name),
dialogTitle = stringResource(R.string.preference_release_channel_select),
preferenceKey = PreferenceUtils.Key.RELEASE_CHANNEL_TAG,
options = linkedMapOf(
0 to stringResource(R.string.preference_release_channel_stable),
1 to stringResource(R.string.preference_release_channel_beta),
2 to stringResource(R.string.preference_release_channel_nightly),
),
extraSelectBehavior = {
PreferenceUtils.get(context)
.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, 0L)
}
)
SettingsSelectCard(
title = stringResource(R.string.preference_release_channel_tag_name),
dialogTitle = stringResource(R.string.preference_release_channel_select),
preferenceKey = PreferenceUtils.Key.RELEASE_CHANNEL_TAG,
options = linkedMapOf(
0 to stringResource(R.string.preference_release_channel_stable),
1 to stringResource(R.string.preference_release_channel_beta),
2 to stringResource(R.string.preference_release_channel_nightly),
),
extraSelectBehavior = {
PreferenceUtils.get(context)
.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, 0L)
}
)
SettingsCard(
title = stringResource(R.string.preference_disable_update_cache),
preferenceKey = PreferenceUtils.Key.DISABLE_UPDATE_CACHE
)
SettingsCard(
title = stringResource(R.string.preference_disable_update_cache),
preferenceKey = PreferenceUtils.Key.DISABLE_UPDATE_CACHE
)
}
}
OptionsGroup(title = stringResource(R.string.preference_category_testing)) {
@@ -70,7 +70,6 @@ import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
import com.geode.launcher.BuildConfig
import com.geode.launcher.MainActivity
import com.geode.launcher.R
import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.preferences.components.BackupButton
@@ -510,6 +509,10 @@ fun GameplaySettingsGroup() {
@Composable
fun UpdateSettingsGroup(releaseViewModel: ReleaseViewModel, snackbarHostState: SnackbarHostState, onUpdate: () -> Unit) {
if (BuildConfig.GOOGLE_PLAY_BUILD) {
return
}
val updateStatus by releaseViewModel.uiState.collectAsState()
OptionsGroup(stringResource(R.string.preference_category_updater)) {
@@ -661,11 +664,14 @@ fun AboutSettingsGroup(developerModeEnabled: Boolean) {
)
}
@Suppress("SENSELESS_COMPARISON")
OptionsLabel(
stringResource(R.string.preference_loader_version_name),
stringResource(
R.string.preference_loader_version_description,
currentRelease ?: "unknown"
if (BuildConfig.PREBUNDLED_GEODE != null)
"${BuildConfig.PREBUNDLED_GEODE}*"
else (currentRelease ?: "unknown")
),
displayInline = true
)
@@ -3,10 +3,9 @@ package com.geode.launcher.updater
import com.geode.launcher.utils.LaunchUtils
import kotlin.time.Instant
import kotlinx.serialization.Serializable
import kotlin.time.ExperimentalTime
@Serializable
data class Asset @OptIn(ExperimentalTime::class) constructor(
data class Asset(
val url: String,
val id: Int,
val name: String,
@@ -14,10 +13,11 @@ data class Asset @OptIn(ExperimentalTime::class) constructor(
val createdAt: Instant,
val updatedAt: Instant,
val browserDownloadUrl: String,
val digest: String?,
)
@Serializable
data class Release @OptIn(ExperimentalTime::class) constructor(
data class Release(
val url: String,
val id: Int,
val targetCommitish: String,
@@ -31,12 +31,29 @@ data class Release @OptIn(ExperimentalTime::class) constructor(
)
@Serializable
data class LoaderVersion @OptIn(ExperimentalTime::class) constructor(
data class LoaderPlatformDownload(
val url: String,
val hash: String?,
)
@Serializable
data class LoaderDownload(
val win: LoaderPlatformDownload,
val mac: LoaderPlatformDownload,
val android32: LoaderPlatformDownload,
val android64: LoaderPlatformDownload,
val ios: LoaderPlatformDownload,
val resources: LoaderPlatformDownload,
)
@Serializable
data class LoaderVersion(
val tag: String,
val version: String,
val createdAt: Instant,
val commitHash: String,
val prerelease: Boolean
val prerelease: Boolean,
val downloads: LoaderDownload,
)
@Serializable
@@ -45,7 +62,25 @@ data class LoaderPayload<T>(
val error: String
)
data class DownloadableAsset(val url: String, val filename: String, val size: Long? = null)
data class DownloadableAsset(
val url: String,
val filename: String,
val size: Long? = null,
val hash: String? = null,
)
private fun mapDownload(download: Asset): DownloadableAsset {
val hash = if (download.digest?.startsWith("sha256:") == true)
download.digest.removePrefix("sha256:")
else null
return DownloadableAsset(
url = download.browserDownloadUrl,
filename = download.name,
size = download.size.toLong(),
hash = hash,
)
}
abstract class Downloadable {
/**
@@ -83,7 +118,6 @@ class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadab
return release.tagName
}
@OptIn(ExperimentalTime::class)
override fun getDescriptor(): Long {
return release.createdAt.epochSeconds
}
@@ -100,11 +134,7 @@ class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadab
override fun getDownload(): DownloadableAsset? {
val download = getGitHubDownload() ?: return null
return DownloadableAsset(
url = download.browserDownloadUrl,
filename = download.name,
size = download.size.toLong()
)
return mapDownload(download)
}
override fun getResourcesDownload(): DownloadableAsset? {
@@ -112,11 +142,7 @@ class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadab
it.name == "resources.zip"
} ?: return null
return DownloadableAsset(
url = download.browserDownloadUrl,
filename = download.name,
size = download.size.toLong()
)
return mapDownload(download)
}
}
@@ -125,7 +151,6 @@ class DownloadableLauncherRelease(val release: Release) : Downloadable() {
return release.tagName
}
@OptIn(ExperimentalTime::class)
override fun getDescriptor(): Long {
return release.createdAt.epochSeconds
}
@@ -149,11 +174,7 @@ class DownloadableLauncherRelease(val release: Release) : Downloadable() {
override fun getDownload(): DownloadableAsset? {
val download = getGitHubDownload() ?: return null
return DownloadableAsset(
url = download.browserDownloadUrl,
filename = download.name,
size = download.size.toLong()
)
return mapDownload(download)
}
override fun getResourcesDownload(): DownloadableAsset? {
@@ -166,24 +187,27 @@ class DownloadableLoaderRelease(private val version: LoaderVersion) : Downloadab
return version.tag
}
@OptIn(ExperimentalTime::class)
override fun getDescriptor(): Long {
return version.createdAt.epochSeconds
}
override fun getDownload(): DownloadableAsset {
val data = if (LaunchUtils.is64bit) version.downloads.android64 else version.downloads.android32
val filename = "geode-${version.tag}-${LaunchUtils.platformName}.zip"
return DownloadableAsset(
url = "https://github.com/geode-sdk/geode/releases/download/${version.tag}/$filename",
filename = filename
url = data.url,
filename = filename,
hash = data.hash,
)
}
override fun getResourcesDownload(): DownloadableAsset {
val data = version.downloads.resources
val filename = "resources.zip"
return DownloadableAsset(
url = "https://github.com/geode-sdk/geode/releases/download/${version.tag}/resources.zip",
filename = filename
url = data.url,
filename = filename,
hash = data.hash,
)
}
}
@@ -25,8 +25,8 @@ import okio.sink
import okio.source
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.io.InterruptedIOException
import java.io.IOException
import kotlin.math.max
import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes
@@ -75,7 +75,8 @@ class ReleaseManager private constructor(
class UpdateException(reason: Reason? = null, cause: Throwable? = null) : Exception(reason?.name, cause) {
enum class Reason {
EXTERNAL_FILE_IN_USE
EXTERNAL_FILE_IN_USE,
HASH_VALIDATION_FAILED
}
var reason: Reason? = reason
@@ -155,17 +156,17 @@ class ReleaseManager private constructor(
val outputFile = File(outputDirectory, download.filename)
try {
DownloadUtils.downloadStream(
val hash = DownloadUtils.downloadFile(
httpClient,
download.url,
onResponse = { body ->
body.source().use { source ->
outputFile.sink().buffer().use { sink ->
sink.writeAll(source)
}
}
}
outputFile
)
if (download.hash != null && download.hash != hash) {
Log.w("ReleaseManager", "downloadLauncherUpdate failed: found $hash but expected ${download.hash}")
outputFile.delete()
throw UpdateException(UpdateException.Reason.HASH_VALIDATION_FAILED)
}
} catch (e: Exception) {
return Result.failure(e)
}
@@ -183,42 +184,52 @@ class ReleaseManager private constructor(
val finalDir = LaunchUtils.getGeodeResourcesDirectory(applicationContext)
val downloadFile = getTempFile("resources.zip")
try {
if (!outputDir.exists()) {
outputDir.mkdirs()
}
DownloadUtils.downloadStream(
val hash = DownloadUtils.downloadFile(
httpClient,
resourceAsset.url,
downloadFile,
onProgress = { progress, outOf ->
if (!skipStateUpdate)
_uiState.value = ReleaseManagerState.InDownload(initialSize + progress, outOf + initialSize)
},
onResponse = { body ->
DownloadUtils.copyZipStreamToDirectory(
body.byteStream(),
outputDir
)
if (finalDir.exists()) {
finalDir.deleteRecursively()
}
if (!outputDir.renameTo(finalDir)) {
println("Failed to rename temporary directory!")
DownloadUtils.copyDirectory(outputDir, finalDir)
}
}
)
if (resourceAsset.hash != null && resourceAsset.hash != hash) {
Log.w("ReleaseManager", "performResourceDownload failed: found $hash but expected ${resourceAsset.hash}")
throw UpdateException(UpdateException.Reason.HASH_VALIDATION_FAILED)
}
downloadFile.inputStream().use { zip ->
DownloadUtils.copyZipStreamToDirectory(
zip,
outputDir
)
}
if (finalDir.exists()) {
finalDir.deleteRecursively()
}
if (!outputDir.renameTo(finalDir)) {
println("Failed to rename temporary directory!")
DownloadUtils.copyDirectory(outputDir, finalDir)
}
} catch (e: Exception) {
sendError(e)
return false
} finally {
val tempPathClone = File(outputPath)
runCatching {
try {
if (downloadFile.exists()) downloadFile.delete()
if (tempPathClone.exists()) tempPathClone.deleteRecursively()
}
} catch (_: IOException) {}
}
return true
@@ -244,12 +255,14 @@ class ReleaseManager private constructor(
val initialSize = if (releaseAsset.size == null && resourcesAsset?.size == null) null else releaseSize + resourcesSize
_uiState.value = ReleaseManagerState.InDownload(0, initialSize)
val outputFile = getTempFile()
val outputFile = getTempFile(LaunchUtils.geodeFilename)
// clone the file instance as renameTo may move the original file
val tempFilePath = outputFile.path
val geodeFile = getGeodeOutputPath()
val downloadFile = getTempFile("geode.zip")
try {
// tempFile should be in same path as geodeFile
val geodeParent = geodeFile.parentFile
@@ -257,45 +270,53 @@ class ReleaseManager private constructor(
geodeParent.mkdirs()
}
DownloadUtils.downloadStream(
val hash = DownloadUtils.downloadFile(
httpClient,
releaseAsset.url,
downloadFile,
onProgress = { progress, outOf ->
if (!skipStateUpdate)
_uiState.value = ReleaseManagerState.InDownload(progress, outOf + resourcesSize)
releaseSize = max(outOf, releaseSize)
},
onResponse = { body ->
DownloadUtils.extractFileFromZipStream(
body.byteStream(),
outputFile,
geodeFile.name
)
// work around a permission issue from adb push
if (geodeFile.exists()) {
geodeFile.delete()
}
val renameSuccessful = runCatching {
outputFile.renameTo(geodeFile)
}.getOrDefault(false)
if (!renameSuccessful) {
// attempt a manual copy if rename fails for whatever reason
DownloadUtils.copyFile(outputFile, geodeFile)
}
}
)
if (releaseAsset.hash != null && releaseAsset.hash != hash) {
Log.w("ReleaseManager", "performUpdate failed: found $hash but expected ${releaseAsset.hash}")
throw UpdateException(UpdateException.Reason.HASH_VALIDATION_FAILED)
}
downloadFile.inputStream().use { zip ->
DownloadUtils.extractFileFromZipStream(
zip,
outputFile,
geodeFile.name
)
}
// work around a permission issue from adb push
if (geodeFile.exists()) {
geodeFile.delete()
}
val renameSuccessful = runCatching {
outputFile.renameTo(geodeFile)
}.getOrDefault(false)
if (!renameSuccessful) {
// attempt a manual copy if rename fails for whatever reason
DownloadUtils.copyFile(outputFile, geodeFile)
}
} catch (e: Exception) {
sendError(e)
return
} finally {
val tempFileClone = File(tempFilePath)
runCatching {
try {
if (downloadFile.exists()) downloadFile.delete()
if (tempFileClone.exists()) tempFileClone.delete()
}
} catch (_: IOException) {}
}
if (resourcesAsset != null) {
@@ -331,6 +352,11 @@ class ReleaseManager private constructor(
}
private suspend fun getLatestLauncherUpdate(): DownloadableLauncherRelease? {
if (BuildConfig.GOOGLE_PLAY_BUILD) {
// don't check for updates on Google Play builds
return null
}
return try {
releaseRepository.getLatestLauncherRelease()
} catch (e: Exception) {
@@ -340,6 +366,8 @@ class ReleaseManager private constructor(
}
suspend fun checkLauncherUpdate() {
if (BuildConfig.GOOGLE_PLAY_BUILD) return
val launcherRelease = getLatestLauncherUpdate() ?: return
// unconditionally track it, we're not using it for checks and it might be good to have
@@ -356,6 +384,8 @@ class ReleaseManager private constructor(
}
fun checkCachedLauncherUpdate() {
if (BuildConfig.GOOGLE_PLAY_BUILD) return
val lastUpdate = PreferenceUtils.get(applicationContext)
.getString(PreferenceUtils.Key.LAST_LAUNCHER_UPDATE) ?: return
@@ -365,6 +395,12 @@ class ReleaseManager private constructor(
}
suspend fun downloadLatestLauncherUpdate(): Result<File> {
if (BuildConfig.GOOGLE_PLAY_BUILD) {
return Result.failure(UnsupportedOperationException(
"Launcher updates are not supported on Google Play builds"
))
}
val update = _availableLauncherUpdateDetails.value
?: getLatestLauncherUpdate()
?: return Result.failure(FileNotFoundException())
@@ -373,6 +409,10 @@ class ReleaseManager private constructor(
}
fun shouldUseCache(): Boolean {
if (BuildConfig.GOOGLE_PLAY_BUILD) {
return true
}
val sharedPreferences = PreferenceUtils.get(applicationContext)
if (sharedPreferences.getBoolean(PreferenceUtils.Key.DISABLE_UPDATE_CACHE)) {
return false
@@ -391,6 +431,11 @@ class ReleaseManager private constructor(
}
private suspend fun checkForNewRelease(isManual: Boolean = false) {
if (BuildConfig.GOOGLE_PLAY_BUILD) {
_uiState.value = ReleaseManagerState.Finished()
return
}
val sharedPreferences = PreferenceUtils.get(applicationContext)
if (!isManual && shouldUseCache()) {
@@ -481,15 +526,14 @@ class ReleaseManager private constructor(
}
}
private fun getTempFile(): File {
val geodeName = LaunchUtils.geodeFilename
private fun getTempFile(filename: String): File {
val geodeDirectory = LaunchUtils.getBaseDirectory(applicationContext)
// warning!! while File::createTempFile may look tempting, a certain brand of phones has a messed up implementation of it
// so we're making a temp file manually (as long as it doesn't collide with the geode download, it's okay)
val suffix = createRandomString()
val tmpName = "tmp-$suffix.$geodeName"
val tmpName = "tmp-$suffix.$filename"
val tempFile = File(geodeDirectory, tmpName)
@@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.AP
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.geode.launcher.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -89,6 +90,10 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
}
fun runReleaseCheck(isManual: Boolean = false) {
if (BuildConfig.GOOGLE_PLAY_BUILD) {
return
}
hasPerformedCheck = true
viewModelScope.launch(Dispatchers.IO) {
@@ -17,10 +17,12 @@ import okio.BufferedSource
import okio.FileSystem
import okio.ForwardingSink
import okio.ForwardingSource
import okio.HashingSink
import okio.Path.Companion.toOkioPath
import okio.Sink
import okio.Source
import okio.buffer
import okio.sink
import java.io.File
import java.io.IOException
import java.io.InputStream
@@ -32,12 +34,31 @@ import java.util.zip.ZipInputStream
typealias ProgressCallback = (progress: Long, outOf: Long) -> Unit
object DownloadUtils {
suspend fun downloadFile(
httpClient: OkHttpClient,
url: String,
outputFile: File,
onProgress: ProgressCallback? = null,
): String = downloadStream(
httpClient,
url,
onProgress = onProgress
).use { body ->
val hashingSink = HashingSink.sha256(outputFile.sink().buffer())
hashingSink.use { sink ->
body.source().use { source ->
source.readAll(sink)
}
}
hashingSink.hash.hex()
}
suspend fun downloadStream(
httpClient: OkHttpClient,
url: String,
onProgress: ProgressCallback? = null,
onResponse: suspend (ResponseBody) -> Unit,
) {
): ResponseBody {
val request = Request.Builder()
.url(url)
.build()
@@ -64,12 +85,15 @@ object DownloadUtils {
val progressClient = progressClientBuilder.build()
val call = progressClient.newCall(request)
call.executeAsync().use { response ->
when (response.code) {
200 -> onResponse(response.body)
else -> throw IOException("unexpected response ${response.code}")
}
val response = call.executeAsync()
val responseCode = response.code
if (responseCode != 200) {
response.close()
throw IOException("unexpected response ${response.code}")
}
return response.body
}
suspend fun copyZipStreamToDirectory(inputStream: InputStream, output: File) = runInterruptible {
@@ -8,6 +8,7 @@ import android.os.Environment
import android.provider.DocumentsContract
import android.provider.MediaStore
import java.io.File
import androidx.core.net.toUri
class FileUtils {
// copied from https://stackoverflow.com/questions/17546101/get-real-path-for-uri-android
@@ -86,7 +87,7 @@ class FileUtils {
val file = File(id)
if (file.exists()) return id
}
val contentUri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), java.lang.Long.valueOf(id))
val contentUri = ContentUris.withAppendedId("content://downloads/public_downloads".toUri(), java.lang.Long.valueOf(id))
return getDataColumn(context, contentUri, null, null)
}
@@ -53,6 +53,7 @@ object GamePackageUtils {
39L to "2.206",
40L to "2.2074", // ok
41L to "2.2081",
42L to "2.209",
)
/**
@@ -63,6 +64,10 @@ object GamePackageUtils {
return gameVersionMap[versionCode] ?: getGameVersionString(packageManager)
}
fun getSupportedUnifiedVersion(): String {
return gameVersionMap[Constants.SUPPORTED_VERSION_CODE] ?: Constants.SUPPORTED_VERSION_STRING
}
fun detectAbiMismatch(context: Context, packageInfo: PackageInfo, loadException: Error): Boolean {
val abi = LaunchUtils.applicationArchitecture
val isExtracted =
@@ -32,7 +32,6 @@ import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import com.geode.launcher.BuildConfig
import com.geode.launcher.R
import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.activityresult.GeodeOpenFileActivityResult
import com.geode.launcher.activityresult.GeodeOpenFilesActivityResult
import com.geode.launcher.activityresult.GeodeSaveFileActivityResult
@@ -192,10 +191,16 @@ object GeodeUtils {
@JvmStatic
fun restartGame() {
restartGame(false)
}
@JvmStatic
fun restartGame(safeMode: Boolean) {
activity.get()?.run {
packageManager.getLaunchIntentForPackage(packageName)?.also {
val mainIntent = Intent.makeRestartActivityTask(it.component)
mainIntent.putExtra("restarted", true)
mainIntent.putExtra("safe-mode", safeMode)
startActivity(mainIntent)
exitProcess(0)
}
@@ -215,13 +220,15 @@ object GeodeUtils {
val relativePath = pathFile.relativeTo(baseDirectory)
val uri = DocumentsContract.buildDocumentUri(
"com.android.externalstorage.documents",
"primary:Android/media/${context.packageName}/$relativePath"
if (BuildConfig.GOOGLE_PLAY_BUILD) {
"primary:Android/data/${context.packageName}/$relativePath"
} else {
"primary:Android/media/${context.packageName}/$relativePath"
}
)
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, DocumentsContract.Document.MIME_TYPE_DIR)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
} else {
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
@@ -409,7 +416,7 @@ object GeodeUtils {
}
fun isGeodeUri(uri: Uri): Boolean {
return "com.geode.launcher.user" == uri.authority
return "${BuildConfig.APPLICATION_ID}.user" == uri.authority
}
private const val INTERNAL_PERMISSION_PREFIX = "geode.permission_internal"
@@ -428,7 +435,7 @@ object GeodeUtils {
Manifest.permission.WRITE_EXTERNAL_STORAGE
)
return permissions.all {
permissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
}
@@ -793,7 +800,7 @@ object GeodeUtils {
external fun internalKeyEvent(timestamp: Long, deviceId: Int, eventSource: Int, keyCode: Int, modifiers: Int, isDown: Boolean, repeatCount: Int)
external fun internalTouchEvent(timestamp: Long, deviceId: Int, eventSource: Int, eventType: Int, ids: IntArray, xs: FloatArray, ys: FloatArray)
external fun internalScrollEvent(timestamp: Long, deviceId: Int, eventSource: Int, scrollX: Float, scrollY: Float)
external fun internalScrollEvent(timestamp: Long, deviceId: Int, eventSource: Int, scrollX: Float, scrollY: Float, mouseX: Float, mouseY: Float)
external fun internalJoystickEvent(timestamp: Long, deviceId: Int, eventSource: Int, leftX: FloatArray, leftY: FloatArray, rightX: FloatArray, rightY: FloatArray, hatX: FloatArray, hatY: FloatArray, leftTrigger: FloatArray, rightTrigger: FloatArray)
external fun inputDeviceAdded(deviceId: Int)
@@ -15,10 +15,6 @@ import androidx.compose.ui.res.painterResource
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.drawable.toBitmap
import com.geode.launcher.R
import kotlinx.datetime.Month
import kotlinx.datetime.TimeZone
import kotlinx.datetime.todayIn
import kotlin.time.Clock
// https://gist.github.com/tkuenneth/ddf598663f041dc79960cda503d14448?permalink_comment_id=4660486#gistcomment-4660486
@Composable
@@ -48,6 +44,7 @@ enum class ApplicationIcon {
GEODE,
PRIDE,
TRANS,
GREEN,
SAPPHIRE;
companion object {
@@ -57,6 +54,7 @@ enum class ApplicationIcon {
"pride" -> PRIDE
"trans" -> TRANS
"sapphire" -> SAPPHIRE
"green" -> GREEN
else -> DEFAULT
}
}
@@ -67,6 +65,7 @@ enum class ApplicationIcon {
DEFAULT -> "default"
TRANS -> "trans"
SAPPHIRE -> "sapphire"
GREEN -> "green"
}
}
@@ -109,5 +108,11 @@ object IconUtils {
iconId = R.mipmap.ic_launcher_sapphire,
nameId = R.string.application_icon_sapphire,
)
ApplicationIcon.GREEN -> ApplicationIconDetails(
id = id.toId(),
component = "MainActivityGreen",
iconId = R.mipmap.ic_launcher_green,
nameId = R.string.application_icon_green,
)
}
}
@@ -2,6 +2,7 @@ package com.geode.launcher.utils
import android.content.Context
import android.os.Build
import com.geode.launcher.BuildConfig
import java.io.File
import java.io.Serializable
@@ -41,6 +42,9 @@ object LaunchUtils {
}
fun isGeodeInstalled(context: Context): Boolean {
@Suppress("SENSELESS_COMPARISON")
if (BuildConfig.PREBUNDLED_GEODE != null) return true
return getInstalledGeodePath(context) != null
}
@@ -48,10 +52,14 @@ object LaunchUtils {
* Returns the directory that Geode/the game should base itself off of.
*/
fun getBaseDirectory(context: Context, ignoreProfile: Boolean = false): File {
// deprecated, but seems to be the best choice of directory (i forced mat to test it)
// also, is getting the first item the correct choice here?? what do they mean
@Suppress("DEPRECATION")
val dir = context.externalMediaDirs.first()
val dir = if (BuildConfig.GOOGLE_PLAY_BUILD) {
context.getExternalFilesDir(null)!!.parentFile!!
} else {
// deprecated, but seems to be the best choice of directory (i forced mat to test it)
// also, is getting the first item the correct choice here?? what do they mean
@Suppress("DEPRECATION")
context.externalMediaDirs.first()
}
// prevent having resources added to system gallery
// accessing this file every time the directory is read may be a little wasteful...
@@ -149,6 +149,7 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
LAST_UPDATE_CHECK_TIME,
LAST_LAUNCHER_UPDATE,
DISABLE_UPDATE_CACHE,
PREBUNDLED_GEODE_MIGRATION_PERFORMED,
}
private fun defaultValueForBooleanKey(key: Key): Boolean {
@@ -199,6 +200,7 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
Key.LAST_UPDATE_CHECK_TIME -> "PreferenceLastUpdateCheckTime"
Key.LAST_LAUNCHER_UPDATE -> "PreferenceLastLauncherUpdate"
Key.DISABLE_UPDATE_CACHE -> "PreferenceDisableUpdateCache"
Key.PREBUNDLED_GEODE_MIGRATION_PERFORMED -> "PreferencePrebundledGeodeMigrationPerformed"
// Key.RELEASE_CHANNEL -> "PreferenceReleaseChannel"
// Key.LAST_DISMISSED_UPDATE -> "PreferenceLastDismissedUpdate"
// Key.FORCE_HRR -> "PreferenceForceHighRefreshRate"
@@ -6,17 +6,19 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.delay
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
const val MS_TO_SEC = 1000L
@Composable
fun useCountdownTimer(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
time: Long,
time: Duration,
onCountdownFinish: () -> Unit
): Long {
var millisUntilFinished by remember {
mutableLongStateOf(time)
mutableStateOf(time)
}
var shouldBeCounting by remember {
@@ -28,9 +30,9 @@ fun useCountdownTimer(
return@LaunchedEffect
}
if (millisUntilFinished > 0) {
delay(MS_TO_SEC)
millisUntilFinished -= MS_TO_SEC
if (millisUntilFinished > Duration.ZERO) {
delay(1.seconds)
millisUntilFinished -= 1.seconds
} else {
onCountdownFinish()
}
@@ -58,5 +60,5 @@ fun useCountdownTimer(
}
}
return millisUntilFinished / MS_TO_SEC
return millisUntilFinished.inWholeSeconds
}
@@ -1,31 +1,45 @@
package org.cocos2dx.lib
import android.content.Context
import android.graphics.*
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Paint.FontMetricsInt
import android.graphics.Rect
import android.graphics.Typeface
import android.text.TextPaint
import android.text.TextUtils
import android.util.Log
import android.view.MotionEvent
import androidx.core.graphics.createBitmap
import java.lang.ref.WeakReference
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.*
import java.util.LinkedList
import kotlin.math.abs
import kotlin.math.ceil
private const val HORIZONTALALIGN_LEFT = 1
private const val HORIZONTALALIGN_RIGHT = 2
private const val HORIZONTALALIGN_CENTER = 3
private const val VERTICALALIGN_TOP = 1
private const val VERTICALALIGN_BOTTOM = 2
private const val VERTICALALIGN_CENTER = 3
@Suppress("KotlinJniMissingFunction")
object Cocos2dxBitmap {
private lateinit var context: WeakReference<Context>
@JvmStatic
private external fun nativeInitBitmapDC(pWidth: Int, pHeight: Int, pPixels: ByteArray)
external fun nativeInitBitmapDC(pWidth: Int, pHeight: Int, pPixels: ByteArray)
fun setContext(context: Context) {
this.context = WeakReference(context)
}
/*
@JvmStatic
@Suppress("unused")
fun createTextBitmap(
string: String,
fontName: String,
@@ -55,7 +69,6 @@ object Cocos2dxBitmap {
1.0f
)
}
*/
@JvmStatic
@Suppress("unused")
@@ -79,19 +92,18 @@ object Cocos2dxBitmap {
strokeB: Float,
strokeSize: Float
) {
val bitmapTotalHeight: Int
val horizontalAlignment = alignment and 15
val verticalAlignment = alignment shr 4 and 15
val horizontalAlignment = alignment and 0x0F
val verticalAlignment = (alignment shr 4) and 0x0F
val pString2 = refactorString(string)
val paint = newPaint(fontName, fontSize, horizontalAlignment)
paint.setARGB(
MotionEvent.ACTION_MASK,
(255.0 * fontTintR.toDouble()).toInt(),
(255.0 * fontTintG.toDouble()).toInt(),
(255.0 * fontTintB.toDouble()).toInt()
255,
(255.0 * fontTintR).toInt(),
(255.0 * fontTintG).toInt(),
(255.0 * fontTintB).toInt()
)
val textProperty = computeTextProperty(pString2, width, height, paint)
bitmapTotalHeight = if (height == 0) {
val bitmapTotalHeight = if (height == 0) {
textProperty.totalHeight
} else {
height
@@ -101,7 +113,8 @@ object Cocos2dxBitmap {
var renderTextDeltaX = 0.0f
var renderTextDeltaY = 0.0f
if (shadow) {
paint.setShadowLayer(shadowBlur, shadowDX, shadowDY, -8553091)
val shadowColor = 0xff7d7d7du.toInt()
paint.setShadowLayer(shadowBlur, shadowDX, shadowDY, shadowColor)
bitmapPaddingX = abs(shadowDX)
bitmapPaddingY = abs(shadowDY)
if (shadowDX.toDouble() < 0.0) {
@@ -111,56 +124,47 @@ object Cocos2dxBitmap {
renderTextDeltaY = bitmapPaddingY
}
}
val bitmap = Bitmap.createBitmap(
textProperty.maxWidth + bitmapPaddingX.toInt(),
bitmapPaddingY.toInt() + bitmapTotalHeight,
Bitmap.Config.ARGB_8888
)
val bitmap = try {
createBitmap(
textProperty.maxWidth + bitmapPaddingX.toInt(),
bitmapTotalHeight + bitmapPaddingY.toInt()
)
} catch (e: Exception) {
// bandaid fix, hopefully someone checks logs to see this
Log.e("Cocos2dxBitmap", "Error creating bitmap: $e")
nativeInitBitmapDC(0, 0, ByteArray(0))
return
}
val canvas = Canvas(bitmap)
val fontMetricsInt = paint.fontMetricsInt
var y = computeY(fontMetricsInt, height, textProperty.totalHeight, verticalAlignment)
val lines = textProperty.lines
val length = lines.size
for (i in 0 until length) {
val line = lines[i]
canvas.drawText(
line!!,
computeX(
line,
textProperty.maxWidth,
horizontalAlignment
).toFloat() + renderTextDeltaX,
y.toFloat() + renderTextDeltaY,
paint
)
for (line in lines) {
val x = computeX(line, textProperty.maxWidth, horizontalAlignment)
canvas.drawText(line, x + renderTextDeltaX, y + renderTextDeltaY, paint)
y += textProperty.heightPerLine
}
if (stroke) {
val paintStroke = newPaint(fontName, fontSize, horizontalAlignment)
paintStroke.style = Paint.Style.STROKE
paintStroke.strokeWidth = 0.5f * strokeSize
paintStroke.strokeWidth = strokeSize * 0.5f
paintStroke.setARGB(
MotionEvent.ACTION_MASK,
255,
strokeR.toInt() * MotionEvent.ACTION_MASK,
strokeG.toInt() * MotionEvent.ACTION_MASK,
strokeB.toInt() * MotionEvent.ACTION_MASK
)
var y2 = computeY(fontMetricsInt, height, textProperty.totalHeight, verticalAlignment)
val lines2 = textProperty.lines
val length2 = lines2.size
for (i2 in 0 until length2) {
val line2 = lines2[i2]
canvas.drawText(
line2!!,
computeX(
line2,
textProperty.maxWidth,
horizontalAlignment
).toFloat() + renderTextDeltaX,
y2.toFloat() + renderTextDeltaY,
paintStroke
)
y2 += textProperty.heightPerLine
var y = computeY(fontMetricsInt, height, textProperty.totalHeight, verticalAlignment)
val lines = textProperty.lines
for (line in lines) {
val x = computeX(line, textProperty.maxWidth, horizontalAlignment)
canvas.drawText(line, x + renderTextDeltaX, y + renderTextDeltaY, paintStroke)
y += textProperty.heightPerLine
}
}
initNativeObject(bitmap)
@@ -168,24 +172,24 @@ object Cocos2dxBitmap {
private fun newPaint(fontName: String, fontSize: Int, horizontalAlignment: Int): Paint {
val paint = Paint()
paint.color = -1
paint.color = Color.WHITE
paint.textSize = fontSize.toFloat()
paint.isAntiAlias = true
if (fontName.endsWith(".ttf")) {
try {
context.get()?.apply {
context.get()!!.apply {
paint.typeface = Cocos2dxTypefaces[this, fontName]
}
} catch (e: Exception) {
Log.e("Cocos2dxBitmap", "error to create ttf type face: $fontName")
Log.e("Cocos2dxBitmap", "error to create ttf type face: $fontName - $e")
paint.typeface = Typeface.create(fontName, Typeface.NORMAL)
}
} else {
paint.typeface = Typeface.create(fontName, Typeface.NORMAL)
}
when (horizontalAlignment) {
2 -> paint.textAlign = Paint.Align.RIGHT
3 -> paint.textAlign = Paint.Align.CENTER
HORIZONTALALIGN_RIGHT -> paint.textAlign = Paint.Align.RIGHT
HORIZONTALALIGN_CENTER -> paint.textAlign = Paint.Align.CENTER
else -> paint.textAlign = Paint.Align.LEFT
}
return paint
@@ -198,14 +202,14 @@ object Cocos2dxBitmap {
paint: Paint
): TextProperty {
val fm = paint.fontMetricsInt
val h = ceil((fm.bottom - fm.top).toDouble()).toInt()
val h = fm.bottom - fm.top
var maxContentWidth = 0
val lines = splitString(string, width, height, paint)
if (width != 0) {
maxContentWidth = width
} else {
for (line in lines) {
val temp = ceil(paint.measureText(line, 0, line!!.length).toDouble()).toInt()
val temp = ceil(paint.measureText(line, 0, line.length)).toInt()
if (temp > maxContentWidth) {
maxContentWidth = temp
}
@@ -214,10 +218,10 @@ object Cocos2dxBitmap {
return TextProperty(maxContentWidth, h, lines)
}
private fun computeX(@Suppress("UNUSED_PARAMETER") text: String?, maxWidth: Int, horizontalAlignment: Int): Int {
private fun computeX(@Suppress("UNUSED_PARAMETER") text: String, maxWidth: Int, horizontalAlignment: Int): Int {
return when (horizontalAlignment) {
2 -> maxWidth
3 -> maxWidth / 2
HORIZONTALALIGN_RIGHT -> maxWidth
HORIZONTALALIGN_CENTER -> maxWidth / 2
else -> 0
}
}
@@ -229,13 +233,15 @@ object Cocos2dxBitmap {
verticalAlignment: Int
): Int {
val y = -fontMetricsInt.top
return if (constrainHeight <= totalHeight) {
return if (constrainHeight > totalHeight) {
when (verticalAlignment) {
VERTICALALIGN_TOP -> -fontMetricsInt.top
VERTICALALIGN_CENTER -> -fontMetricsInt.top + (constrainHeight - totalHeight) / 2
VERTICALALIGN_BOTTOM -> -fontMetricsInt.top + (constrainHeight - totalHeight)
else -> y
}
} else {
y
} else when (verticalAlignment) {
1 -> -fontMetricsInt.top
2 -> -fontMetricsInt.top + (constrainHeight - totalHeight)
3 -> -fontMetricsInt.top + (constrainHeight - totalHeight) / 2
else -> y
}
}
@@ -244,56 +250,56 @@ object Cocos2dxBitmap {
maxWidth: Int,
maxHeight: Int,
paint: Paint
): Array<String?> {
val lines: Array<String?> = string.split("\\n").toTypedArray()
): List<String> {
val lines = string.split("\n")
val fm = paint.fontMetricsInt
val maxLines = maxHeight / ceil((fm.bottom - fm.top).toDouble()).toInt()
val heightPerLine = fm.bottom - fm.top
val maxLines = maxHeight / heightPerLine
return if (maxWidth != 0) {
val strList = LinkedList<String?>()
val strList = LinkedList<String>()
for (line in lines) {
if (ceil(paint.measureText(line).toDouble()).toInt() > maxWidth) {
val lineWidth = ceil(paint.measureText(line)).toInt()
if (lineWidth > maxWidth) {
strList.addAll(divideStringWithMaxWidth(line, maxWidth, paint))
} else {
strList.add(line)
}
if (maxLines > 0 && strList.size >= maxLines) {
break
}
}
if (maxLines > 0 && strList.size > maxLines) {
while (strList.size > maxLines) {
strList.removeLast()
}
}
val ret = arrayOfNulls<String>(strList.size)
strList.toArray(ret)
ret
} else if (maxHeight == 0 || lines.size <= maxLines) {
lines
strList
} else if (maxHeight != 0 && lines.size > maxLines) {
lines.take(maxLines)
} else {
val strList2 = LinkedList<String?>()
for (i in 0 until maxLines) {
strList2.add(lines[i])
}
val ret2 = arrayOfNulls<String>(strList2.size)
strList2.toArray(ret2)
ret2
lines
}
}
private fun divideStringWithMaxWidth(
string: String?,
string: String,
maxWidth: Int,
paint: Paint
): LinkedList<String?> {
val charLength = string!!.length
): LinkedList<String> {
val charLength = string.length
var start = 0
val strList = LinkedList<String?>()
val strList = LinkedList<String>()
var i = 1
while (i <= charLength) {
val tempWidth = ceil(paint.measureText(string, start, i).toDouble()).toInt()
val tempWidth = ceil(paint.measureText(string, start, i)).toInt()
if (tempWidth >= maxWidth) {
val lastIndexOfSpace = string.substring(0, i).lastIndexOf(" ")
if (lastIndexOfSpace != -1 && lastIndexOfSpace > start) {
strList.add(string.substring(start, lastIndexOfSpace))
i = lastIndexOfSpace + 1
@@ -303,16 +309,20 @@ object Cocos2dxBitmap {
} else {
strList.add(string.substring(start, i))
}
while (i < charLength && string[i] == ' ') {
i++
}
start = i
}
i++
}
if (start < charLength) {
strList.add(string.substring(start))
}
return strList
}
@@ -320,19 +330,22 @@ object Cocos2dxBitmap {
if (pString.compareTo("") == 0) {
return " "
}
val strBuilder = StringBuilder(pString)
var start = 0
var index = strBuilder.indexOf("\n")
while (index != -1) {
start = if (index == 0 || strBuilder[index - 1].code == 10) {
start = if (index == 0 || strBuilder[index - 1] == '\n') {
strBuilder.insert(start, " ")
index + 2
} else {
index + 1
}
if (start > strBuilder.length || index == strBuilder.length) {
break
}
index = strBuilder.indexOf("\n", start)
}
return strBuilder.toString()
@@ -340,15 +353,10 @@ object Cocos2dxBitmap {
private fun initNativeObject(pBitmap: Bitmap) {
val pixels = getPixels(pBitmap)
if (pixels != null) {
nativeInitBitmapDC(pBitmap.width, pBitmap.height, pixels)
}
nativeInitBitmapDC(pBitmap.width, pBitmap.height, pixels)
}
private fun getPixels(pBitmap: Bitmap?): ByteArray? {
if (pBitmap == null) {
return null
}
private fun getPixels(pBitmap: Bitmap): ByteArray {
val pixels = ByteArray(pBitmap.width * pBitmap.height * 4)
val buf = ByteBuffer.wrap(pixels)
buf.order(ByteOrder.nativeOrder())
@@ -361,13 +369,18 @@ object Cocos2dxBitmap {
private fun getFontSizeAccordingHeight(height: Int): Int {
val paint = Paint()
val bounds = Rect()
paint.typeface = Typeface.DEFAULT
var incrTextSize = 1
var foundDesiredSize = false
while (!foundDesiredSize) {
paint.textSize = incrTextSize.toFloat()
paint.getTextBounds("SghMNy", 0, "SghMNy".length, bounds)
val text = "SghMNy"
paint.getTextBounds(text, 0, text.length, bounds)
incrTextSize++
if (height - bounds.height() <= 2) {
foundDesiredSize = true
}
@@ -382,23 +395,19 @@ object Cocos2dxBitmap {
if (TextUtils.isEmpty(string)) {
return ""
}
val paint = TextPaint()
paint.typeface = Typeface.DEFAULT
paint.textSize = fontSize
return TextUtils.ellipsize(string, paint, width, TextUtils.TruncateAt.END).toString()
}
private class TextProperty constructor(
private class TextProperty(
val maxWidth: Int,
val heightPerLine: Int, pLines: Array<String?>
val heightPerLine: Int,
val lines: Collection<String>
) {
val lines: Array<String?>
val totalHeight: Int
init {
totalHeight = pLines.size * heightPerLine
lines = pLines
}
val totalHeight: Int = lines.size * heightPerLine
}
}
@@ -8,11 +8,13 @@ import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.Message
import android.util.Log
import android.text.InputType
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.Surface
import android.view.WindowInsets
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import androidx.annotation.RequiresApi
import androidx.appcompat.widget.AppCompatEditText
@@ -25,12 +27,54 @@ private const val HANDLER_CLOSE_IME_KEYBOARD = 3
private const val MS_TO_NS = 1_000_000
private const val SEC_TO_NS = 1_000_000_000.0
fun inputTypeForFlags(i: Int): Int {
if ((i and BaseRobTopActivity.KEYBOARD_INPUT_NUMERIC) != 0) {
return InputType.TYPE_NUMBER_FLAG_DECIMAL or InputType.TYPE_NUMBER_FLAG_SIGNED or InputType.TYPE_CLASS_NUMBER
}
if ((i and BaseRobTopActivity.KEYBOARD_INPUT_PASSWORD) != 0) {
return InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT
}
if ((i and BaseRobTopActivity.KEYBOARD_INPUT_MULTILINE) != 0) {
return InputType.TYPE_TEXT_FLAG_MULTI_LINE or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or InputType.TYPE_CLASS_TEXT
}
return InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS or InputType.TYPE_CLASS_TEXT
}
fun usesInlineKeyboard(): Boolean {
return Build.VERSION.SDK_INT >= 30
}
fun imeOptionsForFlags(i: Int): Int {
val i2 = if ((i and BaseRobTopActivity.KEYBOARD_INPUT_MULTILINE) == 0 || !usesInlineKeyboard())
EditorInfo.IME_ACTION_DONE else EditorInfo.IME_ACTION_NONE
return if (usesInlineKeyboard())
EditorInfo.IME_FLAG_NO_FULLSCREEN or EditorInfo.IME_FLAG_NO_EXTRACT_UI or i2
else i2
}
class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
companion object {
lateinit var cocos2dxGLSurfaceView: Cocos2dxGLSurfaceView
private lateinit var handler: Handler
private lateinit var cocos2dxTextInputWrapper: Cocos2dxTextInputWrapper
private var keyboardVisible = false
private var keyboardHeight = 0
private var keyboardHideCheck = Runnable {
if (keyboardVisible) {
return@Runnable
}
cocos2dxGLSurfaceView.queueEvent {
cocos2dxGLSurfaceView.cocos2dxRenderer.handleTextClosed()
}
}
@JvmStatic
fun openIMEKeyboard() {
val msg = Message()
@@ -50,6 +94,30 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
cocos2dxGLSurfaceView.queueEvent {
Cocos2dxAccelerometer.onSensorChanged(x, y, z, timestamp)
}
@RequiresApi(Build.VERSION_CODES.R)
fun onKeyboardInsets(windowInsets: WindowInsets) {
if (!cocos2dxGLSurfaceView.sendNewCocosEvents) {
return
}
val visible = windowInsets.isVisible(WindowInsets.Type.ime())
val offsetHeight = if (visible) windowInsets.getInsets(WindowInsets.Type.ime()).bottom else 0
if (visible == keyboardVisible && offsetHeight == keyboardHeight) {
return
}
keyboardVisible = visible
keyboardHeight = offsetHeight
handler.removeCallbacks(keyboardHideCheck)
if (!visible) {
handler.postDelayed(keyboardHideCheck, 150L)
} else {
cocos2dxGLSurfaceView.cocos2dxRenderer.handleKeyboardWillShow(keyboardHeight)
}
}
}
private lateinit var cocos2dxRenderer: Cocos2dxRenderer
@@ -65,6 +133,9 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
private set
var supportsResizeEvents = false
// 2.209+ events for keyboard and such
var sendNewCocosEvents = false
var cocos2dxEditText: AppCompatEditText? = null
set(value) {
field = value
@@ -85,9 +156,13 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
HANDLER_OPEN_IME_KEYBOARD -> {
if (cocos2dxEditText?.requestFocus() == true) {
cocos2dxEditText?.apply {
removeTextChangedListener(
cocos2dxTextInputWrapper
)
removeTextChangedListener(cocos2dxTextInputWrapper)
if (sendNewCocosEvents) {
inputType = inputTypeForFlags(BaseRobTopActivity.curKeyboardInputFlags)
imeOptions = imeOptionsForFlags(BaseRobTopActivity.curKeyboardInputFlags)
}
setText("")
val text = msg.obj as String
append(text)
@@ -100,16 +175,13 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
cocos2dxEditText,
0
)
Log.d("GLSurfaceView", "showSoftInput")
return
}
return
}
HANDLER_CLOSE_IME_KEYBOARD -> {
cocos2dxEditText?.apply {
removeTextChangedListener(
cocos2dxTextInputWrapper
)
removeTextChangedListener(cocos2dxTextInputWrapper)
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(
windowToken,
0
@@ -117,8 +189,10 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
}
requestFocus()
Log.d("GLSurfaceView", "HideSoftInput")
cocos2dxRenderer.handleTextClosed()
queueEvent {
cocos2dxRenderer.handleTextClosed()
}
return
}
else -> return
@@ -297,6 +371,97 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
}
}
private fun queueKeyboardEvent(keyCode: Int, event: KeyEvent, down: Boolean): Boolean {
val keypadCode = keyCode == KeyEvent.KEYCODE_MENU || keyCode == KeyEvent.KEYCODE_BACK
if (event.isSystem && !keypadCode) {
return false
}
if (keypadCode && BaseRobTopActivity.blockBackButton) {
return true
}
queueEvent {
sendNextEventTimestamp(event.eventTime * MS_TO_NS)
}
if (useKeyboardEvents) {
if (down) {
queueEvent {
GeodeUtils.nativeKeyDown(keyCode, event.modifiers, event.repeatCount != 0)
}
} else {
queueEvent {
GeodeUtils.nativeKeyUp(keyCode, event.modifiers)
}
}
}
if (controllerEventsEnabled) {
queueEvent {
GeodeUtils.internalKeyEvent(
event.eventTime * MS_TO_NS,
event.deviceId,
event.source,
event.keyCode,
event.modifiers,
down,
event.repeatCount
)
}
}
// geode events override native callbacks
if (useKeyboardEvents || controllerEventsEnabled) {
return true
}
if (sendNewCocosEvents) {
if (KeyEvent.isGamepadButton(keyCode)) {
return false
}
if (keyCode == KeyEvent.KEYCODE_ESCAPE && BaseRobTopActivity.blockBackButton) {
return true
}
// messy logic
val isRepeat = event.repeatCount > 0
if (keypadCode) {
if (down) {
if (isRepeat || BaseRobTopActivity.blockBackButton) {
return true
}
queueEvent {
cocos2dxRenderer.handleKeyDown(keyCode)
}
return true
} else {
return false
}
}
val eventTimestamp = event.eventTime / 1000.0
val shiftPressed = event.isShiftPressed
val ctrlPressed = event.isCtrlPressed
val altPressed = event.isAltPressed
queueEvent {
cocos2dxRenderer.handleKeyboardEvent(
keyCode, down, isRepeat, eventTimestamp,
shiftPressed, ctrlPressed, altPressed
)
}
return true
}
return false
}
private fun legacyKeyDown(keyCode: Int, keyEvent: KeyEvent): Boolean {
return when (keyCode) {
KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_MENU -> {
@@ -354,65 +519,19 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
}
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
if (!useKeyboardEvents && !controllerEventsEnabled) {
if (!queueKeyboardEvent(keyCode, event, true)) {
return legacyKeyDown(keyCode, event)
}
return when (keyCode) {
// ignore system keys
KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_MUTE -> {
super.onKeyDown(keyCode, event)
}
else -> {
if (BaseRobTopActivity.blockBackButton) {
return true
}
queueEvent {
sendNextEventTimestamp(event.eventTime * MS_TO_NS)
if (useKeyboardEvents) {
GeodeUtils.nativeKeyDown(keyCode, event.modifiers, event.repeatCount != 0)
}
if (controllerEventsEnabled) {
GeodeUtils.internalKeyEvent(event.eventTime * MS_TO_NS, event.deviceId, event.source, event.keyCode, event.modifiers, true, event.repeatCount)
}
}
true
}
}
return true
}
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
if (!useKeyboardEvents && !controllerEventsEnabled) {
if (!queueKeyboardEvent(keyCode, event, false)) {
return super.onKeyUp(keyCode, event)
}
return when (keyCode) {
KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_MUTE -> {
super.onKeyUp(keyCode, event)
}
else -> {
if (event.repeatCount != 0 || BaseRobTopActivity.blockBackButton) {
return true
}
queueEvent {
sendNextEventTimestamp(event.eventTime * MS_TO_NS)
if (useKeyboardEvents) {
GeodeUtils.nativeKeyUp(keyCode, event.modifiers)
}
if (controllerEventsEnabled) {
GeodeUtils.internalKeyEvent(event.eventTime * MS_TO_NS, event.deviceId, event.source, event.keyCode, event.modifiers, false, event.repeatCount)
}
}
true
}
}
return true
}
// i'm lazy.
@@ -475,11 +594,15 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
}
override fun onGenericMotionEvent(event: MotionEvent?): Boolean {
if (!useKeyboardEvents && !controllerEventsEnabled) {
val hasGeodeEvents = useKeyboardEvents || controllerEventsEnabled
if (!hasGeodeEvents && !sendNewCocosEvents) {
return super.onGenericMotionEvent(event)
}
if (event?.action == MotionEvent.ACTION_SCROLL) {
val mouseX = event.x
val mouseY = event.y
val scrollX = event.getAxisValue(MotionEvent.AXIS_HSCROLL)
val scrollY = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
val timestamp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@@ -493,7 +616,11 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
}
if (controllerEventsEnabled) {
GeodeUtils.internalScrollEvent(timestamp, event.deviceId, event.source, scrollX, scrollY)
GeodeUtils.internalScrollEvent(timestamp, event.deviceId, event.source, scrollX, scrollY, mouseX, mouseY)
}
if (!hasGeodeEvents && sendNewCocosEvents) {
cocos2dxRenderer.handleScroll(mouseX, mouseY, scrollY, scrollX)
}
}
@@ -28,12 +28,29 @@ class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceVi
@JvmStatic
private external fun nativeKeyDown(keyCode: Int): Boolean
@JvmStatic
private external fun nativeKeyboardEvent(
keyCode: Int,
down: Boolean,
isRepeat: Boolean,
timestamp: Double,
shiftPressed: Boolean,
ctrlPressed: Boolean,
altPressed: Boolean
)
@JvmStatic
private external fun nativeKeyboardWillShow(offset: Int)
@JvmStatic
private external fun nativeOnPause()
@JvmStatic
private external fun nativeOnResume()
@JvmStatic
private external fun nativeScroll(x: Float, y: Float, scrollY: Float, scrollX: Float)
@JvmStatic
private external fun nativeRender()
@@ -133,6 +150,18 @@ class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceVi
nativeKeyDown(keyCode)
}
fun handleKeyboardWillShow(offset: Int) {
nativeKeyboardWillShow(offset)
}
fun handleKeyboardEvent(keyCode: Int, down: Boolean, isRepeat: Boolean, timestamp: Double, shiftPressed: Boolean, ctrlPressed: Boolean, altPressed: Boolean) {
nativeKeyboardEvent(keyCode, down, isRepeat, timestamp, shiftPressed, ctrlPressed, altPressed)
}
fun handleScroll(x: Float, y: Float, scrollY: Float, scrollX: Float) {
nativeScroll(x, y, scrollY, scrollX)
}
fun handleOnPause() {
nativeOnPause()
}
@@ -5,8 +5,10 @@ import android.text.TextWatcher
import android.widget.TextView.OnEditorActionListener
import android.text.Editable
import android.view.KeyEvent
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
import com.customRobTop.JniToCpp
class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurfaceView) : TextWatcher, OnEditorActionListener {
private var originText: String? = null
@@ -19,21 +21,33 @@ class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurf
this.originText = originText
}
override fun afterTextChanged(s: Editable) {
if (!isFullScreenEdit) {
var nModified = s.length - text!!.length
if (nModified > 0) {
cocos2dxGLSurfaceView.insertText(
s.subSequence(text!!.length, s.length).toString()
)
} else {
while (nModified < 0) {
cocos2dxGLSurfaceView.deleteBackward()
nModified++
}
}
text = s.toString()
override fun afterTextChanged(editable: Editable) {
if (isFullScreenEdit) {
return
}
val cText = text ?: return
val str = editable.toString()
val unchanged = str.commonPrefixWith(cText)
var unchangedUntil = unchanged.length
if (unchangedUntil > 0 && unchangedUntil < str.length && Character.isLowSurrogate(str[unchangedUntil])) {
unchangedUntil--
}
val codePointLength = cText.codePointCount(unchangedUntil, cText.length)
repeat(codePointLength) {
cocos2dxGLSurfaceView.deleteBackward()
}
if (unchangedUntil < str.length) {
cocos2dxGLSurfaceView.insertText(
str.substring(unchangedUntil)
)
}
text = str
}
override fun beforeTextChanged(
@@ -47,22 +61,45 @@ class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurf
override fun onTextChanged(pCharSequence: CharSequence, start: Int, before: Int, count: Int) {}
override fun onEditorAction(textView: TextView, actionID: Int, keyEvent: KeyEvent?): Boolean {
if (cocos2dxGLSurfaceView.cocos2dxEditText == textView && isFullScreenEdit) {
for (i in originText!!.length downTo 1) {
cocos2dxGLSurfaceView.deleteBackward()
}
val fullscreenIme = cocos2dxGLSurfaceView.cocos2dxEditText == textView && isFullScreenEdit
if (fullscreenIme) {
var text = textView.text.toString()
if (!text.endsWith('\n')) {
text += '\n'
}
if (originText != text || !cocos2dxGLSurfaceView.sendNewCocosEvents) {
if (cocos2dxGLSurfaceView.sendNewCocosEvents) {
cocos2dxGLSurfaceView.queueEvent {
JniToCpp.textInputCaretToEnd()
}
}
cocos2dxGLSurfaceView.insertText(text)
repeat(originText!!.length) {
cocos2dxGLSurfaceView.deleteBackward()
}
if (!cocos2dxGLSurfaceView.sendNewCocosEvents) {
if (!text.endsWith('\n')) {
text += '\n'
}
}
if (text.isNotEmpty()) {
cocos2dxGLSurfaceView.insertText(text)
}
}
}
if (actionID != 6) {
return false
if (fullscreenIme || actionID == EditorInfo.IME_ACTION_DONE) {
if (cocos2dxGLSurfaceView.sendNewCocosEvents) {
cocos2dxGLSurfaceView.queueEvent {
JniToCpp.textInputDone()
}
}
}
cocos2dxGLSurfaceView.requestFocus()
Cocos2dxGLSurfaceView.closeIMEKeyboard()
if (actionID == EditorInfo.IME_ACTION_DONE) {
cocos2dxGLSurfaceView.requestFocus()
Cocos2dxGLSurfaceView.closeIMEKeyboard()
}
return false
}
}
@@ -1,28 +1,28 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<group android:scaleX="0.75" android:scaleY="0.75" android:translateX="128" android:translateY="128">
android:viewportWidth="1366"
android:viewportHeight="1366">
<group>
<clip-path
android:pathData="M0,0h1024v1024h-1024z"/>
android:pathData="M0,0h1366v1366h-1366z"/>
<path
android:pathData="M0,0h1024v1024h-1024z"
android:pathData="M0,0h1366v1366h-1366z"
android:fillColor="#F4D48E"/>
<path
android:pathData="M512,512m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
android:pathData="M598,683m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
android:fillColor="#F4D48E"/>
<path
android:pathData="M719.5,289C618,241.7 526,152.5 320.5,152.5C115,152.5 3.5,248 -8.5,293C-20.5,338 -8.2,391.5 -13.5,390.5L-33,706.5H1145L1137.5,460C1115.5,426.8 1043.5,399 1007,391.5C970.5,384 822.5,337 719.5,289Z"
android:pathData="M873.8,459.7C752.5,412.3 642.7,323 397.2,323C151.8,323 18.6,418.7 4.3,463.8C-10.1,508.8 4.7,562.4 -1.7,561.4L-25,878H1382L1373,631.1C1346.8,597.8 1260.8,569.9 1217.2,562.4C1173.6,554.9 996.8,507.8 873.8,459.7Z"
android:fillColor="#F4A47B"/>
<path
android:pathData="M561.5,514.5C488.5,476.5 341,402 214.5,402C88,402 30.3,440.7 -8.5,461.5C-47.3,482.3 -54,485.2 -59,486.5L-68.5,808L563.5,814.5L561.5,514.5Z"
android:pathData="M647.9,685.4C569.9,647.4 412.4,573 277.3,573C142.1,573 80.5,611.7 39.1,632.4C-2.4,653.2 -9.5,656.1 -14.9,657.4L-25,978.5L650,985L647.9,685.4Z"
android:fillColor="#E27880"/>
<path
android:pathData="M1020.5,551C994,521.2 893.7,454.1 662.5,452.5C539.2,451.6 363,505.6 259,577.5C163.9,643.2 43,680 -13,686L-124,934.5H1187.5L1020.5,551Z"
android:pathData="M1362.1,728.7C1327,696.7 1194.3,624.7 888.5,623C725.4,622.1 492.3,679.9 354.7,757.1C228.9,827.6 68.9,867 -5.2,873.5L-152,1140H1583L1362.1,728.7Z"
android:fillColor="#B9588F"/>
<path
android:pathData="M819.5,856C638,856 518,772.5 359,772.5C200,772.5 100,860.5 65,869L-94,949.5V1041.5L1177.5,1025.5L1032.5,738C1028,758 967.4,856 819.5,856Z"
android:pathData="M1107,1027.2C885.5,1027.2 739,943.6 544.9,943.6C350.9,943.6 228.8,1031.7 186.1,1040.2L-8,1120.8V1385L1376,1379L1367,909C1361.5,929 1287.6,1027.2 1107,1027.2Z"
android:fillColor="#714A9A"/>
</group>
</vector>
@@ -0,0 +1,29 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="1366"
android:viewportHeight="1366">
<group>
<clip-path
android:pathData="M0,0h1366v1366h-1366z"/>
<path
android:pathData="M0,0h1366v1366h-1366z"
android:fillColor="#9EF4C3"/>
<path
android:pathData="M598,683m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
android:fillColor="#9EF4C3"/>
<path
android:pathData="M873.8,459.7C752.5,412.3 642.7,323 397.2,323C151.8,323 18.6,418.7 4.3,463.8C-10.1,508.8 4.7,562.4 -1.7,561.4L-25,878H1382L1373,631.1C1346.8,597.8 1260.8,569.9 1217.2,562.4C1173.6,554.9 996.8,507.8 873.8,459.7Z"
android:fillColor="#6DDBAD"/>
<path
android:pathData="M647.9,685.4C569.9,647.4 412.4,573 277.3,573C142.1,573 80.5,611.7 39.1,632.4C-2.4,653.2 -9.5,656.1 -14.9,657.4L-25,978.5L650,985L647.9,685.4Z"
android:fillColor="#64C780"/>
<path
android:pathData="M1362.1,728.7C1327,696.7 1194.3,624.7 888.5,623C725.4,622.1 492.3,679.9 354.7,757.1C228.9,827.6 68.9,867 -5.2,873.5L-152,1140H1583L1362.1,728.7Z"
android:fillColor="#46A47D"/>
<path
android:pathData="M1107,1027.2C885.5,1027.2 739,943.6 544.9,943.6C350.9,943.6 228.8,1031.7 186.1,1040.2L-8,1120.8V1385L1376,1379L1367,909C1361.5,929 1287.6,1027.2 1107,1027.2Z"
android:fillColor="#417563"/>
</group>
</vector>
@@ -1,28 +1,29 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<group android:scaleX="0.75" android:scaleY="0.75" android:translateX="128" android:translateY="128">
android:viewportWidth="1366"
android:viewportHeight="1366">
<group>
<clip-path
android:pathData="M0,0h1024v1024h-1024z"/>
android:pathData="M0,0h1366v1366h-1366z"/>
<path
android:pathData="M0,0h1024v1024h-1024z"
android:pathData="M0,0h1366v1366h-1366z"
android:fillColor="#CC7C7C"/>
<path
android:pathData="M512,512m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
android:pathData="M598,683m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
android:fillColor="#CC7C7C"/>
<path
android:pathData="M719.5,289C618,241.7 526,152.5 320.5,152.5C115,152.5 3.5,248 -8.5,293C-20.5,338 -8.2,391.5 -13.5,390.5L-33,706.5H1145L1137.5,460C1115.5,426.8 1043.5,399 1007,391.5C970.5,384 822.5,337 719.5,289Z"
android:pathData="M873.8,459.7C752.5,412.3 642.7,323 397.2,323C151.8,323 18.6,418.7 4.3,463.8C-10.1,508.8 4.7,562.4 -1.7,561.4L-25,878H1382L1373,631.1C1346.8,597.8 1260.8,569.9 1217.2,562.4C1173.6,554.9 996.8,507.8 873.8,459.7Z"
android:fillColor="#C6B471"/>
<path
android:pathData="M561.5,514.5C488.5,476.5 341,402 214.5,402C88,402 30.3,440.7 -8.5,461.5C-47.3,482.3 -54,485.2 -59,486.5L-68.5,808L563.5,814.5L561.5,514.5Z"
android:pathData="M647.9,685.4C569.9,647.4 412.4,573 277.3,573C142.1,573 80.5,611.7 39.1,632.4C-2.4,653.2 -9.5,656.1 -14.9,657.4L-25,978.5L650,985L647.9,685.4Z"
android:fillColor="#59AF7F"/>
<path
android:pathData="M1020.5,551C994,521.2 893.7,454.1 662.5,452.5C539.2,451.6 363,505.6 259,577.5C163.9,643.2 43,680 -13,686L-124,934.5H1187.5L1020.5,551Z"
android:pathData="M1362.1,728.7C1327,696.7 1194.3,624.7 888.5,623C725.4,622.1 492.3,679.9 354.7,757.1C228.9,827.6 68.9,867 -5.2,873.5L-152,1140H1583L1362.1,728.7Z"
android:fillColor="#5F8CAB"/>
<path
android:pathData="M819.5,856C638,856 518,772.5 359,772.5C200,772.5 100,860.5 65,869L-94,949.5V1041.5L1177.5,1025.5L1032.5,738C1028,758 967.4,856 819.5,856Z"
android:pathData="M1107,1027.2C885.5,1027.2 739,943.6 544.9,943.6C350.9,943.6 228.8,1031.7 186.1,1040.2L-8,1120.8V1385L1376,1379L1367,909C1361.5,929 1287.6,1027.2 1107,1027.2Z"
android:fillColor="#70558D"/>
</group>
</vector>
@@ -1,28 +1,29 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<group android:scaleX="0.75" android:scaleY="0.75" android:translateX="128" android:translateY="128">
android:viewportWidth="1366"
android:viewportHeight="1366">
<group>
<clip-path
android:pathData="M0,0h1024v1024h-1024z"/>
android:pathData="M0,0h1366v1366h-1366z"/>
<path
android:pathData="M0,0h1024v1024h-1024z"
android:pathData="M0,0h1366v1366h-1366z"
android:fillColor="#65B8C2"/>
<path
android:pathData="M512,512m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
android:pathData="M598,683m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
android:fillColor="#65B8C2"/>
<path
android:pathData="M719.5,289C618,241.7 526,152.5 320.5,152.5C115,152.5 3.5,248 -8.5,293C-20.5,338 -8.2,391.5 -13.5,390.5L-33,706.5H1145L1137.5,460C1115.5,426.8 1043.5,399 1007,391.5C970.5,384 822.5,337 719.5,289Z"
android:pathData="M873.8,459.7C752.5,412.3 642.7,323 397.2,323C151.8,323 18.6,418.7 4.3,463.8C-10.1,508.8 4.7,562.4 -1.7,561.4L-25,878H1382L1373,631.1C1346.8,597.8 1260.8,569.9 1217.2,562.4C1173.6,554.9 996.8,507.8 873.8,459.7Z"
android:fillColor="#DEA5CD"/>
<path
android:pathData="M561.5,514.5C488.5,476.5 341,402 214.5,402C88,402 30.3,440.7 -8.5,461.5C-47.3,482.3 -54,485.2 -59,486.5L-68.5,808L563.5,814.5L561.5,514.5Z"
android:pathData="M647.9,685.4C569.9,647.4 412.4,573 277.3,573C142.1,573 80.5,611.7 39.1,632.4C-2.4,653.2 -9.5,656.1 -14.9,657.4L-25,978.5L650,985L647.9,685.4Z"
android:fillColor="#DEE8EA"/>
<path
android:pathData="M1020.5,551C994,521.2 893.7,454.1 662.5,452.5C539.2,451.6 363,505.6 259,577.5C163.9,643.2 43,680 -13,686L-124,934.5H1187.5L1020.5,551Z"
android:pathData="M1362.1,728.7C1327,696.7 1194.3,624.7 888.5,623C725.4,622.1 492.3,679.9 354.7,757.1C228.9,827.6 68.9,867 -5.2,873.5L-152,1140H1583L1362.1,728.7Z"
android:fillColor="#C485BD"/>
<path
android:pathData="M819.5,856C638,856 518,772.5 359,772.5C200,772.5 100,860.5 65,869L-94,949.5V1041.5L1177.5,1025.5L1032.5,738C1028,758 967.4,856 819.5,856Z"
android:pathData="M1107,1027.2C885.5,1027.2 739,943.6 544.9,943.6C350.9,943.6 228.8,1031.7 186.1,1040.2L-8,1120.8V1385L1376,1379L1367,909C1361.5,929 1287.6,1027.2 1107,1027.2Z"
android:fillColor="#259CB4"/>
</group>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_green_background"/>
<foreground android:drawable="@drawable/ic_launcher_monochrome_shadow"/>
</adaptive-icon>
+4 -2
View File
@@ -35,7 +35,7 @@
</string>
<string name="launcher_game_update_required_short">A Geometry Dash update is required.</string>
<string name="launcher_game_update_required">The currently installed version of Geometry Dash is no longer supported. Please update Geometry Dash to continue receiving Geode updates.</string>
<string name="launcher_game_update_required">The currently installed version of Geometry Dash is no longer supported. Please update Geometry Dash to version %1$s to continue receiving Geode updates.</string>
<string name="launcher_update_available">A new launcher update is available!</string>
<string name="launcher_update_dismiss">Dismiss</string>
@@ -82,7 +82,7 @@
<string name="launcher_cancelled_crash">Launch paused due to a crash.</string>
<string name="launcher_cancelled_crash_instant">Geode crashed.</string>
<string name="launcher_cancelled_error">Geode cannot be used.</string>
<string name="launcher_cancelled_outdated">A Geometry Dash update is required.</string>
<string name="launcher_cancelled_outdated">Geometry Dash version %1$s is required.</string>
<string name="launcher_cancelled_restart">Restart</string>
<string name="launcher_cancelled_resume">Resume</string>
<string name="launcher_cancelled_retry">Retry</string>
@@ -105,6 +105,7 @@
<string name="release_fetch_manual_check_required">Externally managed library in use, please manually check for updates if you want to overwrite it.</string>
<string name="release_fetch_external_in_use_short">Update failed. External library in use.</string>
<string name="release_fetch_hash_validation_failed_short">Update failed. Integrity check failed.</string>
<string name="release_fetch_no_internet_short">Update failed. Internet not connected.</string>
<string name="release_fetch_generic_short">Update failed. Please try again later.</string>
@@ -337,6 +338,7 @@
<string name="application_icon_pride">Pride</string>
<string name="application_icon_trans">Transgender</string>
<string name="application_icon_sapphire">Sapphire</string>
<string name="application_icon_green">Android</string>
<string name="application_icon_confirm">Change icon to %1$s?</string>
<string name="application_icon_confirm_description">Geode Launcher will close itself to change the application icon.</string>
+4 -8
View File
@@ -1,13 +1,9 @@
buildscript {
val composeBOM by extra("2026.03.01")
}
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id("com.android.application") version "9.1.0" apply false
id("com.android.library") version "9.1.0" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.3.20" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.3.20" apply false
id("com.android.application") version "9.4.1" apply false
id("com.android.library") version "9.4.1" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.4.21" apply false
}
tasks.register<Delete>("clean") {
Binary file not shown.
+3 -2
View File
@@ -1,8 +1,9 @@
#Sun Apr 05 23:29:16 MST 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+5 -8
View File
@@ -1,7 +1,7 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -20,7 +20,7 @@
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
@@ -29,7 +29,7 @@
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -114,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
@@ -172,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -212,8 +210,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
Vendored
+12 -24
View File
@@ -19,12 +19,12 @@
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@@ -51,7 +51,7 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
@@ -65,30 +65,18 @@ echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%