Compare commits

..
1 Commits
Author SHA1 Message Date
Lncvrt f59492ca8e XPSified 2025-08-30 20:41:06 -07:00
110 changed files with 2762 additions and 8239 deletions

No files matched your search

+8 -76
View File
@@ -12,23 +12,17 @@ jobs:
config: config:
- name: Release - name: Release
lower: release lower: release
flavor: standard
- name: Debug - name: Debug
lower: debug lower: debug
flavor: standard
- name: Release
lower: release
flavor: googlePlay
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@v4
- name: Setup Java JDK - name: Setup Java JDK
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 uses: actions/setup-java@v4
with: with:
java-version: 21 java-version: 21
distribution: temurin distribution: temurin
@@ -40,19 +34,9 @@ jobs:
- name: Fix gradle permissions - name: Fix gradle permissions
run: chmod +x ./gradlew run: chmod +x ./gradlew
- name: Build project (standard) - name: Build project
if: ${{ matrix.config.flavor == 'standard' }}
run: | run: |
./gradlew app:assembleStandard${{ matrix.config.name }} \ ./gradlew app:assemble${{ matrix.config.name }} \
-Pandroid.injected.signing.store.file="$HOME/release.keystore" \
-Pandroid.injected.signing.store.password="${{secrets.KEYSTORE_PASSWORD}}" \
-Pandroid.injected.signing.key.alias="${{secrets.KEY_ALIAS}}" \
-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.file="$HOME/release.keystore" \
-Pandroid.injected.signing.store.password="${{secrets.KEYSTORE_PASSWORD}}" \ -Pandroid.injected.signing.store.password="${{secrets.KEYSTORE_PASSWORD}}" \
-Pandroid.injected.signing.key.alias="${{secrets.KEY_ALIAS}}" \ -Pandroid.injected.signing.key.alias="${{secrets.KEY_ALIAS}}" \
@@ -60,78 +44,26 @@ jobs:
- name: Rename universal apk - name: Rename universal apk
id: rename_apk_universal id: rename_apk_universal
if: ${{ matrix.config.flavor == 'standard' }}
run: | run: |
SHORT_SHA=${GITHUB_SHA::7} SHORT_SHA=${GITHUB_SHA::7}
APK_PATH=${{github.workspace}}/app/build/outputs/apk/standard/${{ matrix.config.lower }}/app-standard-universal-${{ matrix.config.lower }}.apk APK_PATH=${{github.workspace}}/app/build/outputs/apk/${{ matrix.config.lower }}/app-universal-${{ matrix.config.lower }}.apk
OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-$SHORT_SHA.apk OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-$SHORT_SHA.apk
mv $APK_PATH $OUT_PATH mv $APK_PATH $OUT_PATH
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
- name: Rename apk - name: Rename apk
id: rename_apk id: rename_apk
if: ${{ matrix.config.flavor == 'standard' }}
run: | run: |
SHORT_SHA=${GITHUB_SHA::7} SHORT_SHA=${GITHUB_SHA::7}
APK_PATH=${{github.workspace}}/app/build/outputs/apk/standard/${{ matrix.config.lower }}/app-standard-armeabi-v7a-${{ matrix.config.lower }}.apk APK_PATH=${{github.workspace}}/app/build/outputs/apk/${{ matrix.config.lower }}/app-armeabi-v7a-${{ matrix.config.lower }}.apk
OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-android32-$SHORT_SHA.apk OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-android32-$SHORT_SHA.apk
mv $APK_PATH $OUT_PATH mv $APK_PATH $OUT_PATH
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
- name: Rename mappings - name: Upload development build
id: rename_mappings uses: actions/upload-artifact@v4
if: ${{ matrix.config.flavor == 'standard' && matrix.config.lower == 'release' }}
run: |
SHORT_SHA=${GITHUB_SHA::7}
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.flavor == 'standard' && matrix.config.lower == 'release' }}
with: with:
name: geode-launcher-${{ matrix.config.lower }} name: geode-launcher-${{ matrix.config.lower }}
path: | path: |
${{ steps.rename_apk_universal.outputs.path }} ${{ steps.rename_apk_universal.outputs.path }}
${{ steps.rename_apk.outputs.path }} ${{ steps.rename_apk.outputs.path }}
${{ steps.rename_mappings.outputs.path }}
- name: Upload debug build
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
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 }}
-15
View File
@@ -1,18 +1,3 @@
# Geode Android Launcher # Geode Android Launcher
Launches a vanilla copy of Geometry Dash with the Geode loader added. Launches a vanilla copy of Geometry Dash with the Geode loader added.
## Shortcut Links
The launcher supports a handful of links as an escape hatch when the main functionality is impaired in some way (such as needing to prevent an automatic launch due to a crash).
You can open these links by copying them into the URL bar in some browsers (like Chrome), or through ADB.
| Link | Action |
|---------------------------------------------|--------------------------------------------------------------------------|
| geode-launcher://main | Opens to the main activity, forces the user to explicitly confirm launch |
| geode-launcher://main/launch | Opens to the game after the launcher is finished checking for updates |
| geode-launcher://main/launch?safe-mode=true | Opens to the game like before, but with safe mode enabled |
| geode-launcher://settings | Opens the main settings |
| geode-launcher://developer-settings | Opens the developer settings |
| geode-launcher://logs | Opens the application logs |
| geode-launcher://crashes | Opens the crash dumps viewer |
+32 -292
View File
@@ -1,7 +1,4 @@
import groovy.json.JsonSlurper val composeBOM: String by rootProject.extra
import java.net.URI
import java.security.MessageDigest
import java.util.zip.ZipInputStream
kotlin { kotlin {
compilerOptions { compilerOptions {
@@ -11,245 +8,20 @@ kotlin {
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.serialization") id("org.jetbrains.kotlin.plugin.serialization")
id("org.jetbrains.kotlin.plugin.compose") 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 { android {
compileSdk = 37 compileSdk = 36
defaultConfig { defaultConfig {
applicationId = "com.geode.launcher" applicationId = "com.geode.launcherXps"
minSdk = 23 minSdk = 23
targetSdk = 37 targetSdk = 35
versionCode = 31 versionCode = 22
versionName = "1.9.0" versionName = "1.0.1"
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
@@ -258,7 +30,7 @@ android {
@Suppress("UnstableApiUsage") @Suppress("UnstableApiUsage")
externalNativeBuild { externalNativeBuild {
cmake { cmake {
arguments("-DANDROID_STL=c++_shared") arguments("-DUSE_TULIPHOOK:BOOL=OFF", "-DANDROID_STL=c++_shared", "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES:BOOL=OFF")
} }
} }
@@ -268,7 +40,7 @@ android {
splits { splits {
abi { abi {
isEnable = !project.hasProperty("disableAbiSplits") isEnable = true
reset() reset()
//noinspection ChromeOsAbiSupport. i'm sorry! //noinspection ChromeOsAbiSupport. i'm sorry!
@@ -278,24 +50,6 @@ 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 { buildTypes {
release { release {
isMinifyEnabled = true isMinifyEnabled = true
@@ -311,8 +65,11 @@ android {
// enables a polyfill for java Instant on api levels < 26 (used for updater) // enables a polyfill for java Instant on api levels < 26 (used for updater)
isCoreLibraryDesugaringEnabled = true isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
} }
buildFeatures { buildFeatures {
compose = true compose = true
@@ -325,51 +82,34 @@ android {
} }
externalNativeBuild { externalNativeBuild {
cmake { cmake {
version = "3.21.0+" version = "3.25.0+"
path = file("src/main/cpp/CMakeLists.txt") path = file("src/main/cpp/CMakeLists.txt")
} }
} }
namespace = "com.geode.launcher" namespace = "com.geode.launcher"
ndkVersion = "30.0.16248370" ndkVersion = "28.1.13356709"
}
androidComponents {
onVariants(selector().withFlavor("distribution" to "googlePlay")) { variant ->
variant.sources.jniLibs?.addGeneratedSourceDirectory(
fullDownloadTask,
MergeGeodeFilesTask::outputDir
)
variant.sources.assets?.addGeneratedSourceDirectory(
downloadGeodeResourcesTask,
DownloadAndUnzipGeodeResourcesTask::outputDir
)
}
} }
dependencies { dependencies {
implementation (platform("androidx.compose:compose-bom:2026.09.00")) implementation (platform("androidx.compose:compose-bom:$composeBOM"))
implementation ("androidx.core:core-ktx:1.19.1") implementation ("androidx.core:core-ktx:1.16.0")
implementation ("androidx.compose.ui:ui") implementation ("androidx.compose.ui:ui")
implementation ("androidx.compose.material3:material3") implementation ("androidx.compose.material3:material3")
implementation ("androidx.compose.ui:ui-tooling-preview") implementation ("androidx.compose.ui:ui-tooling-preview")
implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0") implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0") implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation ("androidx.activity:activity-compose:1.13.0") implementation ("androidx.activity:activity-compose:1.10.1")
implementation ("androidx.activity:activity-ktx:1.13.0") implementation ("androidx.activity:activity-ktx:1.10.1")
implementation ("androidx.appcompat:appcompat:1.8.0") implementation ("androidx.appcompat:appcompat:1.7.0")
implementation ("androidx.documentfile:documentfile:1.1.0") implementation ("androidx.documentfile:documentfile:1.0.1")
implementation ("com.squareup.okio:okio:3.11.0")
implementation ("com.squareup.okio:okio:3.18.2") implementation ("com.squareup.okhttp3:okhttp:4.12.0")
implementation ("com.squareup.okhttp3:okhttp:5.5.0") implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1")
implementation ("com.squareup.okhttp3:okhttp-coroutines:5.5.0") implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.8.1")
implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.6.2")
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0") implementation ("com.mikepenz:multiplatform-markdown-renderer-android:0.33.0")
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.11.0") implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.33.0")
implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0") implementation ("androidx.browser:browser:1.8.0")
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") debugImplementation ("androidx.compose.ui:ui-tooling")
coreLibraryDesugaring ("com.android.tools:desugar_jdk_libs:2.1.5") coreLibraryDesugaring ("com.android.tools:desugar_jdk_libs:2.1.5")
} }
-9
View File
@@ -1,9 +0,0 @@
<?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>
+19 -185
View File
@@ -3,7 +3,7 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<queries> <queries>
<package android:name="com.robtopx.geometryjump" /> <package android:name="xyz.lncvrt.xps0000002200" />
</queries> </queries>
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
@@ -17,10 +17,11 @@
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" /> <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_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.RECORD_AUDIO" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.LIGHTS" />
<uses-feature <uses-feature
android:name="android.hardware.wifi" android:name="android.hardware.wifi"
@@ -28,108 +29,49 @@
<uses-feature <uses-feature
android:glEsVersion="0x00020000" android:glEsVersion="0x00020000"
android:required="true" /> android:required="true" />
<uses-feature
android:name="android.hardware.gamepad"
android:required="false" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:appCategory="game" android:appCategory="game"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:hasFragileUserData="true"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:hasFragileUserData="true"
android:requestLegacyExternalStorage="true" android:requestLegacyExternalStorage="true"
android:enableOnBackInvokedCallback="true"
android:theme="@style/Theme.GeodeLauncher" android:theme="@style/Theme.GeodeLauncher"
tools:targetApi="35"> tools:targetApi="35">
<receiver <activity
android:name=".InstallReceiver" android:name=".AltMainActivity"
android:enabled="true" android:exported="false"
android:exported="false" /> android:theme="@style/Theme.GeodeLauncher" />
<activity <activity
android:name=".preferences.DeveloperSettingsActivity" android:name=".preferences.DeveloperSettingsActivity"
android:exported="true" android:exported="false"
android:label="@string/title_activity_developer_settings" android:label="@string/title_activity_developer_settings"
android:theme="@style/Theme.GeodeLauncher"> android:theme="@style/Theme.GeodeLauncher" />
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="geode-launcher" android:host="developer-settings" />
</intent-filter>
</activity>
<activity <activity
android:name=".preferences.ApplicationLogsActivity" android:name=".preferences.ApplicationLogsActivity"
android:exported="true" android:exported="false"
android:label="@string/title_activity_application_logs" android:label="@string/title_activity_application_logs"
android:theme="@style/Theme.GeodeLauncher">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="geode-launcher" android:host="logs" />
</intent-filter>
</activity>
<activity
android:name=".preferences.CrashLogsActivity"
android:exported="true"
android:label="@string/title_activity_crash_logs"
android:theme="@style/Theme.GeodeLauncher">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="geode-launcher" android:host="crashes" />
</intent-filter>
</activity>
<activity
android:name=".preferences.GeodeLogsListingActivity"
android:exported="true"
android:label="@string/title_activity_geode_logs"
android:theme="@style/Theme.GeodeLauncher">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="geode-launcher" android:host="geode-logs" />
</intent-filter>
</activity>
<activity
android:name=".preferences.TextViewActivity"
android:exported="false"
android:theme="@style/Theme.GeodeLauncher">
</activity>
<activity
android:name=".preferences.ApplicationIconActivity"
android:exported="false"
android:label="@string/title_activity_application_icon"
android:theme="@style/Theme.GeodeLauncher" /> android:theme="@style/Theme.GeodeLauncher" />
<activity <activity
android:name=".preferences.SettingsActivity" android:name=".preferences.SettingsActivity"
android:exported="true" android:exported="false"
android:label="@string/title_activity_settings" android:label="@string/title_activity_settings"
android:theme="@style/Theme.GeodeLauncher"> android:theme="@style/Theme.GeodeLauncher">
<meta-data
<intent-filter> android:name="android.app.lib_name"
<action android:name="android.intent.action.VIEW" /> android:value="" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="geode-launcher" android:host="settings" />
</intent-filter>
</activity> </activity>
<activity <activity
android:name=".GeometryDashActivity" android:name=".GeometryDashActivity"
android:configChanges="fontScale|layoutDirection|density|smallestScreenSize|screenSize|uiMode|screenLayout|orientation|navigation|keyboardHidden|keyboard|locale"
android:exported="true" android:exported="true"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:screenOrientation="landscape" android:screenOrientation="landscape"
android:theme="@style/Theme.NoActionBar.Fullscreen" android:theme="@style/Theme.NoActionBar.Fullscreen"
android:configChanges="orientation|screenSize|smallestScreenSize"
android:windowSoftInputMode="stateAlwaysHidden"> android:windowSoftInputMode="stateAlwaysHidden">
<meta-data <meta-data
android:name="android.app.lib_name" android:name="android.app.lib_name"
@@ -144,119 +86,11 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </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> </activity>
<activity-alias
android:name=".MainActivityTrans"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_trans"
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>
<activity-alias
android:name=".MainActivityPride"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_pride"
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>
<activity-alias
android:name=".MainActivitySapphire"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_sapphire"
android:label="@string/application_icon_sapphire"
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>
<activity-alias
android:name=".MainActivityGeode"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_geode"
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>
<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 <provider
android:name=".UserDirectoryProvider" android:name=".UserDirectoryProvider"
android:authorities="${applicationId}.user" android:authorities="com.geode.launcherXps.user"
android:exported="true" android:exported="true"
android:grantUriPermissions="true" android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS"> android:permission="android.permission.MANAGE_DOCUMENTS">
@@ -266,7 +100,7 @@
</provider> </provider>
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="com.geode.launcherXps.fileprovider"
android:exported="false" android:exported="false"
android:grantUriPermissions="true"> android:grantUriPermissions="true">
<meta-data <meta-data
+37 -7
View File
@@ -1,17 +1,47 @@
cmake_minimum_required(VERSION 3.21) cmake_minimum_required(VERSION 3.18.1)
project(launcherfix) project(launcherfix)
add_library(launcherfix SHARED launcher-fix.cpp patcher.cpp) add_library(launcherfix SHARED launcher-fix.cpp)
target_compile_features(launcherfix PUBLIC cxx_std_20) target_compile_features(launcherfix PUBLIC cxx_std_20)
set_target_properties(launcherfix PROPERTIES CXX_EXTENSIONS OFF) set_target_properties(launcherfix PROPERTIES CXX_EXTENSIONS OFF)
set_target_properties(launcherfix PROPERTIES include(FetchContent)
INTERPROCEDURAL_OPTIMIZATION ON
UNITY_BUILD ON option(USE_TULIPHOOK "enables choice of hooking library" OFF)
C_VISIBILITY_PRESET hidden if (USE_TULIPHOOK)
CXX_VISIBILITY_PRESET hidden FetchContent_Declare(TulipHook
GIT_REPOSITORY https://github.com/geode-sdk/TulipHook.git
GIT_TAG 9aa1f95091ebac18c657d57b755f59d00fe6f1f8
) )
set(TULIP_LINK_SOURCE ON CACHE INTERNAL "")
FetchContent_MakeAvailable(TulipHook)
target_compile_definitions(launcherfix PRIVATE -DUSE_TULIPHOOK=1)
target_link_libraries(launcherfix PRIVATE TulipHook TulipHookInclude)
else()
FetchContent_Declare(dobby
# this is the last version that builds on android at all
GIT_REPOSITORY https://github.com/jmpews/Dobby
GIT_TAG 0932d69c320e786672361ab53825ba8f4245e9d3
)
FetchContent_GetProperties(dobby)
if(NOT dobby_POPULATED)
FetchContent_Populate(dobby)
# fixes Dobby build
# add_compile_definitions(typeof=__typeof__)
set(DOBBY_DEBUG OFF CACHE INTERNAL "" FORCE)
set(DOBBY_GENERATE_SHARED OFF CACHE INTERNAL "" FORCE)
add_subdirectory("${dobby_SOURCE_DIR}" ${dobby_BINARY_DIR} EXCLUDE_FROM_ALL)
target_include_directories(launcherfix PRIVATE ${dobby_SOURCE_DIR}/include)
target_link_libraries(launcherfix PRIVATE dobby)
endif()
endif()
target_link_libraries(launcherfix PRIVATE log) target_link_libraries(launcherfix PRIVATE log)
-42
View File
@@ -1,42 +0,0 @@
#pragma once
#include <string>
#include <array>
#include <string_view>
#include <link.h>
class DataPaths {
public:
static std::string original_data_path;
static std::string data_path;
static bool patch_exceptions;
};
// this is every function that i thought would be relevant
constexpr std::array<const std::string_view, 10> remove_symbols{
"__gxx_personality_v0",
"__cxa_throw",
"__cxa_rethrow",
"__cxa_allocate_exception",
"__cxa_end_catch",
"__cxa_begin_catch",
"__cxa_guard_abort",
"__cxa_guard_acquire",
"__cxa_guard_release",
"__cxa_free_exception"
// conflicts with fix within geode on some devices
// "__emutls_get_address", // added in 2.208
// "__cxa_thread_atexit"
// android 10's libc doesn't have these symbols
// and the ndk is forbidden from exporting them!! so keep it for old mods...
// "_Unwind_RaiseException",
// "_Unwind_Resume"
};
FILE* fopen_hook(const char* pathname, const char* mode);
int rename_hook(const char* old_path, const char* new_path);
int on_dl_iterate(dl_phdr_info* info, size_t size, void* data);
+299 -29
View File
@@ -1,13 +1,36 @@
#include <dlfcn.h>
#include <jni.h> #include <jni.h>
#include <fstream>
#include <string> #include <string>
#include <vector>
#include <cstdint>
#include <android/log.h>
#include <link.h>
#include "base.h" #ifndef DISABLE_LAUNCHER_FIX
#include "log.hpp"
std::string DataPaths::original_data_path{}; #ifdef USE_TULIPHOOK
std::string DataPaths::data_path{}; #include <tulip/TulipHook.hpp>
#else
#include <dobby.h>
#endif
bool DataPaths::patch_exceptions{}; #endif
class DataPaths {
public:
std::string original_data_path{};
std::string data_path{};
std::string load_symbols_from{};
static DataPaths& get_instance() {
static auto paths_instance = DataPaths();
return paths_instance;
}
private:
DataPaths() {}
};
extern "C" extern "C"
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setDataPath( JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setDataPath(
@@ -18,11 +41,25 @@ JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setDataPath(
auto is_copy = jboolean(); auto is_copy = jboolean();
auto data_path_str = env->GetStringUTFChars(data_path, &is_copy); auto data_path_str = env->GetStringUTFChars(data_path, &is_copy);
DataPaths::data_path = std::string(data_path_str); DataPaths::get_instance().data_path = std::string(data_path_str);
env->ReleaseStringUTFChars(data_path, data_path_str); env->ReleaseStringUTFChars(data_path, data_path_str);
} }
extern "C"
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_enableCustomSymbolList(
JNIEnv* env,
jobject,
jstring symbol_path
) {
auto is_copy = jboolean();
auto symbol_path_str = env->GetStringUTFChars(symbol_path, &is_copy);
DataPaths::get_instance().load_symbols_from = std::string(symbol_path_str);
env->ReleaseStringUTFChars(symbol_path, symbol_path_str);
}
extern "C" extern "C"
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setOriginalDataPath( JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setOriginalDataPath(
JNIEnv *env, JNIEnv *env,
@@ -32,52 +69,227 @@ JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setOriginalDataPath(
auto is_copy = jboolean(); auto is_copy = jboolean();
auto data_path_str = env->GetStringUTFChars(data_path, &is_copy); auto data_path_str = env->GetStringUTFChars(data_path, &is_copy);
DataPaths::original_data_path = std::string(data_path_str); DataPaths::get_instance().original_data_path = std::string(data_path_str);
env->ReleaseStringUTFChars(data_path, data_path_str); env->ReleaseStringUTFChars(data_path, data_path_str);
} }
extern "C" // i copied this function from the android linker
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_enableExceptionsRenaming(JNIEnv*, jobject) { std::uint32_t elfhash(const char *_name) {
DataPaths::patch_exceptions = true; const unsigned char *name = reinterpret_cast<const unsigned char*>(_name);
std::uint32_t h = 0, g;
while (*name) {
h = (h << 4) + *name++;
g = h & 0xf0000000;
h ^= g;
h ^= g >> 24;
}
return h;
}
std::array<std::uint8_t, 3> SYMBOL_PATCH_VALUE{":3"};
bool patch_symbol(std::uint32_t* hash_table, char* str_table, ElfW(Sym)* sym_table, const char* orig_name) {
auto hash = elfhash(orig_name);
auto nbucket = hash_table[0];
auto nchain = hash_table[1];
auto bucket = &hash_table[2];
auto chain = &bucket[nbucket];
for (auto i = bucket[hash % nbucket]; i != 0; i = chain[i]) {
auto sym = sym_table + i;
auto sym_name = str_table + sym->st_name;
if (strcmp(sym_name, orig_name) == 0) {
// there's probably no point to checking this, honestly
if (ELF_ST_BIND(sym->st_info) == STB_GLOBAL && ELF_ST_TYPE(sym->st_info) == STT_FUNC) {
// we found it! now go rename the symbol
#ifdef USE_TULIPHOOK
auto res = tulip::hook::writeMemory(sym_name, SYMBOL_PATCH_VALUE.data(), SYMBOL_PATCH_VALUE.size());
if (!res) {
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "failed to patch symbol: %s", res.unwrapErr().c_str());
return false;
}
#else
DobbyCodePatch(reinterpret_cast<void*>(sym_name), SYMBOL_PATCH_VALUE.data(), SYMBOL_PATCH_VALUE.size());
#endif
return true;
}
}
}
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "could not find symbol %s to patch (hash: %u)", orig_name, hash);
return false;
}
std::vector<std::string> get_symbols_listing() {
auto symbol_path = DataPaths::get_instance().load_symbols_from;
if (symbol_path.empty()) {
// this is every function that i thought would be relevant
return {
"__gxx_personality_v0",
"__cxa_throw",
"__cxa_rethrow",
"__cxa_allocate_exception",
"__cxa_end_catch",
"__cxa_begin_catch",
"__cxa_guard_abort",
"__cxa_guard_acquire",
"__cxa_guard_release",
"__cxa_free_exception"
// android 10's libc doesn't have these symbols
// and the ndk is forbidden from exporting them!! so keep it for old mods...
// "_Unwind_RaiseException",
// "_Unwind_Resume"
};
}
std::ifstream symbol_file{symbol_path};
if (!symbol_file) {
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "failed to read symbol file at %s", symbol_path.c_str());
return {};
}
std::vector<std::string> symbols_list{};
std::string current_line{};
while (std::getline(symbol_file, current_line)) {
if (!current_line.empty()) {
symbols_list.push_back(current_line);
}
}
return symbols_list;
}
int on_dl_iterate(dl_phdr_info* info, size_t size, void* data) {
// this is probably going to be gd
if (info->dlpi_name != nullptr && strstr(info->dlpi_name, "libcocos2dcpp.so") != nullptr) {
// step 1: get the dynamic table
std::uintptr_t dyn_addr = 0u;
for (auto i = 0u; i < info->dlpi_phnum; i++) {
auto phdr = info->dlpi_phdr + i;
if (phdr->p_type == PT_DYNAMIC) {
dyn_addr = info->dlpi_addr + phdr->p_vaddr;
break;
}
}
if (dyn_addr == 0u) {
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "failed to find libcocos dynamic section");
return 0;
}
// step 2: get the symbol table
auto dyn_entry = reinterpret_cast<ElfW(Dyn)*>(dyn_addr);
auto dyn_end_reached = false;
std::uintptr_t sym_table_addr = 0u;
std::uintptr_t str_table_addr = 0u;
std::uintptr_t hash_table_addr = 0u;
std::uintptr_t soname_idx = 0u;
while (!dyn_end_reached) {
auto tag = dyn_entry->d_tag;
switch (dyn_entry->d_tag) {
case DT_SYMTAB:
sym_table_addr = info->dlpi_addr + dyn_entry->d_un.d_val;
break;
case DT_STRTAB:
str_table_addr = info->dlpi_addr + dyn_entry->d_un.d_val;
break;
case DT_HASH:
hash_table_addr = info->dlpi_addr + dyn_entry->d_un.d_val;
break;
case DT_SONAME:
soname_idx = dyn_entry->d_un.d_val;
break;
case DT_NULL:
dyn_end_reached = true;
break;
}
dyn_entry++;
}
if (hash_table_addr == 0u || str_table_addr == 0u || sym_table_addr == 0u) {
__android_log_print(ANDROID_LOG_ERROR, "GeodeLauncher-fix", "failed to parse dynamic section (at least one table is null)");
return -1;
}
// patch symbol names
auto hash_table = reinterpret_cast<std::uint32_t*>(hash_table_addr);
auto str_table = reinterpret_cast<char*>(str_table_addr);
auto sym_table = reinterpret_cast<ElfW(Sym)*>(sym_table_addr);
auto so_name = str_table + soname_idx;
if (strcmp(so_name, "libcocos2dcpp.so") != 0) {
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-fix", "DT_SONAME gives us %s! that's not good", so_name);
}
auto has_error = false;
auto symbols_listing = get_symbols_listing();
for (const auto& symbol : symbols_listing) {
if (!patch_symbol(hash_table, str_table, sym_table, symbol.c_str())) {
has_error = true;
}
}
if (has_error) {
__android_log_print(
ANDROID_LOG_INFO,
"GeodeLauncher-fix",
"symbol patch diagnostics\nlibrary path: %s\naddrs: base=%p sym=%p str=%p hash=%p",
info->dlpi_name, reinterpret_cast<void*>(info->dlpi_addr), sym_table, str_table, hash_table
);
}
return 1;
}
return 0;
} }
// this should be called after gd is loaded but before geode // this should be called after gd is loaded but before geode
extern "C" JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_performPatches(JNIEnv*, jobject) { extern "C"
dl_iterate_phdr(on_dl_iterate, nullptr); JNIEXPORT int JNICALL Java_com_geode_launcher_LauncherFix_performExceptionsRenaming(JNIEnv*, jobject) {
return dl_iterate_phdr(on_dl_iterate, nullptr);
} }
std::optional<std::string> redirect_path(const char* pathname) { std::optional<std::string> redirect_path(const char* pathname) {
std::string_view data_path = DataPaths::data_path; auto& data_path = DataPaths::get_instance().data_path;
std::string_view original_data_path = DataPaths::original_data_path; auto& original_data_path = DataPaths::get_instance().original_data_path;
if (data_path.empty() || original_data_path.empty()) [[unlikely]] {
return std::nullopt;
}
if (!data_path.empty() && !original_data_path.empty()) {
// call this a c string optimization // call this a c string optimization
if (std::strncmp(pathname, original_data_path.data(), original_data_path.size()) == 0) { if (std::strncmp(pathname, original_data_path.c_str(), original_data_path.size()) == 0) {
auto remaining_path = (pathname + original_data_path.size()); auto path = data_path + (pathname + original_data_path.size());
auto remaining_len = strlen(remaining_path);
std::string x; return path;
x.reserve(data_path.size() + remaining_len); }
x.append(data_path);
x.append(remaining_path, remaining_len);
return x;
} }
return std::nullopt; return std::nullopt;
} }
FILE* (*fopen_original)(const char *pathname, const char *mode);
FILE* fopen_hook(const char* pathname, const char* mode) { FILE* fopen_hook(const char* pathname, const char* mode) {
auto path_str = redirect_path(pathname); auto path_str = redirect_path(pathname);
auto redirect = path_str.has_value() ? path_str->c_str() : pathname; auto redirect = path_str.has_value() ? path_str->c_str() : pathname;
return fopen(redirect, mode); return fopen_original(redirect, mode);
} }
int (*rename_original)(const char* old_path, const char* new_path);
int rename_hook(const char* old_path, const char* new_path) { int rename_hook(const char* old_path, const char* new_path) {
auto old_path_str = redirect_path(old_path); auto old_path_str = redirect_path(old_path);
auto old_redirect = old_path_str ? old_path_str->c_str() : old_path; auto old_redirect = old_path_str ? old_path_str->c_str() : old_path;
@@ -85,5 +297,63 @@ int rename_hook(const char* old_path, const char* new_path) {
auto new_path_str = redirect_path(new_path); auto new_path_str = redirect_path(new_path);
auto new_redirect = new_path_str ? new_path_str->c_str() : new_path; auto new_redirect = new_path_str ? new_path_str->c_str() : new_path;
return rename(old_redirect, new_redirect); return rename_original(old_redirect, new_redirect);
}
bool hook_function(void* addr, auto* hook, auto** orig) {
#ifdef USE_TULIPHOOK
using namespace tulip::hook;
HandlerMetadata metadata = {
std::make_shared<PlatformConvention>(),
AbstractFunction::from(hook)
};
auto handler_result = createHandler(addr, metadata);
if (!handler_result) {
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-Fix",
"failed to create handler: %s\n", handler_result.unwrapErr().c_str());
return false;
}
auto handler = *handler_result;
HookMetadata hook_metadata = {0};
createHook(handler, reinterpret_cast<void*>(hook), hook_metadata);
WrapperMetadata wrapper_metadata = {
std::make_unique<PlatformConvention>(),
AbstractFunction::from(hook)
};
auto wrapper = createWrapper(addr, std::move(wrapper_metadata));
if (!wrapper) {
__android_log_print(ANDROID_LOG_WARN, "GeodeLauncher-Fix",
"failed to create wrapper: %s\n", wrapper.unwrapErr().c_str());
return false;
}
*reinterpret_cast<void**>(orig) = *wrapper;
return true;
#else
DobbyHook(
addr,
reinterpret_cast<dobby_dummy_func_t>(hook),
reinterpret_cast<dobby_dummy_func_t*>(orig)
);
return true;
#endif
}
[[gnu::constructor]] [[gnu::used]] void setup_hooks() {
#ifndef DISABLE_LAUNCHER_FIX
auto fopen_addr = dlsym(RTLD_NEXT, "fopen");
hook_function(fopen_addr, &fopen_hook, &fopen_original);
auto rename_addr = dlsym(RTLD_NEXT, "rename");
hook_function(rename_addr, &rename_hook, &rename_original);
#endif
} }
-34
View File
@@ -1,34 +0,0 @@
#pragma once
#define LOG_TAG "GeodeLauncher-Fix"
#include <android/log.h>
#include <format>
namespace log {
template<typename... Args>
void log_internal(android_LogPriority prio, std::format_string<Args...> fmt, Args&&... args) {
std::string str = std::format(fmt, std::forward<Args>(args)...);
__android_log_write(prio, LOG_TAG, str.c_str());
}
template<typename... Args>
inline void debug(std::format_string<Args...> fmt, Args&&... args) {
log_internal(ANDROID_LOG_DEBUG, fmt, std::forward<Args>(args)...);
}
template<typename... Args>
inline void info(std::format_string<Args...> fmt, Args&&... args) {
log_internal(ANDROID_LOG_INFO, fmt, std::forward<Args>(args)...);
}
template<typename... Args>
inline void warn(std::format_string<Args...> fmt, Args&&... args) {
log_internal(ANDROID_LOG_WARN, fmt, std::forward<Args>(args)...);
}
template<typename... Args>
inline void error(std::format_string<Args...> fmt, Args&&... args) {
log_internal(ANDROID_LOG_ERROR, fmt, std::forward<Args>(args)...);
}
}
-303
View File
@@ -1,303 +0,0 @@
#include <cstdint>
#include <span>
#include <link.h>
#include <unistd.h>
#include <sys/mman.h>
#include "log.hpp"
#include "base.h"
struct ElfDynamicState {
std::uint32_t* hash_table{};
char* str_table{};
ElfW(Sym)* sym_table{};
void* plt_table{};
std::uint64_t plt_table_size_bytes{};
bool plt_table_rela{};
std::uint64_t soname_idx{};
bool is_empty() const {
return !hash_table || !str_table || !sym_table || !plt_table;
}
};
// copied from tuliphook source :)
// tuliphook sets WRX perms, but we write to pages with W^X protection
bool set_mprotect(std::uintptr_t address, size_t size, int protection) {
auto const page_size = getpagesize();
auto const page_mask = page_size - 1;
auto const ptr = reinterpret_cast<uintptr_t>(address);
auto const aligned_ptr = ptr & (~page_mask);
auto const begin_size = ptr - aligned_ptr;
auto const page_count = (begin_size + size + page_mask) / page_size;
auto const aligned_size = page_count * page_size;
auto status = mprotect(reinterpret_cast<void*>(aligned_ptr), aligned_size, protection);
return status == 0;
}
// i copied this function from the android linker
constexpr std::uint32_t elfhash(std::string_view name) {
std::uint32_t h = 0, g;
for (const auto c : name) {
h = (h << 4) + static_cast<unsigned char>(c);
g = h & 0xf0000000;
h ^= g;
h ^= g >> 24;
}
return h;
}
constexpr std::array<const std::uint8_t, 3> SYMBOL_PATCH_VALUE{":3"};
bool patch_symbol(std::uint32_t* hash_table, char* str_table, ElfW(Sym)* sym_table, const std::string_view orig_name) {
auto hash = elfhash(orig_name);
auto nbucket = hash_table[0];
auto nchain = hash_table[1];
auto bucket = &hash_table[2];
auto chain = &bucket[nbucket];
for (auto i = bucket[hash % nbucket]; i != 0; i = chain[i]) {
auto sym = sym_table + i;
auto sym_name = str_table + sym->st_name;
if (orig_name == sym_name) {
// there's probably no point to checking this, honestly
if (ELF_ST_BIND(sym->st_info) == STB_GLOBAL && ELF_ST_TYPE(sym->st_info) == STT_FUNC) {
// we found it! now go rename the symbol
log::debug("Disabling symbol at {} ({})", reinterpret_cast<void*>(sym_name), sym_name);
if (!set_mprotect(reinterpret_cast<std::uintptr_t>(sym_name), SYMBOL_PATCH_VALUE.size(), PROT_READ | PROT_WRITE)) {
log::warn("failed to patch symbol: could not set writable flag");
return false;
}
memcpy(sym_name, SYMBOL_PATCH_VALUE.data(), SYMBOL_PATCH_VALUE.size());
if (!set_mprotect(reinterpret_cast<std::uintptr_t>(sym_name), SYMBOL_PATCH_VALUE.size(), PROT_READ)) {
log::warn("failed to patch symbol: could not remove writable flag");
return false;
}
return true;
}
}
}
log::warn("could not find symbol {} to patch (hash: {})", orig_name, hash);
return false;
}
bool perform_symbol_patches(dl_phdr_info* info, const ElfDynamicState& state) {
// patch symbol names
auto has_error = false;
for (const auto symbol : remove_symbols) {
if (!patch_symbol(state.hash_table, state.str_table, state.sym_table, symbol)) {
has_error = true;
}
}
return !has_error;
}
#if defined(__LP64__)
#define ELF_R_SYM(i) ELF64_R_SYM(i)
#define ELF_R_TYPE(i) ELF64_R_TYPE(i)
#else
#define ELF_R_SYM(i) ELF32_R_SYM(i)
#define ELF_R_TYPE(i) ELF32_R_TYPE(i)
#endif
template <typename T, typename... Types>
concept is_one_of = (std::same_as<T, Types> || ...);
template <typename T> requires is_one_of<T, ElfW(Rela), ElfW(Rel)>
bool rewrite_symbol_reloc(std::uintptr_t addr, T& r, ElfW(Sym)* sym, void* func) {
auto type = ELF_R_TYPE(r.r_info);
// we're only interested in JUMP_SLOT symbols
if (type != R_AARCH64_JUMP_SLOT && type != R_ARM_JUMP_SLOT) {
log::warn("asked to patch unknown symbol type: {:#x}", type);
return false;
}
auto write_addr = reinterpret_cast<std::uintptr_t*>(addr);
auto val = reinterpret_cast<std::uintptr_t>(func);
if constexpr (std::is_same_v<T, ElfW(Rela)>) {
// reloc = S + A
val += r.r_addend;
}
log::debug("Patching relocation at vaddr {:#x} (offset={:#x}): {:#x} => {:#x}", addr, r.r_offset, *write_addr, val);
if (!set_mprotect(addr, sizeof(std::uintptr_t), PROT_WRITE | PROT_READ)) {
log::warn("Failed to patch relocation: failed to add writable flag");
return false;
}
// why use memcpy when you can do this :)
*write_addr = val;
if (!set_mprotect(addr, sizeof(std::uintptr_t), PROT_READ)) {
log::warn("Failed to patch relocation: failed to remove writable flag");
return false;
}
return true;
}
template <typename T> requires is_one_of<T, ElfW(Rela), ElfW(Rel)>
bool perform_reloc_patches(dl_phdr_info* info, const ElfDynamicState& state) {
auto table_size = state.plt_table_size_bytes / sizeof(T);
auto failed = false;
for (auto& plt : std::span{
reinterpret_cast<T*>(state.plt_table),
static_cast<std::size_t>(table_size)
}) {
auto sym = ELF_R_SYM(plt.r_info);
if (sym == 0) continue;
auto sym_data = state.sym_table + sym;
auto sym_name = state.str_table + sym_data->st_name;
auto reloc_addr = info->dlpi_addr + plt.r_offset;
if (strcmp(sym_name, "fopen") == 0) {
if (!rewrite_symbol_reloc(reloc_addr, plt, sym_data, reinterpret_cast<void*>(&fopen_hook))) {
failed = true;
}
} else if (strcmp(sym_name, "rename") == 0) {
if (!rewrite_symbol_reloc(reloc_addr, plt, sym_data, reinterpret_cast<void*>(&rename_hook))) {
failed = true;
}
}
}
return !failed;
}
int on_dl_iterate(dl_phdr_info* info, size_t size, void* data) {
// this is probably going to be gd
if (info->dlpi_name != nullptr && strstr(info->dlpi_name, "libcocos2dcpp.so") != nullptr) {
log::debug("Base address of libcocos2dcpp: {:#x}", info->dlpi_addr);
// step 1: get the dynamic table
std::uintptr_t dyn_addr = 0u;
for (auto& phdr : std::span{ info->dlpi_phdr, info->dlpi_phnum}) {
if (phdr.p_type == PT_DYNAMIC) {
dyn_addr = info->dlpi_addr + phdr.p_vaddr;
break;
}
}
if (dyn_addr == 0u) {
log::warn("failed to find libcocos dynamic section");
return 0;
}
// step 2: get the symbol table
auto dyn_entry = reinterpret_cast<ElfW(Dyn)*>(dyn_addr);
auto dyn_end_reached = false;
ElfDynamicState state{};
while (!dyn_end_reached) {
auto tag = dyn_entry->d_tag;
switch (dyn_entry->d_tag) {
case DT_SYMTAB:
state.sym_table = reinterpret_cast<ElfW(Sym)*>(info->dlpi_addr + dyn_entry->d_un.d_val);
break;
case DT_STRTAB:
state.str_table = reinterpret_cast<char*>(info->dlpi_addr + dyn_entry->d_un.d_val);
break;
case DT_HASH:
state.hash_table = reinterpret_cast<std::uint32_t*>(info->dlpi_addr + dyn_entry->d_un.d_val);
break;
case DT_SONAME:
state.soname_idx = dyn_entry->d_un.d_val;
break;
case DT_JMPREL:
state.plt_table = reinterpret_cast<void*>(info->dlpi_addr + dyn_entry->d_un.d_val);
break;
case DT_PLTREL: {
// for Android, arm64 = DT_RELA, arm32 = DT_REL
// could just gate this based on architecture but whatever
auto val = dyn_entry->d_un.d_val;
state.plt_table_rela = val == DT_RELA;
if (val != DT_RELA && val != DT_REL) {
log::warn("unrecognized PLTREL type {:#x}", val);
}
break;
}
case DT_PLTRELSZ:
state.plt_table_size_bytes = dyn_entry->d_un.d_val;
break;
case DT_NULL:
dyn_end_reached = true;
break;
}
dyn_entry++;
}
if (state.is_empty()) {
log::error("failed to parse dynamic section (at least one table is null)");
return -1;
}
bool failed = false;
auto so_name = state.str_table + state.soname_idx;
if (strcmp(so_name, "libcocos2dcpp.so") != 0) {
log::warn("DT_SONAME gives us {}! that's not good", so_name);
failed = true;
}
auto reloc_failed = state.plt_table_rela
? !perform_reloc_patches<ElfW(Rela)>(info, state)
: !perform_reloc_patches<ElfW(Rel)>(info, state);
if (reloc_failed) {
failed = true;
}
if (DataPaths::patch_exceptions) {
if (!perform_symbol_patches(info, state)) {
failed = true;
}
}
if (failed) {
log::info(
"symbol patch diagnostics\nlibrary path: {}\naddrs: base={:#x} sym={} str={} hash={} plt={}",
info->dlpi_name,
info->dlpi_addr,
reinterpret_cast<void*>(state.sym_table),
reinterpret_cast<void*>(state.str_table),
reinterpret_cast<void*>(state.hash_table),
state.plt_table
);
}
return 1;
}
return 0;
}
@@ -2,106 +2,33 @@ package com.customRobTop
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.ActivityNotFoundException import android.content.*
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.DocumentsContract
import android.provider.OpenableColumns
import android.provider.Settings import android.provider.Settings
import android.util.Log
import android.widget.Toast 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.closeIMEKeyboard
import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.openIMEKeyboard 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.lang.ref.WeakReference
import java.util.UUID import java.util.*
@Suppress("unused", "UNUSED_PARAMETER") @Suppress("unused", "UNUSED_PARAMETER")
object BaseRobTopActivity { 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 private var isLoaded = false
var blockBackButton = false var blockBackButton = false
private var keyboardActive = false private var keyboardActive = false
var curKeyboardInputFlags = 0
var isPaused = false var isPaused = false
lateinit var me: WeakReference<Activity> lateinit var me: WeakReference<Activity>
private var shouldResumeSound = true private var shouldResumeSound = true
private var pendingExportPaths = emptyList<String>() fun setCurrentActivity(currentActivity: Activity) {
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) 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") @SuppressLint("HardwareIds")
@@ -134,52 +61,33 @@ object BaseRobTopActivity {
@JvmStatic @JvmStatic
fun openURL(url: String) { fun openURL(url: String) {
Log.d("MAIN", "Open URL")
me.get()?.runOnUiThread { me.get()?.runOnUiThread {
try { try {
val intent = Intent(Intent.ACTION_VIEW, url.toUri()) me.get()?.startActivity(
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) Intent(
me.get()?.startActivity(intent) "android.intent.action.VIEW",
Uri.parse(url)
)
)
} catch (e: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
Toast.makeText( Toast.makeText(
me.get(), me.get(),
R.string.no_activity_found, "No activity found to open this URL.",
Toast.LENGTH_SHORT, Toast.LENGTH_SHORT,
).show() ).show()
} }
} }
} }
@JvmStatic
fun copyToClipboard(str: String) {
val context = me.get() ?: return
context.runOnUiThread {
val clipboardManager = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboardManager.setPrimaryClip(
ClipData.newPlainText("text", str)
)
}
}
@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 @JvmStatic
fun sendMail(subject: String, body: String, to: String) { fun sendMail(subject: String, body: String, to: String) {
me.get()?.runOnUiThread { me.get()?.runOnUiThread {
val i = Intent(Intent.ACTION_SEND) val i = Intent("android.intent.action.SEND")
i.type = "message/rfc822" i.type = "message/rfc822"
i.putExtra(Intent.EXTRA_EMAIL, arrayOf(to)) i.putExtra("android.intent.extra.EMAIL", arrayOf(to))
i.putExtra(Intent.EXTRA_SUBJECT, subject) i.putExtra("android.intent.extra.SUBJECT", subject)
i.putExtra(Intent.EXTRA_TEXT, body) i.putExtra("android.intent.extra.TEXT", body)
try { try {
me.get()?.startActivity(Intent.createChooser(i, "Send mail...")) me.get()?.startActivity(Intent.createChooser(i, "Send mail..."))
} catch (e: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
@@ -349,164 +257,4 @@ object BaseRobTopActivity {
level = DeprecationLevel.HIDDEN level = DeprecationLevel.HIDDEN
) )
fun logEvent(event: String) {} 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,16 +55,4 @@ object JniToCpp {
@JvmStatic @JvmStatic
external fun videoAdShowed() external fun videoAdShowed()
@JvmStatic
external fun filesPicked(files: Array<String>)
@JvmStatic
external fun fileShareFinished(success: Boolean)
@JvmStatic
external fun textInputCaretToEnd()
@JvmStatic
external fun textInputDone()
} }
@@ -1,40 +0,0 @@
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()
}
}
}
@@ -1,40 +0,0 @@
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
}
}
@@ -0,0 +1,522 @@
package com.geode.launcher
import android.os.Bundle
import android.text.format.Formatter
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.geode.launcher.main.*
import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.launch
class AltMainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
clearDownloadedApks(this)
val gdInstalled = GamePackageUtils.isGameInstalled(packageManager)
val geodeInstalled = LaunchUtils.isGeodeInstalled(this)
val returnMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_MESSAGE)
val returnExtendedMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE)
@Suppress("DEPRECATION") // the new api is android 13 only (why)
val returnError = intent.extras?.getSerializable(LaunchUtils.LAUNCHER_KEY_RETURN_ERROR)
as? LaunchUtils.LauncherError
val loadFailureInfo = if (returnError != null && returnMessage != null) {
LoadFailureInfo(returnError, returnMessage, returnExtendedMessage)
} else if (LaunchUtils.lastSessionCrashed(this)) {
LoadFailureInfo(LaunchUtils.LauncherError.CRASHED)
} else { null }
setContent {
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val launchViewModel = viewModel<LaunchViewModel>(factory = LaunchViewModel.Factory)
if (loadFailureInfo != null) {
launchViewModel.loadFailure = loadFailureInfo
}
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
Surface(
modifier = Modifier.fillMaxSize()
) {
AltMainScreen(launchViewModel)
}
}
}
}
if (gdInstalled && geodeInstalled) {
intent.getBooleanExtra("restarted", false).let {
if (it) {
onLaunch(this)
}
}
}
}
}
data class LaunchStatusInfo(
val title: String?,
val details: String? = null,
val progress: (() -> Float)? = null
)
@Composable
fun LaunchCancelledIcon(cancelReason: LaunchViewModel.LaunchCancelReason, modifier: Modifier = Modifier) {
when (cancelReason) {
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED,
LaunchViewModel.LaunchCancelReason.GAME_MISSING -> Icon(painterResource(R.drawable.icon_error), contentDescription = null, modifier)
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED,
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> Icon(Icons.Default.Warning, contentDescription = null, modifier)
else -> Icon(Icons.Default.Info, contentDescription = null, modifier)
}
}
@Composable
fun mapCancelReasonToInfo(cancelReason: LaunchViewModel.LaunchCancelReason): LaunchStatusInfo {
return when (cancelReason) {
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_crash)
)
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> LaunchStatusInfo(
title = stringResource(R.string.geode_download_title)
)
LaunchViewModel.LaunchCancelReason.GAME_MISSING -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_error),
details = stringResource(R.string.game_not_found)
)
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_error),
details = stringResource(R.string.launcher_cancelled_outdated)
)
LaunchViewModel.LaunchCancelReason.AUTOMATIC,
LaunchViewModel.LaunchCancelReason.MANUAL-> LaunchStatusInfo(
title = null
)
else -> LaunchStatusInfo(
title = stringResource(R.string.launcher_cancelled_manual)
)
}
}
@Composable
fun mapLaunchStatusToInfo(state: LaunchViewModel.LaunchUIState, inSafeMode: Boolean = false): LaunchStatusInfo {
return when (state) {
is LaunchViewModel.LaunchUIState.Initial,
is LaunchViewModel.LaunchUIState.Working,
is LaunchViewModel.LaunchUIState.Ready -> LaunchStatusInfo(
title = stringResource(R.string.launcher_starting_game),
details = if (inSafeMode) stringResource(R.string.launcher_in_safe_mode) else null
)
is LaunchViewModel.LaunchUIState.UpdateCheck -> LaunchStatusInfo(
title = stringResource(R.string.release_fetch_in_progress)
)
is LaunchViewModel.LaunchUIState.Updating -> {
val context = LocalContext.current
val downloaded = remember(state.downloaded) {
Formatter.formatShortFileSize(context, state.downloaded)
}
val outOf = remember(state.outOf) {
state.outOf?.run {
Formatter.formatShortFileSize(context, this)
}
}
LaunchStatusInfo(
title = stringResource(R.string.launcher_downloading_update),
details = stringResource(R.string.launcher_downloading_update_details, downloaded, outOf ?: "…"),
progress = {
val progress = state.downloaded / state.outOf!!.toDouble()
progress.toFloat()
}.takeIf { state.outOf != null }
)
}
is LaunchViewModel.LaunchUIState.Cancelled -> {
return mapCancelReasonToInfo(state.reason)
}
}
}
@Composable
fun LaunchCancelledBody(statusInfo: LaunchStatusInfo, icon: @Composable () -> Unit, inProgress: Boolean, modifier: Modifier = Modifier) {
if (statusInfo.title == null && statusInfo.details == null && !inProgress) {
return
}
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.width(300.dp)
) {
if (statusInfo.title != null) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.bodyLarge) {
icon()
Spacer(Modifier.size(8.dp))
Text(statusInfo.title)
}
}
}
if (inProgress) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
if (statusInfo.details != null) {
Text(
statusInfo.details,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
}
}
}
@Composable
fun LaunchProgressBody(statusInfo: LaunchStatusInfo, modifier: Modifier = Modifier) {
Column(
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalAlignment = Alignment.Start,
modifier = modifier.width(300.dp)
) {
if (statusInfo.title != null) {
Text(statusInfo.title, style = MaterialTheme.typography.bodyLarge)
}
if (statusInfo.progress != null) {
LinearProgressIndicator(
statusInfo.progress,
modifier = Modifier.fillMaxWidth()
)
} else {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
if (statusInfo.details != null) {
Text(statusInfo.details, style = MaterialTheme.typography.bodyMedium)
}
}
}
@Composable
fun RetryButtonContents(reason: LaunchViewModel.LaunchCancelReason) {
when (reason) {
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED -> {
Icon(
painterResource(R.drawable.icon_resume),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_cancelled_resume))
}
LaunchViewModel.LaunchCancelReason.MANUAL,
LaunchViewModel.LaunchCancelReason.AUTOMATIC -> {
Icon(
Icons.Filled.PlayArrow,
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_launch))
}
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> {
Icon(
Icons.Filled.Refresh,
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_cancelled_retry))
}
// these shouldn't be allowing retries anyways
LaunchViewModel.LaunchCancelReason.GAME_MISSING,
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED -> {}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun LaunchProgressCard(
uiState: LaunchViewModel.LaunchUIState,
crashInfo: LoadFailureInfo?,
onCancel: () -> Unit,
onResume: (safeMode: Boolean) -> Unit,
onMore: () -> Unit,
extraOptions: @Composable () -> Unit,
safeModeEnabled: Boolean,
modifier: Modifier = Modifier
) {
var showSafeModeDialog by remember { mutableStateOf(false) }
val status = mapLaunchStatusToInfo(uiState, safeModeEnabled)
Box(modifier = modifier) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (uiState is LaunchViewModel.LaunchUIState.Cancelled) {
LaunchCancelledBody(
statusInfo = status,
icon = { LaunchCancelledIcon(uiState.reason, Modifier.size(32.dp)) },
inProgress = uiState.inProgress
)
if (uiState.reason.isGameInstallIssue()) {
val context = LocalContext.current
val showDownload = remember { GamePackageUtils.showDownloadBadge(context.packageManager) }
if (showDownload) {
GooglePlayBadge(modifier = Modifier.align(Alignment.CenterHorizontally))
}
}
if (uiState.reason.allowsRetry()) {
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally),
modifier = Modifier.align(Alignment.CenterHorizontally)
) {
LongPressButton(onClick = { onResume(false) }, onLongPress = {
showSafeModeDialog = true
}) {
RetryButtonContents(uiState.reason)
}
if (crashInfo != null) {
FilledTonalButton(onClick = onMore) {
Icon(
painterResource(R.drawable.icon_question_mark),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_cancelled_help_alt))
}
}
extraOptions()
}
}
} else {
LaunchProgressBody(statusInfo = status, modifier = Modifier.padding(top = 16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.width(300.dp)) {
TextButton(onClick = onCancel) {
Text(stringResource(R.string.release_fetch_button_cancel))
}
Spacer(Modifier.weight(1.0f))
extraOptions()
}
}
}
}
if (showSafeModeDialog) {
SafeModeDialog(
onDismiss = {
showSafeModeDialog = false
},
onLaunch = {
onResume(true)
showSafeModeDialog = false
}
)
}
}
@Composable
fun ExtraOptions(onSettings: () -> Unit) {
IconButton(
onClick = onSettings,
) {
Icon(
Icons.Filled.Settings,
contentDescription = stringResource(R.string.launcher_settings_icon_alt)
)
}
}
@Composable
fun GeodeLogo(modifier: Modifier = Modifier) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
) {
Image(
painter = painterResource(id = R.drawable.geode_logo),
contentDescription = null,
modifier = Modifier.size(76.dp, 76.dp)
)
Text(
stringResource(R.string.launcher_title),
style = MaterialTheme.typography.displayMedium,
modifier = Modifier.padding(12.dp)
)
}
}
@Composable
fun AltMainScreen(
launchViewModel: LaunchViewModel = viewModel(factory = LaunchViewModel.Factory)
) {
val context = LocalContext.current
LaunchedEffect(Unit) {
launchViewModel.beginLaunchFlow()
}
val launchUIState by launchViewModel.uiState.collectAsState()
val coroutineScope = rememberCoroutineScope()
var launchInSafeMode by remember { mutableStateOf(false) }
var showErrorInfo by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding(),
contentAlignment = Alignment.Center
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(8.dp)
.verticalScroll(rememberScrollState())
) {
GeodeLogo()
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
LaunchProgressCard(
launchUIState,
crashInfo = launchViewModel.currentCrashInfo(),
onCancel = {
launchInSafeMode = false
coroutineScope.launch {
launchViewModel.cancelLaunch()
}
},
onResume = { safeMode ->
launchInSafeMode = safeMode
launchViewModel.clearCrashInfo()
coroutineScope.launch {
launchViewModel.beginLaunchFlow(true)
}
},
onMore = {
showErrorInfo = true
},
extraOptions = {
ExtraOptions(
onSettings = {
coroutineScope.launch {
launchViewModel.cancelLaunch(true)
}
onSettings(context)
}
)
},
safeModeEnabled = launchInSafeMode
)
// only show launcher update in a case where a user won't see it ingame
if (launchUIState is LaunchViewModel.LaunchUIState.Cancelled) {
val gameVersion = remember { GamePackageUtils.getGameVersionCodeOrNull(context.packageManager) }
if (gameVersion != null && gameVersion < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
UnsupportedVersionWarning()
}
val nextLauncherUpdate by launchViewModel.nextLauncherUpdate.collectAsState()
if (nextLauncherUpdate != null) {
LauncherUpdateIndicator()
}
}
}
}
}
if (launchUIState is LaunchViewModel.LaunchUIState.Ready) {
UpdateWarning(launchInSafeMode) {
launchInSafeMode = false
coroutineScope.launch {
launchViewModel.cancelLaunch()
}
}
}
val loadFailure = launchViewModel.currentCrashInfo()
if (showErrorInfo && loadFailure != null) {
ErrorInfoSheet(loadFailure, onDismiss = { showErrorInfo = false })
}
}
@@ -12,18 +12,15 @@ import android.os.Environment
import android.text.InputType import android.text.InputType
import android.util.Log import android.util.Log
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowInsets
import android.view.WindowManager import android.view.WindowManager
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.activity.OnBackPressedCallback import android.window.OnBackInvokedDispatcher
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.AppCompatEditText
import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ComposeView
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import androidx.core.view.updatePadding
import com.customRobTop.BaseRobTopActivity import com.customRobTop.BaseRobTopActivity
import com.customRobTop.JniToCpp import com.customRobTop.JniToCpp
import com.geode.launcher.main.LaunchNotification import com.geode.launcher.main.LaunchNotification
@@ -35,6 +32,7 @@ import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.GeodeUtils import com.geode.launcher.utils.GeodeUtils
import com.geode.launcher.utils.LaunchUtils import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils import com.geode.launcher.utils.PreferenceUtils
import org.cocos2dx.lib.Cocos2dxEditText
import org.cocos2dx.lib.Cocos2dxGLSurfaceView import org.cocos2dx.lib.Cocos2dxGLSurfaceView
import org.cocos2dx.lib.Cocos2dxHelper import org.cocos2dx.lib.Cocos2dxHelper
import org.cocos2dx.lib.Cocos2dxRenderer import org.cocos2dx.lib.Cocos2dxRenderer
@@ -125,13 +123,13 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
return return
} }
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
override fun handleOnBackPressed() { onBackInvokedDispatcher.registerOnBackInvokedCallback(OnBackInvokedDispatcher.PRIORITY_DEFAULT) {
mGLSurfaceView?.sendKeyBack() mGLSurfaceView?.sendKeyBack()
} }
})
mGLSurfaceView?.manualBackEvents = true mGLSurfaceView?.manualBackEvents = true
} }
}
private fun createVersionFile() { private fun createVersionFile() {
val versionPath = File(filesDir, "game_version.txt") val versionPath = File(filesDir, "game_version.txt")
@@ -145,7 +143,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
returnMessage: String? = null, returnMessage: String? = null,
returnExtendedMessage: String? = null returnExtendedMessage: String? = null
) { ) {
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)!!.apply { val launchIntent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
if (error != null && !returnMessage.isNullOrEmpty()) { if (error != null && !returnMessage.isNullOrEmpty()) {
@@ -171,88 +169,32 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
tryLoadLibrary(gdPackageInfo, Constants.FMOD_LIB_NAME) tryLoadLibrary(gdPackageInfo, Constants.FMOD_LIB_NAME)
tryLoadLibrary(gdPackageInfo, Constants.COCOS_LIB_NAME) tryLoadLibrary(gdPackageInfo, Constants.COCOS_LIB_NAME)
val gameVersionCode = GamePackageUtils.getGameVersionCode(packageManager) if (GamePackageUtils.getGameVersionCode(packageManager) >= 39L) {
if (gameVersionCode >= 39L) { /*
val customSymbols = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.CUSTOM_SYMBOL_LIST)
if (customSymbols) {
val symbolFile = File(LaunchUtils.getBaseDirectory(this), "exception_symbols.txt")
LauncherFix.enableCustomSymbolList(symbolFile.path)
}
*/
// this fix requires geode v3, which is 2.206+ // 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 // there is a short period in which 2.206 users will still have geode v2, but whatever. ig
LauncherFix.enableExceptionsRenaming() LauncherFix.performExceptionsRenaming()
} }
LauncherFix.performPatches()
loadInternalMods() loadInternalMods()
val baseView = createView() setContentView(createView())
if (gameVersionCode >= 42L) {
mGLSurfaceView?.sendNewCocosEvents = true
}
if (!GeodeUtils.handleSafeArea && Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
val frameLayoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
val frameLayout = FrameLayout(this)
frameLayout.layoutParams = frameLayoutParams
frameLayout.setOnApplyWindowInsetsListener { v, insets ->
val displayInsets = insets.displayCutout
if (displayInsets != null) {
v.updatePadding(
left = displayInsets.safeInsetLeft,
top = displayInsets.safeInsetTop,
right = displayInsets.safeInsetRight,
bottom = displayInsets.safeInsetBottom
)
} else {
v.updatePadding(
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)
}
setupPostLibraryLoad(gdPackageInfo) setupPostLibraryLoad(gdPackageInfo)
val geodeFailed = try { try {
loadGeodeLibrary() loadGeodeLibrary()
false
} catch (e: UnsatisfiedLinkError) { } catch (e: UnsatisfiedLinkError) {
handleGeodeException(e) handleGeodeException(e)
true
} catch (e: Exception) { } catch (e: Exception) {
handleGeodeException(e) handleGeodeException(e)
true
}
setupNotificationView(baseView, geodeFailed)
}
private fun setupNotificationView(baseView: FrameLayout, geodeFailed: Boolean) {
val cards = determineDisplayedCards(this, geodeFailed)
if (cards.isNotEmpty()) {
val notificationView = ComposeView(this)
baseView.addView(notificationView)
notificationView.setContent {
LaunchNotification(cards)
}
} }
} }
@@ -393,15 +335,8 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
return "$sourceDir!/lib/$architecture/" return "$sourceDir!/lib/$architecture/"
} }
@SuppressLint("UnsafeDynamicallyLoadedCode")
private fun loadGeodeLibrary() { private fun loadGeodeLibrary() {
@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 // Load Geode if exists
// bundling the object with the application allows for nicer backtraces // bundling the object with the application allows for nicer backtraces
try { try {
@@ -409,29 +344,18 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
System.loadLibrary("geode") System.loadLibrary("geode")
return return
} catch (e: UnsatisfiedLinkError) { } catch (e: UnsatisfiedLinkError) {
if (!loadExternalGeode()) {
throw e
}
}
}
}
@SuppressLint("UnsafeDynamicallyLoadedCode")
private fun loadExternalGeode(): Boolean {
// but users may prefer it stored with data // but users may prefer it stored with data
val geodeFilename = LaunchUtils.geodeFilename val geodeFilename = LaunchUtils.geodeFilename
val geodePath = File(filesDir.path, "launcher/$geodeFilename") val geodePath = File(filesDir.path, "launcher/$geodeFilename")
if (geodePath.exists()) { if (geodePath.exists()) {
println("Loading Geode from ${geodePath.path}")
System.load(geodePath.path) System.load(geodePath.path)
return true return
} }
// you know zmx i have 0 clue what this does so im // you know zmx i have 0 clue what this does so im
// just gonna like copy the binary from external // just gonna like copy the binary from external
// also i get 20 million permission denied errors // also i get 20 million permission denied errors
val externalGeodePath = LaunchUtils.getInstalledGeodePath(this) val externalGeodePath = LaunchUtils.getInstalledGeodePath(this)!!
?: return false
val copiedPath = File(filesDir.path, "copied") val copiedPath = File(filesDir.path, "copied")
if (copiedPath.exists()) { if (copiedPath.exists()) {
@@ -448,16 +372,14 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
) )
if (copiedGeodePath.exists()) { if (copiedGeodePath.exists()) {
// android 17 fix
copiedGeodePath.setWritable(false)
println("Loading Geode from ${externalGeodePath.name}") println("Loading Geode from ${externalGeodePath.name}")
System.load(copiedGeodePath.path) System.load(copiedGeodePath.path)
return true return
} }
} }
return false throw e
}
} }
private fun createView(): FrameLayout { private fun createView(): FrameLayout {
@@ -484,7 +406,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT ViewGroup.LayoutParams.WRAP_CONTENT
) )
val editText = AppCompatEditText(this) val editText = Cocos2dxEditText(this)
editText.layoutParams = editTextLayoutParams editText.layoutParams = editTextLayoutParams
frameLayout.addView(editText) frameLayout.addView(editText)
@@ -493,6 +415,19 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
this.mGLSurfaceView = glSurfaceView this.mGLSurfaceView = glSurfaceView
frameLayout.addView(this.mGLSurfaceView) frameLayout.addView(this.mGLSurfaceView)
val showNotification = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.ENABLE_REDESIGN)
if (showNotification) {
val hasCards = determineDisplayedCards(this)
if (hasCards.isNotEmpty()) {
val notificationView = ComposeView(this)
frameLayout.addView(notificationView)
notificationView.setContent {
LaunchNotification()
}
}
}
glSurfaceView.setEGLContextClientVersion(2) glSurfaceView.setEGLContextClientVersion(2)
glSurfaceView.setEGLConfigChooser(EGLConfigChooser()) glSurfaceView.setEGLConfigChooser(EGLConfigChooser())
@@ -508,13 +443,9 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
renderer.limitFrameRate(frameRate) renderer.limitFrameRate(frameRate)
} }
editText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS editText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
glSurfaceView.cocos2dxEditText = editText glSurfaceView.cocos2dxEditText = editText
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
glSurfaceView.defaultFocusHighlightEnabled = false
}
return frameLayout return frameLayout
} }
@@ -534,11 +465,11 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
val limitedRefreshRate = preferenceUtils.getInt(PreferenceUtils.Key.LIMIT_FRAME_RATE).takeIf { it != 0 } val limitedRefreshRate = preferenceUtils.getInt(PreferenceUtils.Key.LIMIT_FRAME_RATE).takeIf { it != 0 }
mLimitedRefreshRate = limitedRefreshRate mLimitedRefreshRate = limitedRefreshRate
mForceRefreshRate = true mForceRefreshRate = preferenceUtils.getBoolean(PreferenceUtils.Key.FORCE_HRR)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (mForceRefreshRate && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val chosenDisplay = display.supportedModes?.maxByOrNull { it.refreshRate } val chosenDisplay = display.supportedModes?.maxByOrNull { it.refreshRate }
if (chosenDisplay != null) { if (chosenDisplay != null) {
val chosenRefreshRate = chosenDisplay.refreshRate var chosenRefreshRate = chosenDisplay.refreshRate
println("setupUIState: Forcing a refresh rate of $chosenRefreshRate (display ${chosenDisplay.modeId})") println("setupUIState: Forcing a refresh rate of $chosenRefreshRate (display ${chosenDisplay.modeId})")
window.attributes.preferredRefreshRate = chosenRefreshRate window.attributes.preferredRefreshRate = chosenRefreshRate
@@ -657,14 +588,6 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
mGLSurfaceView?.sendTimestampEvents = true mGLSurfaceView?.sendTimestampEvents = true
true true
} }
GeodeUtils.CAPABILITY_RICH_INPUT -> {
mGLSurfaceView?.enableControllerEvents()
true
}
GeodeUtils.CAPABILITY_RESIZE_CALLBACK -> {
mGLSurfaceView?.supportsResizeEvents = true
true
}
else -> false else -> false
} }
} }
@@ -701,56 +624,6 @@ 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 { class EGLConfigChooser : GLSurfaceView.EGLConfigChooser {
// this comes from EGL14, but is unavailable on EGL10 // this comes from EGL14, but is unavailable on EGL10
// also EGL14 is incompatible with EGL10. so whatever // also EGL14 is incompatible with EGL10. so whatever
@@ -1,33 +0,0 @@
package com.geode.launcher
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.widget.Toast
import com.geode.launcher.main.clearDownloadedApks
class InstallReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val activityIntent = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
if (activityIntent != null) {
context.startActivity(activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
}
PackageInstaller.STATUS_SUCCESS -> {
clearDownloadedApks(context)
}
else -> {
val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
println("failed to install update: $status, $msg")
val message = context.getString(R.string.launcher_self_update_failed, msg)
Toast.makeText(context, message, Toast.LENGTH_SHORT)
.show()
}
}
}
}
@@ -13,7 +13,7 @@ object LauncherFix {
external fun setOriginalDataPath(dataPath: String) external fun setOriginalDataPath(dataPath: String)
external fun enableExceptionsRenaming() external fun performExceptionsRenaming()
external fun performPatches() external fun enableCustomSymbolList(symbolsPath: String)
} }
File diff suppressed because it is too large. Load diff
@@ -10,10 +10,7 @@ import android.provider.DocumentsContract
import android.provider.DocumentsProvider import android.provider.DocumentsProvider
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.annotation.RequiresApi import 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.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
import java.io.File import java.io.File
private val DEFAULT_ROOT_PROJECTION: Array<String> = arrayOf( private val DEFAULT_ROOT_PROJECTION: Array<String> = arrayOf(
@@ -63,17 +60,8 @@ class UserDirectoryProvider : DocumentsProvider() {
DocumentsContract.Root.FLAG_SUPPORTS_RECENTS or DocumentsContract.Root.FLAG_SUPPORTS_RECENTS or
DocumentsContract.Root.FLAG_SUPPORTS_SEARCH DocumentsContract.Root.FLAG_SUPPORTS_SEARCH
) )
add(DocumentsContract.Root.COLUMN_TITLE, context.getString(R.string.app_name))
val selectedIconKey = PreferenceUtils.get(context).getString(PreferenceUtils.Key.SELECTED_ICON) add(DocumentsContract.Root.COLUMN_ICON, R.drawable.geode_logo)
?: "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 return result
@@ -1,70 +0,0 @@
package com.geode.launcher.log
import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
abstract class BaseDirectoryViewModel<T>(protected val baseDirectory: File) : ViewModel() {
private val _lineState = mutableStateListOf<T>()
val lineState = _lineState
private val _isLoading = MutableStateFlow(false)
val isLoading = _isLoading.asStateFlow()
private var logJob: Job? = null
abstract fun getFileList(): List<T>
abstract fun getItemKey(item: T): Any
open fun clearAllFiles() {
logJob?.cancel()
viewModelScope.launch(Dispatchers.IO) {
if (baseDirectory.exists()) {
val files = baseDirectory.listFiles()
files?.forEach {
it.delete()
}
}
}
_lineState.clear()
}
private fun loadLogs() {
_isLoading.value = true
logJob?.cancel()
logJob = viewModelScope.launch(Dispatchers.IO) {
val lines = getFileList()
_lineState.clear()
_lineState.addAll(lines)
_isLoading.value = false
logJob = null
}
}
fun removeFile(filename: String) {
logJob?.cancel()
val crashPath = File(baseDirectory, filename)
if (crashPath.exists()) {
crashPath.delete()
loadLogs()
}
}
init {
loadLogs()
}
}
@@ -1,89 +0,0 @@
package com.geode.launcher.log
import android.app.Application
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.geode.launcher.utils.LaunchUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
import kotlin.time.Instant
data class CrashDump(
val filename: String,
val lastModified: Instant,
val fullPath: String,
)
class CrashViewModel(crashDirectory: File) : BaseDirectoryViewModel<CrashDump>(crashDirectory) {
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as Application
val crashDirectory = LaunchUtils.getCrashDirectory(application)
CrashViewModel(crashDirectory)
}
}
}
var hasIndicator = false
override fun getItemKey(item: CrashDump) = item.filename
override fun getFileList(): List<CrashDump> {
val crashDirectory = baseDirectory
if (!crashDirectory.exists()) {
return emptyList()
}
val children = crashDirectory.listFiles {
// ignore indicator files (including old file)
_, name -> !LaunchUtils.CRASH_INDICATOR_NAMES.contains(name)
} ?: return emptyList()
hasIndicator = LaunchUtils.CRASH_INDICATOR_NAMES.any {
File(crashDirectory, it).exists()
}
return children
.map {
CrashDump(
filename = it.name,
lastModified = Instant.fromEpochMilliseconds(it.lastModified()),
fullPath = it.path,
)
}
.sortedByDescending { it.lastModified }
}
fun clearIndicator() {
viewModelScope.launch(Dispatchers.IO) {
LaunchUtils.CRASH_INDICATOR_NAMES.forEach {
val crashFile = File(baseDirectory, it)
if (crashFile.exists()) {
crashFile.delete()
}
}
hasIndicator = false
}
}
fun createIndicator() {
viewModelScope.launch(Dispatchers.IO) {
val crashFile = File(baseDirectory, LaunchUtils.CRASH_INDICATOR_NAMES.first())
crashFile.createNewFile()
hasIndicator = true
}
}
override fun clearAllFiles() {
super.clearAllFiles()
hasIndicator = false
}
}
@@ -1,66 +0,0 @@
package com.geode.launcher.log
import android.app.Application
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.geode.launcher.utils.LaunchUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import kotlin.time.Instant
data class GeodeLog(
val filename: String,
val lastModified: Instant,
val fileSize: Long,
val fullPath: String,
)
class GeodeLogsViewModel(crashDirectory: File) : BaseDirectoryViewModel<GeodeLog>(crashDirectory) {
companion object {
val Factory: ViewModelProvider.Factory = viewModelFactory {
initializer {
val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] as Application
val crashDirectory = LaunchUtils.getGeodeLogsDirectory(application)
GeodeLogsViewModel(crashDirectory)
}
}
}
override fun getItemKey(item: GeodeLog) = item.filename
override fun getFileList(): List<GeodeLog> {
if (!baseDirectory.exists()) {
return emptyList()
}
val children = baseDirectory.listFiles()
?: return emptyList()
return children
.map {
GeodeLog(
filename = it.name,
lastModified = Instant.fromEpochMilliseconds(it.lastModified()),
fileSize = it.length(),
fullPath = it.path,
)
}
.sortedByDescending { it.lastModified }
}
suspend fun getFileText(filename: String): List<String> {
val logFile = File(baseDirectory, filename)
if (!logFile.exists()) {
return listOf("File does not exist!")
}
return runCatching {
withContext(Dispatchers.IO) {
logFile.readLines()
}
}.getOrDefault(emptyList())
}
}
@@ -1,14 +1,13 @@
package com.geode.launcher.log package com.geode.launcher.log
import kotlin.time.Clock import kotlinx.datetime.Clock
import kotlin.time.Instant import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toJavaInstant
import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.toLocalDateTime
import okio.Buffer import okio.Buffer
import okio.BufferedSource import okio.BufferedSource
import java.io.IOException import java.io.IOException
import kotlin.time.ExperimentalTime
import kotlin.time.toJavaInstant
fun BufferedSource.readCChar(): Char { fun BufferedSource.readCChar(): Char {
return this.readUtf8CodePoint().toChar() return this.readUtf8CodePoint().toChar()
@@ -116,7 +115,7 @@ enum class LogPriority {
/** /**
* Represents a log entry from logcat. * Represents a log entry from logcat.
*/ */
data class LogLine @OptIn(ExperimentalTime::class) constructor( data class LogLine(
val process: ProcessInformation, val process: ProcessInformation,
val time: Instant, val time: Instant,
val logId: Int, val logId: Int,
@@ -135,7 +134,6 @@ data class LogLine @OptIn(ExperimentalTime::class) constructor(
else -> throw IOException("LogLine::fromInputStream: unknown format for (headerSize = $size)") else -> throw IOException("LogLine::fromInputStream: unknown format for (headerSize = $size)")
} }
@OptIn(ExperimentalTime::class)
fun fromBufferedSource(source: BufferedSource): LogLine { fun fromBufferedSource(source: BufferedSource): LogLine {
/* /*
// from android <liblog/include/log/log_read.h> // from android <liblog/include/log/log_read.h>
@@ -202,7 +200,6 @@ data class LogLine @OptIn(ExperimentalTime::class) constructor(
) )
} }
@OptIn(ExperimentalTime::class)
fun showException(exception: Exception) = LogLine( fun showException(exception: Exception) = LogLine(
process = ProcessInformation(0, 0, 0), process = ProcessInformation(0, 0, 0),
time = Clock.System.now(), time = Clock.System.now(),
@@ -213,14 +210,12 @@ data class LogLine @OptIn(ExperimentalTime::class) constructor(
) )
} }
@OptIn(ExperimentalTime::class)
val identifier = time.toJavaInstant() val identifier = time.toJavaInstant()
val messageTrimmed by lazy { val messageTrimmed by lazy {
this.message.trim { it <= ' ' } this.message.trim { it <= ' ' }
} }
@OptIn(ExperimentalTime::class)
val formattedTime by lazy { this.time.toLocalDateTime(TimeZone.currentSystemDefault()) } val formattedTime by lazy { this.time.toLocalDateTime(TimeZone.currentSystemDefault()) }
val asSimpleString by lazy { val asSimpleString by lazy {
"$formattedTime [${this.priority.toChar()}/${this.tag}]: ${this.messageTrimmed}" "$formattedTime [${this.priority.toChar()}/${this.tag}]: ${this.messageTrimmed}"
@@ -3,13 +3,16 @@ package com.geode.launcher.main
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.AbsoluteRoundedCornerShape import androidx.compose.foundation.shape.AbsoluteRoundedCornerShape
@@ -24,13 +27,12 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.geode.launcher.R import com.geode.launcher.R
import com.geode.launcher.utils.Constants import com.geode.launcher.utils.Constants
import androidx.core.net.toUri
fun onDownloadGame(context: Context) { fun onDownloadGame(context: Context) {
val appUrl = "https://play.google.com/store/apps/details?id=${Constants.PACKAGE_NAME}" val appUrl = "https://play.google.com/store/apps/details?id=${Constants.PACKAGE_NAME}"
try { try {
val intent = Intent(Intent.ACTION_VIEW).apply { val intent = Intent(Intent.ACTION_VIEW).apply {
data = appUrl.toUri() data = Uri.parse(appUrl)
setPackage("com.android.vending") setPackage("com.android.vending")
} }
context.startActivity(intent) context.startActivity(intent)
@@ -53,6 +55,22 @@ fun GooglePlayBadge(modifier: Modifier = Modifier) {
) )
} }
@Composable
fun StatusIndicator(
icon: @Composable () -> Unit,
text: @Composable () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
ElevatedCard(modifier = modifier, onClick = onClick) {
Row(modifier = Modifier.padding(8.dp)) {
icon()
Spacer(Modifier.size(8.dp))
text()
}
}
}
@Composable @Composable
fun InlineDialog(headline: @Composable () -> Unit, body: @Composable () -> Unit, modifier: Modifier = Modifier) { fun InlineDialog(headline: @Composable () -> Unit, body: @Composable () -> Unit, modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier) { ElevatedCard(modifier = modifier) {
@@ -1,7 +1,6 @@
package com.geode.launcher.main package com.geode.launcher.main
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
@@ -18,8 +17,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -34,15 +34,13 @@ import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextLinkStyles
@@ -55,14 +53,10 @@ import com.geode.launcher.preferences.ApplicationLogsActivity
import com.geode.launcher.BuildConfig import com.geode.launcher.BuildConfig
import com.geode.launcher.R import com.geode.launcher.R
import com.geode.launcher.UserDirectoryProvider import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.preferences.GeodeLogsListingActivity
import com.geode.launcher.preferences.TextViewActivity
import com.geode.launcher.ui.theme.Typography import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.utils.GamePackageUtils import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.LaunchUtils import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.launch
import java.io.File
data class LoadFailureInfo( data class LoadFailureInfo(
val title: LaunchUtils.LauncherError, val title: LaunchUtils.LauncherError,
@@ -140,28 +134,14 @@ fun ErrorInfoTitle(failureReason: LaunchUtils.LauncherError) {
} }
fun findSomethingForCrash(context: Context): File? {
val lastCrash = LaunchUtils.getLastCrash(context)
if (lastCrash != null) {
return lastCrash
}
val logsDirectory = LaunchUtils.getGeodeLogsDirectory(context)
if (!logsDirectory.exists()) {
return null
}
return logsDirectory.listFiles()?.maxByOrNull { it.lastModified() }
}
fun onShareCrash(context: Context) { fun onShareCrash(context: Context) {
val lastCrash = findSomethingForCrash(context) val lastCrash = LaunchUtils.getLastCrash(context)
if (lastCrash == null) { if (lastCrash == null) {
Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show() Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show()
return return
} }
val baseDirectory = LaunchUtils.getBaseDirectory(context, true) val baseDirectory = LaunchUtils.getBaseDirectory(context)
val providerLocation = lastCrash.toRelativeString(baseDirectory) val providerLocation = lastCrash.toRelativeString(baseDirectory)
val documentsPath = "${UserDirectoryProvider.ROOT}${providerLocation}" val documentsPath = "${UserDirectoryProvider.ROOT}${providerLocation}"
@@ -180,19 +160,6 @@ fun onShareCrash(context: Context) {
} }
} }
fun openLastCrash(context: Context) {
val lastCrash = findSomethingForCrash(context)
if (lastCrash == null) {
Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show()
return
}
val launchIntent = Intent(context, TextViewActivity::class.java).run {
putExtra(TextViewActivity.EXTRA_LOG_VIEW_FILENAME, lastCrash.path)
}
context.startActivity(launchIntent)
}
fun onShowLogs(context: Context) { fun onShowLogs(context: Context) {
val launchIntent = Intent(context, ApplicationLogsActivity::class.java) val launchIntent = Intent(context, ApplicationLogsActivity::class.java)
context.startActivity(launchIntent) context.startActivity(launchIntent)
@@ -202,10 +169,6 @@ fun onShowLogs(context: Context) {
fun ErrorInfoDescription(description: String, modifier: Modifier = Modifier) { fun ErrorInfoDescription(description: String, modifier: Modifier = Modifier) {
val context = LocalContext.current val context = LocalContext.current
val currentRelease = remember { val currentRelease = remember {
@Suppress("SENSELESS_COMPARISON")
if (BuildConfig.PREBUNDLED_GEODE != null)
"${BuildConfig.PREBUNDLED_GEODE}*"
else
PreferenceUtils.get(context).getString(PreferenceUtils.Key.CURRENT_VERSION_TAG) PreferenceUtils.get(context).getString(PreferenceUtils.Key.CURRENT_VERSION_TAG)
} }
@@ -213,7 +176,6 @@ fun ErrorInfoDescription(description: String, modifier: Modifier = Modifier) {
GamePackageUtils.getUnifiedVersionName(context.packageManager) GamePackageUtils.getUnifiedVersionName(context.packageManager)
} }
SelectionContainer {
Column(modifier = modifier) { Column(modifier = modifier) {
Text( Text(
stringResource(R.string.launcher_error_details), stringResource(R.string.launcher_error_details),
@@ -243,17 +205,13 @@ fun ErrorInfoDescription(description: String, modifier: Modifier = Modifier) {
) )
} }
} }
}
@Composable @Composable
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
fun ErrorInfoActions(extraDetails: String?, modifier: Modifier = Modifier) { fun ErrorInfoActions(extraDetails: String?, modifier: Modifier = Modifier) {
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current val context = LocalContext.current
val clipboard = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
val resources = LocalResources.current
FlowRow( FlowRow(
modifier = modifier, modifier = modifier,
horizontalArrangement = Arrangement.spacedBy( horizontalArrangement = Arrangement.spacedBy(
@@ -271,15 +229,7 @@ fun ErrorInfoActions(extraDetails: String?, modifier: Modifier = Modifier) {
} }
FilledTonalButton(onClick = { FilledTonalButton(onClick = {
val clipboardLabel = resources.getString(R.string.application_log_copy_label) clipboardManager.setText(AnnotatedString(extraDetails))
coroutineScope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText(
clipboardLabel, extraDetails
))
)
}
}) { }) {
Icon( Icon(
painterResource(R.drawable.icon_content_copy), painterResource(R.drawable.icon_content_copy),
@@ -289,34 +239,16 @@ fun ErrorInfoActions(extraDetails: String?, modifier: Modifier = Modifier) {
Text(stringResource(R.string.launcher_error_copy)) Text(stringResource(R.string.launcher_error_copy))
} }
} else { } else {
val crashFile = remember { findSomethingForCrash(context) }
val readableCrash = remember { crashFile?.extension == "log" }
if (crashFile != null) {
Button(onClick = { onShareCrash(context) }) { Button(onClick = { onShareCrash(context) }) {
Icon( Icon(
painterResource(R.drawable.icon_share), Icons.Filled.Share,
contentDescription = null contentDescription = null
) )
Spacer(Modifier.size(ButtonDefaults.IconSpacing)) Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_error_export_crash)) Text(stringResource(R.string.launcher_error_export_crash))
} }
if (readableCrash) { FilledTonalButton(onClick = { onShowLogs(context) }) {
FilledTonalButton(onClick = { openLastCrash(context) }) {
Icon(
painterResource(R.drawable.icon_description),
contentDescription = null
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.launcher_error_view_details))
}
}
} else {
Button(onClick = {
val launchIntent = Intent(context, GeodeLogsListingActivity::class.java)
context.startActivity(launchIntent)
}) {
Icon( Icon(
painterResource(R.drawable.icon_description), painterResource(R.drawable.icon_description),
contentDescription = null contentDescription = null
@@ -327,7 +259,6 @@ fun ErrorInfoActions(extraDetails: String?, modifier: Modifier = Modifier) {
} }
} }
} }
}
@Composable @Composable
@@ -4,12 +4,21 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -19,24 +28,27 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.geode.launcher.GeometryDashActivity import com.geode.launcher.GeometryDashActivity
import com.geode.launcher.R import com.geode.launcher.R
import com.geode.launcher.preferences.SettingsActivity import com.geode.launcher.preferences.SettingsActivity
import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.utils.Constants import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.GamePackageUtils import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.GeodeUtils import com.geode.launcher.utils.GeodeUtils
import com.geode.launcher.utils.PreferenceUtils import com.geode.launcher.utils.PreferenceUtils
import com.geode.launcher.utils.useCountdownTimer
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlin.time.Duration.Companion.milliseconds
@Composable @Composable
@@ -59,7 +71,7 @@ fun UpdateWarning(inSafeMode: Boolean = false, onDismiss: () -> Unit) {
AlertDialog( AlertDialog(
icon = { icon = {
Icon( Icon(
painterResource(R.drawable.icon_warning), Icons.Filled.Warning,
contentDescription = stringResource(R.string.launcher_warning_icon_alt) contentDescription = stringResource(R.string.launcher_warning_icon_alt)
) )
}, },
@@ -104,11 +116,11 @@ fun SafeModeDialog(onDismiss: () -> Unit, onLaunch: () -> Unit) {
AlertDialog( AlertDialog(
icon = { icon = {
Icon( Icon(
painterResource(R.drawable.icon_shield_alert), Icons.Filled.Warning,
contentDescription = null contentDescription = stringResource(R.string.launcher_warning_icon_alt)
) )
}, },
title = { Text(stringResource(R.string.preference_launch_safe_mode_confirm_title)) }, title = { Text(stringResource(R.string.safe_mode_enable_title)) },
text = { Text(stringResource(R.string.safe_mode_enable_description)) }, text = { Text(stringResource(R.string.safe_mode_enable_description)) },
dismissButton = { dismissButton = {
TextButton(onClick = onDismiss) { TextButton(onClick = onDismiss) {
@@ -143,7 +155,7 @@ fun onSettings(context: Context) {
} }
@Composable @Composable
fun LongPressButton(onClick: () -> Unit, onLongPress: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, content: @Composable RowScope.() -> Unit) { fun LongPressButton(onClick: () -> Unit, onLongPress: () -> Unit, enabled: Boolean = true, modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) {
// compose apis don't provide a good way of adding long press to a button // compose apis don't provide a good way of adding long press to a button
var isLongPress by remember { mutableStateOf(false) } var isLongPress by remember { mutableStateOf(false) }
@@ -155,11 +167,11 @@ fun LongPressButton(onClick: () -> Unit, onLongPress: () -> Unit, modifier: Modi
interactionSource.interactions.collectLatest { interaction -> interactionSource.interactions.collectLatest { interaction ->
when (interaction) { when (interaction) {
is PressInteraction.Press -> { is PressInteraction.Press -> {
delay(viewConfiguration.longPressTimeoutMillis.milliseconds) delay(viewConfiguration.longPressTimeoutMillis)
// perform a second delay to make the action more obvious // perform a second delay to make the action more obvious
haptics.performHapticFeedback(HapticFeedbackType.LongPress) haptics.performHapticFeedback(HapticFeedbackType.LongPress)
delay(viewConfiguration.longPressTimeoutMillis.milliseconds) delay(viewConfiguration.longPressTimeoutMillis)
haptics.performHapticFeedback(HapticFeedbackType.LongPress) haptics.performHapticFeedback(HapticFeedbackType.LongPress)
@@ -186,13 +198,104 @@ fun LongPressButton(onClick: () -> Unit, onLongPress: () -> Unit, modifier: Modi
} }
@Composable @Composable
fun UnsupportedVersionWarning(modifier: Modifier = Modifier) { fun PlayButton(
val gameVersion = GamePackageUtils.getSupportedUnifiedVersion() stopAutomaticLaunch: Boolean,
blockLaunch: Boolean,
onPlayGame: (Boolean) -> Unit
) {
val context = LocalContext.current
var showSafeModeDialog by remember { mutableStateOf(false) }
var launchInSafeMode by remember { mutableStateOf(false) }
val shouldAutomaticallyLaunch by PreferenceUtils.useBooleanPreference(
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY
)
if (shouldAutomaticallyLaunch && !stopAutomaticLaunch && !showSafeModeDialog) {
val countdownTimer = useCountdownTimer(
time = 3000,
onCountdownFinish = { onPlayGame(launchInSafeMode) }
)
if (countdownTimer != 0L) {
Text(
pluralStringResource(
R.plurals.automatically_load_countdown,
countdownTimer.toInt(),
countdownTimer
),
style = Typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
Spacer(Modifier.size(12.dp))
}
}
Row {
LongPressButton(
enabled = !blockLaunch,
onLongPress = {
showSafeModeDialog = true
},
onClick = {
onPlayGame(launchInSafeMode)
}
) {
Icon(
Icons.Filled.PlayArrow,
contentDescription = context.getString(R.string.launcher_launch_icon_alt)
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(context.getString(R.string.launcher_launch))
}
Spacer(Modifier.size(2.dp))
IconButton(onClick = { onSettings(context) }) {
Icon(
Icons.Filled.Settings,
contentDescription = context.getString(R.string.launcher_settings_icon_alt)
)
}
}
if (showSafeModeDialog) {
SafeModeDialog(
onDismiss = {
launchInSafeMode = false
showSafeModeDialog = false
},
onLaunch = {
launchInSafeMode = true
onPlayGame(true)
showSafeModeDialog = false
}
)
}
}
@Composable
fun LaunchBlockedLabel(text: String) {
val context = LocalContext.current
val showDownload = remember { GamePackageUtils.showDownloadBadge(context.packageManager) }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text, textAlign = TextAlign.Center, modifier = Modifier.padding(12.dp))
if (showDownload) {
GooglePlayBadge()
}
}
}
@Composable
fun UnsupportedVersionWarning(modifier: Modifier = Modifier) {
InlineDialog( InlineDialog(
headline = { headline = {
Icon( Icon(
painterResource(R.drawable.icon_warning), Icons.Default.Warning,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(28.dp) modifier = Modifier.size(28.dp)
) )
@@ -204,7 +307,7 @@ fun UnsupportedVersionWarning(modifier: Modifier = Modifier) {
}, },
body = { body = {
Text( Text(
stringResource(R.string.launcher_game_update_required, gameVersion), stringResource(R.string.launcher_game_update_required),
) )
}, },
modifier = modifier.padding(8.dp) modifier = modifier.padding(8.dp)
@@ -11,7 +11,11 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.displayCutoutPadding import androidx.compose.foundation.layout.displayCutoutPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.LocalContentColor import androidx.compose.material3.LocalContentColor
@@ -26,6 +30,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -36,17 +41,14 @@ import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.updater.ReleaseManager import com.geode.launcher.updater.ReleaseManager
import com.geode.launcher.utils.Constants import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.GamePackageUtils import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.GeodeUtils
import com.geode.launcher.utils.PreferenceUtils import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
enum class LaunchNotificationType { enum class LaunchNotificationType {
GEODE_UPDATED, LAUNCHER_UPDATE_AVAILABLE, UPDATE_FAILED, UNSUPPORTED_VERSION, FAILED_LOAD; GEODE_UPDATED, LAUNCHER_UPDATE_AVAILABLE, UPDATE_FAILED, UNSUPPORTED_VERSION;
} }
fun determineDisplayedCards(context: Context, geodeFailed: Boolean = false): List<LaunchNotificationType> { fun determineDisplayedCards(context: Context): List<LaunchNotificationType> {
val cards = mutableListOf<LaunchNotificationType>() val cards = mutableListOf<LaunchNotificationType>()
if (GamePackageUtils.getGameVersionCode(context.packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) { if (GamePackageUtils.getGameVersionCode(context.packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
@@ -54,8 +56,10 @@ fun determineDisplayedCards(context: Context, geodeFailed: Boolean = false): Lis
} }
val releaseManager = ReleaseManager.get(context) val releaseManager = ReleaseManager.get(context)
val availableUpdate = releaseManager.availableLauncherUpdateTag.value val loadAutomatically = PreferenceUtils.get(context).getBoolean(PreferenceUtils.Key.LOAD_AUTOMATICALLY)
if (availableUpdate != null && !releaseManager.dismissedLauncherUpdate) {
val availableUpdate = releaseManager.availableLauncherUpdate.value
if (loadAutomatically && availableUpdate != null) {
cards.add(LaunchNotificationType.LAUNCHER_UPDATE_AVAILABLE) cards.add(LaunchNotificationType.LAUNCHER_UPDATE_AVAILABLE)
} }
@@ -69,10 +73,6 @@ fun determineDisplayedCards(context: Context, geodeFailed: Boolean = false): Lis
cards.add(LaunchNotificationType.UPDATE_FAILED) cards.add(LaunchNotificationType.UPDATE_FAILED)
} }
if (geodeFailed) {
cards.add(LaunchNotificationType.FAILED_LOAD)
}
return cards return cards
} }
@@ -89,7 +89,7 @@ fun NotificationCardFromType(type: LaunchNotificationType) {
} }
AnimatedNotificationCard( AnimatedNotificationCard(
displayLength = 5.seconds, displayLength = 5000L,
onClick = { onClick = {
showInfoDialog = true showInfoDialog = true
} }
@@ -107,30 +107,27 @@ fun NotificationCardFromType(type: LaunchNotificationType) {
OutdatedVersionContent() OutdatedVersionContent()
} }
} }
LaunchNotificationType.FAILED_LOAD -> {
AnimatedNotificationCard {
ForwardCompatibilityContent()
}
}
else -> {} else -> {}
} }
} }
@Composable @Composable
fun LaunchNotification(cards: List<LaunchNotificationType>) { fun LaunchNotification() {
val context = LocalContext.current
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME) val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
val theme = Theme.fromInt(themeOption) val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND) val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val cards = determineDisplayedCards(context)
CompositionLocalProvider(LocalTheme provides theme) { CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) { GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
// surface is not in use, so this is unfortunately not provided // surface is not in use, so this is unfortunately not provided
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) { CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
Column( Column(
modifier = if (GeodeUtils.handleSafeArea) modifier = Modifier.displayCutoutPadding()
Modifier.displayCutoutPadding()
else Modifier
) { ) {
cards.forEach { cards.forEach {
NotificationCardFromType(it) NotificationCardFromType(it)
@@ -142,15 +139,15 @@ fun LaunchNotification(cards: List<LaunchNotificationType>) {
} }
@Composable @Composable
fun AnimatedNotificationCard(modifier: Modifier = Modifier, visibilityDelay: Duration = Duration.ZERO, displayLength: Duration = 3.seconds, onClick: (() -> Unit)? = null, contents: @Composable () -> Unit) { fun AnimatedNotificationCard(modifier: Modifier = Modifier, visibilityDelay: Long = 0L, displayLength: Long = 3000L, onClick: (() -> Unit)? = null, contents: @Composable () -> Unit) {
val state = remember { val state = remember {
MutableTransitionState(false).apply { MutableTransitionState(false).apply {
targetState = visibilityDelay <= Duration.ZERO targetState = visibilityDelay <= 0
} }
} }
LaunchedEffect(true) { LaunchedEffect(true) {
if (visibilityDelay > Duration.ZERO) { if (visibilityDelay > 0) {
delay(visibilityDelay) delay(visibilityDelay)
state.targetState = true state.targetState = true
} }
@@ -202,7 +199,7 @@ fun LauncherUpdateContent(modifier: Modifier = Modifier) {
}, },
leadingContent = { leadingContent = {
Icon( Icon(
painterResource(R.drawable.icon_info), Icons.Filled.Info,
contentDescription = null, contentDescription = null,
) )
}, },
@@ -210,7 +207,6 @@ fun LauncherUpdateContent(modifier: Modifier = Modifier) {
) )
} }
/*
@Composable @Composable
fun UpdateNotificationContent(modifier: Modifier = Modifier) { fun UpdateNotificationContent(modifier: Modifier = Modifier) {
ListItem( ListItem(
@@ -227,7 +223,6 @@ fun UpdateNotificationContent(modifier: Modifier = Modifier) {
modifier = modifier.width(IntrinsicSize.Max) modifier = modifier.width(IntrinsicSize.Max)
) )
} }
*/
@Composable @Composable
fun OutdatedVersionContent(modifier: Modifier = Modifier) { fun OutdatedVersionContent(modifier: Modifier = Modifier) {
@@ -237,7 +232,7 @@ fun OutdatedVersionContent(modifier: Modifier = Modifier) {
}, },
leadingContent = { leadingContent = {
Icon( Icon(
painterResource(R.drawable.icon_warning), Icons.Filled.Warning,
contentDescription = null, contentDescription = null,
) )
}, },
@@ -256,29 +251,10 @@ fun UpdateFailedContent(modifier: Modifier = Modifier) {
}, },
leadingContent = { leadingContent = {
Icon( Icon(
painterResource(R.drawable.icon_warning), Icons.Filled.Warning,
contentDescription = null, contentDescription = null,
) )
}, },
modifier = modifier.width(IntrinsicSize.Max) modifier = modifier.width(IntrinsicSize.Max)
) )
} }
@Composable
fun ForwardCompatibilityContent(modifier: Modifier = Modifier) {
ListItem(
headlineContent = {
Text(text = stringResource(id = R.string.launcher_notification_forwards_compatibility))
},
leadingContent = {
Icon(
painterResource(R.drawable.icon_warning),
contentDescription = null,
)
},
supportingContent = {
Text(text = stringResource(id = R.string.launcher_notification_forwards_compatibility_description))
},
modifier = modifier.width(IntrinsicSize.Max)
)
}
@@ -7,7 +7,6 @@ import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.geode.launcher.BuildConfig
import com.geode.launcher.updater.ReleaseManager import com.geode.launcher.updater.ReleaseManager
import com.geode.launcher.utils.Constants import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.GamePackageUtils import com.geode.launcher.utils.GamePackageUtils
@@ -19,11 +18,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
data class LaunchArguments( private const val COOLDOWN_LENGTH_MS = 3000L
val autoSafeMode: Boolean,
val forcePause: Boolean,
val forceLaunch: Boolean
)
class LaunchViewModel(private val application: Application): ViewModel() { class LaunchViewModel(private val application: Application): ViewModel() {
companion object { companion object {
@@ -42,13 +37,7 @@ class LaunchViewModel(private val application: Application): ViewModel() {
private var readyTimer: CountDownTimer? = null private var readyTimer: CountDownTimer? = null
private fun initReadyTimer() { private fun initReadyTimer() {
val cooldownLength = PreferenceUtils.get(application).getInt(PreferenceUtils.Key.WAIT_PERIOD) * 1000L readyTimer = object : CountDownTimer(COOLDOWN_LENGTH_MS, 1000) {
if (cooldownLength == 0L) {
readyTimerPassed = true
return
}
readyTimer = object : CountDownTimer(cooldownLength, 1000) {
override fun onTick(millisUntilFinished: Long) { override fun onTick(millisUntilFinished: Long) {
// no tick necessary // no tick necessary
} }
@@ -66,25 +55,17 @@ class LaunchViewModel(private val application: Application): ViewModel() {
} }
enum class LaunchCancelReason { enum class LaunchCancelReason {
MANUAL, AUTOMATIC, LAST_LAUNCH_CRASHED, LAST_LAUNCH_CRASHED_AUTOMATIC, MANUAL, AUTOMATIC, LAST_LAUNCH_CRASHED, GEODE_NOT_FOUND, GAME_MISSING, GAME_OUTDATED;
GEODE_NOT_FOUND, GAME_MISSING, GAME_OUTDATED;
fun allowsRetry() = when (this) { fun allowsRetry() = when (this) {
MANUAL, MANUAL,
AUTOMATIC, AUTOMATIC,
LAST_LAUNCH_CRASHED, LAST_LAUNCH_CRASHED,
LAST_LAUNCH_CRASHED_AUTOMATIC,
GEODE_NOT_FOUND -> true GEODE_NOT_FOUND -> true
GAME_MISSING, GAME_MISSING,
GAME_OUTDATED -> false GAME_OUTDATED -> false
} }
fun isCrash() = when (this) {
LAST_LAUNCH_CRASHED,
LAST_LAUNCH_CRASHED_AUTOMATIC -> true
else -> false
}
fun isGameInstallIssue() = when (this) { fun isGameInstallIssue() = when (this) {
GAME_OUTDATED, GAME_OUTDATED,
GAME_MISSING -> true GAME_MISSING -> true
@@ -99,30 +80,18 @@ class LaunchViewModel(private val application: Application): ViewModel() {
data class Cancelled(val reason: LaunchCancelReason, val inProgress: Boolean = false) : LaunchUIState() data class Cancelled(val reason: LaunchCancelReason, val inProgress: Boolean = false) : LaunchUIState()
data object Working : LaunchUIState() data object Working : LaunchUIState()
data object Ready : LaunchUIState() data object Ready : LaunchUIState()
fun isInProgress() = when (this) {
is Ready,
is Working,
is Updating,
is UpdateCheck -> true
else -> false
}
} }
private val _uiState = MutableStateFlow<LaunchUIState>(LaunchUIState.Initial) private val _uiState = MutableStateFlow<LaunchUIState>(LaunchUIState.Initial)
val uiState = _uiState.asStateFlow() val uiState = _uiState.asStateFlow()
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdateTag val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdate
var loadFailure: LoadFailureInfo? = null var loadFailure: LoadFailureInfo? = null
var launchArguments: LaunchArguments? = null
private var hasCheckedForUpdates = false private var hasCheckedForUpdates = false
private var isCancelling = false private var isCancelling = false
private var hasManuallyStarted = false private var hasManuallyStarted = false
private var restoreUpdateState: LaunchUIState? = null
private suspend fun determineUpdateStatus() { private suspend fun determineUpdateStatus() {
ReleaseManager.get(application).uiState.takeWhile { ReleaseManager.get(application).uiState.takeWhile {
it is ReleaseManager.ReleaseManagerState.InUpdateCheck || it is ReleaseManager.ReleaseManagerState.InDownload it is ReleaseManager.ReleaseManagerState.InUpdateCheck || it is ReleaseManager.ReleaseManagerState.InDownload
@@ -137,19 +106,12 @@ class LaunchViewModel(private val application: Application): ViewModel() {
// flow terminates once update check is finished // flow terminates once update check is finished
hasCheckedForUpdates = true hasCheckedForUpdates = true
val currentState = restoreUpdateState
if (currentState != null) {
_uiState.emit(currentState)
return
}
preReadyCheck() preReadyCheck()
} }
fun currentCrashInfo(): LoadFailureInfo? { fun currentCrashInfo(): LoadFailureInfo? {
val currentState = _uiState.value val currentState = _uiState.value
if (currentState is LaunchUIState.Cancelled && currentState.reason.isCrash()) { if (currentState is LaunchUIState.Cancelled && currentState.reason == LaunchCancelReason.LAST_LAUNCH_CRASHED) {
return loadFailure return loadFailure
} }
@@ -158,12 +120,12 @@ class LaunchViewModel(private val application: Application): ViewModel() {
fun clearCrashInfo() { fun clearCrashInfo() {
val currentState = _uiState.value val currentState = _uiState.value
if (currentState is LaunchUIState.Cancelled && currentState.reason.isCrash()) { if (currentState is LaunchUIState.Cancelled && currentState.reason == LaunchCancelReason.LAST_LAUNCH_CRASHED) {
loadFailure = null loadFailure = null
} }
} }
private fun preReadyCheck() { private suspend fun preReadyCheck() {
if (isCancelling) { if (isCancelling) {
// don't let the game start if cancelled // don't let the game start if cancelled
return return
@@ -172,8 +134,7 @@ class LaunchViewModel(private val application: Application): ViewModel() {
val packageManager = application.packageManager val packageManager = application.packageManager
if (!GamePackageUtils.isGameInstalled(packageManager)) { if (!GamePackageUtils.isGameInstalled(packageManager)) {
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING)) return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
return
} }
if (ReleaseManager.get(application).isInUpdate) { if (ReleaseManager.get(application).isInUpdate) {
@@ -183,51 +144,32 @@ class LaunchViewModel(private val application: Application): ViewModel() {
return return
} }
// play store build only comes with the one build of geode if (loadFailure != null) {
val minVersionCode = if (BuildConfig.GOOGLE_PLAY_BUILD) Constants.SUPPORTED_VERSION_CODE else Constants.SUPPORTED_VERSION_CODE_MIN // last launch crashed, so cancel
if (GamePackageUtils.getGameVersionCode(packageManager) < minVersionCode) { return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.LAST_LAUNCH_CRASHED))
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_OUTDATED)) }
return
if (GamePackageUtils.getGameVersionCode(packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN) {
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_OUTDATED))
} }
if (!LaunchUtils.isGeodeInstalled(application)) { if (!LaunchUtils.isGeodeInstalled(application)) {
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GEODE_NOT_FOUND)) return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GEODE_NOT_FOUND))
return
}
// if forcing immediate launch, then act as if it's manually started (skips timers)
val forceImmediate = launchArguments?.forceLaunch == true
// last launch crashed, so cancel
if (!forceImmediate && loadFailure != null) {
// show a different message if this is the first thing users see
val cancelType = if (_uiState.value == LaunchUIState.Initial) {
LaunchCancelReason.LAST_LAUNCH_CRASHED_AUTOMATIC
} else LaunchCancelReason.LAST_LAUNCH_CRASHED
_uiState.tryEmit(LaunchUIState.Cancelled(cancelType))
return
}
if (forceImmediate) {
hasManuallyStarted = true
} }
val loadAutomatically = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.LOAD_AUTOMATICALLY) val loadAutomatically = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.LOAD_AUTOMATICALLY)
val forcePause = launchArguments?.forcePause == true if (!loadAutomatically && !hasManuallyStarted) {
if (!hasManuallyStarted && (forcePause || !loadAutomatically)) {
return cancelLaunch(true) return cancelLaunch(true)
} }
if (!readyTimerPassed && !hasManuallyStarted) { if (!readyTimerPassed && !hasManuallyStarted) {
_uiState.tryEmit(LaunchUIState.Working) return _uiState.emit(LaunchUIState.Working)
return
} }
_uiState.tryEmit(LaunchUIState.Ready) _uiState.emit(LaunchUIState.Ready)
} }
fun beginLaunchFlow(isRestart: Boolean = false) { suspend fun beginLaunchFlow(isRestart: Boolean = false) {
if (isRestart) { if (isRestart) {
hasManuallyStarted = true hasManuallyStarted = true
} }
@@ -243,50 +185,23 @@ class LaunchViewModel(private val application: Application): ViewModel() {
isCancelling = false isCancelling = false
if (!GamePackageUtils.isGameInstalled(application.packageManager)) { if (!GamePackageUtils.isGameInstalled(application.packageManager)) {
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING)) return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
return
} }
if (readyTimer == null) { if (readyTimer == null) {
initReadyTimer() initReadyTimer()
} }
if (!BuildConfig.GOOGLE_PLAY_BUILD) {
val releaseManager = ReleaseManager.get(application)
val hasGeode = LaunchUtils.isGeodeInstalled(application) val hasGeode = LaunchUtils.isGeodeInstalled(application)
val shouldUpdate = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY) val shouldUpdate = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
if ((shouldUpdate && !hasCheckedForUpdates) || !hasGeode) { if ((shouldUpdate && !hasCheckedForUpdates) || !hasGeode) {
releaseManager.checkForUpdates(false) ReleaseManager.get(application).checkForUpdates(false)
} else {
releaseManager.afterUseCachedUpdate()
}
if (isRestart) {
releaseManager.dismissedLauncherUpdate = true
}
} }
preReadyCheck() preReadyCheck()
} }
fun retryUpdate() { suspend fun cancelLaunch(isAutomatic: Boolean = false) {
if (BuildConfig.GOOGLE_PLAY_BUILD) {
return
}
ReleaseManager.get(application).checkForUpdates(true)
if (ReleaseManager.get(application).isInUpdate) {
restoreUpdateState = _uiState.value
viewModelScope.launch {
determineUpdateStatus()
}
return
}
}
fun cancelLaunch(isAutomatic: Boolean = false) {
// no need to double cancel // no need to double cancel
if (_uiState.value is LaunchUIState.Cancelled) { if (_uiState.value is LaunchUIState.Cancelled) {
return return
@@ -295,17 +210,14 @@ class LaunchViewModel(private val application: Application): ViewModel() {
val reason = if (isAutomatic) LaunchCancelReason.AUTOMATIC else LaunchCancelReason.MANUAL val reason = if (isAutomatic) LaunchCancelReason.AUTOMATIC else LaunchCancelReason.MANUAL
isCancelling = true isCancelling = true
_uiState.emit(LaunchUIState.Cancelled(reason, true))
val releaseManager = ReleaseManager.get(application) val releaseManager = ReleaseManager.get(application)
if (releaseManager.isInUpdate) { if (releaseManager.isInUpdate) {
_uiState.tryEmit(LaunchUIState.Cancelled(reason, true))
viewModelScope.launch {
releaseManager.cancelUpdate() releaseManager.cancelUpdate()
} }
}
_uiState.tryEmit(LaunchUIState.Cancelled(reason, false)) _uiState.emit(LaunchUIState.Cancelled(reason, false))
} }
override fun onCleared() { override fun onCleared() {
@@ -1,24 +1,26 @@
package com.geode.launcher.main package com.geode.launcher.main
import android.app.PendingIntent import android.content.ActivityNotFoundException
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageInstaller import android.net.Uri
import android.provider.DocumentsContract
import android.text.format.Formatter
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
@@ -27,17 +29,16 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -45,32 +46,26 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.geode.launcher.InstallReceiver
import com.geode.launcher.R import com.geode.launcher.R
import com.geode.launcher.main.LauncherUpdater.downloadUpdate import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.ui.theme.Typography import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.updater.ReleaseManager import com.geode.launcher.updater.ReleaseManager
import com.geode.launcher.updater.ReleaseViewModel
import com.geode.launcher.utils.LaunchUtils import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils import com.geode.launcher.utils.PreferenceUtils
import com.mikepenz.markdown.compose.LocalBulletListHandler import com.mikepenz.markdown.compose.LocalBulletListHandler
import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.markdownTypography import com.mikepenz.markdown.m3.markdownTypography
import kotlinx.coroutines.Dispatchers import kotlinx.datetime.toJavaInstant
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
import java.io.File import java.io.File
import java.net.ConnectException
import java.net.UnknownHostException
import java.text.DateFormat import java.text.DateFormat
import java.util.Date import java.util.Date
import kotlin.time.toJavaInstant
fun clearDownloadedApks(context: Context) { fun clearDownloadedApks(context: Context) {
// technically we should be using the activity results but it was too inconsistent for my liking // technically we should be using the activity results but it was too inconsistent for my liking
@@ -89,164 +84,55 @@ fun clearDownloadedApks(context: Context) {
it.delete() it.delete()
} }
context.filesDir.listFiles { _, name -> name.lowercase().endsWith(".apk") }?.forEach {
it.delete()
}
preferenceUtils.setBoolean(PreferenceUtils.Key.CLEANUP_APKS, false) preferenceUtils.setBoolean(PreferenceUtils.Key.CLEANUP_APKS, false)
} }
// lazy singleton idc fun generateInstallIntent(uri: Uri): Intent {
object LauncherUpdater { // maybe one day i'll rewrite this to use packageinstaller. not today
enum class LauncherUpdateState { return Intent(Intent.ACTION_INSTALL_PACKAGE).apply {
Began, setDataAndType(uri, "application/vnd.android.package-archive")
Downloading, addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
Active,
Inactive,
Finished;
}
private val _uiState = MutableStateFlow<LauncherUpdateState>(LauncherUpdateState.Inactive)
val uiState = _uiState.asStateFlow()
suspend fun resetState() {
_uiState.emit(LauncherUpdateState.Inactive)
}
suspend fun downloadUpdate(context: Context): Result<File> {
_uiState.emit(LauncherUpdateState.Downloading)
return withContext(Dispatchers.IO) {
ReleaseManager.get(context)
.downloadLatestLauncherUpdate()
} }
} }
suspend fun installPackage(context: Context, file: File) { fun installLauncherUpdate(context: Context) {
_uiState.emit(LauncherUpdateState.Began) val nextUpdate = ReleaseManager.get(context).availableLauncherUpdate.value
val launcherDownload = nextUpdate?.getDownload()
val installer = context.packageManager.packageInstaller if (launcherDownload != null) {
val outputFile = launcherDownload.filename
val baseDirectory = LaunchUtils.getBaseDirectory(context)
installer.registerSessionCallback(object : PackageInstaller.SessionCallback() { val outputPathFile = File(baseDirectory, outputFile)
override fun onActiveChanged(sessionId: Int, active: Boolean) { if (!outputPathFile.exists()) {
if (active) {
_uiState.tryEmit(LauncherUpdateState.Active)
} else {
_uiState.tryEmit(LauncherUpdateState.Began)
}
}
override fun onBadgingChanged(sessionId: Int) {}
override fun onCreated(sessionId: Int) {
_uiState.tryEmit(LauncherUpdateState.Began)
}
override fun onFinished(sessionId: Int, success: Boolean) {
_uiState.tryEmit(LauncherUpdateState.Finished)
}
override fun onProgressChanged(sessionId: Int, progress: Float) {
_uiState.tryEmit(LauncherUpdateState.Active)
}
})
withContext(Dispatchers.IO) { runInterruptible {
file.inputStream().use { apkStream ->
val length = file.length()
val params = PackageInstaller.SessionParams(
PackageInstaller.SessionParams.MODE_FULL_INSTALL,
)
val sessionId = installer.createSession(params)
installer.openSession(sessionId).use { session ->
session.openWrite("package", 0, length).use { sessionStream ->
apkStream.copyTo(sessionStream)
session.fsync(sessionStream)
}
val intent = Intent(context, InstallReceiver::class.java)
val pi = PendingIntent.getBroadcast(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
)
session.commit(pi.intentSender)
}
}
}}
}
}
suspend fun installLauncherUpdate(context: Context) {
val launcherDownload = downloadUpdate(context)
.getOrElse { e ->
e.printStackTrace()
Toast.makeText(
context, context.getString(
R.string.launcher_update_download_failed,
e.cause
), Toast.LENGTH_SHORT
).show()
return return
} }
if (!launcherDownload.exists()) { val outputPath = "${UserDirectoryProvider.ROOT}${outputFile}"
return
} val uri = DocumentsContract.buildDocumentUri("${context.packageName}.user", outputPath)
PreferenceUtils.get(context).setBoolean(PreferenceUtils.Key.CLEANUP_APKS, true) PreferenceUtils.get(context).setBoolean(PreferenceUtils.Key.CLEANUP_APKS, true)
try { try {
LauncherUpdater.installPackage(context, launcherDownload) val intent = generateInstallIntent(uri)
} catch (e: Exception) { context.startActivity(intent)
e.printStackTrace() } catch (_: ActivityNotFoundException) {
val msg = context.getString(R.string.launcher_self_update_failed, e.message) // if it fails to install, just open it in the browser
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show() try {
val downloadUrl = launcherDownload.url
LauncherUpdater.resetState() val downloadIntent = Intent(Intent.ACTION_VIEW).apply {
} data = Uri.parse(downloadUrl)
} }
@Composable context.startActivity(downloadIntent)
fun LauncherUpdateDialog(isCancelling: Boolean, onDismiss: () -> Unit) { } catch (_: ActivityNotFoundException) {
val updateState by LauncherUpdater.uiState.collectAsState() Toast.makeText(context, context.getString(R.string.no_activity_found), Toast.LENGTH_SHORT).show()
LaunchedEffect(updateState) {
if (updateState == LauncherUpdater.LauncherUpdateState.Finished) {
onDismiss()
} }
} }
AlertDialog(
icon = {
Icon(
painterResource(R.drawable.icon_update),
contentDescription = null
)
},
title = {
if (isCancelling) {
Text(stringResource(R.string.release_fetch_cancelling))
} else if (updateState == LauncherUpdater.LauncherUpdateState.Downloading) {
Text(stringResource(R.string.launcher_downloading_update))
} else { } else {
Text(stringResource(R.string.launcher_self_update_progress)) Toast.makeText(context, context.getString(R.string.release_fetch_no_releases), Toast.LENGTH_SHORT).show()
} }
},
text = {
LinearProgressIndicator()
},
confirmButton = {
TextButton(onClick = onDismiss, enabled = !isCancelling) {
Text(stringResource(R.string.message_box_cancel))
}
},
onDismissRequest = {}
)
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@@ -254,51 +140,18 @@ fun LauncherUpdateDialog(isCancelling: Boolean, onDismiss: () -> Unit) {
fun LauncherUpdateInformation(onDismiss: () -> Unit) { fun LauncherUpdateInformation(onDismiss: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val releaseManager = remember { ReleaseManager.get(context) } val nextRelease = remember { ReleaseManager.get(context).availableLauncherUpdate.value }
val nextReleaseName by releaseManager.availableLauncherUpdateTag.collectAsState()
val nextRelease by releaseManager.availableLauncherUpdateDetails.collectAsState()
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
val coroutineScope = rememberCoroutineScope()
var updateJob by remember { mutableStateOf<Job?>(null) } if (nextRelease != null) {
var cancellingUpdate by remember { mutableStateOf(false) }
var beginUpdate by remember { mutableStateOf(false) }
if (beginUpdate) {
LauncherUpdateDialog(cancellingUpdate, onDismiss = {
cancellingUpdate = true
coroutineScope.launch {
updateJob?.cancelAndJoin()
beginUpdate = false
}
})
}
LaunchedEffect(Unit) {
if (nextReleaseName != null && nextRelease == null) {
coroutineScope.launch { withContext(Dispatchers.IO) {
ReleaseManager.get(context)
.checkLauncherUpdate()
}}
}
}
val nextReleaseNameSaved = nextReleaseName
if (nextReleaseNameSaved != null) {
ModalBottomSheet(onDismissRequest = { onDismiss() }, sheetState = sheetState) { ModalBottomSheet(onDismissRequest = { onDismiss() }, sheetState = sheetState) {
Box(Modifier.verticalScroll(rememberScrollState())) { Box(Modifier.verticalScroll(rememberScrollState())) {
Column(modifier = Modifier.padding(horizontal = 24.dp)) { Column(modifier = Modifier.padding(horizontal = 24.dp)) {
Text( Text(
stringResource(R.string.launcher_update_name, nextReleaseNameSaved), stringResource(R.string.launcher_update_name, nextRelease.release.tagName),
style = Typography.headlineSmall, style = Typography.headlineSmall,
) )
val nextRelease = nextRelease
if (nextRelease != null) {
val releasedTime = remember { val releasedTime = remember {
DateFormat.getDateInstance().format( DateFormat.getDateInstance().format(
Date.from(nextRelease.release.publishedAt.toJavaInstant()) Date.from(nextRelease.release.publishedAt.toJavaInstant())
@@ -312,14 +165,7 @@ fun LauncherUpdateInformation(onDismiss: () -> Unit) {
Row(modifier = Modifier.padding(vertical = 12.dp)) { Row(modifier = Modifier.padding(vertical = 12.dp)) {
Button( Button(
onClick = { onClick = { installLauncherUpdate(context) },
updateJob = coroutineScope.launch {
cancellingUpdate = false
beginUpdate = true
installLauncherUpdate(context)
}
},
) { ) {
Icon(painterResource(R.drawable.icon_download), contentDescription = null) Icon(painterResource(R.drawable.icon_download), contentDescription = null)
Spacer(Modifier.size(ButtonDefaults.IconSpacing)) Spacer(Modifier.size(ButtonDefaults.IconSpacing))
@@ -350,27 +196,15 @@ fun LauncherUpdateInformation(onDismiss: () -> Unit) {
Markdown( Markdown(
content = nextRelease.release.body.replace("\r", ""), content = nextRelease.release.body.replace("\r", ""),
typography = markdownTypography( typography = markdownTypography(
textLink = TextLinkStyles( link = MaterialTheme.typography.bodyLarge.copy(
style = MaterialTheme.typography.bodyLarge.copy(
textDecoration = TextDecoration.Underline, textDecoration = TextDecoration.Underline,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
).toSpanStyle()
), ),
), ),
flavour = GFMFlavourDescriptor(), flavour = GFMFlavourDescriptor(),
) )
} }
} }
} else {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp)
) {
CircularProgressIndicator()
}
}
Spacer(modifier = Modifier.size(4.dp)) Spacer(modifier = Modifier.size(4.dp))
} }
@@ -382,7 +216,6 @@ fun LauncherUpdateInformation(onDismiss: () -> Unit) {
@Composable @Composable
fun LauncherUpdateIndicator(modifier: Modifier = Modifier) { fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
val context = LocalContext.current val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
var showInfoDialog by remember { mutableStateOf(false) } var showInfoDialog by remember { mutableStateOf(false) }
@@ -392,24 +225,7 @@ fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
} }
} }
var updateJob by remember { mutableStateOf<Job?>(null) } ElevatedCard(modifier) {
var cancellingUpdate by remember { mutableStateOf(false) }
var beginUpdate by remember { mutableStateOf(false) }
if (beginUpdate) {
LauncherUpdateDialog(cancellingUpdate) {
cancellingUpdate = true
coroutineScope.launch {
updateJob?.cancelAndJoin()
updateJob = null
beginUpdate = false
}
}
}
// there's like a single pixel of the shadow that gets clipped without this...
ElevatedCard(modifier = modifier.padding(bottom = 1.dp)) {
Column( Column(
// buttons add enough padding already, lower to compensate // buttons add enough padding already, lower to compensate
modifier = Modifier.padding( modifier = Modifier.padding(
@@ -433,14 +249,7 @@ fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
} }
TextButton( TextButton(
onClick = { onClick = { installLauncherUpdate(context) },
updateJob = coroutineScope.launch {
cancellingUpdate = false
beginUpdate = true
installLauncherUpdate(context)
}
},
) { ) {
Text(stringResource(R.string.launcher_install)) Text(stringResource(R.string.launcher_install))
} }
@@ -448,3 +257,174 @@ fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
} }
} }
} }
@Composable
fun UpdateProgressIndicator(
message: String,
releaseViewModel: ReleaseViewModel,
modifier: Modifier = Modifier,
progress: (() -> Float)? = null
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start,
modifier = modifier
) {
Text(message, style = MaterialTheme.typography.labelMedium)
Spacer(modifier = Modifier.padding(4.dp))
if (progress == null) {
LinearProgressIndicator()
} else {
LinearProgressIndicator(progress = progress)
}
TextButton(
onClick = {
releaseViewModel.cancelUpdate()
},
modifier = Modifier.offset((-12).dp)
) {
Text(stringResource(R.string.release_fetch_button_cancel))
}
}
}
@Composable
fun UpdateMessageIndicator(
message: String,
releaseViewModel: ReleaseViewModel,
modifier: Modifier = Modifier,
allowRetry: Boolean = false
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
Text(
message,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.padding(4.dp))
if (allowRetry) {
OutlinedButton(
onClick = {
releaseViewModel.runReleaseCheck(true)
},
) {
Icon(
Icons.Filled.Refresh,
contentDescription = stringResource(R.string.launcher_retry_icon_alt)
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(stringResource(R.string.release_fetch_button_retry))
}
}
}
}
@Composable
fun UpdateCard(releaseViewModel: ReleaseViewModel, modifier: Modifier = Modifier) {
val context = LocalContext.current
val releaseState by releaseViewModel.uiState.collectAsState()
when (val state = releaseState) {
is ReleaseViewModel.ReleaseUIState.Failure -> {
val message = when (state.exception) {
is UnknownHostException, is ConnectException ->
stringResource(R.string.release_fetch_no_internet)
is ReleaseManager.UpdateException -> {
when (state.exception.reason) {
ReleaseManager.UpdateException.Reason.EXTERNAL_FILE_IN_USE -> stringResource(
R.string.release_fetch_manual_check_required
)
else -> null
}
}
else -> null
}
UpdateMessageIndicator(
stringResource(
R.string.release_fetch_failed,
message ?: stringResource(R.string.release_fetch_try_later)
),
modifier = modifier,
allowRetry = true,
releaseViewModel = releaseViewModel
)
}
is ReleaseViewModel.ReleaseUIState.InDownload -> {
val downloaded = remember(state.downloaded) {
Formatter.formatShortFileSize(context, state.downloaded)
}
val outOf = remember(state.outOf) {
state.outOf?.run {
Formatter.formatShortFileSize(context, this)
}
}
UpdateProgressIndicator(
if (outOf != null) stringResource(
R.string.release_fetch_downloading,
downloaded,
outOf
) else stringResource(
R.string.release_fetch_downloading_indeterminate,
downloaded,
),
modifier = modifier,
releaseViewModel = releaseViewModel,
progress = {
val progress = state.downloaded / state.outOf!!.toDouble()
progress.toFloat()
}.takeIf { state.outOf != null }
)
}
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
UpdateProgressIndicator(
stringResource(R.string.release_fetch_in_progress),
modifier = modifier,
releaseViewModel = releaseViewModel
)
}
is ReleaseViewModel.ReleaseUIState.Finished -> {
if (state.hasUpdated) {
UpdateMessageIndicator(
stringResource(R.string.release_fetch_success),
modifier = modifier,
releaseViewModel = releaseViewModel
)
}
}
is ReleaseViewModel.ReleaseUIState.Cancelled -> {
if (state.isCancelling) {
UpdateProgressIndicator(
stringResource(R.string.release_fetch_cancelling),
modifier = modifier,
releaseViewModel = releaseViewModel
)
} else {
UpdateMessageIndicator(
stringResource(R.string.release_fetch_cancelled),
modifier = modifier,
allowRetry = true,
releaseViewModel = releaseViewModel
)
}
}
}
val nextUpdate by releaseViewModel.nextLauncherUpdate.collectAsState()
if (!releaseViewModel.isInUpdate && nextUpdate != null) {
LauncherUpdateIndicator(
modifier = modifier
)
}
}
@@ -1,248 +0,0 @@
package com.geode.launcher.preferences
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.geode.launcher.R
import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.utils.PreferenceUtils
import androidx.compose.ui.semantics.Role
import com.geode.launcher.utils.ApplicationIcon
import com.geode.launcher.utils.ApplicationIconDetails
import com.geode.launcher.utils.IconUtils
import com.geode.launcher.utils.adaptiveIconPainterResource
class ApplicationIconActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
ApplicationIconScreen(onBackPressedDispatcher)
}
}
}
}
}
}
@Composable
fun IconSelectConfirmDialog(selectedIcon: ApplicationIconDetails, onDismissRequest: () -> Unit) {
AlertDialog(
icon = {
Image(
adaptiveIconPainterResource(selectedIcon.iconId),
contentDescription = null
)
},
title = {
Text(stringResource(
R.string.application_icon_confirm,
stringResource(selectedIcon.nameId)
))
},
text = {
Text(stringResource(R.string.application_icon_confirm_description))
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
confirmButton = {
val context = LocalContext.current
TextButton(onClick = { onSelectIcon(context, selectedIcon) }) {
Text(stringResource(R.string.message_box_accept))
}
},
onDismissRequest = onDismissRequest
)
}
fun onSelectIcon(context: Context, selectedIcon: ApplicationIconDetails) {
val packageManager = context.packageManager
try {
ApplicationIcon.entries.forEach {
val iconData = IconUtils.getIconDetails(it)
packageManager.setComponentEnabledSetting(
ComponentName(context, "${context.packageName.removeSuffix(".play")}.${iconData.component}"),
if (iconData.id == selectedIcon.id)
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
else PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP
)
}
} catch (_: IllegalArgumentException) {
Toast.makeText(context, R.string.application_icon_set_failed, Toast.LENGTH_SHORT).show()
}
PreferenceUtils.get(context).setString(PreferenceUtils.Key.SELECTED_ICON, selectedIcon.id)
}
@Composable
fun IconCard(iconData: ApplicationIconDetails, selected: Boolean, modifier: Modifier = Modifier) {
var showSelection by remember { mutableStateOf(false) }
if (showSelection) {
IconSelectConfirmDialog(iconData) {
showSelection = false
}
}
Row(
Modifier
.fillMaxWidth()
.padding(vertical = 1.dp)
.clip(RoundedCornerShape(4.dp))
.then(modifier)
.selectable(
selected = selected,
onClick = {
if (!selected)
showSelection = true
},
role = Role.RadioButton
)
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(horizontal = 16.dp, vertical = 8.dp),
Arrangement.SpaceBetween,
Alignment.CenterVertically,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
adaptiveIconPainterResource(iconData.iconId),
contentDescription = null
)
Text(stringResource(iconData.nameId))
}
RadioButton(
selected = selected,
onClick = null
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ApplicationIconScreen(
onBackPressedDispatcher: OnBackPressedDispatcher?,
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
Column {
TopAppBar(
navigationIcon = {
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
Icon(
painterResource(R.drawable.icon_arrow_back),
contentDescription = stringResource(R.string.back_icon_alt)
)
}
},
title = {
Text(
stringResource(R.string.title_activity_application_icon),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
scrollBehavior = scrollBehavior,
)
}
}
) { innerPadding ->
Column(
Modifier
.padding(innerPadding)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.SpaceEvenly,
) {
val selectedIcon by PreferenceUtils.useStringPreference(PreferenceUtils.Key.SELECTED_ICON)
Column(Modifier
.padding(horizontal = 12.dp)
.clip(RoundedCornerShape(16.dp))
) {
ApplicationIcon.entries.forEach {
val iconData = IconUtils.getIconDetails(it)
IconCard(iconData, selectedIcon == iconData.id)
}
}
Spacer(Modifier.height(4.dp))
}
}
}
@@ -1,5 +1,6 @@
package com.geode.launcher.preferences package com.geode.launcher.preferences
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@@ -8,6 +9,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -16,7 +18,11 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -41,11 +47,16 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@@ -55,13 +66,10 @@ import com.geode.launcher.R
import com.geode.launcher.log.LogLine import com.geode.launcher.log.LogLine
import com.geode.launcher.log.LogPriority import com.geode.launcher.log.LogPriority
import com.geode.launcher.log.LogViewModel import com.geode.launcher.log.LogViewModel
import com.geode.launcher.preferences.components.SelectDialog
import com.geode.launcher.preferences.components.SelectOption
import com.geode.launcher.ui.theme.GeodeLauncherTheme import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.ui.theme.Typography import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.ui.theme.robotoMonoFamily
import com.geode.launcher.utils.PreferenceUtils import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -75,10 +83,9 @@ class ApplicationLogsActivity : ComponentActivity() {
val theme = Theme.fromInt(themeOption) val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND) val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
CompositionLocalProvider(LocalTheme provides theme) { CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) { GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
@@ -140,26 +147,35 @@ fun SelectLogLevelDialog(
@Composable @Composable
fun LogCard(logLine: LogLine, modifier: Modifier = Modifier) { fun LogCard(logLine: LogLine, modifier: Modifier = Modifier) {
SelectionContainer { val haptics = LocalHapticFeedback.current
val clipboardManager = LocalClipboardManager.current
Column( Column(
modifier = modifier modifier = modifier
.padding(8.dp) .padding(8.dp)
.pointerInput(Unit) {
detectTapGestures(
onLongPress = {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
clipboardManager.setText(AnnotatedString(logLine.asSimpleString))
}
)
},
) { ) {
Text( Text(
"[${logLine.priority.toChar()}] ${logLine.formattedTime}", "[${logLine.priority.toChar()}] ${logLine.formattedTime}",
style = Typography.labelSmall, style = Typography.labelSmall,
color = getLogColor(logLine.priority), color = getLogColor(logLine.priority),
fontFamily = robotoMonoFamily fontFamily = FontFamily.Monospace
) )
Text( Text(
"[${logLine.tag}]: ${logLine.messageTrimmed}", "[${logLine.tag}]: ${logLine.messageTrimmed}",
fontFamily = robotoMonoFamily, fontFamily = FontFamily.Monospace,
style = Typography.bodyMedium style = Typography.bodyMedium
) )
} }
} }
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -171,7 +187,6 @@ fun ApplicationLogsScreen(
val isLoading by logViewModel.isLoading.collectAsState() val isLoading by logViewModel.isLoading.collectAsState()
val context = LocalContext.current val context = LocalContext.current
val resources = LocalResources.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
@@ -219,7 +234,7 @@ fun ApplicationLogsScreen(
navigationIcon = { navigationIcon = {
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) { IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
Icon( Icon(
painterResource(R.drawable.icon_arrow_back), imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back_icon_alt) contentDescription = stringResource(R.string.back_icon_alt)
) )
} }
@@ -227,17 +242,26 @@ fun ApplicationLogsScreen(
actions = { actions = {
IconButton(onClick = { IconButton(onClick = {
coroutineScope.launch { coroutineScope.launch {
saveLauncher.launch(resources.getString(R.string.application_logs_default_filename)) val data = logViewModel.getLogData()
val sendIntent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_TEXT, data)
type = "text/plain"
}
val shareIntent = Intent.createChooser(sendIntent, null)
context.startActivity(shareIntent)
} }
}, enabled = !logLines.isEmpty()) { }, enabled = !logLines.isEmpty()) {
Icon( Icon(
painterResource(R.drawable.icon_save), Icons.Filled.Share,
contentDescription = stringResource(R.string.application_logs_export), contentDescription = stringResource(R.string.application_logs_share)
) )
} }
IconButton(onClick = { showMoreOptions = !showMoreOptions }) { IconButton(onClick = { showMoreOptions = !showMoreOptions }) {
Icon( Icon(
painterResource(R.drawable.icon_more_vert), Icons.Filled.MoreVert,
contentDescription = stringResource(R.string.application_logs_more) contentDescription = stringResource(R.string.application_logs_more)
) )
} }
@@ -256,7 +280,7 @@ fun ApplicationLogsScreen(
trailingIcon = { trailingIcon = {
if (logViewModel.filterCrashes) { if (logViewModel.filterCrashes) {
Icon( Icon(
painterResource(R.drawable.icon_check), Icons.Filled.Check,
contentDescription = stringResource(R.string.application_logs_crash_only_enabled_alt) contentDescription = stringResource(R.string.application_logs_crash_only_enabled_alt)
) )
} }
@@ -285,6 +309,23 @@ fun ApplicationLogsScreen(
} }
) )
DropdownMenuItem(
leadingIcon = {
Icon(
painterResource(R.drawable.icon_save),
contentDescription = null,
)
},
text = {
Text(stringResource(R.string.application_logs_export))
},
onClick = {
saveLauncher.launch(context.getString(R.string.application_logs_default_filename))
showMoreOptions = false
},
enabled = !logLines.isEmpty()
)
DropdownMenuItem( DropdownMenuItem(
leadingIcon = { leadingIcon = {
Icon( Icon(
@@ -1,237 +0,0 @@
package com.geode.launcher.preferences
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.provider.DocumentsContract
import android.widget.Toast
import java.text.DateFormat
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.geode.launcher.R
import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.log.CrashDump
import com.geode.launcher.log.CrashViewModel
import com.geode.launcher.preferences.components.DirectoryListingScreen
import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.ui.theme.robotoMonoFamily
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
import java.io.File
import java.util.Date
import kotlin.time.toJavaInstant
class CrashLogsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
CrashLogsScreen(onBackPressedDispatcher)
}
}
}
}
}
}
fun shareCrash(context: Context, filename: String) {
val baseDirectory = LaunchUtils.getBaseDirectory(context, true)
val crashPath = File(LaunchUtils.getCrashDirectory(context), filename)
if (!crashPath.exists()) {
Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show()
return
}
val providerLocation = crashPath.toRelativeString(baseDirectory)
val documentsPath = "${UserDirectoryProvider.ROOT}${providerLocation}"
val uri = DocumentsContract.buildDocumentUri("${context.packageName}.user", documentsPath)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, uri)
type = "application/octet-stream"
}
try {
context.startActivity(shareIntent)
} catch (_: ActivityNotFoundException) {
Toast.makeText(context, R.string.no_activity_found, Toast.LENGTH_SHORT).show()
}
}
@Composable
fun CrashCard(crashDump: CrashDump, crashViewModel: CrashViewModel, modifier: Modifier = Modifier) {
val isReadable = crashDump.filename.endsWith(".log")
val context = LocalContext.current
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier
.fillMaxWidth()
.padding(vertical = 1.dp, horizontal = 8.dp)
.height(64.dp)
.then(modifier)
.clickable(isReadable) {
val launchIntent = Intent(context, TextViewActivity::class.java).run {
putExtra(TextViewActivity.EXTRA_LOG_VIEW_FILENAME, crashDump.fullPath)
}
context.startActivity(launchIntent)
}
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.padding(vertical = 8.dp, horizontal = 8.dp)
) {
Column(
verticalArrangement = Arrangement.SpaceAround,
modifier = Modifier.fillMaxHeight().offset(x = 12.dp).weight(0.7f)
) {
Text(
crashDump.filename,
fontFamily = robotoMonoFamily,
style = MaterialTheme.typography.titleSmall,
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
val formattedLastModified = remember {
DateFormat.getDateTimeInstance()
.format(
Date.from(crashDump.lastModified.toJavaInstant())
)
}
Text(formattedLastModified, style = MaterialTheme.typography.labelMedium)
}
Row(modifier = Modifier.weight(0.3f), horizontalArrangement = Arrangement.End) {
val context = LocalContext.current
IconButton(onClick = {
shareCrash(context, crashDump.filename)
}) {
Icon(
painterResource(R.drawable.icon_share),
contentDescription = stringResource(R.string.application_logs_share)
)
}
IconButton(onClick = {
crashViewModel.removeFile(crashDump.filename)
}) {
Icon(
painterResource(R.drawable.icon_delete),
contentDescription = stringResource(R.string.application_crashes_delete_one)
)
}
}
}
}
@Composable
fun CrashLogsScreen(
onBackPressedDispatcher: OnBackPressedDispatcher?,
crashViewModel: CrashViewModel = viewModel(factory = CrashViewModel.Factory)
) {
DirectoryListingScreen(
onBackPressedDispatcher,
crashViewModel,
titleId = R.string.title_activity_crash_logs,
noneAvailableId = R.string.application_crashes_no_dumps,
additionalOptions = { onDismiss ->
val developerMode by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
if (developerMode) {
if (crashViewModel.hasIndicator) {
DropdownMenuItem(
leadingIcon = {
Icon(
painterResource(R.drawable.icon_remove_selection),
contentDescription = null
)
},
text = {
Text(stringResource(R.string.application_crashes_clear_indicator))
}, onClick = {
crashViewModel.clearIndicator()
onDismiss()
}
)
} else {
DropdownMenuItem(
leadingIcon = {
Icon(
painterResource(R.drawable.icon_skull),
contentDescription = null
)
},
text = {
Text(stringResource(R.string.application_crashes_create_indicator))
}, onClick = {
crashViewModel.createIndicator()
onDismiss()
}
)
}
}
}
) { line, shape ->
CrashCard(
line,
crashViewModel,
modifier = Modifier.clip(shape)
)
}
}
@Preview(showSystemUi = true)
@Composable
fun CrashLogsPreview() {
GeodeLauncherTheme {
CrashLogsScreen(null)
}
}
@@ -1,26 +1,24 @@
package com.geode.launcher.preferences package com.geode.launcher.preferences
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedDispatcher import androidx.activity.OnBackPressedDispatcher
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -34,25 +32,14 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.geode.launcher.BuildConfig
import com.geode.launcher.R import com.geode.launcher.R
import com.geode.launcher.preferences.components.OptionsButton
import com.geode.launcher.preferences.components.OptionsGroup
import com.geode.launcher.preferences.components.ProfileCreateCard
import com.geode.launcher.preferences.components.ProfileSelectCard
import com.geode.launcher.preferences.components.SettingsCard
import com.geode.launcher.preferences.components.SettingsSelectCard
import com.geode.launcher.preferences.components.SettingsStringCard
import com.geode.launcher.ui.theme.GeodeLauncherTheme import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.utils.LaunchUtils import com.geode.launcher.utils.LabelledText
import com.geode.launcher.utils.PreferenceUtils import com.geode.launcher.utils.PreferenceUtils
class DeveloperSettingsActivity : ComponentActivity() { class DeveloperSettingsActivity : ComponentActivity() {
@@ -65,10 +52,9 @@ class DeveloperSettingsActivity : ComponentActivity() {
val theme = Theme.fromInt(themeOption) val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND) val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
CompositionLocalProvider(LocalTheme provides theme) { CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) { GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
@@ -81,30 +67,6 @@ class DeveloperSettingsActivity : ComponentActivity() {
} }
} }
fun onOpenFolder(context: Context) {
val file = LaunchUtils.getBaseDirectory(context)
val clipboardManager =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboardManager.setPrimaryClip(
ClipData.newPlainText(
context.getString(R.string.export_folder_tag),
file.path
)
)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
Toast.makeText(
context,
context.getString(R.string.text_copied),
Toast.LENGTH_SHORT
).show()
}
}
fun onOpenLogs(context: Context) {
val launchIntent = Intent(context, ApplicationLogsActivity::class.java)
context.startActivity(launchIntent)
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) { fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
@@ -118,7 +80,7 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
navigationIcon = { navigationIcon = {
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) { IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
Icon( Icon(
painterResource(R.drawable.icon_arrow_back), imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back_icon_alt) contentDescription = stringResource(R.string.back_icon_alt)
) )
} }
@@ -131,7 +93,6 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
) )
}, },
scrollBehavior = scrollBehavior, scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.background),
) )
} }
} }
@@ -146,12 +107,16 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
val developerMode by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE) val developerMode by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
OptionsGroup {
SettingsCard( SettingsCard(
title = stringResource(R.string.preference_developer_mode), title = stringResource(R.string.preference_developer_mode),
preferenceKey = PreferenceUtils.Key.DEVELOPER_MODE, preferenceKey = PreferenceUtils.Key.DEVELOPER_MODE,
) )
}
InlineText(
stringResource(R.string.preference_developer_mode_about)
)
HorizontalDivider()
if (developerMode) { if (developerMode) {
OptionsGroup(title = stringResource(R.string.preference_category_gameplay)) { OptionsGroup(title = stringResource(R.string.preference_category_gameplay)) {
@@ -159,24 +124,19 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
title = stringResource(R.string.preference_launch_arguments_name), title = stringResource(R.string.preference_launch_arguments_name),
dialogTitle = stringResource(R.string.preference_launch_arguments_set_title), dialogTitle = stringResource(R.string.preference_launch_arguments_set_title),
preferenceKey = PreferenceUtils.Key.LAUNCH_ARGUMENTS, preferenceKey = PreferenceUtils.Key.LAUNCH_ARGUMENTS,
filterInput = { filterInput = { it.filter { c ->
it.filter { c ->
// if only there was a better way to define this! // if only there was a better way to define this!
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*(){}<>[]?:;'\"~`-_+=\\| ".contains( "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*(){}<>[]?:;'\"~`-_+=\\| ".contains(c)
c }}
)
}
}
) )
/*
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
SettingsCard( SettingsCard(
title = stringResource(R.string.preference_force_hrr), title = stringResource(R.string.preference_force_hrr),
preferenceKey = PreferenceUtils.Key.FORCE_HRR, preferenceKey = PreferenceUtils.Key.FORCE_HRR,
) )
} }
/*
SettingsCard( SettingsCard(
title = stringResource(R.string.preference_override_exceptions_name), title = stringResource(R.string.preference_override_exceptions_name),
description = stringResource(R.string.preference_override_exceptions_description), description = stringResource(R.string.preference_override_exceptions_description),
@@ -185,29 +145,11 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
*/ */
} }
OptionsGroup(stringResource(R.string.preference_category_developer)) {
val context = LocalContext.current
OptionsButton(
title = stringResource(R.string.preferences_view_logs),
icon = {
Icon(
painterResource(R.drawable.icon_mobile_code),
contentDescription = null
)
},
onClick = { onOpenLogs(context) }
)
OptionsButton(
title = stringResource(R.string.preferences_copy_external_button),
description = LaunchUtils.getBaseDirectory(context).path,
onClick = { onOpenFolder(context) }
)
}
if (!BuildConfig.GOOGLE_PLAY_BUILD) {
OptionsGroup(title = stringResource(R.string.preference_category_updater)) { OptionsGroup(title = stringResource(R.string.preference_category_updater)) {
val context = LocalContext.current SettingsCard(
title = stringResource(R.string.preference_use_index_update_api),
preferenceKey = PreferenceUtils.Key.USE_INDEX_API
)
SettingsSelectCard( SettingsSelectCard(
title = stringResource(R.string.preference_release_channel_tag_name), title = stringResource(R.string.preference_release_channel_tag_name),
@@ -217,51 +159,25 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
0 to stringResource(R.string.preference_release_channel_stable), 0 to stringResource(R.string.preference_release_channel_stable),
1 to stringResource(R.string.preference_release_channel_beta), 1 to stringResource(R.string.preference_release_channel_beta),
2 to stringResource(R.string.preference_release_channel_nightly), 2 to stringResource(R.string.preference_release_channel_nightly),
), )
extraSelectBehavior = {
PreferenceUtils.get(context)
.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, 0L)
}
) )
SettingsCard( ElevatedCard(modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)) {
title = stringResource(R.string.preference_disable_update_cache), LabelledText(
preferenceKey = PreferenceUtils.Key.DISABLE_UPDATE_CACHE label = stringResource(R.string.preference_developer_update_notice),
icon = {
Icon(Icons.Filled.Info, null)
},
modifier = Modifier.padding(8.dp)
) )
} }
} }
OptionsGroup(title = stringResource(R.string.preference_category_testing)) { OptionsGroup(title = stringResource(R.string.preference_category_testing)) {
SettingsSelectCard(
title = stringResource(R.string.preference_launch_delay),
dialogTitle = stringResource(R.string.preference_launch_delay_select),
preferenceKey = PreferenceUtils.Key.WAIT_PERIOD,
options = linkedMapOf(
0 to stringResource(R.string.preference_launch_delay_instant),
1 to pluralStringResource(
R.plurals.preference_launch_delay_value,
1,
1
),
3 to pluralStringResource(
R.plurals.preference_launch_delay_value,
3,
3
),
5 to pluralStringResource(
R.plurals.preference_launch_delay_value,
5,
5
),
)
)
/*
SettingsCard( SettingsCard(
title = stringResource(R.string.preference_disable_redesign), title = stringResource(R.string.preference_enable_redesign),
preferenceKey = PreferenceUtils.Key.DISABLE_REDESIGN, preferenceKey = PreferenceUtils.Key.ENABLE_REDESIGN,
) )
*/
} }
OptionsGroup(title = stringResource(R.string.preference_profiles)) { OptionsGroup(title = stringResource(R.string.preference_profiles)) {
@@ -269,8 +185,6 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
ProfileSelectCard() ProfileSelectCard()
} }
} }
Spacer(Modifier.height(4.dp))
} }
} }
} }
@@ -1,187 +0,0 @@
package com.geode.launcher.preferences
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.provider.DocumentsContract
import android.text.format.Formatter
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.geode.launcher.R
import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.log.GeodeLog
import com.geode.launcher.log.GeodeLogsViewModel
import com.geode.launcher.preferences.components.DirectoryListingScreen
import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.ui.theme.robotoMonoFamily
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
import java.io.File
class GeodeLogsListingActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
GeodeLogsListingScreen(onBackPressedDispatcher)
}
}
}
}
}
}
fun shareLog(context: Context, filename: String) {
val baseDirectory = LaunchUtils.getBaseDirectory(context, true)
val crashPath = File(LaunchUtils.getGeodeLogsDirectory(context), filename)
if (!crashPath.exists()) {
Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show()
return
}
val providerLocation = crashPath.toRelativeString(baseDirectory)
val documentsPath = "${UserDirectoryProvider.ROOT}${providerLocation}"
val uri = DocumentsContract.buildDocumentUri("${context.packageName}.user", documentsPath)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, uri)
type = "text/plain"
}
try {
context.startActivity(shareIntent)
} catch (_: ActivityNotFoundException) {
Toast.makeText(context, R.string.no_activity_found, Toast.LENGTH_SHORT).show()
}
}
@Composable
fun GeodeLogCard(geodeLog: GeodeLog, geodeLogsViewModel: GeodeLogsViewModel, modifier: Modifier = Modifier) {
val context = LocalContext.current
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 1.dp, horizontal = 8.dp)
.height(64.dp)
.then(modifier)
.clickable {
val launchIntent = Intent(context, TextViewActivity::class.java).run {
putExtra(TextViewActivity.EXTRA_LOG_VIEW_FILENAME, geodeLog.fullPath)
}
context.startActivity(launchIntent)
}
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.padding(vertical = 8.dp, horizontal = 8.dp)
) {
Column(
verticalArrangement = Arrangement.SpaceAround,
modifier = Modifier.fillMaxHeight().offset(x = 12.dp).weight(0.7f)
) {
Text(
geodeLog.filename,
fontFamily = robotoMonoFamily,
style = MaterialTheme.typography.titleSmall,
overflow = TextOverflow.Ellipsis,
maxLines = 1
)
val context = LocalContext.current
val formattedSize = remember(geodeLog.fileSize) {
Formatter.formatShortFileSize(context, geodeLog.fileSize)
}
Text(formattedSize, style = MaterialTheme.typography.labelMedium)
}
Row(modifier = Modifier.weight(0.3f), horizontalArrangement = Arrangement.End) {
val context = LocalContext.current
IconButton(onClick = {
shareLog(context, geodeLog.filename)
}) {
Icon(
painterResource(R.drawable.icon_share),
contentDescription = stringResource(R.string.application_logs_share)
)
}
IconButton(onClick = {
geodeLogsViewModel.removeFile(geodeLog.filename)
}) {
Icon(
painterResource(R.drawable.icon_delete),
contentDescription = stringResource(R.string.application_crashes_delete_one)
)
}
}
}
}
@Composable
fun GeodeLogsListingScreen(
onBackPressedDispatcher: OnBackPressedDispatcher?,
geodeLogsViewModel: GeodeLogsViewModel = viewModel(factory = GeodeLogsViewModel.Factory)
) {
DirectoryListingScreen(
onBackPressedDispatcher,
geodeLogsViewModel,
titleId = R.string.title_activity_geode_logs,
noneAvailableId = R.string.application_geode_logs_no_logs,
) { line, shape ->
GeodeLogCard(
line,
geodeLogsViewModel,
modifier = Modifier.clip(shape)
)
}
}
@@ -3,12 +3,12 @@ package com.geode.launcher.preferences
import android.app.UiModeManager import android.app.UiModeManager
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.text.format.DateUtils
import android.view.WindowManager import android.view.WindowManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
@@ -20,88 +20,46 @@ import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring import androidx.compose.animation.core.spring
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material.icons.Icons
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.*
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.geode.launcher.BuildConfig import com.geode.launcher.BuildConfig
import com.geode.launcher.R import com.geode.launcher.R
import com.geode.launcher.UserDirectoryProvider import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.preferences.components.BackupButton import com.geode.launcher.updater.ReleaseViewModel
import com.geode.launcher.preferences.components.OptionsButton
import com.geode.launcher.preferences.components.OptionsCard
import com.geode.launcher.preferences.components.OptionsGroup
import com.geode.launcher.preferences.components.OptionsLabel
import com.geode.launcher.preferences.components.OptionsTitle
import com.geode.launcher.preferences.components.SafeModeDialog
import com.geode.launcher.preferences.components.SettingsCard
import com.geode.launcher.preferences.components.SettingsFPSCard
import com.geode.launcher.preferences.components.SettingsRangeCard
import com.geode.launcher.preferences.components.SettingsSelectCard
import com.geode.launcher.preferences.components.SettingsStringSelectCard
import com.geode.launcher.ui.theme.GeodeLauncherTheme import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.ui.theme.robotoMonoFamily
import com.geode.launcher.updater.ReleaseViewModel
import com.geode.launcher.utils.ApplicationIcon
import com.geode.launcher.utils.Constants import com.geode.launcher.utils.Constants
import com.geode.launcher.utils.GamePackageUtils import com.geode.launcher.utils.GamePackageUtils
import com.geode.launcher.utils.IconUtils
import com.geode.launcher.utils.LaunchUtils import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.launch
import java.net.ConnectException import java.net.ConnectException
import java.net.UnknownHostException import java.net.UnknownHostException
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.time.Clock
class SettingsActivity : ComponentActivity() { class SettingsActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -113,10 +71,9 @@ class SettingsActivity : ComponentActivity() {
val theme = Theme.fromInt(themeOption) val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND) val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
CompositionLocalProvider(LocalTheme provides theme) { CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) { GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
Surface( Surface(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background color = MaterialTheme.colorScheme.background
@@ -131,6 +88,25 @@ class SettingsActivity : ComponentActivity() {
} }
} }
fun onOpenFolder(context: Context) {
val file = LaunchUtils.getBaseDirectory(context)
val clipboardManager =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboardManager.setPrimaryClip(
ClipData.newPlainText(
context.getString(R.string.export_folder_tag),
file.path
)
)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
Toast.makeText(
context,
context.getString(R.string.text_copied),
Toast.LENGTH_SHORT
).show()
}
}
fun onOpenFileManager(context: Context) { fun onOpenFileManager(context: Context) {
// future thoughts: Intent.ACTION_VIEW may not work on some devices // future thoughts: Intent.ACTION_VIEW may not work on some devices
// the "correct" solution is to go through a list of possible intents/applications // the "correct" solution is to go through a list of possible intents/applications
@@ -151,7 +127,7 @@ fun onOpenFileManager(context: Context) {
try { try {
context.startActivity(intent) context.startActivity(intent)
} catch (_: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
Toast.makeText( Toast.makeText(
context, context,
context.getText(R.string.no_activity_found), Toast.LENGTH_SHORT context.getText(R.string.no_activity_found), Toast.LENGTH_SHORT
@@ -159,73 +135,14 @@ fun onOpenFileManager(context: Context) {
} }
} }
@Composable
fun UpdateErrorDialog(
error: Exception,
onDismiss: () -> Unit
) {
val clipboard = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
val clipboardLabel = stringResource(R.string.application_log_copy_label)
AlertDialog(
icon = {
Icon(
painterResource(R.drawable.icon_warning),
contentDescription = null,
)
},
title = { Text(stringResource(R.string.preference_check_for_updates_help_title)) },
text = {
SelectionContainer {
Text(
error.stackTraceToString(),
fontFamily = robotoMonoFamily,
style = Typography.bodyMedium,
modifier = Modifier.verticalScroll(rememberScrollState())
)
}
},
dismissButton = {
TextButton(onClick = {
coroutineScope.launch {
clipboard.setClipEntry(
ClipEntry(ClipData.newPlainText(clipboardLabel, error.stackTraceToString()))
)
}
}) {
Text(stringResource(R.string.preference_check_for_updates_help_copy))
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.message_box_accept))
}
},
onDismissRequest = onDismiss
)
}
@Composable @Composable
fun UpdateIndicator( fun UpdateIndicator(
snackbarHostState: SnackbarHostState, snackbarHostState: SnackbarHostState,
updateStatus: ReleaseViewModel.ReleaseUIState updateStatus: ReleaseViewModel.ReleaseUIState
) { ) {
val context = LocalContext.current val context = LocalContext.current
val resources = LocalResources.current
var enablePopup by remember { mutableStateOf(false) } var enablePopup by remember { mutableStateOf(false) }
var enableHelp by remember { mutableStateOf(false) }
var currentError by remember { mutableStateOf<Exception?>(null) }
val currentErrorS = currentError
if (currentErrorS != null && enableHelp) {
UpdateErrorDialog(currentErrorS) {
enableHelp = false
}
}
when (updateStatus) { when (updateStatus) {
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> { is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
@@ -259,31 +176,26 @@ fun UpdateIndicator(
is ReleaseViewModel.ReleaseUIState.Failure -> { is ReleaseViewModel.ReleaseUIState.Failure -> {
val message = when (updateStatus.exception) { val message = when (updateStatus.exception) {
is UnknownHostException, is ConnectException -> is UnknownHostException, is ConnectException ->
resources.getString(R.string.release_fetch_no_internet) context.getString(R.string.release_fetch_no_internet)
else -> resources.getString(R.string.preference_check_for_updates_failed) else -> context.getString(R.string.preference_check_for_updates_failed)
} }
currentError = updateStatus.exception snackbarHostState.showSnackbar(message)
val snackbarResult = snackbarHostState.showSnackbar(message, resources.getString(R.string.preference_check_for_updates_failed_action))
if (snackbarResult == SnackbarResult.ActionPerformed) {
enableHelp = true
}
} }
is ReleaseViewModel.ReleaseUIState.Finished -> { is ReleaseViewModel.ReleaseUIState.Finished -> {
if (updateStatus.hasUpdated) { if (updateStatus.hasUpdated) {
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
resources.getString(R.string.preference_check_for_updates_success) context.getString(R.string.preference_check_for_updates_success)
) )
} else { } else {
val gameVersion = GamePackageUtils.getGameVersionCodeOrNull(context.packageManager) val gameVersion = GamePackageUtils.getGameVersionCodeOrNull(context.packageManager)
if (gameVersion != null && gameVersion < Constants.SUPPORTED_VERSION_CODE) { if (gameVersion != null && gameVersion < Constants.SUPPORTED_VERSION_CODE) {
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
resources.getString(R.string.launcher_game_update_required_short) context.getString(R.string.launcher_game_update_required_short)
) )
} else { } else {
snackbarHostState.showSnackbar( snackbarHostState.showSnackbar(
resources.getString(R.string.preference_check_for_updates_none_found) context.getString(R.string.preference_check_for_updates_none_found)
) )
} }
} }
@@ -314,41 +226,82 @@ fun updateTheme(context: Context, theme: Int) {
} }
} }
fun onOpenLogs(context: Context) {
val launchIntent = Intent(context, ApplicationLogsActivity::class.java)
context.startActivity(launchIntent)
}
fun onOpenDeveloperOptions(context: Context) { fun onOpenDeveloperOptions(context: Context) {
val launchIntent = Intent(context, DeveloperSettingsActivity::class.java) val launchIntent = Intent(context, DeveloperSettingsActivity::class.java)
context.startActivity(launchIntent) context.startActivity(launchIntent)
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DeveloperModeDialog(onDismiss: () -> Unit, onEnable: () -> Unit) { fun SettingsScreen(
AlertDialog( onBackPressedDispatcher: OnBackPressedDispatcher?,
icon = { releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
Icon( ) {
painterResource(R.drawable.icon_warning),
contentDescription = null,
)
},
title = { Text(stringResource(R.string.preference_developer_options_dialog_title)) },
text = { Text(stringResource(R.string.preference_developer_mode_about)) },
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.message_box_cancel))
}
},
confirmButton = {
TextButton(onClick = onEnable) {
Text(stringResource(R.string.message_box_accept))
}
},
onDismissRequest = onDismiss
)
}
@Composable
fun GeneralSettingsGroup() {
val context = LocalContext.current val context = LocalContext.current
OptionsGroup(stringResource(R.string.preference_category_testing)) { val currentRelease by PreferenceUtils.useStringPreference(PreferenceUtils.Key.CURRENT_VERSION_TAG)
val updateStatus by releaseViewModel.uiState.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
var showUpdateInProgress by remember { mutableStateOf(false) }
if (showUpdateInProgress) {
LaunchedEffect(snackbarHostState) {
snackbarHostState.showSnackbar(
context.getString(R.string.preference_check_for_updates_already_updating)
)
showUpdateInProgress = false
}
}
// fix theme transition by giving it the exact same animation as the top bar
val containerColor by animateColorAsState(
targetValue = MaterialTheme.colorScheme.background,
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
label = "background color"
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
containerColor = containerColor,
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = context.getString(R.string.back_icon_alt)
)
}
},
title = {
Text(
context.getString(R.string.title_activity_settings),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
scrollBehavior = scrollBehavior,
)
},
content = { innerPadding ->
Column(
Modifier
.padding(innerPadding)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
OptionsGroup(context.getString(R.string.preference_category_testing)) {
SettingsSelectCard( SettingsSelectCard(
title = stringResource(R.string.preference_theme_name), title = stringResource(R.string.preference_theme_name),
dialogTitle = stringResource(R.string.preference_theme_select), dialogTitle = stringResource(R.string.preference_theme_select),
@@ -364,54 +317,16 @@ fun GeneralSettingsGroup() {
title = stringResource(R.string.preference_black_background_name), title = stringResource(R.string.preference_black_background_name),
preferenceKey = PreferenceUtils.Key.BLACK_BACKGROUND preferenceKey = PreferenceUtils.Key.BLACK_BACKGROUND
) )
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
SettingsCard(
title = stringResource(R.string.preference_disable_styles),
preferenceKey = PreferenceUtils.Key.DISABLE_USER_THEME
)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val currentIcon by PreferenceUtils.useStringPreference(PreferenceUtils.Key.SELECTED_ICON)
val iconName = remember(currentIcon) {
IconUtils.getIconDetails(
ApplicationIcon.fromId(currentIcon ?: "default")
).nameId
}
OptionsButton(
title = stringResource(R.string.preference_set_application_icon),
onClick = {
val launchIntent = Intent(context, ApplicationIconActivity::class.java)
context.startActivity(launchIntent)
},
description = stringResource(iconName),
displayInline = true,
)
}
OptionsButton( OptionsButton(
title = stringResource(R.string.preferences_open_file_manager), title = stringResource(R.string.preferences_open_file_manager),
icon = {
Icon(
painterResource(R.drawable.icon_folder),
contentDescription = null
)
},
onClick = { onOpenFileManager(context) } onClick = { onOpenFileManager(context) }
) )
} }
}
@Composable
fun GameplaySettingsGroup() {
val context = LocalContext.current
OptionsGroup(stringResource(R.string.preference_category_gameplay)) { OptionsGroup(stringResource(R.string.preference_category_gameplay)) {
SettingsCard( SettingsCard(
title = stringResource(R.string.preference_load_automatically_name), title = context.getString(R.string.preference_load_automatically_name),
description = stringResource(R.string.preference_load_automatically_description), description = context.getString(R.string.preference_load_automatically_description),
preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY, preferenceKey = PreferenceUtils.Key.LOAD_AUTOMATICALLY,
) )
SettingsSelectCard( SettingsSelectCard(
@@ -456,7 +371,7 @@ fun GameplaySettingsGroup() {
step = 5, step = 5,
) { ) {
SettingsCard( SettingsCard(
title = stringResource(R.string.preference_screen_zoom_fit_name), title = context.getString(R.string.preference_screen_zoom_fit_name),
preferenceKey = PreferenceUtils.Key.SCREEN_ZOOM_FIT, preferenceKey = PreferenceUtils.Key.SCREEN_ZOOM_FIT,
asCard = false asCard = false
) )
@@ -477,86 +392,31 @@ fun GameplaySettingsGroup() {
preferenceKey = PreferenceUtils.Key.LIMIT_FRAME_RATE, preferenceKey = PreferenceUtils.Key.LIMIT_FRAME_RATE,
maxFrameRate = maxFrameRate maxFrameRate = maxFrameRate
) )
var showSafeModeDialog by remember { mutableStateOf(false) }
OptionsButton(
title = stringResource(R.string.preference_launch_safe_mode),
icon = {
Icon(
painterResource(R.drawable.icon_shield),
contentDescription = null
)
}
) {
showSafeModeDialog = true
} }
if (showSafeModeDialog) { OptionsGroup(context.getString(R.string.preference_category_updater)) {
SafeModeDialog(onDismiss = {
showSafeModeDialog = false
}) {
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)!!.apply {
// hardcode it, why not
action = Intent.ACTION_VIEW
data = "geode-launcher://main/launch?safe-mode=true".toUri()
}
context.startActivity(launchIntent)
}
}
}
}
@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)) {
SettingsCard( SettingsCard(
title = stringResource(R.string.preference_update_automatically_name), title = context.getString(R.string.preference_update_automatically_name),
preferenceKey = PreferenceUtils.Key.UPDATE_AUTOMATICALLY, preferenceKey = PreferenceUtils.Key.UPDATE_AUTOMATICALLY,
) )
val lastUpdateCheck by PreferenceUtils.useLongPreference(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME)
val checkAgoString = remember(lastUpdateCheck) {
if (lastUpdateCheck != 0L) {
val now = Clock.System.now().toEpochMilliseconds()
DateUtils.getRelativeTimeSpanString(
lastUpdateCheck,
now,
DateUtils.MINUTE_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE
)
} else {
"never"
}
}
OptionsCard( OptionsCard(
title = { title = {
OptionsTitle( OptionsTitle(
title = stringResource(R.string.preference_check_for_updates_button), title = stringResource(R.string.preference_check_for_updates_button),
icon = {
Icon(
painterResource(R.drawable.icon_update),
contentDescription = null
)
},
description = stringResource( description = stringResource(
R.string.preference_check_for_updates_description, R.string.preference_check_for_updates_description,
checkAgoString currentRelease ?: "unknown"
) )
.takeIf { lastUpdateCheck != 0L }
) )
}, },
modifier = Modifier modifier = Modifier
.clickable( .clickable(
onClick = { onClick = {
onUpdate() if (releaseViewModel.isInUpdate) {
showUpdateInProgress = true
} else {
releaseViewModel.runReleaseCheck(true)
}
}, },
role = Role.Button role = Role.Button
) )
@@ -564,117 +424,53 @@ fun UpdateSettingsGroup(releaseViewModel: ReleaseViewModel, snackbarHostState: S
UpdateIndicator(snackbarHostState, updateStatus) UpdateIndicator(snackbarHostState, updateStatus)
} }
} }
}
@Composable
fun AdvancedSettingsGroup(developerModeEnabled: Boolean, snackbarHostState: SnackbarHostState) {
val context = LocalContext.current
OptionsGroup(stringResource(R.string.preference_category_developer)) { OptionsGroup(stringResource(R.string.preference_category_developer)) {
if (developerModeEnabled) { OptionsButton(
title = stringResource(R.string.preferences_view_logs),
icon = {
Icon(painterResource(R.drawable.icon_description), contentDescription = null)
},
onClick = { onOpenLogs(context) }
)
OptionsButton( OptionsButton(
title = stringResource(R.string.preference_open_developer_options), title = stringResource(R.string.preference_open_developer_options),
icon = { icon = {
Icon( Icon(painterResource(R.drawable.icon_data_object), contentDescription = null)
painterResource(R.drawable.icon_data_object),
contentDescription = null
)
}, },
onClick = { onOpenDeveloperOptions(context) } onClick = { onOpenDeveloperOptions(context) }
) )
}
OptionsButton( OptionsButton(
title = stringResource(R.string.preferences_view_geode_logs), title = context.getString(R.string.preferences_copy_external_button),
icon = { description = LaunchUtils.getBaseDirectory(context).path,
Icon( onClick = { onOpenFolder(context) }
painterResource(R.drawable.icon_description),
contentDescription = null
) )
},
onClick = {
val launchIntent = Intent(context, GeodeLogsListingActivity::class.java)
context.startActivity(launchIntent)
} }
)
OptionsButton(
title = stringResource(R.string.preferences_view_crashes),
icon = {
Icon(
painterResource(R.drawable.icon_bug_report),
contentDescription = null
)
},
onClick = {
val launchIntent = Intent(context, CrashLogsActivity::class.java)
context.startActivity(launchIntent)
}
)
BackupButton(snackbarHostState)
}
}
@Composable
fun AboutSettingsGroup(developerModeEnabled: Boolean) {
val context = LocalContext.current
val resources = LocalResources.current
val currentRelease by PreferenceUtils.useStringPreference(PreferenceUtils.Key.CURRENT_VERSION_TAG)
OptionsGroup(stringResource(R.string.preference_category_about)) { OptionsGroup(stringResource(R.string.preference_category_about)) {
var launcherButtonTapped by remember { mutableIntStateOf(0) } val clipboard = LocalClipboardManager.current
var showDeveloperDialog by remember { mutableStateOf(false) }
OptionsButton( OptionsButton(
stringResource(R.string.preference_launcher_version_name), stringResource(R.string.preference_launcher_version_name),
stringResource( stringResource(R.string.preference_launcher_version_description, BuildConfig.VERSION_NAME),
R.string.preference_launcher_version_description,
BuildConfig.VERSION_NAME
),
displayInline = true displayInline = true
) { ) {
launcherButtonTapped += 1 clipboard.setText(AnnotatedString(context.getString(
R.string.preference_launcher_version,
if (launcherButtonTapped >= 7) { BuildConfig.VERSION_NAME
if (developerModeEnabled) { )))
Toast.makeText(
context,
R.string.preference_enable_developer_again,
Toast.LENGTH_SHORT
)
.show()
} else {
showDeveloperDialog = true
}
launcherButtonTapped = 0
}
} }
if (showDeveloperDialog) { OptionsButton(
DeveloperModeDialog(
onDismiss = {
showDeveloperDialog = false
},
onEnable = {
PreferenceUtils.get(context)
.setBoolean(PreferenceUtils.Key.DEVELOPER_MODE, true)
showDeveloperDialog = false
}
)
}
@Suppress("SENSELESS_COMPARISON")
OptionsLabel(
stringResource(R.string.preference_loader_version_name), stringResource(R.string.preference_loader_version_name),
stringResource( stringResource(R.string.preference_loader_version_description, currentRelease ?: "unknown"),
R.string.preference_loader_version_description,
if (BuildConfig.PREBUNDLED_GEODE != null)
"${BuildConfig.PREBUNDLED_GEODE}*"
else (currentRelease ?: "unknown")
),
displayInline = true displayInline = true
) ) {
clipboard.setText(AnnotatedString(context.getString(
R.string.preference_loader_version,
currentRelease ?: "unknown"
)))
}
val gameInstalled = remember { val gameInstalled = remember {
GamePackageUtils.isGameInstalled(context.packageManager) GamePackageUtils.isGameInstalled(context.packageManager)
@@ -686,118 +482,38 @@ fun AboutSettingsGroup(developerModeEnabled: Boolean) {
} }
val gameSource = remember { val gameSource = remember {
resources.getString( context.getString(when (GamePackageUtils.identifyGameSource(context.packageManager)) {
when (GamePackageUtils.identifyGameSource(context.packageManager)) {
GamePackageUtils.GameSource.GOOGLE -> R.string.preference_game_source_google GamePackageUtils.GameSource.GOOGLE -> R.string.preference_game_source_google
GamePackageUtils.GameSource.AMAZON -> R.string.preference_game_source_amazon GamePackageUtils.GameSource.AMAZON -> R.string.preference_game_source_amazon
else -> R.string.preference_game_source_unknown else -> R.string.preference_game_source_unknown
} })
)
} }
OptionsLabel( OptionsButton(
stringResource(R.string.preference_game_version_name), stringResource(R.string.preference_game_version_name),
stringResource( stringResource(R.string.preference_game_version_description, gameVersion, gameSource),
R.string.preference_game_version_description, displayInline = true
) {
clipboard.setText(AnnotatedString(context.getString(
R.string.preference_game_version,
gameVersion, gameVersion,
gameSource gameSource
), )))
displayInline = true }
)
OptionsLabel( OptionsButton(
stringResource(R.string.preference_architecture_name), stringResource(R.string.preference_architecture_name),
stringResource( stringResource(R.string.preference_architecture_description, Build.MODEL, LaunchUtils.applicationArchitecture),
R.string.preference_architecture_description, displayInline = true
) {
clipboard.setText(AnnotatedString(context.getString(
R.string.preference_architecture,
Build.MODEL, Build.MODEL,
LaunchUtils.applicationArchitecture LaunchUtils.applicationArchitecture
), )))
displayInline = true
)
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBackPressedDispatcher: OnBackPressedDispatcher?,
releaseViewModel: ReleaseViewModel = viewModel(factory = ReleaseViewModel.Factory)
) {
val resources = LocalResources.current
val snackbarHostState = remember { SnackbarHostState() }
var showUpdateInProgress by remember { mutableStateOf(false) }
if (showUpdateInProgress) {
LaunchedEffect(snackbarHostState) {
snackbarHostState.showSnackbar(
resources.getString(R.string.preference_check_for_updates_already_updating)
)
showUpdateInProgress = false
}
}
// fix theme transition by giving it the exact same animation as the top bar
val containerColor by animateColorAsState(
targetValue = MaterialTheme.colorScheme.background,
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
label = "background color"
)
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
containerColor = containerColor,
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
Icon(
painterResource(R.drawable.icon_arrow_back),
contentDescription = stringResource(R.string.back_icon_alt)
)
}
},
title = {
Text(
stringResource(R.string.title_activity_settings),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.background),
)
},
content = { innerPadding ->
Column(
Modifier
.padding(innerPadding)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
GeneralSettingsGroup()
GameplaySettingsGroup()
UpdateSettingsGroup(releaseViewModel, snackbarHostState, onUpdate = {
if (releaseViewModel.isInUpdate) {
showUpdateInProgress = true
} else {
releaseViewModel.runReleaseCheck(true)
}
})
val developerModeEnabled by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
AdvancedSettingsGroup(developerModeEnabled, snackbarHostState)
AboutSettingsGroup(developerModeEnabled)
Spacer(Modifier.height(4.dp))
} }
} }
) )
@@ -1,9 +1,8 @@
package com.geode.launcher.preferences.components package com.geode.launcher.preferences
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.activity.compose.LocalActivity import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -14,41 +13,58 @@ import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.geode.launcher.R import com.geode.launcher.R
import com.geode.launcher.ui.theme.GeodeLauncherTheme import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.Typography import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.utils.LabelledText import com.geode.launcher.utils.LabelledText
import com.geode.launcher.utils.PreferenceUtils import com.geode.launcher.utils.PreferenceUtils
import com.geode.launcher.utils.Profile
import com.geode.launcher.utils.ProfileManager import com.geode.launcher.utils.ProfileManager
import kotlin.collections.get
import kotlin.math.log10 import kotlin.math.log10
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
import kotlin.system.exitProcess import kotlin.system.exitProcess
@@ -66,24 +82,16 @@ fun getSetting(context: Context, preferenceKey: PreferenceUtils.Key): Boolean {
} }
@Composable @Composable
fun OptionsGroup(title: String? = null, content: @Composable () -> Unit) { fun OptionsGroup(title: String, content: @Composable () -> Unit) {
Column { Column {
if (title != null) {
Text( Text(
title, title,
style = Typography.titleMedium, style = Typography.titleMedium,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) )
}
Column(modifier = Modifier
.padding(horizontal = 12.dp)
.clip(RoundedCornerShape(16.dp))
) {
content() content()
} }
} }
}
@Composable @Composable
fun SettingsSelectCard( fun SettingsSelectCard(
@@ -220,6 +228,243 @@ fun SettingsStringCard(
} }
} }
@Composable
fun StringDialog(
title: String,
onDismissRequest: () -> Unit,
onSelect: (String) -> Unit,
initialValue: String,
filterInput: ((String) -> String)? = null
) {
var enteredValue by remember { mutableStateOf(initialValue) }
AlertDialog(
onDismissRequest = { onDismissRequest() },
title = {
Text(title)
},
text = {
OutlinedTextField(
value = enteredValue,
onValueChange = {
enteredValue = filterInput?.invoke(it) ?: it
},
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
autoCorrectEnabled = false
),
trailingIcon = {
if (enteredValue.isNotEmpty()) {
IconButton(onClick = { enteredValue = "" }) {
Icon(
Icons.Filled.Clear,
contentDescription = stringResource(R.string.preference_text_clear)
)
}
}
}
)
},
confirmButton = {
TextButton(onClick = { onSelect(enteredValue) }) {
Text(stringResource(R.string.message_box_accept))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
)
}
@Composable
fun RangeDialog(
title: String,
onDismissRequest: () -> Unit,
onSelect: (Int) -> Unit,
labelSuffix: String,
initialValue: Int,
range: IntRange,
scale: Int,
step: Int,
children: @Composable () -> Unit
) {
var enteredValue by remember {
mutableFloatStateOf(initialValue / scale.toFloat())
}
AlertDialog(
onDismissRequest = { onDismissRequest() },
title = {
Text(title)
},
text = {
Column {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1.0f, false)
) {
val precision = log10(scale.toFloat()).roundToInt()
Slider(
value = enteredValue,
onValueChange = { enteredValue = it },
valueRange = (range.first.toFloat()/scale)..(range.last.toFloat()/scale),
steps = ((range.last - range.first) - 1) / step,
modifier = Modifier.weight(1.0f)
)
Text("%.${precision}f$labelSuffix".format(enteredValue))
}
children()
}
},
confirmButton = {
TextButton(onClick = { onSelect((enteredValue * scale).roundToInt()) }) {
Text(stringResource(R.string.message_box_accept))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
)
}
@Composable
fun FrameRateDialog(
title: String,
onDismissRequest: () -> Unit,
onSelect: (Int) -> Unit,
initialValue: Int,
maxFrameRate: Int,
) {
var enteredValue by remember {
mutableStateOf(
if (initialValue == 0) maxFrameRate.toString()
else initialValue.toString()
)
}
val minFrameRate = 5
val currentValue = enteredValue.toIntOrNull()
val maximumReached = currentValue != null && currentValue > maxFrameRate
val minimumReached = currentValue != null && currentValue < minFrameRate
AlertDialog(
onDismissRequest = { onDismissRequest() },
title = {
Text(title)
},
text = {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1.0f, false)
) {
IconButton(onClick = {
val prevValue = max((currentValue ?: 0) - 5, minFrameRate)
enteredValue = prevValue.toString()
}, enabled = currentValue == null || currentValue > minFrameRate) {
Icon(
painterResource(R.drawable.icon_remove),
contentDescription = stringResource(R.string.preference_limit_framerate_subtract)
)
}
Spacer(Modifier.size(8.dp))
OutlinedTextField(
value = enteredValue,
onValueChange = {
enteredValue = it
},
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
),
suffix = {
Text(stringResource(R.string.preference_limit_framerate_suffix))
},
label = {
Text(stringResource(R.string.preference_limit_framerate_label))
},
isError = minimumReached || maximumReached,
modifier = Modifier.weight(1.0f),
)
Spacer(Modifier.size(8.dp))
IconButton(onClick = {
val nextValue = min((currentValue ?: 0) + 5, maxFrameRate)
enteredValue = nextValue.toString()
}, enabled = currentValue == null || currentValue < maxFrameRate) {
Icon(
Icons.Filled.Add,
contentDescription = stringResource(R.string.preference_limit_framerate_add)
)
}
IconButton(onClick = { onSelect(0) }) {
Icon(
painterResource(R.drawable.icon_delete),
contentDescription = stringResource(R.string.preference_limit_framerate_reset)
)
}
}
if (minimumReached) {
Spacer(Modifier.size(8.dp))
Text(
stringResource(R.string.preference_limit_framerate_error_min, minFrameRate),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelLarge,
)
} else if (maximumReached) {
Spacer(Modifier.size(8.dp))
Text(
stringResource(
R.string.preference_limit_framerate_error_max,
maxFrameRate
),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelLarge,
)
}
}
},
confirmButton = {
TextButton(
onClick = {
// automatically disable if already max
if (currentValue == maxFrameRate) {
onSelect(0)
} else {
onSelect(currentValue ?: 0)
}
},
enabled = !minimumReached && !maximumReached && currentValue != null
) {
Text(stringResource(R.string.message_box_accept))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
)
}
@Composable @Composable
fun SettingsFPSCard( fun SettingsFPSCard(
title: String, title: String,
@@ -315,6 +560,122 @@ fun SettingsRangeCard(
} }
} }
internal val LocalSelectValue = compositionLocalOf<Any> { 0 }
internal val LocalSelectSetValue = staticCompositionLocalOf<(Any) -> Unit> { {} }
@Composable
fun <T> SelectOption(name: String, value: T, enabled: Boolean = true, leadingContent: @Composable (Boolean) -> Unit = {}) {
val currentValue = LocalSelectValue.current
val setValue = LocalSelectSetValue.current
val isSelected = currentValue.equals(value)
// do not give the row or column padding!! it messes up the selection effect
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(
onClick = { setValue(value as Any) },
role = Role.RadioButton,
enabled = enabled
)
.padding(horizontal = 12.dp)
) {
Row(modifier = Modifier.weight(1.0f, true), verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = isSelected,
onClick = { setValue(value as Any) },
enabled = enabled
)
Text(name, style = Typography.bodyMedium)
}
leadingContent(isSelected)
}
}
@Composable
fun ProfileCreateDialog(onDismissRequest: () -> Unit) {
var enteredValue by remember { mutableStateOf("") }
val filename = enteredValue.take(16)
.lowercase()
.map {
if ("qwertyuiopasdfghjklzxcvbnm1234567890-_.".contains(it))
it else '_'
}
.joinToString("")
val context = LocalContext.current
val minimumReached = enteredValue.isEmpty() || filename.isEmpty()
val profileManager = ProfileManager.get(context)
val currentProfiles = remember {
profileManager.getProfiles().map { it.path }.toSet()
}
val isDuplicate = currentProfiles.contains(filename)
AlertDialog(
onDismissRequest = { onDismissRequest() },
title = {
Text(stringResource(R.string.preference_profiles_create))
},
text = {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1.0f, false)
) {
OutlinedTextField(
value = enteredValue,
onValueChange = {
enteredValue = it
},
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
),
label = {
Text(stringResource(R.string.preference_profiles_create_name))
},
isError = isDuplicate
)
}
if (isDuplicate) {
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.preference_profiles_create_duplicate))
} else if (!minimumReached) {
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.preference_profiles_create_info, filename))
}
}
},
confirmButton = {
TextButton(
onClick = {
ProfileManager.get(context).storeProfile(Profile(filename, enteredValue))
onDismissRequest()
},
enabled = !minimumReached && !isDuplicate
) {
Text(stringResource(R.string.preference_profiles_create_action))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
)
}
@Composable @Composable
fun ProfileCreateCard() { fun ProfileCreateCard() {
var showDialog by remember { mutableStateOf(false) } var showDialog by remember { mutableStateOf(false) }
@@ -344,7 +705,7 @@ fun ProfileSelectCard() {
profileManager.getCurrentProfile() profileManager.getCurrentProfile()
} }
val currentProfileName = remember { var currentProfileName = remember {
val savedProfile = currentProfileId val savedProfile = currentProfileId
if (savedProfile == null) { if (savedProfile == null) {
"Default" "Default"
@@ -388,8 +749,8 @@ fun ProfileSelectCard() {
.setCurrentProfile(selectedProfileId) .setCurrentProfile(selectedProfileId)
activity?.run { activity?.run {
packageManager.getLaunchIntentForPackage(packageName)?.also { intent -> packageManager.getLaunchIntentForPackage(packageName)?.also {
val mainIntent = Intent.makeRestartActivityTask(intent.component) val mainIntent = Intent.makeRestartActivityTask(it.component)
startActivity(mainIntent) startActivity(mainIntent)
exitProcess(0) exitProcess(0)
} }
@@ -422,7 +783,7 @@ fun ProfileSelectCard() {
) )
} else { } else {
Icon( Icon(
painterResource(R.drawable.icon_delete), Icons.Filled.Delete,
contentDescription = stringResource(R.string.preference_profiles_delete_alt) contentDescription = stringResource(R.string.preference_profiles_delete_alt)
) )
} }
@@ -434,6 +795,66 @@ fun ProfileSelectCard() {
} }
} }
@Composable
fun <T> SelectDialog(
title: String,
onDismissRequest: () -> Unit,
onSelect: (T) -> Unit,
initialValue: T,
options: @Composable () -> Unit,
) {
val (selectedValue, setSelectedValue) = remember { mutableStateOf(initialValue) }
Dialog(onDismissRequest = onDismissRequest) {
Card(
shape = RoundedCornerShape(16.dp)
) {
// styling a dialog is actually a little hard if you're doing what i'm doing
// maybe there's a better way to make these padding values...
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(
title,
style = Typography.titleLarge,
modifier = Modifier.padding(
start = 28.dp,
top = 24.dp,
bottom = 12.dp
)
)
CompositionLocalProvider(
LocalSelectValue provides (selectedValue as Any),
LocalSelectSetValue provides ({
@Suppress("UNCHECKED_CAST")
setSelectedValue(it as T)
})
) {
options()
}
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(
bottom = 16.dp,
end = 16.dp,
top = 4.dp
)
) {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
TextButton(onClick = { onSelect(selectedValue) }) {
Text(stringResource(R.string.message_box_accept))
}
}
}
}
}
}
@Composable @Composable
fun OptionsButton(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, displayInline: Boolean = false, onClick: () -> Unit) { fun OptionsButton(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, displayInline: Boolean = false, onClick: () -> Unit) {
OptionsCard( OptionsCard(
@@ -454,24 +875,6 @@ fun OptionsButton(title: String, description: String? = null, icon: (@Composable
} }
} }
@Composable
fun OptionsLabel(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, displayInline: Boolean = false) {
OptionsCard(
title = {
OptionsTitle(
title = title,
description = description.takeIf { !displayInline },
icon = icon
)
},
wrapContent = true
) {
if (displayInline && description != null) {
Text(description, textAlign = TextAlign.End)
}
}
}
@Composable @Composable
fun SettingsCard(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, asCard: Boolean = true, preferenceKey: PreferenceUtils.Key) { fun SettingsCard(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, asCard: Boolean = true, preferenceKey: PreferenceUtils.Key) {
val context = LocalContext.current val context = LocalContext.current
@@ -487,25 +890,24 @@ fun SettingsCard(title: String, description: String? = null, icon: (@Composable
icon = icon icon = icon
) )
}, },
modifier = Modifier.toggleable( modifier = if (asCard) Modifier.toggleable(
value = settingEnabled.value, value = settingEnabled.value,
onValueChange = { settingEnabled.value = toggleSetting(context, preferenceKey) }, onValueChange = { settingEnabled.value = toggleSetting(context, preferenceKey) },
role = Role.Switch, role = Role.Switch,
), ) else Modifier
backgroundColor = if (asCard)
MaterialTheme.colorScheme.surfaceContainer
else MaterialTheme.colorScheme.surfaceContainerHigh
) { ) {
Switch( Switch(
checked = settingEnabled.value, checked = settingEnabled.value,
onCheckedChange = null onCheckedChange = if (!asCard)
{ _ -> settingEnabled.value = toggleSetting(context, preferenceKey) }
else null
) )
} }
} }
@Composable @Composable
fun OptionsTitle(modifier: Modifier = Modifier, title: String, description: String? = null, icon: (@Composable () -> Unit)? = null) { fun OptionsTitle(modifier: Modifier = Modifier, title: String, description: String? = null, icon: (@Composable () -> Unit)? = null) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (icon != null) { if (icon != null) {
icon() icon()
} }
@@ -518,7 +920,7 @@ fun OptionsTitle(modifier: Modifier = Modifier, title: String, description: Stri
Text( Text(
description, description,
style = Typography.labelMedium, style = Typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSecondaryContainer
) )
} }
} }
@@ -526,20 +928,10 @@ fun OptionsTitle(modifier: Modifier = Modifier, title: String, description: Stri
} }
@Composable @Composable
fun OptionsCard( fun OptionsCard(modifier: Modifier = Modifier, wrapContent: Boolean = false, title: @Composable () -> Unit, content: @Composable () -> Unit) {
modifier: Modifier = Modifier,
wrapContent: Boolean = false,
backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainer,
title: @Composable () -> Unit,
content: @Composable () -> Unit,
) {
Row( Row(
Modifier modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 1.dp)
.clip(RoundedCornerShape(4.dp))
.then(modifier)
.background(backgroundColor)
.defaultMinSize(minHeight = 64.dp) .defaultMinSize(minHeight = 64.dp)
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
Arrangement.SpaceBetween, Arrangement.SpaceBetween,
@@ -558,31 +950,6 @@ fun OptionsCard(
} }
} }
@Composable
fun SafeModeDialog(onDismiss: () -> Unit, onLaunch: () -> Unit) {
AlertDialog(
icon = {
Icon(
painterResource(R.drawable.icon_shield_alert),
contentDescription = null
)
},
title = { Text(stringResource(R.string.safe_mode_enable_title)) },
text = { Text(stringResource(R.string.safe_mode_enable_description)) },
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.message_box_cancel))
}
},
confirmButton = {
TextButton(onClick = onLaunch) {
Text(stringResource(R.string.message_box_continue))
}
},
onDismissRequest = onDismiss
)
}
@Composable @Composable
fun InlineText(label: String, icon: @Composable (() -> Unit)? = null, modifier: Modifier = Modifier) { fun InlineText(label: String, icon: @Composable (() -> Unit)? = null, modifier: Modifier = Modifier) {
LabelledText(label = label, icon = icon, modifier = modifier.padding(horizontal = 16.dp, vertical = 4.dp)) LabelledText(label = label, icon = icon, modifier = modifier.padding(horizontal = 16.dp, vertical = 4.dp))
@@ -1,218 +0,0 @@
package com.geode.launcher.preferences
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.provider.DocumentsContract
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.geode.launcher.R
import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.ui.theme.GeodeLauncherTheme
import com.geode.launcher.ui.theme.LocalTheme
import com.geode.launcher.ui.theme.Theme
import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.ui.theme.robotoMonoFamily
import com.geode.launcher.utils.LaunchUtils
import com.geode.launcher.utils.PreferenceUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
class TextViewActivity : ComponentActivity() {
companion object {
const val EXTRA_LOG_VIEW_FILENAME = "EXTRA_LOG_FILENAME"
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
val filename = intent.getStringExtra(EXTRA_LOG_VIEW_FILENAME)
if (filename.isNullOrEmpty()) {
val launchIntent = Intent(this, GeodeLogsListingActivity::class.java)
startActivity(launchIntent)
return
}
setContent {
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
val theme = Theme.fromInt(themeOption)
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
CompositionLocalProvider(LocalTheme provides theme) {
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
GeodeLogView(onBackPressedDispatcher, filename)
}
}
}
}
}
}
fun onShareFile(context: Context, filename: File) {
val baseDirectory = LaunchUtils.getBaseDirectory(context, true)
val providerLocation = filename.toRelativeString(baseDirectory)
val documentsPath = "${UserDirectoryProvider.ROOT}${providerLocation}"
val uri = DocumentsContract.buildDocumentUri("${context.packageName}.user", documentsPath)
val shareIntent = Intent(Intent.ACTION_SEND).apply {
putExtra(Intent.EXTRA_STREAM, uri)
type = "application/octet-stream"
}
try {
context.startActivity(shareIntent)
} catch (_: ActivityNotFoundException) {
Toast.makeText(context, R.string.no_activity_found, Toast.LENGTH_SHORT).show()
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GeodeLogView(
onBackPressedDispatcher: OnBackPressedDispatcher?,
filename: String,
) {
var loadingFile by remember { mutableStateOf(true) }
var description by remember { mutableStateOf(emptyList<String>()) }
val toRead = File(filename)
LaunchedEffect(filename) {
loadingFile = true
withContext(Dispatchers.IO) {
if (!toRead.isFile) {
description = listOf("File does not exist!")
} else {
description = toRead.readLines()
}
}
loadingFile = false
}
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
Column {
TopAppBar(
navigationIcon = {
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
Icon(
painterResource(R.drawable.icon_arrow_back),
contentDescription = stringResource(R.string.back_icon_alt)
)
}
},
title = {
Text(
toRead.name,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
scrollBehavior = scrollBehavior,
actions = {
val context = LocalContext.current
IconButton(onClick = { onShareFile(context, toRead) }) {
Icon(
painterResource(R.drawable.icon_share),
contentDescription = stringResource(R.string.application_logs_share)
)
}
}
)
if (loadingFile) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
}
}
}
) { innerPadding ->
if (!loadingFile) {
if (description.isEmpty()) {
Text(
stringResource(R.string.application_geode_logs_empty),
textAlign = TextAlign.Center,
style = Typography.bodyLarge,
modifier = Modifier.fillMaxSize().padding(8.dp)
)
} else {
SelectionContainer {
LazyColumn(modifier = Modifier
.padding(innerPadding)
.padding(horizontal = 8.dp)
) {
items(description) { line ->
Text(
line,
fontFamily = robotoMonoFamily,
style = Typography.bodyMedium
)
}
item {
Spacer(Modifier.height(16.dp))
}
}
}
}
}
}
}
@@ -1,196 +0,0 @@
package com.geode.launcher.preferences.components
import androidx.activity.OnBackPressedDispatcher
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.geode.launcher.R
import com.geode.launcher.log.BaseDirectoryViewModel
import com.geode.launcher.ui.theme.Typography
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CrashLogsTopBar(
onBackPressedDispatcher: OnBackPressedDispatcher?,
directoryViewModel: BaseDirectoryViewModel<*>,
scrollBehavior: TopAppBarScrollBehavior,
@StringRes titleId: Int,
additionalOptions: (@Composable (() -> Unit) -> Unit)? = null,
) {
val isLoading by directoryViewModel.isLoading.collectAsState()
var showMoreOptions by remember { mutableStateOf(false) }
Column {
TopAppBar(
navigationIcon = {
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
Icon(
painterResource(R.drawable.icon_arrow_back),
contentDescription = stringResource(R.string.back_icon_alt)
)
}
},
actions = {
IconButton(onClick = { showMoreOptions = !showMoreOptions }) {
Icon(
painterResource(R.drawable.icon_more_vert),
contentDescription = stringResource(R.string.application_logs_more)
)
}
DropdownMenu(
expanded = showMoreOptions,
onDismissRequest = { showMoreOptions = false }
) {
DropdownMenuItem(
leadingIcon = {
Icon(
painterResource(R.drawable.icon_delete),
contentDescription = null
)
},
text = {
Text(stringResource(R.string.application_logs_delete))
}, onClick = {
directoryViewModel.clearAllFiles()
showMoreOptions = false
}
)
additionalOptions?.invoke {
showMoreOptions = false
}
}
},
title = {
Text(
stringResource(titleId),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
scrollBehavior = scrollBehavior,
)
if (isLoading) {
LinearProgressIndicator(
modifier = Modifier.fillMaxWidth()
)
}
}
}
@Composable
fun <T> CrashLogsListing(
modifier: Modifier = Modifier,
directoryViewModel: BaseDirectoryViewModel<T>,
@StringRes noneAvailableId: Int,
logCard: @Composable (T, Shape) -> Unit,
) {
val logLines = directoryViewModel.lineState
val isLoading by directoryViewModel.isLoading.collectAsState()
LazyColumn(
modifier,
verticalArrangement = Arrangement.SpaceEvenly,
) {
if (!isLoading && logLines.isEmpty()) {
item {
Text(
stringResource(noneAvailableId),
textAlign = TextAlign.Center,
style = Typography.bodyLarge,
modifier = Modifier.fillMaxSize().padding(8.dp)
)
}
}
val itemsSize = logLines.size
itemsIndexed(
logLines,
key = { index, line -> directoryViewModel.getItemKey(line) }
) { index, line ->
val shape = RoundedCornerShape(
topStart = if (index == 0) 16.dp else 4.dp,
topEnd = if (index == 0) 16.dp else 4.dp,
bottomStart = if (index + 1 == itemsSize) 16.dp else 4.dp,
bottomEnd = if (index + 1 == itemsSize) 16.dp else 4.dp
)
logCard(line, shape)
}
item {
Spacer(Modifier.height(4.dp))
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun <T> DirectoryListingScreen(
onBackPressedDispatcher: OnBackPressedDispatcher?,
directoryViewModel: BaseDirectoryViewModel<T>,
@StringRes titleId: Int,
@StringRes noneAvailableId: Int,
additionalOptions: (@Composable (() -> Unit) -> Unit)? = null,
logCard: @Composable (T, Shape) -> Unit,
) {
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
CrashLogsTopBar(
onBackPressedDispatcher,
directoryViewModel,
scrollBehavior,
titleId,
additionalOptions = additionalOptions
)
}
) { innerPadding ->
CrashLogsListing(
modifier = Modifier
.padding(innerPadding)
.fillMaxWidth(),
directoryViewModel,
noneAvailableId = noneAvailableId,
logCard = logCard
)
}
}
@@ -1,482 +0,0 @@
package com.geode.launcher.preferences.components
import android.content.Context
import android.util.Log
import android.util.Xml
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import com.geode.launcher.R
import com.geode.launcher.utils.LaunchUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okhttp3.FormBody
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
import okio.Buffer
import okio.ByteString.Companion.decodeBase64
import okio.GzipSource
import okio.buffer
import okio.source
import org.xmlpull.v1.XmlPullParser
import java.io.File
import kotlin.experimental.xor
import kotlin.time.Duration.Companion.seconds
const val TAG = "com.geode.launcher.SaveBackup"
data class UserDetails(
val userName: String,
val gjp2: String,
val accountId: Int,
val uuid: Int,
val udid: String,
val binaryVersion: Int,
)
enum class BackupParseFailure {
SAVE_MISSING,
DECODE_FAILED,
DETAILS_MISSING,
SAVE_TOO_LARGE
}
class BackupParseException(val failureCode: BackupParseFailure) : Exception("parse error: $failureCode")
enum class BackupUploadFailure {
GENERIC_ERROR,
NETWORK_ERROR,
INVALID_LOGIN
}
class BackupUploadException(val failureCode: BackupUploadFailure, val gjCode: Int = -1) : Exception("upload error: $failureCode")
suspend fun getAccountBackupUrl(backupClient: OkHttpClient, details: UserDetails): Result<HttpUrl> {
return try {
// accountID=%i&type=1&secret=Wmfd2893gb7
val body = FormBody.Builder()
.add("secret", "Wmfd2893gb7")
.add("type", "1")
.add("accountID", details.accountId.toString())
.build()
val request = Request.Builder()
.url("https://www.boomlings.com/database/getAccountURL.php")
.addHeader("User-Agent", "")
.post(body)
.build()
val call = backupClient.newCall(request)
call.executeAsync()
} catch (e: Exception) {
Log.w(TAG, "getAccountBackupUrl failed: ${e.stackTraceToString()}")
return Result.failure(BackupUploadException(BackupUploadFailure.NETWORK_ERROR))
}.use { response ->
if (response.code != 200) {
return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
}
val url = response.body.string().toHttpUrlOrNull()
?: return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
Result.success(url)
}
}
fun decryptFileToBuffer(file: File, buffer: Buffer) = file.source().buffer()
.use { baseBuffer ->
val key = 0xb.toByte()
val nullByte = 0x0.toByte()
while (!baseBuffer.exhausted()) {
val b = baseBuffer.readByte()
val d = b.xor(key)
// gd sometimes encodes garbage after null byte, treat it like a c string
if (d == nullByte)
break
buffer.writeByte(d.toInt())
}
}
fun generateSaveString(gameManager: File, localLevels: File): String = Buffer()
.use { saveBuffer ->
decryptFileToBuffer(gameManager, saveBuffer)
saveBuffer.writeByte(';'.code)
decryptFileToBuffer(localLevels, saveBuffer)
saveBuffer.readUtf8()
}
fun generateBackupRequest(details: UserDetails, url: HttpUrl, gameManager: File, localLevels: File): Request {
val saveData = generateSaveString(gameManager, localLevels)
// %s/database/accounts/backupGJAccountNew.php
val backupUrl = url.newBuilder()
.addPathSegments("database/accounts/backupGJAccountNew.php")
.build()
// gameVersion=22&binaryVersion=%i&dvs=2&udid=%s&uuid=%i&accountID=%i&gjp2=%s&secret=Wmfv3899gc9&saveData=%s
val body = FormBody.Builder()
.add("gameVersion", "22")
.add("binaryVersion", details.binaryVersion.toString())
.add("dvs", "2")
.add("udid", details.udid)
.add("uuid", details.uuid.toString())
.add("accountID", details.accountId.toString())
.add("secret", "Wmfv3899gc9")
.add("gjp2", details.gjp2)
.add("saveData", saveData)
.build()
return Request.Builder()
.url(backupUrl)
.addHeader("User-Agent", "")
.post(body)
.build()
}
suspend fun performBackupRequest(backupClient: OkHttpClient, details: UserDetails, url: HttpUrl, gameManager: File, localLevels: File): Result<Unit> {
return try {
// we split this into multiple functions to free up as many references as we can
// go little gc go
backupClient.newCall(
generateBackupRequest(details, url, gameManager, localLevels)
).executeAsync()
} catch (e: Exception) {
Log.w(TAG, "performBackupRequest failed: ${e.stackTraceToString()}")
return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
}.use { response ->
if (response.code != 200) {
return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
}
val responseBody = response.body.string().toIntOrNull()
?: return Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR))
when (responseBody) {
1 -> Result.success(Unit)
-5 -> Result.failure(BackupUploadException(BackupUploadFailure.INVALID_LOGIN, responseBody))
else -> Result.failure(BackupUploadException(BackupUploadFailure.GENERIC_ERROR, responseBody))
}
}
}
fun skip(parser: XmlPullParser) {
if (parser.eventType != XmlPullParser.START_TAG) {
throw IllegalStateException()
}
var depth = 1
while (depth != 0) {
when (parser.next()) {
XmlPullParser.END_TAG -> depth--
XmlPullParser.START_TAG -> depth++
}
}
}
fun getNextValue(parser: XmlPullParser): String? {
while (parser.eventType != XmlPullParser.START_TAG && parser.eventType != XmlPullParser.END_DOCUMENT) {
parser.next()
}
if (parser.eventType == XmlPullParser.START_TAG) {
// we could support t/f here but we also don't have to
if (parser.name != "s" && parser.name != "i") {
return null
}
return parser.nextText()
}
return null
}
fun parseSaveFile(parser: XmlPullParser): Result<UserDetails> = try {
var userName: String? = null
var gjp2: String? = null
var accountId: Int? = null
var uuid: Int? = null
var udid: String? = null
var binaryVersion: Int? = null
var inPlist = false
var inDict = false
var eventType = parser.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
when (eventType) {
XmlPullParser.START_TAG -> {
if (inDict) {
// ignore inner elements, just in case they conflict
if (parser.name != "k") {
skip(parser)
} else {
val keyName = parser.nextText()
when (keyName) {
"GJA_001" -> userName = getNextValue(parser)
"GJA_003" -> accountId = getNextValue(parser)?.toIntOrNull()
"GJA_005" -> gjp2 = getNextValue(parser)
"playerUserID" -> uuid = getNextValue(parser)?.toIntOrNull()
"playerUDID" -> udid = getNextValue(parser)
"binaryVersion" -> binaryVersion = getNextValue(parser)?.toIntOrNull()
}
}
}
// traverse to initial plist -> dict element
if (!inPlist && parser.name == "plist") {
inPlist = true
}
if (!inDict && parser.name == "dict") {
inDict = true
}
}
}
eventType = parser.next()
}
if (userName == null || gjp2 == null || accountId == null || uuid == null || udid == null || binaryVersion == null) {
Result.failure(BackupParseException(BackupParseFailure.DETAILS_MISSING))
} else {
Result.success(
UserDetails(
userName = userName,
gjp2 = gjp2,
accountId = accountId,
uuid = uuid,
udid = udid,
binaryVersion = binaryVersion
)
)
}
} catch (e: Exception) {
Log.w(TAG, "parseSaveFile failed: ${e.stackTraceToString()}")
Result.failure(BackupParseException(BackupParseFailure.DECODE_FAILED))
}
fun decryptFileToString(file: File) = Buffer()
.use { decryptedBuffer ->
decryptFileToBuffer(file, decryptedBuffer)
decryptedBuffer.readUtf8()
}
suspend fun getUserDetails(gameManager: File): Result<UserDetails> {
return try {
val base64Decoded = run {
// free decryptString after decoding
val decryptString = decryptFileToString(gameManager)
decryptString.decodeBase64()
} ?: return Result.failure(BackupParseException(BackupParseFailure.DECODE_FAILED))
// gzipSource will close the underlying buffer when closed
GzipSource(
Buffer().write(base64Decoded)
).buffer().use { gzipSource ->
runInterruptible {
val parser = Xml.newPullParser()
parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
parser.setInput(gzipSource.inputStream(), null)
parseSaveFile(parser)
}
}
} catch (e: Exception) {
Log.w(TAG, "getUserDetails failed: ${e.stackTraceToString()}")
return Result.failure(BackupParseException(BackupParseFailure.DECODE_FAILED))
}
}
const val MB_TO_BYTES = 1048576 // 2^20
suspend fun backupSaveToCloud(context: Context): Result<Unit> {
val baseDirectory = LaunchUtils.getBaseDirectory(context)
val gameManager = File(baseDirectory,"/save/CCGameManager.dat")
val localLevels = File(baseDirectory, "/save/CCLocalLevels.dat")
if (!gameManager.isFile || !localLevels.isFile) {
return Result.failure(BackupParseException(BackupParseFailure.SAVE_MISSING))
}
// hardcode 32mb max size
// this is how the game checks it, which probably isn't the best
val totalSize = (gameManager.length() + localLevels.length()) / MB_TO_BYTES.toDouble()
println("Backup - calculated size of %.2fmb".format(totalSize))
if (totalSize > 32.0) {
return Result.failure(BackupParseException(BackupParseFailure.SAVE_TOO_LARGE))
}
return withContext(Dispatchers.IO) {
val userDetails = getUserDetails(gameManager).getOrElse {
return@withContext Result.failure(it)
}
println("Backup - performing as ${userDetails.userName}, accountID=${userDetails.accountId}")
val backupClient = OkHttpClient.Builder()
.writeTimeout(500.seconds)
.build()
val backupUrl = getAccountBackupUrl(backupClient, userDetails).getOrElse {
return@withContext Result.failure(it)
}
performBackupRequest(backupClient, userDetails, backupUrl, gameManager, localLevels).getOrElse {
return@withContext Result.failure(it)
}
println("Backup completed!")
Result.success(Unit)
}
}
@Composable
fun BackupConfirmDialog(onDismiss: () -> Unit, onEnable: () -> Unit) {
AlertDialog(
icon = {
Icon(
painterResource(R.drawable.icon_cloud_alert),
contentDescription = null,
)
},
title = { Text(stringResource(R.string.preference_backup_confirm_title)) },
text = { Text(stringResource(R.string.preference_backup_confirm_description)) },
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.message_box_cancel))
}
},
confirmButton = {
TextButton(onClick = onEnable) {
Text(stringResource(R.string.message_box_continue))
}
},
onDismissRequest = onDismiss
)
}
fun messageFromResult(context: Context, result: Result<Unit>): String {
val exception = result.exceptionOrNull()
?: return context.getString(R.string.preference_backup_success)
return when (exception) {
is BackupParseException -> when (exception.failureCode) {
BackupParseFailure.SAVE_MISSING -> context.getString(R.string.preference_backup_save_missing)
BackupParseFailure.DECODE_FAILED -> context.getString(R.string.preference_backup_save_decode_failed)
BackupParseFailure.DETAILS_MISSING -> context.getString(R.string.preference_backup_save_details_missing)
BackupParseFailure.SAVE_TOO_LARGE -> context.getString(R.string.preference_backup_save_too_large)
}
is BackupUploadException -> when (exception.failureCode) {
BackupUploadFailure.GENERIC_ERROR -> context.getString(R.string.preference_backup_failed, exception.gjCode)
BackupUploadFailure.NETWORK_ERROR -> context.getString(R.string.preference_backup_failed_network)
BackupUploadFailure.INVALID_LOGIN -> context.getString(R.string.preference_backup_failed_invalid_login)
}
else -> context.getString(R.string.preference_backup_failed_generic)
}
}
@Composable
fun BackupDialog(onDismiss: () -> Unit, isCancelling: Boolean) {
AlertDialog(
icon = {
Icon(
painterResource(R.drawable.icon_cloud_upload),
contentDescription = null
)
},
title = {
if (isCancelling) {
Text(stringResource(R.string.preference_backup_cancelling))
} else {
Text(stringResource(R.string.preference_backup_in_progress))
}
},
text = {
LinearProgressIndicator()
},
confirmButton = {
TextButton(onClick = onDismiss, enabled = !isCancelling) {
Text(stringResource(R.string.message_box_cancel))
}
},
onDismissRequest = {}
)
}
@Composable
fun BackupButton(snackbarHostState: SnackbarHostState) {
val context = LocalContext.current
var showBackupDialog by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
var backupInProgress by remember { mutableStateOf(false) }
var backupJob by remember { mutableStateOf<Job?>(null) }
OptionsButton(
title = stringResource(R.string.preference_backup_cloud),
icon = {
Icon(
painterResource(R.drawable.icon_cloud_upload),
contentDescription = null
)
},
) {
showBackupDialog = true
}
if (showBackupDialog) {
BackupConfirmDialog(onDismiss = {
showBackupDialog = false
}) {
showBackupDialog = false
backupJob = coroutineScope.launch {
backupInProgress = true
val responseStatus = backupSaveToCloud(context)
backupInProgress = false
snackbarHostState.showSnackbar(messageFromResult(context, responseStatus))
}
}
}
var isCancelling by remember { mutableStateOf(false) }
if (backupInProgress) {
BackupDialog(onDismiss = {
coroutineScope.launch {
isCancelling = true
backupJob?.cancelAndJoin()
isCancelling = false
backupInProgress = false
}
}, isCancelling)
}
}
@@ -1,490 +0,0 @@
package com.geode.launcher.preferences.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.geode.launcher.R
import com.geode.launcher.ui.theme.Typography
import com.geode.launcher.utils.Profile
import com.geode.launcher.utils.ProfileManager
import kotlin.math.log10
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
@Composable
fun StringDialog(
title: String,
onDismissRequest: () -> Unit,
onSelect: (String) -> Unit,
initialValue: String,
filterInput: ((String) -> String)? = null
) {
var enteredValue by remember { mutableStateOf(initialValue) }
BaseDialog(
onDismissRequest = { onDismissRequest() },
title = title,
content = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
OutlinedTextField(
value = enteredValue,
onValueChange = {
enteredValue = filterInput?.invoke(it) ?: it
},
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Ascii,
autoCorrectEnabled = false
),
trailingIcon = {
if (enteredValue.isNotEmpty()) {
IconButton(onClick = { enteredValue = "" }) {
Icon(
painterResource(R.drawable.icon_close_small),
contentDescription = stringResource(R.string.preference_text_clear)
)
}
}
}
)
}
},
confirmButton = {
TextButton(onClick = { onSelect(enteredValue) }) {
Text(stringResource(R.string.message_box_accept))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
)
}
@Composable
fun RangeDialog(
title: String,
onDismissRequest: () -> Unit,
onSelect: (Int) -> Unit,
labelSuffix: String,
initialValue: Int,
range: IntRange,
scale: Int,
step: Int,
children: @Composable () -> Unit
) {
var enteredValue by remember {
mutableFloatStateOf(initialValue / scale.toFloat())
}
BaseDialog(
onDismissRequest = { onDismissRequest() },
title = title,
content = {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
) {
val precision = log10(scale.toFloat()).roundToInt()
Slider(
value = enteredValue,
onValueChange = { enteredValue = it },
valueRange = (range.first.toFloat()/scale)..(range.last.toFloat()/scale),
steps = ((range.last - range.first) - 1) / step,
modifier = Modifier.weight(1.0f)
)
Text("%.${precision}f$labelSuffix".format(enteredValue))
}
children()
},
confirmButton = {
TextButton(onClick = { onSelect((enteredValue * scale).roundToInt()) }) {
Text(stringResource(R.string.message_box_accept))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
)
}
@Composable
fun FrameRateDialog(
title: String,
onDismissRequest: () -> Unit,
onSelect: (Int) -> Unit,
initialValue: Int,
maxFrameRate: Int,
) {
var enteredValue by remember {
mutableStateOf(
if (initialValue == 0) maxFrameRate.toString()
else initialValue.toString()
)
}
val minFrameRate = 5
val currentValue = enteredValue.toIntOrNull()
val maximumReached = currentValue != null && currentValue > maxFrameRate
val minimumReached = currentValue != null && currentValue < minFrameRate
BaseDialog(
onDismissRequest = { onDismissRequest() },
title = title,
content = {
Column(
modifier = Modifier
.padding(horizontal = 12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = {
val prevValue = max((currentValue ?: 0) - 5, minFrameRate)
enteredValue = prevValue.toString()
}, enabled = currentValue == null || currentValue > minFrameRate) {
Icon(
painterResource(R.drawable.icon_remove),
contentDescription = stringResource(R.string.preference_limit_framerate_subtract)
)
}
Spacer(Modifier.size(8.dp))
OutlinedTextField(
value = enteredValue,
onValueChange = {
enteredValue = it
},
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
),
suffix = {
Text(stringResource(R.string.preference_limit_framerate_suffix))
},
label = {
Text(stringResource(R.string.preference_limit_framerate_label))
},
isError = minimumReached || maximumReached,
modifier = Modifier.weight(1.0f),
)
Spacer(Modifier.size(8.dp))
IconButton(onClick = {
val nextValue = min((currentValue ?: 0) + 5, maxFrameRate)
enteredValue = nextValue.toString()
}, enabled = currentValue == null || currentValue < maxFrameRate) {
Icon(
painterResource(R.drawable.icon_add),
contentDescription = stringResource(R.string.preference_limit_framerate_add)
)
}
IconButton(onClick = { onSelect(0) }) {
Icon(
painterResource(R.drawable.icon_delete),
contentDescription = stringResource(R.string.preference_limit_framerate_reset)
)
}
}
if (minimumReached) {
Spacer(Modifier.size(8.dp))
Text(
stringResource(R.string.preference_limit_framerate_error_min, minFrameRate),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelLarge,
)
} else if (maximumReached) {
Spacer(Modifier.size(8.dp))
Text(
stringResource(
R.string.preference_limit_framerate_error_max,
maxFrameRate
),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.labelLarge,
)
}
}
},
confirmButton = {
TextButton(
onClick = {
// automatically disable if already max
if (currentValue == maxFrameRate) {
onSelect(0)
} else {
onSelect(currentValue ?: 0)
}
},
enabled = !minimumReached && !maximumReached && currentValue != null
) {
Text(stringResource(R.string.message_box_accept))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
)
}
@Composable
fun ProfileCreateDialog(onDismissRequest: () -> Unit) {
var enteredValue by remember { mutableStateOf("") }
val filename = enteredValue.take(16)
.lowercase()
.map {
if ("qwertyuiopasdfghjklzxcvbnm1234567890-_.".contains(it))
it else '_'
}
.joinToString("")
val context = LocalContext.current
val minimumReached = enteredValue.isEmpty() || filename.isEmpty()
val profileManager = ProfileManager.get(context)
val currentProfiles = remember {
profileManager.getProfiles().map { it.path }.toSet()
}
val isDuplicate = currentProfiles.contains(filename)
BaseDialog(
onDismissRequest = { onDismissRequest() },
title = stringResource(R.string.preference_profiles_create),
content = {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
) {
OutlinedTextField(
value = enteredValue,
onValueChange = {
enteredValue = it
},
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
),
label = {
Text(stringResource(R.string.preference_profiles_create_name))
},
isError = isDuplicate,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
if (isDuplicate) {
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.preference_profiles_create_duplicate))
} else if (!minimumReached) {
Spacer(Modifier.size(8.dp))
Text(stringResource(R.string.preference_profiles_create_info, filename))
}
}
},
confirmButton = {
TextButton(
onClick = {
ProfileManager.get(context).storeProfile(Profile(filename, enteredValue))
onDismissRequest()
},
enabled = !minimumReached && !isDuplicate
) {
Text(stringResource(R.string.preference_profiles_create_action))
}
},
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
)
}
@Composable
fun BaseDialog(
title: String,
onDismissRequest: () -> Unit,
dismissButton: @Composable () -> Unit,
confirmButton: @Composable () -> Unit,
content: @Composable (ColumnScope.() -> Unit),
) {
Dialog(onDismissRequest = onDismissRequest) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
) {
// styling a dialog is actually a little hard if you're doing what i'm doing
// maybe there's a better way to make these padding values...
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(
title,
style = Typography.titleLarge,
modifier = Modifier.padding(
start = 28.dp,
top = 24.dp,
bottom = 12.dp
)
)
content()
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(
bottom = 16.dp,
end = 16.dp,
top = 4.dp
)
) {
dismissButton()
confirmButton()
}
}
}
}
}
internal val LocalSelectValue = compositionLocalOf<Any> { 0 }
internal val LocalSelectSetValue = staticCompositionLocalOf<(Any) -> Unit> { {} }
@Composable
fun <T> SelectOption(name: String, value: T, enabled: Boolean = true, leadingContent: @Composable (Boolean) -> Unit = {}) {
val currentValue = LocalSelectValue.current
val setValue = LocalSelectSetValue.current
val isSelected = currentValue.equals(value)
// do not give the row or column padding!! it messes up the selection effect
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable(
onClick = { setValue(value as Any) },
role = Role.RadioButton,
enabled = enabled
)
.padding(horizontal = 12.dp)
) {
Row(modifier = Modifier.weight(1.0f, true), verticalAlignment = Alignment.CenterVertically) {
RadioButton(
selected = isSelected,
onClick = { setValue(value as Any) },
enabled = enabled
)
Text(name, style = Typography.bodyMedium)
}
leadingContent(isSelected)
}
}
@Composable
fun <T> SelectDialog(
title: String,
onDismissRequest: () -> Unit,
onSelect: (T) -> Unit,
initialValue: T,
options: @Composable () -> Unit,
) {
val (selectedValue, setSelectedValue) = remember { mutableStateOf(initialValue) }
BaseDialog(
title = title,
onDismissRequest = onDismissRequest,
dismissButton = {
TextButton(onClick = onDismissRequest) {
Text(stringResource(R.string.message_box_cancel))
}
},
confirmButton = {
TextButton(onClick = { onSelect(selectedValue) }) {
Text(stringResource(R.string.message_box_accept))
}
}
) {
CompositionLocalProvider(
LocalSelectValue provides (selectedValue as Any),
LocalSelectSetValue provides ({
@Suppress("UNCHECKED_CAST")
setSelectedValue(it as T)
})
) {
options()
}
}
}
@@ -1,35 +0,0 @@
package com.geode.launcher.ui.theme
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import com.geode.launcher.R
val robotoMonoFamily = FontFamily(
Font(R.font.robotomono_medium, FontWeight.Medium),
Font(R.font.robotomono_regular, FontWeight.Normal)
)
val quicksandFamily = FontFamily(
Font(R.font.quicksand_semibold, FontWeight.SemiBold)
)
val googleSansFamily = FontFamily(
Font(R.font.googlesans_medium, FontWeight.Medium)
)
val launcherTitleStyle = TextStyle(
fontFamily = quicksandFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 32.sp,
letterSpacing = 0.sp
)
val sapphireTitleStyle = TextStyle(
fontFamily = googleSansFamily,
fontWeight = FontWeight.Medium,
fontSize = 32.sp,
letterSpacing = 0.sp
)
@@ -1,5 +1,6 @@
package com.geode.launcher.ui.theme package com.geode.launcher.ui.theme
import android.app.Activity
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -8,9 +9,14 @@ import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme( private val DarkColorScheme = darkColorScheme(
primary = Purple80, primary = Purple80,
@@ -22,6 +28,16 @@ private val LightColorScheme = lightColorScheme(
primary = Purple40, primary = Purple40,
secondary = PurpleGrey40, secondary = PurpleGrey40,
tertiary = Pink40 tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
) )
enum class Theme { enum class Theme {
@@ -1,7 +1,7 @@
package com.geode.launcher.updater package com.geode.launcher.updater
import com.geode.launcher.utils.LaunchUtils import com.geode.launcher.utils.LaunchUtils
import kotlin.time.Instant import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
@@ -13,7 +13,6 @@ data class Asset(
val createdAt: Instant, val createdAt: Instant,
val updatedAt: Instant, val updatedAt: Instant,
val browserDownloadUrl: String, val browserDownloadUrl: String,
val digest: String?,
) )
@Serializable @Serializable
@@ -30,30 +29,13 @@ data class Release(
val name: String?, val name: String?,
) )
@Serializable
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 @Serializable
data class LoaderVersion( data class LoaderVersion(
val tag: String, val tag: String,
val version: String, val version: String,
val createdAt: Instant, val createdAt: Instant,
val commitHash: String, val commitHash: String,
val prerelease: Boolean, val prerelease: Boolean
val downloads: LoaderDownload,
) )
@Serializable @Serializable
@@ -62,46 +44,12 @@ data class LoaderPayload<T>(
val error: String val error: String
) )
data class DownloadableAsset( data class DownloadableAsset(val url: String, val filename: String, val size: Long? = null)
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 { abstract class Downloadable {
/**
* Human-readable representation of the current version.
*/
abstract fun getDescription(): String abstract fun getDescription(): String
/**
* Unique value that refers to the current version, used for comparison.
*/
abstract fun getDescriptor(): Long abstract fun getDescriptor(): Long
/**
* Release download link for the current platform
*/
abstract fun getDownload(): DownloadableAsset? abstract fun getDownload(): DownloadableAsset?
/**
* Resources download link for current platform.
*/
abstract fun getResourcesDownload(): DownloadableAsset?
} }
class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadable() { class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadable() {
@@ -134,15 +82,11 @@ class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadab
override fun getDownload(): DownloadableAsset? { override fun getDownload(): DownloadableAsset? {
val download = getGitHubDownload() ?: return null val download = getGitHubDownload() ?: return null
return mapDownload(download) return DownloadableAsset(
} url = download.browserDownloadUrl,
filename = download.name,
override fun getResourcesDownload(): DownloadableAsset? { size = download.size.toLong()
val download = release.assets.find { )
it.name == "resources.zip"
} ?: return null
return mapDownload(download)
} }
} }
@@ -168,17 +112,18 @@ class DownloadableLauncherRelease(val release: Release) : Downloadable() {
* 0 | 0 | 1 * 0 | 0 | 1
* surprise! it's an xnor * surprise! it's an xnor
*/ */
asset.name.endsWith("apk") && asset.name.contains("android32") == use32BitPlatform
(asset.name.contains("android32")) == use32BitPlatform
} }
} }
override fun getDownload(): DownloadableAsset? { override fun getDownload(): DownloadableAsset? {
val download = getGitHubDownload() ?: return null val download = getGitHubDownload() ?: return null
return mapDownload(download) return DownloadableAsset(
} url = download.browserDownloadUrl,
filename = download.name,
override fun getResourcesDownload(): DownloadableAsset? { size = download.size.toLong()
return null )
} }
} }
@@ -191,23 +136,11 @@ class DownloadableLoaderRelease(private val version: LoaderVersion) : Downloadab
return version.createdAt.epochSeconds return version.createdAt.epochSeconds
} }
override fun getDownload(): DownloadableAsset { override fun getDownload(): DownloadableAsset? {
val data = if (LaunchUtils.is64bit) version.downloads.android64 else version.downloads.android32
val filename = "geode-${version.tag}-${LaunchUtils.platformName}.zip" val filename = "geode-${version.tag}-${LaunchUtils.platformName}.zip"
return DownloadableAsset( return DownloadableAsset(
url = data.url, url = "https://github.com/geode-sdk/geode/releases/download/${version.tag}/$filename",
filename = filename, filename = filename
hash = data.hash,
)
}
override fun getResourcesDownload(): DownloadableAsset {
val data = version.downloads.resources
val filename = "resources.zip"
return DownloadableAsset(
url = data.url,
filename = filename,
hash = data.hash,
) )
} }
} }
@@ -18,18 +18,10 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.Cache import okhttp3.Cache
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okio.HashingSink
import okio.blackholeSink
import okio.buffer import okio.buffer
import okio.sink
import okio.source import okio.source
import java.io.File import java.io.File
import java.io.FileNotFoundException
import java.io.InterruptedIOException import java.io.InterruptedIOException
import java.io.IOException
import kotlin.math.max
import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes
private const val TAG_LATEST = "latest" private const val TAG_LATEST = "latest"
private const val TAG_BETA = "prerelease" private const val TAG_BETA = "prerelease"
@@ -75,8 +67,7 @@ class ReleaseManager private constructor(
class UpdateException(reason: Reason? = null, cause: Throwable? = null) : Exception(reason?.name, cause) { class UpdateException(reason: Reason? = null, cause: Throwable? = null) : Exception(reason?.name, cause) {
enum class Reason { enum class Reason {
EXTERNAL_FILE_IN_USE, EXTERNAL_FILE_IN_USE
HASH_VALIDATION_FAILED
} }
var reason: Reason? = reason var reason: Reason? = reason
@@ -91,18 +82,10 @@ class ReleaseManager private constructor(
val isInUpdate: Boolean val isInUpdate: Boolean
get() = _uiState.value !is ReleaseManagerState.Failure && _uiState.value !is ReleaseManagerState.Finished get() = _uiState.value !is ReleaseManagerState.Failure && _uiState.value !is ReleaseManagerState.Finished
private val _availableLauncherUpdateTag = MutableStateFlow<String?>(null) private val _availableLauncherUpdate = MutableStateFlow<DownloadableLauncherRelease?>(null)
val availableLauncherUpdateTag = _availableLauncherUpdateTag.asStateFlow() val availableLauncherUpdate = _availableLauncherUpdate.asStateFlow()
private val _availableLauncherUpdateDetails = MutableStateFlow<DownloadableLauncherRelease?>(null)
val availableLauncherUpdateDetails = _availableLauncherUpdateDetails.asStateFlow()
var dismissedLauncherUpdate = false
var skipStateUpdate = false
private fun sendError(e: Exception) { private fun sendError(e: Exception) {
skipStateUpdate = true
_uiState.value = ReleaseManagerState.Failure(e) _uiState.value = ReleaseManagerState.Failure(e)
// ignore cancellation, it's good actually // ignore cancellation, it's good actually
@@ -139,6 +122,7 @@ class ReleaseManager private constructor(
val gameVersion = GamePackageUtils.getGameVersionCode(applicationContext.packageManager) val gameVersion = GamePackageUtils.getGameVersionCode(applicationContext.packageManager)
val preferences = PreferenceUtils.get(applicationContext) val preferences = PreferenceUtils.get(applicationContext)
if (preferences.getBoolean(PreferenceUtils.Key.USE_INDEX_API)) {
val targetTag = preferences.getInt(PreferenceUtils.Key.RELEASE_CHANNEL_TAG) val targetTag = preferences.getInt(PreferenceUtils.Key.RELEASE_CHANNEL_TAG)
return when (targetTag) { return when (targetTag) {
@@ -146,98 +130,46 @@ class ReleaseManager private constructor(
1 -> releaseRepository.getLatestIndexRelease(gameVersion, true) 1 -> releaseRepository.getLatestIndexRelease(gameVersion, true)
else -> releaseRepository.getLatestIndexRelease(gameVersion, false) else -> releaseRepository.getLatestIndexRelease(gameVersion, false)
} }
} else {
val targetTag = getBestReleaseForGameVersion(gameVersion) ?: return null
return when (targetTag) {
TAG_LATEST -> releaseRepository.getLatestGeodeRelease()
TAG_BETA -> releaseRepository.getLatestGeodePreRelease()
else -> releaseRepository.getReleaseByTag(targetTag)
}
}
} }
private suspend fun downloadLauncherUpdate(release: Downloadable): Result<File> { private suspend fun downloadLauncherUpdate(release: Downloadable) {
val download = release.getDownload() val download = release.getDownload() ?: return
?: return Result.failure(FileNotFoundException())
val outputDirectory = applicationContext.filesDir val outputDirectory = LaunchUtils.getBaseDirectory(applicationContext)
val outputFile = File(outputDirectory, download.filename) val outputFile = File(outputDirectory, download.filename)
try { if (outputFile.exists()) {
val hash = DownloadUtils.downloadFile( // only download the apk once
httpClient, return
download.url,
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)
} }
return Result.success(outputFile) _uiState.value = ReleaseManagerState.InDownload(0, download.size)
}
private suspend fun performResourceDownload(resourceAsset: DownloadableAsset, initialSize: Long): Boolean {
val guessSize = if (resourceAsset.size == null) null else initialSize + resourceAsset.size
val guessInitial = if (guessSize == null) 0 else initialSize
_uiState.value = ReleaseManagerState.InDownload(guessInitial, guessSize)
val outputDir = getTempResourcesDirectory()
val outputPath = outputDir.path
val finalDir = LaunchUtils.getGeodeResourcesDirectory(applicationContext)
val downloadFile = getTempFile("resources.zip")
try { try {
if (!outputDir.exists()) { val fileStream = DownloadUtils.downloadStream(
outputDir.mkdirs()
}
val hash = DownloadUtils.downloadFile(
httpClient, httpClient,
resourceAsset.url, download.url
downloadFile, ) { progress, outOf ->
onProgress = { progress, outOf -> _uiState.value = ReleaseManagerState.InDownload(progress, outOf)
if (!skipStateUpdate)
_uiState.value = ReleaseManagerState.InDownload(initialSize + progress, outOf + initialSize)
},
)
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 -> fileStream.copyTo(outputFile.outputStream())
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) { } catch (e: Exception) {
sendError(e) sendError(e)
return false return
} finally {
val tempPathClone = File(outputPath)
try {
if (downloadFile.exists()) downloadFile.delete()
if (tempPathClone.exists()) tempPathClone.deleteRecursively()
} catch (_: IOException) {}
} }
return true
} }
private suspend fun performUpdate(release: Downloadable) { private suspend fun performUpdate(release: Downloadable) {
skipStateUpdate = false
val releaseAsset = release.getDownload() val releaseAsset = release.getDownload()
if (releaseAsset == null) { if (releaseAsset == null) {
val noAssetException = Exception("missing Android download") val noAssetException = Exception("missing Android download")
@@ -246,92 +178,40 @@ class ReleaseManager private constructor(
return return
} }
val resourcesAsset = release.getResourcesDownload()
var releaseSize = releaseAsset.size ?: 0L
val resourcesSize = resourcesAsset?.size ?: 0L
// set an initial download size // set an initial download size
val initialSize = if (releaseAsset.size == null && resourcesAsset?.size == null) null else releaseSize + resourcesSize _uiState.value = ReleaseManagerState.InDownload(0, releaseAsset.size)
_uiState.value = ReleaseManagerState.InDownload(0, initialSize)
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 { try {
// tempFile should be in same path as geodeFile val fileStream = DownloadUtils.downloadStream(
val geodeParent = geodeFile.parentFile
if (geodeParent != null && !geodeParent.exists()) {
geodeParent.mkdirs()
}
val hash = DownloadUtils.downloadFile(
httpClient, httpClient,
releaseAsset.url, releaseAsset.url
downloadFile, ) { progress, outOf ->
onProgress = { progress, outOf -> _uiState.value = ReleaseManagerState.InDownload(progress, outOf)
if (!skipStateUpdate)
_uiState.value = ReleaseManagerState.InDownload(progress, outOf + resourcesSize)
releaseSize = max(outOf, releaseSize)
}
)
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 -> val geodeFile = getGeodeOutputPath()
DownloadUtils.extractFileFromZipStream(
zip,
outputFile,
geodeFile.name
)
}
// work around a permission issue from adb push // work around a permission issue from adb push
if (geodeFile.exists()) { if (geodeFile.exists()) {
geodeFile.delete() geodeFile.delete()
} }
val renameSuccessful = runCatching { geodeFile.parentFile?.mkdirs()
outputFile.renameTo(geodeFile)
}.getOrDefault(false)
if (!renameSuccessful) { DownloadUtils.extractFileFromZipStream(
// attempt a manual copy if rename fails for whatever reason fileStream,
DownloadUtils.copyFile(outputFile, geodeFile) geodeFile.outputStream(),
} geodeFile.name
)
} catch (e: Exception) { } catch (e: Exception) {
sendError(e) sendError(e)
return return
} finally {
val tempFileClone = File(tempFilePath)
try {
if (downloadFile.exists()) downloadFile.delete()
if (tempFileClone.exists()) tempFileClone.delete()
} catch (_: IOException) {}
} }
if (resourcesAsset != null) { downloadLauncherUpdateIfNecessary()
if (!performResourceDownload(resourcesAsset, releaseSize)) {
return
}
}
// extraction performed // extraction performed
updatePreferences(release) updatePreferences(release)
val currentTime = Clock.System.now().toEpochMilliseconds()
PreferenceUtils.get(applicationContext)
.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
_uiState.value = ReleaseManagerState.Finished(true) _uiState.value = ReleaseManagerState.Finished(true)
} }
@@ -345,104 +225,25 @@ class ReleaseManager private constructor(
val sharedPreferences = PreferenceUtils.get(applicationContext) val sharedPreferences = PreferenceUtils.get(applicationContext)
val originalFileHash = sharedPreferences.getString(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED) val originalFileHash = sharedPreferences.getString(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED)
?: return true ?: return false
val currentFileHash = computeFileHash(geodeFile) val currentFileHash = computeFileHash(geodeFile)
return originalFileHash != currentFileHash return originalFileHash != currentFileHash
} }
private suspend fun getLatestLauncherUpdate(): DownloadableLauncherRelease? { private fun checkLauncherUpdate(launcherUpdate: DownloadableLauncherRelease) {
if (BuildConfig.GOOGLE_PLAY_BUILD) { if (launcherUpdate.release.tagName != BuildConfig.VERSION_NAME) {
// don't check for updates on Google Play builds _availableLauncherUpdate.value = launcherUpdate
return null
}
return try {
releaseRepository.getLatestLauncherRelease()
} catch (e: Exception) {
sendError(e)
return null
} }
} }
suspend fun checkLauncherUpdate() { private suspend fun downloadLauncherUpdateIfNecessary() {
if (BuildConfig.GOOGLE_PLAY_BUILD) return val update = _availableLauncherUpdate.value ?: return
downloadLauncherUpdate(update)
val launcherRelease = getLatestLauncherUpdate() ?: return
// unconditionally track it, we're not using it for checks and it might be good to have
_availableLauncherUpdateDetails.value = launcherRelease
val launcherTag = launcherRelease.release.tagName
PreferenceUtils.get(applicationContext)
.setString(PreferenceUtils.Key.LAST_LAUNCHER_UPDATE, launcherTag)
if (launcherTag != BuildConfig.VERSION_NAME) {
_availableLauncherUpdateTag.value = launcherTag
}
}
fun checkCachedLauncherUpdate() {
if (BuildConfig.GOOGLE_PLAY_BUILD) return
val lastUpdate = PreferenceUtils.get(applicationContext)
.getString(PreferenceUtils.Key.LAST_LAUNCHER_UPDATE) ?: return
if (lastUpdate != BuildConfig.VERSION_NAME) {
_availableLauncherUpdateTag.value = lastUpdate
}
}
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())
return downloadLauncherUpdate(update)
}
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
}
val lastCheckTime = sharedPreferences.getLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME)
// only check for updates if it's been over 15 minutes since last check
val checkMinTime = Clock.System.now().minus(15.minutes).toEpochMilliseconds()
return lastCheckTime > checkMinTime && !fileWasExternallyModified()
}
fun afterUseCachedUpdate() {
checkCachedLauncherUpdate()
_uiState.tryEmit(ReleaseManagerState.Finished())
} }
private suspend fun checkForNewRelease(isManual: Boolean = false) { 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()) {
afterUseCachedUpdate()
return
}
val release = try { val release = try {
getLatestRelease() getLatestRelease()
} catch (e: Exception) { } catch (e: Exception) {
@@ -450,23 +251,32 @@ class ReleaseManager private constructor(
return return
} }
checkLauncherUpdate() val launcherRelease = try {
releaseRepository.getLatestLauncherRelease()
} catch (e: Exception) {
sendError(e)
return
}
if (launcherRelease != null) {
checkLauncherUpdate(launcherRelease)
}
if (release == null) { if (release == null) {
val currentTime = Clock.System.now().toEpochMilliseconds() downloadLauncherUpdateIfNecessary()
sharedPreferences.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
_uiState.value = ReleaseManagerState.Finished() _uiState.value = ReleaseManagerState.Finished()
return return
} }
val sharedPreferences = PreferenceUtils.get(applicationContext)
val currentVersion = sharedPreferences.getLong(PreferenceUtils.Key.CURRENT_VERSION_TIMESTAMP) val currentVersion = sharedPreferences.getLong(PreferenceUtils.Key.CURRENT_VERSION_TIMESTAMP)
val latestVersion = release.getDescriptor() val latestVersion = release.getDescriptor()
// check if an update is needed // check if an update is needed
if (latestVersion == currentVersion && !fileWasExternallyModified()) { if (latestVersion == currentVersion && !fileWasExternallyModified()) {
val currentTime = Clock.System.now().toEpochMilliseconds() downloadLauncherUpdateIfNecessary()
sharedPreferences.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
_uiState.value = ReleaseManagerState.Finished() _uiState.value = ReleaseManagerState.Finished()
return return
@@ -476,6 +286,8 @@ class ReleaseManager private constructor(
// check if the file was externally modified // check if the file was externally modified
if (!allowOverwriting && fileWasExternallyModified(true)) { if (!allowOverwriting && fileWasExternallyModified(true)) {
downloadLauncherUpdateIfNecessary()
sendError(UpdateException(UpdateException.Reason.EXTERNAL_FILE_IN_USE)) sendError(UpdateException(UpdateException.Reason.EXTERNAL_FILE_IN_USE))
return return
} }
@@ -503,14 +315,8 @@ class ReleaseManager private constructor(
sharedPreferences.setString(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED, fileHash) sharedPreferences.setString(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED, fileHash)
} }
private fun computeFileHash(file: File): String { private fun computeFileHash(file: File): String =
val sink = HashingSink.md5(blackholeSink()) file.source().buffer().readByteString().md5().hex()
file.source().buffer().use {
it.readAll(sink)
}
return sink.hash.hex()
}
private fun getGeodeOutputPath(): File { private fun getGeodeOutputPath(): File {
val geodeName = LaunchUtils.geodeFilename val geodeName = LaunchUtils.geodeFilename
@@ -519,40 +325,6 @@ class ReleaseManager private constructor(
return File(geodeDirectory, geodeName) return File(geodeDirectory, geodeName)
} }
private fun createRandomString(): String {
val alphabet = ('A'..'Z') + ('a'..'z') + ('0'..'9')
return buildString(8) {
repeat(8) { append(alphabet.random()) }
}
}
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.$filename"
val tempFile = File(geodeDirectory, tmpName)
return tempFile
}
private fun getTempResourcesDirectory(): File {
val finalDir = LaunchUtils.getGeodeResourcesDirectory(applicationContext)
val suffix = createRandomString()
val tempDirName = "tmp-$suffix-geode.loader"
val parentDir = finalDir.parentFile
return if (parentDir != null) {
File(finalDir.parentFile, tempDirName)
} else {
File(tempDirName)
}
}
/** /**
* Cancels the current update job. * Cancels the current update job.
*/ */
@@ -570,14 +342,10 @@ class ReleaseManager private constructor(
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
fun checkForUpdates(isManual: Boolean = false): StateFlow<ReleaseManagerState> { fun checkForUpdates(isManual: Boolean = false): StateFlow<ReleaseManagerState> {
if (!isInUpdate) { if (!isInUpdate) {
if (isManual || !shouldUseCache()) {
_uiState.value = ReleaseManagerState.InUpdateCheck _uiState.value = ReleaseManagerState.InUpdateCheck
updateJob = GlobalScope.launch { updateJob = GlobalScope.launch {
checkForNewRelease(isManual) checkForNewRelease(isManual)
} }
} else {
afterUseCachedUpdate()
}
} }
return _uiState.asStateFlow() return _uiState.asStateFlow()
@@ -1,23 +1,19 @@
package com.geode.launcher.updater package com.geode.launcher.updater
import com.geode.launcher.BuildConfig import com.geode.launcher.utils.DownloadUtils.executeCoroutine
import com.geode.launcher.utils.LaunchUtils import kotlinx.datetime.Clock
import kotlin.time.Clock
import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.DateTimeUnit
import kotlin.time.Instant import kotlinx.datetime.Instant
import kotlinx.datetime.until import kotlinx.datetime.until
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy import kotlinx.serialization.json.JsonNamingStrategy
import kotlinx.serialization.json.okio.decodeFromBufferedSource import kotlinx.serialization.json.okio.decodeFromBufferedSource
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttp
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.coroutines.executeAsync
import java.io.IOException import java.io.IOException
import java.net.URL import java.net.URL
import kotlin.time.ExperimentalTime
class ReleaseRepository(private val httpClient: OkHttpClient) { class ReleaseRepository(private val httpClient: OkHttpClient) {
companion object { companion object {
@@ -31,10 +27,8 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
private const val GEODE_API_BASE = "https://api.geode-sdk.org/v1" private const val GEODE_API_BASE = "https://api.geode-sdk.org/v1"
} }
private val userAgent = "okhttp/${OkHttp.VERSION} (launcher=${BuildConfig.VERSION_NAME};arch=${LaunchUtils.applicationArchitecture})"
suspend fun getLatestLauncherRelease(): DownloadableLauncherRelease? { suspend fun getLatestLauncherRelease(): DownloadableLauncherRelease? {
val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/android-launcher/releases/latest" val releasePath = "$GITHUB_API_BASE/repos/Lncvrt/xps-geode-launcher/releases/latest"
val url = URL(releasePath) val url = URL(releasePath)
@@ -55,7 +49,7 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
return getReleaseByUrl(url)?.let(::DownloadableGitHubLoaderRelease) return getReleaseByUrl(url)?.let(::DownloadableGitHubLoaderRelease)
} }
@OptIn(ExperimentalSerializationApi::class, ExperimentalTime::class) @OptIn(ExperimentalSerializationApi::class)
suspend fun getLatestGeodePreRelease(): DownloadableGitHubLoaderRelease? { suspend fun getLatestGeodePreRelease(): DownloadableGitHubLoaderRelease? {
val releasesUrl = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases?per_page=2" val releasesUrl = "$GITHUB_API_BASE/repos/geode-sdk/geode/releases?per_page=2"
val url = URL(releasesUrl) val url = URL(releasesUrl)
@@ -64,13 +58,12 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
.url(url) .url(url)
.addHeader("Accept", "application/json") .addHeader("Accept", "application/json")
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION) .addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
.addHeader("User-Agent", userAgent)
.build() .build()
val call = httpClient.newCall(request) val call = httpClient.newCall(request)
val response = call.executeCoroutine()
return call.executeAsync().use { response -> return when (response.code) {
when (response.code) {
200 -> { 200 -> {
val format = Json { val format = Json {
namingStrategy = JsonNamingStrategy.SnakeCase namingStrategy = JsonNamingStrategy.SnakeCase
@@ -78,7 +71,7 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
} }
val release = format.decodeFromBufferedSource<List<Release>>( val release = format.decodeFromBufferedSource<List<Release>>(
response.body.source() response.body!!.source()
) )
(release.find { it.tagName != "nightly" }) (release.find { it.tagName != "nightly" })
@@ -100,7 +93,7 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
throw IOException(msg) throw IOException(msg)
} }
throw IOException("response 403: ${response.body.string()}") throw IOException("response 403: ${response.body!!.string()}")
} }
404 -> { 404 -> {
null null
@@ -110,9 +103,7 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
} }
} }
} }
}
@OptIn(ExperimentalTime::class)
private fun generateRateLimitMessage(resetTime: Instant): String { private fun generateRateLimitMessage(resetTime: Instant): String {
val currentTime = Clock.System.now() val currentTime = Clock.System.now()
val resetDelay = currentTime.until(resetTime, DateTimeUnit.SECOND) val resetDelay = currentTime.until(resetTime, DateTimeUnit.SECOND)
@@ -122,18 +113,18 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
return "api ratelimit reached, try again in ${formattedWait}" return "api ratelimit reached, try again in ${formattedWait}"
} }
@OptIn(ExperimentalSerializationApi::class, ExperimentalTime::class) @OptIn(ExperimentalSerializationApi::class)
private suspend fun getReleaseByUrl(url: URL): Release? { private suspend fun getReleaseByUrl(url: URL): Release? {
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(url)
.addHeader("Accept", "application/json") .addHeader("Accept", "application/json")
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION) .addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
.addHeader("User-Agent", userAgent)
.build() .build()
val call = httpClient.newCall(request) val call = httpClient.newCall(request)
return call.executeAsync().use { response -> val response = call.executeCoroutine()
when (response.code) {
return when (response.code) {
200 -> { 200 -> {
val format = Json { val format = Json {
namingStrategy = JsonNamingStrategy.SnakeCase namingStrategy = JsonNamingStrategy.SnakeCase
@@ -141,7 +132,7 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
} }
val release = format.decodeFromBufferedSource<Release>( val release = format.decodeFromBufferedSource<Release>(
response.body.source() response.body!!.source()
) )
release release
@@ -162,7 +153,7 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
throw IOException(msg) throw IOException(msg)
} }
throw IOException("response 403: ${response.body.string()}") throw IOException("response 403: ${response.body!!.string()}")
} }
404 -> { 404 -> {
null null
@@ -172,7 +163,6 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
} }
} }
} }
}
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
suspend fun getLatestIndexRelease(gd: Long, prerelease: Boolean = false): DownloadableLoaderRelease? { suspend fun getLatestIndexRelease(gd: Long, prerelease: Boolean = false): DownloadableLoaderRelease? {
@@ -186,12 +176,12 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(url)
.addHeader("Accept", "application/json") .addHeader("Accept", "application/json")
.addHeader("User-Agent", userAgent)
.build() .build()
val call = httpClient.newCall(request) val call = httpClient.newCall(request)
return call.executeAsync().use { response -> val response = call.executeCoroutine()
when (response.code) {
return when (response.code) {
200 -> { 200 -> {
val format = Json { val format = Json {
namingStrategy = JsonNamingStrategy.SnakeCase namingStrategy = JsonNamingStrategy.SnakeCase
@@ -199,7 +189,7 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
} }
val body = format.decodeFromBufferedSource<LoaderPayload<LoaderVersion>>( val body = format.decodeFromBufferedSource<LoaderPayload<LoaderVersion>>(
response.body.source() response.body!!.source()
) )
val release = body.payload ?: throw IOException("index error: ${body.error}") val release = body.payload ?: throw IOException("index error: ${body.error}")
@@ -215,4 +205,3 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
} }
} }
} }
}
@@ -7,7 +7,6 @@ import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.AP
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory import androidx.lifecycle.viewmodel.viewModelFactory
import com.geode.launcher.BuildConfig
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -54,7 +53,7 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
val isInUpdate val isInUpdate
get() = ReleaseManager.get(application).isInUpdate get() = ReleaseManager.get(application).isInUpdate
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdateTag val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdate
var hasPerformedCheck = false var hasPerformedCheck = false
private set private set
@@ -90,10 +89,6 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
} }
fun runReleaseCheck(isManual: Boolean = false) { fun runReleaseCheck(isManual: Boolean = false) {
if (BuildConfig.GOOGLE_PLAY_BUILD) {
return
}
hasPerformedCheck = true hasPerformedCheck = true
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
@@ -3,7 +3,7 @@ package com.geode.launcher.utils
import android.annotation.SuppressLint import android.annotation.SuppressLint
object Constants { object Constants {
const val PACKAGE_NAME = "com.robtopx.geometryjump" const val PACKAGE_NAME = "xyz.lncvrt.xps0000002200"
const val COCOS_LIB_NAME = "cocos2dcpp" const val COCOS_LIB_NAME = "cocos2dcpp"
const val FMOD_LIB_NAME = "fmod" const val FMOD_LIB_NAME = "fmod"
@@ -17,10 +17,10 @@ object Constants {
const val SUPPORTED_VERSION_CODE_MIN = 37L const val SUPPORTED_VERSION_CODE_MIN = 37L
// anything below this version code shows a warning on the main menu // anything below this version code shows a warning on the main menu
const val SUPPORTED_VERSION_CODE_MIN_WARNING = 41L const val SUPPORTED_VERSION_CODE_MIN_WARNING = 40L
// anything above this version code will show a warning when starting the game // anything above this version code will show a warning when starting the game
const val SUPPORTED_VERSION_CODE = 41L const val SUPPORTED_VERSION_CODE = 40L
const val SUPPORTED_VERSION_STRING = "2.2.144" const val SUPPORTED_VERSION_STRING = "2.2.143"
} }
@@ -2,63 +2,37 @@ package com.geode.launcher.utils
import android.os.Build import android.os.Build
import android.os.FileUtils import android.os.FileUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType import okhttp3.MediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody import okhttp3.Response
import okhttp3.ResponseBody import okhttp3.ResponseBody
import okhttp3.coroutines.executeAsync
import okio.Buffer import okio.Buffer
import okio.BufferedSink
import okio.BufferedSource import okio.BufferedSource
import okio.FileSystem
import okio.ForwardingSink
import okio.ForwardingSource import okio.ForwardingSource
import okio.HashingSink
import okio.Path.Companion.toOkioPath
import okio.Sink
import okio.Source import okio.Source
import okio.buffer import okio.buffer
import okio.sink
import java.io.File
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
typealias ProgressCallback = (progress: Long, outOf: Long) -> Unit typealias ProgressCallback = (progress: Long, outOf: Long) -> Unit
object DownloadUtils { 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( suspend fun downloadStream(
httpClient: OkHttpClient, httpClient: OkHttpClient,
url: String, url: String,
onProgress: ProgressCallback? = null, onProgress: ProgressCallback? = null
): ResponseBody { ): InputStream {
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(url)
.build() .build()
@@ -76,7 +50,7 @@ object DownloadUtils {
val originalResponse = chain.proceed(chain.request()) val originalResponse = chain.proceed(chain.request())
originalResponse.newBuilder() originalResponse.newBuilder()
.body(ProgressResponseBody( .body(ProgressResponseBody(
originalResponse.body, onProgress originalResponse.body!!, onProgress
)) ))
.build() .build()
} }
@@ -85,67 +59,50 @@ object DownloadUtils {
val progressClient = progressClientBuilder.build() val progressClient = progressClientBuilder.build()
val call = progressClient.newCall(request) val call = progressClient.newCall(request)
val response = call.executeAsync() val response = call.executeCoroutine()
val responseCode = response.code return response.body!!.byteStream()
if (responseCode != 200) {
response.close()
throw IOException("unexpected response ${response.code}")
} }
return response.body suspend fun Call.executeCoroutine(): Response {
return suspendCancellableCoroutine { continuation ->
this.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (continuation.isCancelled) {
return
} }
suspend fun copyZipStreamToDirectory(inputStream: InputStream, output: File) = runInterruptible { continuation.resumeWithException(e)
ZipInputStream(inputStream).use { zipStream ->
if (output.exists()) output.deleteRecursively()
output.mkdirs()
val canonicalOutput = output.canonicalPath
var entry = zipStream.nextEntry
while (entry != null) {
val destination = File(output, entry.name)
if (!destination.canonicalPath.startsWith(canonicalOutput)) {
throw IOException("attempted copy to outside of output directory: ${entry.name}")
} }
if (destination.isDirectory) { override fun onResponse(call: Call, response: Response) {
destination.mkdirs() continuation.resume(response)
} else {
destination.parentFile?.mkdirs()
destination.outputStream().use { destinationStream ->
zipStream.copyTo(destinationStream)
}
} }
})
entry = zipStream.nextEntry continuation.invokeOnCancellation {
this.cancel()
} }
} }
} }
/** suspend fun extractFileFromZipStream(inputStream: InputStream, outputStream: OutputStream, zipPath: String) {
* Extracts a file named by zipPath from inputStream and copies it to outputStream
*
* inputStream and outputStream are both closed after this function executes
*/
suspend fun extractFileFromZipStream(inputStream: InputStream, output: File, zipPath: String) {
// note to self: ZipInputStreams are a little silly // note to self: ZipInputStreams are a little silly
// (runInterruptible allows it to cancel, otherwise it waits for the stream to finish) // (runInterruptible allows it to cancel, otherwise it waits for the stream to finish)
runInterruptible { runInterruptible {
inputStream.use { input -> inputStream.use {
output.outputStream().use { out -> outputStream.use {
// nice indentation // nice indentation
ZipInputStream(input).use { zip -> val zip = ZipInputStream(inputStream)
var entry = zip.nextEntry zip.use {
var entry = it.nextEntry
while (entry != null) { while (entry != null) {
if (entry.name == zipPath) { if (entry.name == zipPath) {
zip.copyTo(out) it.copyTo(outputStream)
return@runInterruptible return@runInterruptible
} }
entry = zip.nextEntry entry = it.nextEntry
} }
// no matching entries found, throw exception // no matching entries found, throw exception
@@ -156,22 +113,6 @@ object DownloadUtils {
} }
} }
suspend fun copyFile(from: File, to: File) {
val fs = FileSystem.SYSTEM
val fromPath = from.toOkioPath()
val toPath = to.toOkioPath()
withContext(Dispatchers.IO) {
fs.copy(fromPath, toPath)
}
}
/**
* Copies data from inputStream to outputStream
*
* Both streams are closed upon the conclusion of this function.
*/
fun copyFile(inputStream: InputStream, outputStream: OutputStream) { fun copyFile(inputStream: InputStream, outputStream: OutputStream) {
// gotta love copying // gotta love copying
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -184,13 +125,6 @@ object DownloadUtils {
}} }}
} }
} }
suspend fun copyDirectory(sourceFile: File, outputFile: File) = withContext(Dispatchers.IO) {
sourceFile.copyRecursively(
outputFile,
overwrite = true
)
}
} }
// based on <https://github.com/square/okhttp/blob/master/samples/guide/src/main/java/okhttp3/recipes/Progress.java> // based on <https://github.com/square/okhttp/blob/master/samples/guide/src/main/java/okhttp3/recipes/Progress.java>
@@ -233,38 +167,3 @@ private class ProgressResponseBody (
} }
} }
} }
// now based on <https://github.com/square/okhttp/blob/master/samples/guide/src/main/java/okhttp3/recipes/UploadProgress.java#L84>
class ProgressRequestBody(
private val requestBody: RequestBody,
private val progressCallback: ProgressCallback
) : RequestBody() {
override fun contentType(): MediaType? {
return requestBody.contentType()
}
override fun contentLength(): Long {
return requestBody.contentLength()
}
override fun writeTo(sink: BufferedSink) {
val bufferedSink = this.sink(sink).buffer()
requestBody.writeTo(bufferedSink)
bufferedSink.flush()
}
private fun sink(sink: Sink): Sink {
return object : ForwardingSink(sink) {
var totalBytesWritten = 0L
override fun write(source: Buffer, byteCount: Long) {
super.write(source, byteCount)
totalBytesWritten += byteCount
progressCallback(
totalBytesWritten,
contentLength()
)
}
}
}
}
@@ -8,7 +8,6 @@ import android.os.Environment
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.MediaStore import android.provider.MediaStore
import java.io.File import java.io.File
import androidx.core.net.toUri
class FileUtils { class FileUtils {
// copied from https://stackoverflow.com/questions/17546101/get-real-path-for-uri-android // copied from https://stackoverflow.com/questions/17546101/get-real-path-for-uri-android
@@ -87,7 +86,7 @@ class FileUtils {
val file = File(id) val file = File(id)
if (file.exists()) return id if (file.exists()) return id
} }
val contentUri = ContentUris.withAppendedId("content://downloads/public_downloads".toUri(), java.lang.Long.valueOf(id)) val contentUri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), java.lang.Long.valueOf(id))
return getDataColumn(context, contentUri, null, null) return getDataColumn(context, contentUri, null, null)
} }
@@ -51,9 +51,7 @@ object GamePackageUtils {
37L to "2.200", 37L to "2.200",
38L to "2.205", 38L to "2.205",
39L to "2.206", 39L to "2.206",
40L to "2.2074", // ok 40L to "2.2074" // ok
41L to "2.2081",
42L to "2.209",
) )
/** /**
@@ -64,25 +62,14 @@ object GamePackageUtils {
return gameVersionMap[versionCode] ?: getGameVersionString(packageManager) 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 { fun detectAbiMismatch(context: Context, packageInfo: PackageInfo, loadException: Error): Boolean {
val abi = LaunchUtils.applicationArchitecture val abi = LaunchUtils.applicationArchitecture
val isExtracted = val isExtracted =
packageInfo.applicationInfo!!.flags and ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS == ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS packageInfo.applicationInfo!!.flags and ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS == ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS
val isSplit = (packageInfo.applicationInfo?.splitSourceDirs?.size ?: 0) > 1 val isSplit = (packageInfo.applicationInfo?.splitSourceDirs?.size ?: 0) > 1
val versionCode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode
} else {
@Suppress("DEPRECATION")
packageInfo.versionCode.toLong()
}
val metadata = val metadata =
"Geometry Dash metadata:\nSplit sources: $isSplit\nExtracted libraries: $isExtracted\nLauncher architecture: $abi\nGame version: $versionCode" "Geometry Dash metadata:\nSplit sources: $isSplit\nExtracted libraries: $isExtracted\nLauncher architecture: $abi"
Log.i("GeodeLauncher", metadata) Log.i("GeodeLauncher", metadata)
// easiest check! these messages are hardcoded in bionic/linker/linker_phdr.cpp // easiest check! these messages are hardcoded in bionic/linker/linker_phdr.cpp
@@ -7,10 +7,6 @@ import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.hardware.lights.Light
import android.hardware.lights.LightState
import android.hardware.lights.LightsRequest
import android.net.ConnectivityManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
@@ -20,7 +16,6 @@ import android.os.VibratorManager
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION import android.provider.Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION
import android.util.Log import android.util.Log
import android.view.InputDevice
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -32,6 +27,7 @@ import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import com.geode.launcher.BuildConfig import com.geode.launcher.BuildConfig
import com.geode.launcher.R import com.geode.launcher.R
import com.geode.launcher.UserDirectoryProvider
import com.geode.launcher.activityresult.GeodeOpenFileActivityResult import com.geode.launcher.activityresult.GeodeOpenFileActivityResult
import com.geode.launcher.activityresult.GeodeOpenFilesActivityResult import com.geode.launcher.activityresult.GeodeOpenFilesActivityResult
import com.geode.launcher.activityresult.GeodeSaveFileActivityResult import com.geode.launcher.activityresult.GeodeSaveFileActivityResult
@@ -191,16 +187,10 @@ object GeodeUtils {
@JvmStatic @JvmStatic
fun restartGame() { fun restartGame() {
restartGame(false)
}
@JvmStatic
fun restartGame(safeMode: Boolean) {
activity.get()?.run { activity.get()?.run {
packageManager.getLaunchIntentForPackage(packageName)?.also { packageManager.getLaunchIntentForPackage(packageName)?.also {
val mainIntent = Intent.makeRestartActivityTask(it.component) val mainIntent = Intent.makeRestartActivityTask(it.component)
mainIntent.putExtra("restarted", true) mainIntent.putExtra("restarted", true)
mainIntent.putExtra("safe-mode", safeMode)
startActivity(mainIntent) startActivity(mainIntent)
exitProcess(0) exitProcess(0)
} }
@@ -213,22 +203,24 @@ object GeodeUtils {
val context = activity.get()!! val context = activity.get()!!
val pathFile = File(path) val pathFile = File(path)
val baseDirectory = LaunchUtils.getBaseDirectory(context, true) val baseDirectory = LaunchUtils.getBaseDirectory(context)
val isInternalPath = pathFile.startsWith(baseDirectory) val isInternalPath = pathFile.startsWith(baseDirectory)
val intent = if (isInternalPath) { val intent = if (isInternalPath) {
val relativePath = pathFile.relativeTo(baseDirectory) // TODO: figure out how to get this to point to the path it should be pointing at
val uri = DocumentsContract.buildDocumentUri( // (the best i got was pointing at a file)
"com.android.externalstorage.documents", // val relativePath = pathFile.relativeTo(baseDirectory)
if (BuildConfig.GOOGLE_PLAY_BUILD) {
"primary:Android/data/${context.packageName}/$relativePath"
} else {
"primary:Android/media/${context.packageName}/$relativePath"
}
)
Intent(Intent.ACTION_VIEW).apply { Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, DocumentsContract.Document.MIME_TYPE_DIR) data = DocumentsContract.buildRootUri(
"${context.packageName}.user", UserDirectoryProvider.ROOT
)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
)
} }
} else { } else {
Intent(Intent.ACTION_OPEN_DOCUMENT).apply { Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
@@ -398,15 +390,6 @@ object GeodeUtils {
return "" return ""
} }
@JvmStatic
fun getGameVersionCode(): Long {
activity.get()?.run {
return GamePackageUtils.getGameVersionCode(packageManager)
}
return 0L
}
/** /**
* Returns the current version code of the launcher. * Returns the current version code of the launcher.
*/ */
@@ -416,7 +399,7 @@ object GeodeUtils {
} }
fun isGeodeUri(uri: Uri): Boolean { fun isGeodeUri(uri: Uri): Boolean {
return "${BuildConfig.APPLICATION_ID}.user" == uri.authority return "com.geode.launcher.user" == uri.authority
} }
private const val INTERNAL_PERMISSION_PREFIX = "geode.permission_internal" private const val INTERNAL_PERMISSION_PREFIX = "geode.permission_internal"
@@ -435,7 +418,7 @@ object GeodeUtils {
Manifest.permission.WRITE_EXTERNAL_STORAGE Manifest.permission.WRITE_EXTERNAL_STORAGE
) )
permissions.all { return permissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
} }
} }
@@ -493,10 +476,6 @@ object GeodeUtils {
*/ */
@JvmStatic @JvmStatic
fun vibrate(ms: Long) { fun vibrate(ms: Long) {
if (ms == 0L) {
getVibrator()?.cancel()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
getVibrator()?.vibrate(VibrationEffect.createOneShot(ms, VibrationEffect.DEFAULT_AMPLITUDE)) getVibrator()?.vibrate(VibrationEffect.createOneShot(ms, VibrationEffect.DEFAULT_AMPLITUDE))
} else { } else {
@@ -563,8 +542,6 @@ object GeodeUtils {
const val CAPABILITY_EXTENDED_INPUT = "extended_input" const val CAPABILITY_EXTENDED_INPUT = "extended_input"
const val CAPABILITY_TIMESTAMP_INPUT = "timestamp_inputs" const val CAPABILITY_TIMESTAMP_INPUT = "timestamp_inputs"
const val CAPABILITY_RICH_INPUT = "internal_callbacks_v2"
const val CAPABILITY_RESIZE_CALLBACK = "resize_callbacks"
private var capabilityListener: WeakReference<CapabilityListener?> = WeakReference(null) private var capabilityListener: WeakReference<CapabilityListener?> = WeakReference(null)
@@ -594,193 +571,6 @@ object GeodeUtils {
.launchUrl(activity, url.toUri()) .launchUrl(activity, url.toUri())
} }
/**
* Determines if any gamepad or joystick input devices are connected.
* This is useful during start, but tracking inputDeviceAdded events is better during runtime.
*/
@JvmStatic
fun controllersConnected(): Int {
val deviceIds = InputDevice.getDeviceIds()
return deviceIds.count {
InputDevice.getDevice(it)?.run {
sources and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD || sources and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
} ?: false
}
}
@JvmStatic
fun getConnectedDevices(): IntArray = InputDevice.getDeviceIds()
@JvmStatic
fun getDevice(deviceId: Int): InputDevice? = InputDevice.getDevice(deviceId)
@JvmStatic
fun getDeviceBatteryCapacity(deviceId: Int): Float {
val device = InputDevice.getDevice(deviceId) ?: return 0.0f
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
device.batteryState.capacity
} else {
0.0f
}
}
@JvmStatic
fun deviceHasBattery(deviceId: Int): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
return false
}
val device = InputDevice.getDevice(deviceId) ?: return false
return device.batteryState.isPresent
}
@JvmStatic
fun getDeviceBatteryStatus(deviceId: Int): Int {
val device = InputDevice.getDevice(deviceId) ?: return 0
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
device.batteryState.status
} else {
0
}
}
@JvmStatic
fun getDeviceLightsCount(deviceId: Int): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
return 0
}
val device = InputDevice.getDevice(deviceId) ?: return 0
val lightsManager = device.lightsManager
return lightsManager.lights.size
}
// this is based on paddleboat's api
@JvmStatic
fun getLightType(deviceId: Int): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
return 0
}
val device = InputDevice.getDevice(deviceId) ?: return 0
val lightsManager = device.lightsManager
var flags = 0
lightsManager.lights.forEach { light ->
if (light.type == Light.LIGHT_TYPE_PLAYER_ID) {
flags = flags or 1
} else if (light.hasRgbControl()) {
flags = flags or 2
}
}
return flags
}
/**
* Sets the color for a device.
* @return false if the given device does not exist or setting lights was otherwise unsuccessful
*/
@JvmStatic
fun setDeviceLightColor(deviceId: Int, color: Int, type: Int): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
return false
}
val device = InputDevice.getDevice(deviceId) ?: return false
val lightsManager = device.lightsManager
val lights = lightsManager.lights
if (lights.isEmpty()) {
return false
}
val actedLights = lights.count { light ->
val state = LightState.Builder()
if (light.type == Light.LIGHT_TYPE_PLAYER_ID && (type and 1 == 1)) {
state.setPlayerId(color)
} else if (light.hasRgbControl() && (type and 2 == 2)) {
state.setColor(color)
} else {
return@count false
}
val request = LightsRequest.Builder()
.addLight(light, state.build())
.build()
lightsManager.openSession()
.requestLights(request)
true
}
return actedLights > 0
}
@JvmStatic
fun getDeviceHapticsCount(deviceId: Int): Int {
val device = InputDevice.getDevice(deviceId) ?: return 0
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
return if (device.vibrator.hasVibrator()) 1 else 0
}
val vibratorManager = device.vibratorManager
val vibratorIds = vibratorManager.vibratorIds
return vibratorIds.size
}
private fun performVibration(vibrator: Vibrator?, durationMs: Long, intensity: Int) {
vibrator?.apply {
if (intensity == 0) {
cancel()
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val vibrationEffect = VibrationEffect.createOneShot(durationMs, intensity)
vibrate(vibrationEffect)
} else {
vibrate(durationMs)
}
}
}
}
/**
* Vibrates a given device. Set motorIdx == -1 to vibrate all available motors on a device.
*/
@JvmStatic
fun vibrateDevice(deviceId: Int, durationMs: Long, intensity: Int, motorIdx: Int): Boolean {
val device = InputDevice.getDevice(deviceId) ?: return false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager = device.vibratorManager
val vibratorIds = vibratorManager.vibratorIds
if (motorIdx == -1) {
vibratorIds.forEach {
val vibrator = vibratorManager.getVibrator(it)
performVibration(vibrator, durationMs, intensity)
}
} else {
val deviceId = vibratorIds.getOrNull(motorIdx) ?: return false
val vibrator = vibratorManager.getVibrator(deviceId)
performVibration(vibrator, durationMs, intensity)
}
} else {
val vibrator = device.vibrator
performVibration(vibrator, durationMs, intensity)
}
return true
}
@JvmStatic
fun getConnectivityManager(): ConnectivityManager? = activity.get()?.run {
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
}
external fun nativeKeyUp(keyCode: Int, modifiers: Int) external fun nativeKeyUp(keyCode: Int, modifiers: Int)
external fun nativeKeyDown(keyCode: Int, modifiers: Int, isRepeating: Boolean) external fun nativeKeyDown(keyCode: Int, modifiers: Int, isRepeating: Boolean)
external fun nativeActionScroll(scrollX: Float, scrollY: Float) external fun nativeActionScroll(scrollX: Float, scrollY: Float)
@@ -792,18 +582,4 @@ object GeodeUtils {
* @see reportPlatformCapability * @see reportPlatformCapability
*/ */
external fun setNextInputTimestamp(timestamp: Long) external fun setNextInputTimestamp(timestamp: Long)
const val TOUCH_TYPE_BEGIN = 0
const val TOUCH_TYPE_MOVE = 1
const val TOUCH_TYPE_END = 2
const val TOUCH_TYPE_CANCEL = 3
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, 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)
external fun inputDeviceChanged(deviceId: Int)
external fun inputDeviceRemoved(deviceId: Int)
} }
@@ -1,118 +0,0 @@
package com.geode.launcher.utils
import android.graphics.drawable.AdaptiveIconDrawable
import android.os.Build
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.painterResource
import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.drawable.toBitmap
import com.geode.launcher.R
// https://gist.github.com/tkuenneth/ddf598663f041dc79960cda503d14448?permalink_comment_id=4660486#gistcomment-4660486
@Composable
fun adaptiveIconPainterResource(@DrawableRes id: Int): Painter {
val context = LocalContext.current
val res = LocalResources.current
val theme = context.theme
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val adaptiveIcon = remember(id) {
ResourcesCompat.getDrawable(res, id, theme) as? AdaptiveIconDrawable
}
if (adaptiveIcon != null) {
remember(id) {
BitmapPainter(adaptiveIcon.toBitmap().asImageBitmap())
}
} else {
painterResource(id)
}
} else {
painterResource(id)
}
}
enum class ApplicationIcon {
DEFAULT,
GEODE,
PRIDE,
TRANS,
GREEN,
SAPPHIRE;
companion object {
fun fromId(id: String) = when (id) {
"default" -> DEFAULT
"geode" -> GEODE
"pride" -> PRIDE
"trans" -> TRANS
"sapphire" -> SAPPHIRE
"green" -> GREEN
else -> DEFAULT
}
}
fun toId() = when (this) {
PRIDE -> "pride"
GEODE -> "geode"
DEFAULT -> "default"
TRANS -> "trans"
SAPPHIRE -> "sapphire"
GREEN -> "green"
}
}
data class ApplicationIconDetails(
val id: String,
val component: String,
@param:DrawableRes val iconId: Int,
@param:StringRes val nameId: Int,
)
object IconUtils {
fun getIconDetails(id: ApplicationIcon) = when (id) {
ApplicationIcon.DEFAULT -> ApplicationIconDetails(
id = id.toId(),
component = "MainActivity",
iconId = R.mipmap.ic_launcher,
nameId = R.string.application_icon_default
)
ApplicationIcon.GEODE -> ApplicationIconDetails(
id = id.toId(),
component = "MainActivityGeode",
iconId = R.mipmap.ic_launcher_geode,
nameId = R.string.application_icon_geode
)
ApplicationIcon.PRIDE -> ApplicationIconDetails(
id = id.toId(),
component = "MainActivityPride",
iconId = R.mipmap.ic_launcher_pride,
nameId = R.string.application_icon_pride
)
ApplicationIcon.TRANS -> ApplicationIconDetails(
id = id.toId(),
component = "MainActivityTrans",
iconId = R.mipmap.ic_launcher_trans,
nameId = R.string.application_icon_trans
)
ApplicationIcon.SAPPHIRE -> ApplicationIconDetails(
id = id.toId(),
component = "MainActivitySapphire",
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,7 +2,6 @@ package com.geode.launcher.utils
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import com.geode.launcher.BuildConfig
import java.io.File import java.io.File
import java.io.Serializable import java.io.Serializable
@@ -42,9 +41,6 @@ object LaunchUtils {
} }
fun isGeodeInstalled(context: Context): Boolean { fun isGeodeInstalled(context: Context): Boolean {
@Suppress("SENSELESS_COMPARISON")
if (BuildConfig.PREBUNDLED_GEODE != null) return true
return getInstalledGeodePath(context) != null return getInstalledGeodePath(context) != null
} }
@@ -52,14 +48,10 @@ object LaunchUtils {
* Returns the directory that Geode/the game should base itself off of. * Returns the directory that Geode/the game should base itself off of.
*/ */
fun getBaseDirectory(context: Context, ignoreProfile: Boolean = false): File { fun getBaseDirectory(context: Context, ignoreProfile: Boolean = false): File {
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) // 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 // also, is getting the first item the correct choice here?? what do they mean
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
context.externalMediaDirs.first() val dir = context.externalMediaDirs.first()
}
// prevent having resources added to system gallery // prevent having resources added to system gallery
// accessing this file every time the directory is read may be a little wasteful... // accessing this file every time the directory is read may be a little wasteful...
@@ -81,23 +73,13 @@ object LaunchUtils {
return File(getBaseDirectory(context), "save") return File(getBaseDirectory(context), "save")
} }
val CRASH_INDICATOR_NAMES = setOf("lastSessionDidCrash", "last-pid", "last-crashed") private const val CRASH_INDICATOR_NAME = "lastSessionDidCrash"
fun getCrashDirectory(context: Context): File { private fun getCrashDirectory(context: Context): File {
val base = getBaseDirectory(context) val base = getBaseDirectory(context)
return File(base, "game/geode/crashlogs/") return File(base, "game/geode/crashlogs/")
} }
fun getGeodeLogsDirectory(context: Context): File {
val base = getBaseDirectory(context)
return File(base, "game/geode/logs/")
}
fun getGeodeResourcesDirectory(context: Context): File {
val base = getBaseDirectory(context)
return File(base, "game/geode/resources/geode.loader/")
}
fun getLastCrash(context: Context): File? { fun getLastCrash(context: Context): File? {
val crashDirectory = getCrashDirectory(context) val crashDirectory = getCrashDirectory(context)
if (!crashDirectory.exists()) { if (!crashDirectory.exists()) {
@@ -106,7 +88,7 @@ object LaunchUtils {
val children = crashDirectory.listFiles { val children = crashDirectory.listFiles {
// ignore indicator files (including old file) // ignore indicator files (including old file)
_, name -> !CRASH_INDICATOR_NAMES.contains(name) _, name -> name != CRASH_INDICATOR_NAME && name != "last-pid"
} }
return children?.maxByOrNull { it.lastModified() } return children?.maxByOrNull { it.lastModified() }
@@ -114,9 +96,9 @@ object LaunchUtils {
fun lastSessionCrashed(context: Context): Boolean { fun lastSessionCrashed(context: Context): Boolean {
val base = getCrashDirectory(context) val base = getCrashDirectory(context)
return CRASH_INDICATOR_NAMES.any { val crashIndicatorFile = File(base, CRASH_INDICATOR_NAME)
File(base, it).exists()
} return crashIndicatorFile.exists()
} }
enum class LauncherError : Serializable { enum class LauncherError : Serializable {
@@ -127,35 +127,35 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
enum class Key { enum class Key {
LOAD_AUTOMATICALLY, LOAD_AUTOMATICALLY,
UPDATE_AUTOMATICALLY, UPDATE_AUTOMATICALLY,
RELEASE_CHANNEL,
CURRENT_VERSION_TAG, CURRENT_VERSION_TAG,
CURRENT_VERSION_TIMESTAMP, CURRENT_VERSION_TIMESTAMP,
THEME, THEME,
BLACK_BACKGROUND, BLACK_BACKGROUND,
CURRENT_RELEASE_MODIFIED, CURRENT_RELEASE_MODIFIED,
LAST_DISMISSED_UPDATE,
DISMISSED_GJ_UPDATE, DISMISSED_GJ_UPDATE,
LAUNCH_ARGUMENTS, LAUNCH_ARGUMENTS,
LIMIT_ASPECT_RATIO, LIMIT_ASPECT_RATIO,
DISPLAY_MODE, DISPLAY_MODE,
FORCE_HRR,
ENABLE_REDESIGN,
RELEASE_CHANNEL_TAG, RELEASE_CHANNEL_TAG,
DEVELOPER_MODE, DEVELOPER_MODE,
CLEANUP_APKS, CLEANUP_APKS,
CUSTOM_SYMBOL_LIST,
CUSTOM_ASPECT_RATIO, CUSTOM_ASPECT_RATIO,
SCREEN_ZOOM, SCREEN_ZOOM,
SCREEN_ZOOM_FIT, SCREEN_ZOOM_FIT,
LIMIT_FRAME_RATE, LIMIT_FRAME_RATE,
DISABLE_USER_THEME, USE_INDEX_API,
SELECTED_ICON,
WAIT_PERIOD,
LAST_UPDATE_CHECK_TIME,
LAST_LAUNCHER_UPDATE,
DISABLE_UPDATE_CACHE,
PREBUNDLED_GEODE_MIGRATION_PERFORMED,
} }
private fun defaultValueForBooleanKey(key: Key): Boolean { private fun defaultValueForBooleanKey(key: Key): Boolean {
return when (key) { return when (key) {
Key.UPDATE_AUTOMATICALLY -> true Key.UPDATE_AUTOMATICALLY, Key.FORCE_HRR -> true
Key.DEVELOPER_MODE -> BuildConfig.DEBUG Key.DEVELOPER_MODE -> BuildConfig.DEBUG
Key.USE_INDEX_API -> true
else -> false else -> false
} }
} }
@@ -163,14 +163,12 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
private fun defaultValueForIntKey(key: Key) = when (key) { private fun defaultValueForIntKey(key: Key) = when (key) {
Key.DISPLAY_MODE -> if (this.getBoolean(Key.LIMIT_ASPECT_RATIO)) 1 else 0 Key.DISPLAY_MODE -> if (this.getBoolean(Key.LIMIT_ASPECT_RATIO)) 1 else 0
Key.SCREEN_ZOOM -> 100 Key.SCREEN_ZOOM -> 100
Key.WAIT_PERIOD -> 3
// people wanted a reset on nightly anyways, so this is a good excuse to do so // people wanted a reset on nightly anyways, so this is a good excuse to do so
else -> 0 else -> 0
} }
private fun defaultValueForStringKey(key: Key) = when (key) { private fun defaultValueForStringKey(key: Key) = when (key) {
Key.CUSTOM_ASPECT_RATIO -> "16_9" Key.CUSTOM_ASPECT_RATIO -> "16_9"
Key.SELECTED_ICON -> "default"
else -> null else -> null
} }
@@ -178,34 +176,28 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
return when (key) { return when (key) {
Key.LOAD_AUTOMATICALLY -> "PreferenceLoadAutomatically" Key.LOAD_AUTOMATICALLY -> "PreferenceLoadAutomatically"
Key.UPDATE_AUTOMATICALLY -> "PreferenceUpdateAutomatically" Key.UPDATE_AUTOMATICALLY -> "PreferenceUpdateAutomatically"
Key.RELEASE_CHANNEL -> "PreferenceReleaseChannel"
Key.CURRENT_VERSION_TAG -> "PreferenceCurrentVersionName" Key.CURRENT_VERSION_TAG -> "PreferenceCurrentVersionName"
Key.CURRENT_VERSION_TIMESTAMP -> "PreferenceCurrentVersionDescriptor" Key.CURRENT_VERSION_TIMESTAMP -> "PreferenceCurrentVersionDescriptor"
Key.THEME -> "PreferenceTheme" Key.THEME -> "PreferenceTheme"
Key.BLACK_BACKGROUND -> "PreferenceBlackBackground" Key.BLACK_BACKGROUND -> "PreferenceBlackBackground"
Key.CURRENT_RELEASE_MODIFIED -> "PreferenceReleaseModifiedHash" Key.CURRENT_RELEASE_MODIFIED -> "PreferenceReleaseModifiedHash"
Key.LAST_DISMISSED_UPDATE -> "PreferenceLastDismissedUpdate"
Key.DISMISSED_GJ_UPDATE -> "PreferenceDismissedGJUpdate" Key.DISMISSED_GJ_UPDATE -> "PreferenceDismissedGJUpdate"
Key.LAUNCH_ARGUMENTS -> "PreferenceLaunchArguments" Key.LAUNCH_ARGUMENTS -> "PreferenceLaunchArguments"
Key.LIMIT_ASPECT_RATIO -> "PreferenceLimitAspectRatio" Key.LIMIT_ASPECT_RATIO -> "PreferenceLimitAspectRatio"
Key.DISPLAY_MODE -> "PreferenceDisplayMode" Key.DISPLAY_MODE -> "PreferenceDisplayMode"
Key.FORCE_HRR -> "PreferenceForceHighRefreshRate"
Key.ENABLE_REDESIGN -> "PreferenceEnableRedesign"
Key.RELEASE_CHANNEL_TAG -> "PreferenceReleaseChannelTag" Key.RELEASE_CHANNEL_TAG -> "PreferenceReleaseChannelTag"
Key.DEVELOPER_MODE -> "PreferenceDeveloperMode" Key.DEVELOPER_MODE -> "PreferenceDeveloperMode"
Key.CLEANUP_APKS -> "PreferenceCleanupPackages" Key.CLEANUP_APKS -> "PreferenceCleanupPackages"
Key.CUSTOM_SYMBOL_LIST -> "PreferenceCustomSymbolList"
Key.CUSTOM_ASPECT_RATIO -> "PreferenceCustomAspectRatio" Key.CUSTOM_ASPECT_RATIO -> "PreferenceCustomAspectRatio"
Key.SCREEN_ZOOM -> "PreferenceScreenZoom" Key.SCREEN_ZOOM -> "PreferenceScreenZoom"
Key.SCREEN_ZOOM_FIT -> "PreferenceScreenZoomFit" Key.SCREEN_ZOOM_FIT -> "PreferenceScreenZoomFit"
Key.LIMIT_FRAME_RATE -> "PreferenceLimitFrameRate" Key.LIMIT_FRAME_RATE -> "PreferenceLimitFrameRate"
Key.DISABLE_USER_THEME -> "PreferenceDisableUserTheme" Key.USE_INDEX_API -> "PreferenceUseIndexApi"
Key.SELECTED_ICON -> "PreferenceSelectedIcon"
Key.WAIT_PERIOD -> "PreferenceWaitPeriod"
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"
// Key.DISABLE_REDESIGN -> "PreferenceDisableRedesign"
// Key.CUSTOM_SYMBOL_LIST -> "PreferenceCustomSymbolList"
} }
} }
@@ -6,19 +6,17 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
const val MS_TO_SEC = 1000L const val MS_TO_SEC = 1000L
@Composable @Composable
fun useCountdownTimer( fun useCountdownTimer(
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
time: Duration, time: Long,
onCountdownFinish: () -> Unit onCountdownFinish: () -> Unit
): Long { ): Long {
var millisUntilFinished by remember { var millisUntilFinished by remember {
mutableStateOf(time) mutableLongStateOf(time)
} }
var shouldBeCounting by remember { var shouldBeCounting by remember {
@@ -30,9 +28,9 @@ fun useCountdownTimer(
return@LaunchedEffect return@LaunchedEffect
} }
if (millisUntilFinished > Duration.ZERO) { if (millisUntilFinished > 0) {
delay(1.seconds) delay(MS_TO_SEC)
millisUntilFinished -= 1.seconds millisUntilFinished -= MS_TO_SEC
} else { } else {
onCountdownFinish() onCountdownFinish()
} }
@@ -60,5 +58,5 @@ fun useCountdownTimer(
} }
} }
return millisUntilFinished.inWholeSeconds return millisUntilFinished / MS_TO_SEC
} }
@@ -1,45 +1,31 @@
package org.cocos2dx.lib package org.cocos2dx.lib
import android.content.Context import android.content.Context
import android.graphics.Bitmap import android.graphics.*
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Paint.FontMetricsInt import android.graphics.Paint.FontMetricsInt
import android.graphics.Rect
import android.graphics.Typeface
import android.text.TextPaint import android.text.TextPaint
import android.text.TextUtils import android.text.TextUtils
import android.util.Log import android.util.Log
import android.view.MotionEvent import android.view.MotionEvent
import androidx.core.graphics.createBitmap
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import java.util.LinkedList import java.util.*
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.ceil 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") @Suppress("KotlinJniMissingFunction")
object Cocos2dxBitmap { object Cocos2dxBitmap {
private lateinit var context: WeakReference<Context> private lateinit var context: WeakReference<Context>
@JvmStatic @JvmStatic
external fun nativeInitBitmapDC(pWidth: Int, pHeight: Int, pPixels: ByteArray) private external fun nativeInitBitmapDC(pWidth: Int, pHeight: Int, pPixels: ByteArray)
fun setContext(context: Context) { fun setContext(context: Context) {
this.context = WeakReference(context) this.context = WeakReference(context)
} }
@JvmStatic /*
@Suppress("unused")
fun createTextBitmap( fun createTextBitmap(
string: String, string: String,
fontName: String, fontName: String,
@@ -69,6 +55,7 @@ object Cocos2dxBitmap {
1.0f 1.0f
) )
} }
*/
@JvmStatic @JvmStatic
@Suppress("unused") @Suppress("unused")
@@ -92,18 +79,19 @@ object Cocos2dxBitmap {
strokeB: Float, strokeB: Float,
strokeSize: Float strokeSize: Float
) { ) {
val horizontalAlignment = alignment and 0x0F val bitmapTotalHeight: Int
val verticalAlignment = (alignment shr 4) and 0x0F val horizontalAlignment = alignment and 15
val verticalAlignment = alignment shr 4 and 15
val pString2 = refactorString(string) val pString2 = refactorString(string)
val paint = newPaint(fontName, fontSize, horizontalAlignment) val paint = newPaint(fontName, fontSize, horizontalAlignment)
paint.setARGB( paint.setARGB(
255, MotionEvent.ACTION_MASK,
(255.0 * fontTintR).toInt(), (255.0 * fontTintR.toDouble()).toInt(),
(255.0 * fontTintG).toInt(), (255.0 * fontTintG.toDouble()).toInt(),
(255.0 * fontTintB).toInt() (255.0 * fontTintB.toDouble()).toInt()
) )
val textProperty = computeTextProperty(pString2, width, height, paint) val textProperty = computeTextProperty(pString2, width, height, paint)
val bitmapTotalHeight = if (height == 0) { bitmapTotalHeight = if (height == 0) {
textProperty.totalHeight textProperty.totalHeight
} else { } else {
height height
@@ -113,8 +101,7 @@ object Cocos2dxBitmap {
var renderTextDeltaX = 0.0f var renderTextDeltaX = 0.0f
var renderTextDeltaY = 0.0f var renderTextDeltaY = 0.0f
if (shadow) { if (shadow) {
val shadowColor = 0xff7d7d7du.toInt() paint.setShadowLayer(shadowBlur, shadowDX, shadowDY, -8553091)
paint.setShadowLayer(shadowBlur, shadowDX, shadowDY, shadowColor)
bitmapPaddingX = abs(shadowDX) bitmapPaddingX = abs(shadowDX)
bitmapPaddingY = abs(shadowDY) bitmapPaddingY = abs(shadowDY)
if (shadowDX.toDouble() < 0.0) { if (shadowDX.toDouble() < 0.0) {
@@ -124,47 +111,56 @@ object Cocos2dxBitmap {
renderTextDeltaY = bitmapPaddingY renderTextDeltaY = bitmapPaddingY
} }
} }
val bitmap = Bitmap.createBitmap(
val bitmap = try {
createBitmap(
textProperty.maxWidth + bitmapPaddingX.toInt(), textProperty.maxWidth + bitmapPaddingX.toInt(),
bitmapTotalHeight + bitmapPaddingY.toInt() bitmapPaddingY.toInt() + bitmapTotalHeight,
Bitmap.Config.ARGB_8888
) )
} 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 canvas = Canvas(bitmap)
val fontMetricsInt = paint.fontMetricsInt val fontMetricsInt = paint.fontMetricsInt
var y = computeY(fontMetricsInt, height, textProperty.totalHeight, verticalAlignment) var y = computeY(fontMetricsInt, height, textProperty.totalHeight, verticalAlignment)
val lines = textProperty.lines val lines = textProperty.lines
val length = lines.size
for (line in lines) { for (i in 0 until length) {
val x = computeX(line, textProperty.maxWidth, horizontalAlignment) val line = lines[i]
canvas.drawText(line, x + renderTextDeltaX, y + renderTextDeltaY, paint) canvas.drawText(
line!!,
computeX(
line,
textProperty.maxWidth,
horizontalAlignment
).toFloat() + renderTextDeltaX,
y.toFloat() + renderTextDeltaY,
paint
)
y += textProperty.heightPerLine y += textProperty.heightPerLine
} }
if (stroke) { if (stroke) {
val paintStroke = newPaint(fontName, fontSize, horizontalAlignment) val paintStroke = newPaint(fontName, fontSize, horizontalAlignment)
paintStroke.style = Paint.Style.STROKE paintStroke.style = Paint.Style.STROKE
paintStroke.strokeWidth = strokeSize * 0.5f paintStroke.strokeWidth = 0.5f * strokeSize
paintStroke.setARGB( paintStroke.setARGB(
255, MotionEvent.ACTION_MASK,
strokeR.toInt() * MotionEvent.ACTION_MASK, strokeR.toInt() * MotionEvent.ACTION_MASK,
strokeG.toInt() * MotionEvent.ACTION_MASK, strokeG.toInt() * MotionEvent.ACTION_MASK,
strokeB.toInt() * MotionEvent.ACTION_MASK strokeB.toInt() * MotionEvent.ACTION_MASK
) )
var y = computeY(fontMetricsInt, height, textProperty.totalHeight, verticalAlignment) var y2 = computeY(fontMetricsInt, height, textProperty.totalHeight, verticalAlignment)
val lines = textProperty.lines val lines2 = textProperty.lines
val length2 = lines2.size
for (line in lines) { for (i2 in 0 until length2) {
val x = computeX(line, textProperty.maxWidth, horizontalAlignment) val line2 = lines2[i2]
canvas.drawText(line, x + renderTextDeltaX, y + renderTextDeltaY, paintStroke) canvas.drawText(
y += textProperty.heightPerLine line2!!,
computeX(
line2,
textProperty.maxWidth,
horizontalAlignment
).toFloat() + renderTextDeltaX,
y2.toFloat() + renderTextDeltaY,
paintStroke
)
y2 += textProperty.heightPerLine
} }
} }
initNativeObject(bitmap) initNativeObject(bitmap)
@@ -172,24 +168,24 @@ object Cocos2dxBitmap {
private fun newPaint(fontName: String, fontSize: Int, horizontalAlignment: Int): Paint { private fun newPaint(fontName: String, fontSize: Int, horizontalAlignment: Int): Paint {
val paint = Paint() val paint = Paint()
paint.color = Color.WHITE paint.color = -1
paint.textSize = fontSize.toFloat() paint.textSize = fontSize.toFloat()
paint.isAntiAlias = true paint.isAntiAlias = true
if (fontName.endsWith(".ttf")) { if (fontName.endsWith(".ttf")) {
try { try {
context.get()!!.apply { context.get()?.apply {
paint.typeface = Cocos2dxTypefaces[this, fontName] paint.typeface = Cocos2dxTypefaces[this, fontName]
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("Cocos2dxBitmap", "error to create ttf type face: $fontName - $e") Log.e("Cocos2dxBitmap", "error to create ttf type face: $fontName")
paint.typeface = Typeface.create(fontName, Typeface.NORMAL) paint.typeface = Typeface.create(fontName, Typeface.NORMAL)
} }
} else { } else {
paint.typeface = Typeface.create(fontName, Typeface.NORMAL) paint.typeface = Typeface.create(fontName, Typeface.NORMAL)
} }
when (horizontalAlignment) { when (horizontalAlignment) {
HORIZONTALALIGN_RIGHT -> paint.textAlign = Paint.Align.RIGHT 2 -> paint.textAlign = Paint.Align.RIGHT
HORIZONTALALIGN_CENTER -> paint.textAlign = Paint.Align.CENTER 3 -> paint.textAlign = Paint.Align.CENTER
else -> paint.textAlign = Paint.Align.LEFT else -> paint.textAlign = Paint.Align.LEFT
} }
return paint return paint
@@ -202,14 +198,14 @@ object Cocos2dxBitmap {
paint: Paint paint: Paint
): TextProperty { ): TextProperty {
val fm = paint.fontMetricsInt val fm = paint.fontMetricsInt
val h = fm.bottom - fm.top val h = ceil((fm.bottom - fm.top).toDouble()).toInt()
var maxContentWidth = 0 var maxContentWidth = 0
val lines = splitString(string, width, height, paint) val lines = splitString(string, width, height, paint)
if (width != 0) { if (width != 0) {
maxContentWidth = width maxContentWidth = width
} else { } else {
for (line in lines) { for (line in lines) {
val temp = ceil(paint.measureText(line, 0, line.length)).toInt() val temp = ceil(paint.measureText(line, 0, line!!.length).toDouble()).toInt()
if (temp > maxContentWidth) { if (temp > maxContentWidth) {
maxContentWidth = temp maxContentWidth = temp
} }
@@ -218,10 +214,10 @@ object Cocos2dxBitmap {
return TextProperty(maxContentWidth, h, lines) 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) { return when (horizontalAlignment) {
HORIZONTALALIGN_RIGHT -> maxWidth 2 -> maxWidth
HORIZONTALALIGN_CENTER -> maxWidth / 2 3 -> maxWidth / 2
else -> 0 else -> 0
} }
} }
@@ -233,15 +229,13 @@ object Cocos2dxBitmap {
verticalAlignment: Int verticalAlignment: Int
): Int { ): Int {
val y = -fontMetricsInt.top 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 y
} else when (verticalAlignment) {
1 -> -fontMetricsInt.top
2 -> -fontMetricsInt.top + (constrainHeight - totalHeight)
3 -> -fontMetricsInt.top + (constrainHeight - totalHeight) / 2
else -> y
} }
} }
@@ -250,56 +244,56 @@ object Cocos2dxBitmap {
maxWidth: Int, maxWidth: Int,
maxHeight: Int, maxHeight: Int,
paint: Paint paint: Paint
): List<String> { ): Array<String?> {
val lines = string.split("\n") val lines: Array<String?> = string.split("\\n").toTypedArray()
val fm = paint.fontMetricsInt val fm = paint.fontMetricsInt
val heightPerLine = fm.bottom - fm.top val maxLines = maxHeight / ceil((fm.bottom - fm.top).toDouble()).toInt()
val maxLines = maxHeight / heightPerLine
return if (maxWidth != 0) { return if (maxWidth != 0) {
val strList = LinkedList<String>() val strList = LinkedList<String?>()
for (line in lines) { for (line in lines) {
val lineWidth = ceil(paint.measureText(line)).toInt() if (ceil(paint.measureText(line).toDouble()).toInt() > maxWidth) {
if (lineWidth > maxWidth) {
strList.addAll(divideStringWithMaxWidth(line, maxWidth, paint)) strList.addAll(divideStringWithMaxWidth(line, maxWidth, paint))
} else { } else {
strList.add(line) strList.add(line)
} }
if (maxLines > 0 && strList.size >= maxLines) { if (maxLines > 0 && strList.size >= maxLines) {
break break
} }
} }
if (maxLines > 0 && strList.size > maxLines) { if (maxLines > 0 && strList.size > maxLines) {
while (strList.size > maxLines) { while (strList.size > maxLines) {
strList.removeLast() strList.removeLast()
} }
} }
val ret = arrayOfNulls<String>(strList.size)
strList strList.toArray(ret)
} else if (maxHeight != 0 && lines.size > maxLines) { ret
lines.take(maxLines) } else if (maxHeight == 0 || lines.size <= maxLines) {
} else {
lines lines
} 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
} }
} }
private fun divideStringWithMaxWidth( private fun divideStringWithMaxWidth(
string: String, string: String?,
maxWidth: Int, maxWidth: Int,
paint: Paint paint: Paint
): LinkedList<String> { ): LinkedList<String?> {
val charLength = string.length val charLength = string!!.length
var start = 0 var start = 0
val strList = LinkedList<String>() val strList = LinkedList<String?>()
var i = 1 var i = 1
while (i <= charLength) { while (i <= charLength) {
val tempWidth = ceil(paint.measureText(string, start, i)).toInt() val tempWidth = ceil(paint.measureText(string, start, i).toDouble()).toInt()
if (tempWidth >= maxWidth) { if (tempWidth >= maxWidth) {
val lastIndexOfSpace = string.substring(0, i).lastIndexOf(" ") val lastIndexOfSpace = string.substring(0, i).lastIndexOf(" ")
if (lastIndexOfSpace != -1 && lastIndexOfSpace > start) { if (lastIndexOfSpace != -1 && lastIndexOfSpace > start) {
strList.add(string.substring(start, lastIndexOfSpace)) strList.add(string.substring(start, lastIndexOfSpace))
i = lastIndexOfSpace + 1 i = lastIndexOfSpace + 1
@@ -309,20 +303,16 @@ object Cocos2dxBitmap {
} else { } else {
strList.add(string.substring(start, i)) strList.add(string.substring(start, i))
} }
while (i < charLength && string[i] == ' ') { while (i < charLength && string[i] == ' ') {
i++ i++
} }
start = i start = i
} }
i++ i++
} }
if (start < charLength) { if (start < charLength) {
strList.add(string.substring(start)) strList.add(string.substring(start))
} }
return strList return strList
} }
@@ -330,22 +320,19 @@ object Cocos2dxBitmap {
if (pString.compareTo("") == 0) { if (pString.compareTo("") == 0) {
return " " return " "
} }
val strBuilder = StringBuilder(pString) val strBuilder = StringBuilder(pString)
var start = 0 var start = 0
var index = strBuilder.indexOf("\n") var index = strBuilder.indexOf("\n")
while (index != -1) { while (index != -1) {
start = if (index == 0 || strBuilder[index - 1] == '\n') { start = if (index == 0 || strBuilder[index - 1].code == 10) {
strBuilder.insert(start, " ") strBuilder.insert(start, " ")
index + 2 index + 2
} else { } else {
index + 1 index + 1
} }
if (start > strBuilder.length || index == strBuilder.length) { if (start > strBuilder.length || index == strBuilder.length) {
break break
} }
index = strBuilder.indexOf("\n", start) index = strBuilder.indexOf("\n", start)
} }
return strBuilder.toString() return strBuilder.toString()
@@ -353,10 +340,15 @@ object Cocos2dxBitmap {
private fun initNativeObject(pBitmap: Bitmap) { private fun initNativeObject(pBitmap: Bitmap) {
val pixels = getPixels(pBitmap) 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 { private fun getPixels(pBitmap: Bitmap?): ByteArray? {
if (pBitmap == null) {
return null
}
val pixels = ByteArray(pBitmap.width * pBitmap.height * 4) val pixels = ByteArray(pBitmap.width * pBitmap.height * 4)
val buf = ByteBuffer.wrap(pixels) val buf = ByteBuffer.wrap(pixels)
buf.order(ByteOrder.nativeOrder()) buf.order(ByteOrder.nativeOrder())
@@ -369,18 +361,13 @@ object Cocos2dxBitmap {
private fun getFontSizeAccordingHeight(height: Int): Int { private fun getFontSizeAccordingHeight(height: Int): Int {
val paint = Paint() val paint = Paint()
val bounds = Rect() val bounds = Rect()
paint.typeface = Typeface.DEFAULT paint.typeface = Typeface.DEFAULT
var incrTextSize = 1 var incrTextSize = 1
var foundDesiredSize = false var foundDesiredSize = false
while (!foundDesiredSize) { while (!foundDesiredSize) {
paint.textSize = incrTextSize.toFloat() paint.textSize = incrTextSize.toFloat()
val text = "SghMNy" paint.getTextBounds("SghMNy", 0, "SghMNy".length, bounds)
paint.getTextBounds(text, 0, text.length, bounds)
incrTextSize++ incrTextSize++
if (height - bounds.height() <= 2) { if (height - bounds.height() <= 2) {
foundDesiredSize = true foundDesiredSize = true
} }
@@ -395,19 +382,23 @@ object Cocos2dxBitmap {
if (TextUtils.isEmpty(string)) { if (TextUtils.isEmpty(string)) {
return "" return ""
} }
val paint = TextPaint() val paint = TextPaint()
paint.typeface = Typeface.DEFAULT paint.typeface = Typeface.DEFAULT
paint.textSize = fontSize paint.textSize = fontSize
return TextUtils.ellipsize(string, paint, width, TextUtils.TruncateAt.END).toString() return TextUtils.ellipsize(string, paint, width, TextUtils.TruncateAt.END).toString()
} }
private class TextProperty( private class TextProperty constructor(
val maxWidth: Int, val maxWidth: Int,
val heightPerLine: Int, val heightPerLine: Int, pLines: Array<String?>
val lines: Collection<String>
) { ) {
val totalHeight: Int = lines.size * heightPerLine val lines: Array<String?>
val totalHeight: Int
init {
totalHeight = pLines.size * heightPerLine
lines = pLines
}
} }
} }
@@ -0,0 +1,18 @@
package org.cocos2dx.lib
import android.content.Context
import android.view.KeyEvent
import androidx.appcompat.widget.AppCompatEditText
class Cocos2dxEditText(context: Context) : AppCompatEditText(context) {
var cocos2dxGLSurfaceView: Cocos2dxGLSurfaceView? = null
override fun onKeyDown(keyCode: Int, keyEvent: KeyEvent): Boolean {
super.onKeyDown(keyCode, keyEvent)
if (keyCode != KeyEvent.KEYCODE_BACK) {
return true
}
cocos2dxGLSurfaceView?.requestFocus()
return true
}
}
@@ -2,59 +2,23 @@ package org.cocos2dx.lib
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.hardware.input.InputManager
import android.opengl.GLSurfaceView import android.opengl.GLSurfaceView
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.Message import android.os.Message
import android.text.InputType import android.util.Log
import android.view.InputDevice
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MotionEvent import android.view.MotionEvent
import android.view.Surface import android.view.Surface
import android.view.WindowInsets
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.appcompat.widget.AppCompatEditText
import com.customRobTop.BaseRobTopActivity import com.customRobTop.BaseRobTopActivity
import com.geode.launcher.utils.GeodeUtils import com.geode.launcher.utils.GeodeUtils
import kotlin.math.abs
private const val HANDLER_OPEN_IME_KEYBOARD = 2 private const val HANDLER_OPEN_IME_KEYBOARD = 2
private const val HANDLER_CLOSE_IME_KEYBOARD = 3 private const val HANDLER_CLOSE_IME_KEYBOARD = 3
private const val MS_TO_NS = 1_000_000 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) { class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
companion object { companion object {
@@ -62,19 +26,6 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
private lateinit var handler: Handler private lateinit var handler: Handler
private lateinit var cocos2dxTextInputWrapper: Cocos2dxTextInputWrapper 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 @JvmStatic
fun openIMEKeyboard() { fun openIMEKeyboard() {
val msg = Message() val msg = Message()
@@ -94,30 +45,6 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
cocos2dxGLSurfaceView.queueEvent { cocos2dxGLSurfaceView.queueEvent {
Cocos2dxAccelerometer.onSensorChanged(x, y, z, timestamp) 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 private lateinit var cocos2dxRenderer: Cocos2dxRenderer
@@ -129,18 +56,13 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
var sendTimestampEvents = false var sendTimestampEvents = false
var manualBackEvents = false var manualBackEvents = false
var controllerEventsEnabled = false
private set
var supportsResizeEvents = false
// 2.209+ events for keyboard and such var cocos2dxEditText: Cocos2dxEditText? = null
var sendNewCocosEvents = false
var cocos2dxEditText: AppCompatEditText? = null
set(value) { set(value) {
field = value field = value
field?.setOnEditorActionListener(cocos2dxTextInputWrapper) field?.setOnEditorActionListener(cocos2dxTextInputWrapper)
field?.cocos2dxGLSurfaceView = this
requestFocus() requestFocus()
} }
@@ -156,13 +78,9 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
HANDLER_OPEN_IME_KEYBOARD -> { HANDLER_OPEN_IME_KEYBOARD -> {
if (cocos2dxEditText?.requestFocus() == true) { if (cocos2dxEditText?.requestFocus() == true) {
cocos2dxEditText?.apply { cocos2dxEditText?.apply {
removeTextChangedListener(cocos2dxTextInputWrapper) removeTextChangedListener(
cocos2dxTextInputWrapper
if (sendNewCocosEvents) { )
inputType = inputTypeForFlags(BaseRobTopActivity.curKeyboardInputFlags)
imeOptions = imeOptionsForFlags(BaseRobTopActivity.curKeyboardInputFlags)
}
setText("") setText("")
val text = msg.obj as String val text = msg.obj as String
append(text) append(text)
@@ -175,13 +93,16 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
cocos2dxEditText, cocos2dxEditText,
0 0
) )
Log.d("GLSurfaceView", "showSoftInput")
return return
} }
return return
} }
HANDLER_CLOSE_IME_KEYBOARD -> { HANDLER_CLOSE_IME_KEYBOARD -> {
cocos2dxEditText?.apply { cocos2dxEditText?.apply {
removeTextChangedListener(cocos2dxTextInputWrapper) removeTextChangedListener(
cocos2dxTextInputWrapper
)
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow( (context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(
windowToken, windowToken,
0 0
@@ -189,10 +110,8 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
} }
requestFocus() requestFocus()
Log.d("GLSurfaceView", "HideSoftInput")
queueEvent {
cocos2dxRenderer.handleTextClosed() cocos2dxRenderer.handleTextClosed()
}
return return
} }
else -> return else -> return
@@ -231,11 +150,6 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
queueEvent { cocos2dxRenderer.handleOnResume() } queueEvent { cocos2dxRenderer.handleOnResume() }
} }
private fun sendNextEventTimestamp(timestamp: Long) {
if (sendTimestampEvents)
GeodeUtils.setNextInputTimestamp(timestamp)
}
override fun onTouchEvent(motionEvent: MotionEvent): Boolean { override fun onTouchEvent(motionEvent: MotionEvent): Boolean {
val pointerNumber = motionEvent.pointerCount val pointerNumber = motionEvent.pointerCount
val ids = IntArray(pointerNumber) val ids = IntArray(pointerNumber)
@@ -243,7 +157,6 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
val ys = FloatArray(pointerNumber) val ys = FloatArray(pointerNumber)
val timestamp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) val timestamp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
motionEvent.eventTimeNanos else motionEvent.eventTime * MS_TO_NS motionEvent.eventTimeNanos else motionEvent.eventTime * MS_TO_NS
val timestampSec = timestamp / SEC_TO_NS
for (i in 0 until pointerNumber) { for (i in 0 until pointerNumber) {
ids[i] = motionEvent.getPointerId(i) ids[i] = motionEvent.getPointerId(i)
@@ -256,16 +169,10 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
val xDown = xs[0] val xDown = xs[0]
val f = ys[0] val f = ys[0]
queueEvent { queueEvent {
sendNextEventTimestamp(timestamp) if (sendTimestampEvents)
GeodeUtils.setNextInputTimestamp(timestamp)
if (controllerEventsEnabled) cocos2dxRenderer.handleActionDown(idDown, xDown, f)
GeodeUtils.internalTouchEvent(
timestamp, motionEvent.deviceId, motionEvent.source,
// slice here to convert to single element array
GeodeUtils.TOUCH_TYPE_BEGIN, ids.sliceArray(0..0), xs.sliceArray(0..0), ys.sliceArray(0..0)
)
else
cocos2dxRenderer.handleActionDown(idDown, xDown, f, timestampSec)
} }
true true
} }
@@ -274,43 +181,28 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
val f2 = xs[0] val f2 = xs[0]
val f3 = ys[0] val f3 = ys[0]
queueEvent { queueEvent {
sendNextEventTimestamp(timestamp) if (sendTimestampEvents)
GeodeUtils.setNextInputTimestamp(timestamp)
if (controllerEventsEnabled) cocos2dxRenderer.handleActionUp(idUp, f2, f3)
GeodeUtils.internalTouchEvent(
timestamp, motionEvent.deviceId, motionEvent.source,
GeodeUtils.TOUCH_TYPE_END, ids.sliceArray(0..0), xs.sliceArray(0..0), ys.sliceArray(0..0)
)
else
cocos2dxRenderer.handleActionUp(idUp, f2, f3, timestampSec)
} }
true true
} }
MotionEvent.ACTION_MOVE -> { MotionEvent.ACTION_MOVE -> {
queueEvent { queueEvent {
sendNextEventTimestamp(timestamp) if (sendTimestampEvents)
GeodeUtils.setNextInputTimestamp(timestamp)
if (controllerEventsEnabled) cocos2dxRenderer.handleActionMove(ids, xs, ys)
GeodeUtils.internalTouchEvent(
timestamp, motionEvent.deviceId, motionEvent.source,
GeodeUtils.TOUCH_TYPE_MOVE, ids, xs, ys
)
else
cocos2dxRenderer.handleActionMove(ids, xs, ys, timestampSec)
} }
true true
} }
MotionEvent.ACTION_CANCEL -> { MotionEvent.ACTION_CANCEL -> {
queueEvent { queueEvent {
sendNextEventTimestamp(timestamp) if (sendTimestampEvents)
GeodeUtils.setNextInputTimestamp(timestamp)
if (controllerEventsEnabled) cocos2dxRenderer.handleActionCancel(ids, xs, ys)
GeodeUtils.internalTouchEvent(
timestamp, motionEvent.deviceId, motionEvent.source,
GeodeUtils.TOUCH_TYPE_CANCEL, ids, xs, ys
)
else
cocos2dxRenderer.handleActionCancel(ids, xs, ys, timestampSec)
} }
true true
} }
@@ -320,15 +212,10 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
val xPointerDown = motionEvent.getX(indexPointerDown) val xPointerDown = motionEvent.getX(indexPointerDown)
val y = motionEvent.getY(indexPointerDown) val y = motionEvent.getY(indexPointerDown)
queueEvent { queueEvent {
sendNextEventTimestamp(timestamp) if (sendTimestampEvents)
GeodeUtils.setNextInputTimestamp(timestamp)
if (controllerEventsEnabled) cocos2dxRenderer.handleActionDown(idPointerDown, xPointerDown, y)
GeodeUtils.internalTouchEvent(
timestamp, motionEvent.deviceId, motionEvent.source,
GeodeUtils.TOUCH_TYPE_BEGIN, intArrayOf(idPointerDown), floatArrayOf(xPointerDown), floatArrayOf(y)
)
else
cocos2dxRenderer.handleActionDown(idPointerDown, xPointerDown, y, timestampSec)
} }
true true
} }
@@ -338,15 +225,10 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
val xPointerUp = motionEvent.getX(indexPointUp) val xPointerUp = motionEvent.getX(indexPointUp)
val y2 = motionEvent.getY(indexPointUp) val y2 = motionEvent.getY(indexPointUp)
queueEvent { queueEvent {
sendNextEventTimestamp(timestamp) if (sendTimestampEvents)
GeodeUtils.setNextInputTimestamp(timestamp)
if (controllerEventsEnabled) cocos2dxRenderer.handleActionUp(idPointerUp, xPointerUp, y2)
GeodeUtils.internalTouchEvent(
timestamp, motionEvent.deviceId, motionEvent.source,
GeodeUtils.TOUCH_TYPE_END, intArrayOf(idPointerUp), floatArrayOf(xPointerUp), floatArrayOf(y2)
)
else
cocos2dxRenderer.handleActionUp(idPointerUp, xPointerUp, y2, timestampSec)
} }
true true
} }
@@ -363,103 +245,6 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
if (!isInEditMode) { if (!isInEditMode) {
cocos2dxRenderer.setScreenWidthAndHeight(newSurfaceWidth, newSurfaceHeight) cocos2dxRenderer.setScreenWidthAndHeight(newSurfaceWidth, newSurfaceHeight)
} }
if (supportsResizeEvents) {
queueEvent {
GeodeUtils.resizeSurface(newSurfaceWidth, newSurfaceHeight)
}
}
}
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 { private fun legacyKeyDown(keyCode: Int, keyEvent: KeyEvent): Boolean {
@@ -476,179 +261,95 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
} }
fun sendKeyBack() { fun sendKeyBack() {
if (cocos2dxEditText?.isFocused == true) {
requestFocus()
}
if (BaseRobTopActivity.blockBackButton) { if (BaseRobTopActivity.blockBackButton) {
return return
} }
if (useKeyboardEvents) {
queueEvent {
cocos2dxRenderer.handleKeyDown(KeyEvent.KEYCODE_BACK)
}
} else {
val currentTime = System.nanoTime() val currentTime = System.nanoTime()
queueEvent { queueEvent {
sendNextEventTimestamp(currentTime) if (sendTimestampEvents)
GeodeUtils.setNextInputTimestamp(currentTime)
if (controllerEventsEnabled) {
GeodeUtils.internalKeyEvent(
currentTime,
-1,
InputDevice.SOURCE_ANY,
KeyEvent.KEYCODE_BACK,
0,
true,
0
)
GeodeUtils.internalKeyEvent(
currentTime,
-1,
InputDevice.SOURCE_ANY,
KeyEvent.KEYCODE_BACK,
0,
false,
0
)
}
if (useKeyboardEvents) {
GeodeUtils.nativeKeyDown(KeyEvent.KEYCODE_BACK, 0, false) GeodeUtils.nativeKeyDown(KeyEvent.KEYCODE_BACK, 0, false)
GeodeUtils.nativeKeyUp(KeyEvent.KEYCODE_BACK, 0)
} else if (!controllerEventsEnabled) {
cocos2dxRenderer.handleKeyDown(KeyEvent.KEYCODE_BACK)
} }
} }
} }
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
if (!queueKeyboardEvent(keyCode, event, true)) { if (!useKeyboardEvents) {
return legacyKeyDown(keyCode, event) 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 return true
} }
queueEvent {
if (sendTimestampEvents)
GeodeUtils.setNextInputTimestamp(event.eventTime * MS_TO_NS)
GeodeUtils.nativeKeyDown(keyCode, event.modifiers, event.repeatCount != 0)
}
true
}
}
}
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean { override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
if (!queueKeyboardEvent(keyCode, event, false)) { if (!useKeyboardEvents) {
return super.onKeyUp(keyCode, event) 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 return true
} }
// i'm lazy. queueEvent {
// https://developer.android.com/develop/ui/views/touch-and-input/game-controllers/controller-input if (sendTimestampEvents)
private fun getCenteredAxis( GeodeUtils.setNextInputTimestamp(event.eventTime * MS_TO_NS)
event: MotionEvent,
device: InputDevice, GeodeUtils.nativeKeyUp(keyCode, event.modifiers)
axis: Int,
historyPos: Int
): Float {
device.getMotionRange(axis, event.source)?.apply {
val value: Float = if (historyPos < 0) {
event.getAxisValue(axis)
} else {
event.getHistoricalAxisValue(axis, historyPos)
} }
if (abs(value) > flat) { true
return value
} }
} }
return 0.0f
}
private fun getJoystickMotion(event: MotionEvent, axisHorizontal: Int, axisVertical: Int): Pair<FloatArray, FloatArray> {
val positionChangesX = ArrayList<Float>()
val positionChangesY = ArrayList<Float>()
val inputDevice = event.device
(0 until event.historySize).forEach { i ->
val x = getCenteredAxis(event, inputDevice, axisHorizontal, i)
val y = getCenteredAxis(event, inputDevice, axisVertical, i)
positionChangesX.add(x)
positionChangesY.add(y)
}
val x = getCenteredAxis(event, inputDevice, axisHorizontal, -1)
val y = getCenteredAxis(event, inputDevice, axisVertical, -1)
positionChangesX.add(x)
positionChangesY.add(y)
return Pair(positionChangesX.toFloatArray(), positionChangesY.toFloatArray())
}
private fun getJoystickMotion(event: MotionEvent, axis: Int): FloatArray {
val positionChanges = ArrayList<Float>()
val inputDevice = event.device
(0 until event.historySize).forEach { i ->
val x = getCenteredAxis(event, inputDevice, axis, i)
positionChanges.add(x)
}
val x = getCenteredAxis(event, inputDevice, axis, -1)
positionChanges.add(x)
return positionChanges.toFloatArray()
} }
override fun onGenericMotionEvent(event: MotionEvent?): Boolean { override fun onGenericMotionEvent(event: MotionEvent?): Boolean {
val hasGeodeEvents = useKeyboardEvents || controllerEventsEnabled if (!useKeyboardEvents) {
if (!hasGeodeEvents && !sendNewCocosEvents) {
return super.onGenericMotionEvent(event) return super.onGenericMotionEvent(event)
} }
if (event?.action == MotionEvent.ACTION_SCROLL) { if (event?.action == MotionEvent.ACTION_SCROLL) {
val mouseX = event.x
val mouseY = event.y
val scrollX = event.getAxisValue(MotionEvent.AXIS_HSCROLL) val scrollX = event.getAxisValue(MotionEvent.AXIS_HSCROLL)
val scrollY = event.getAxisValue(MotionEvent.AXIS_VSCROLL) val scrollY = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
val timestamp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) val timestamp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
event.eventTimeNanos else event.eventTime * MS_TO_NS event.eventTimeNanos else event.eventTime * MS_TO_NS
queueEvent { queueEvent {
sendNextEventTimestamp(timestamp) if (sendTimestampEvents)
GeodeUtils.setNextInputTimestamp(timestamp)
if (useKeyboardEvents) {
GeodeUtils.nativeActionScroll(scrollX, scrollY) GeodeUtils.nativeActionScroll(scrollX, scrollY)
} }
if (controllerEventsEnabled) {
GeodeUtils.internalScrollEvent(timestamp, event.deviceId, event.source, scrollX, scrollY, mouseX, mouseY)
}
if (!hasGeodeEvents && sendNewCocosEvents) {
cocos2dxRenderer.handleScroll(mouseX, mouseY, scrollY, scrollX)
}
}
return true
}
if (controllerEventsEnabled && event?.action == MotionEvent.ACTION_MOVE && event.source and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK) {
val (motionLeftX, motionLeftY) = getJoystickMotion(event, MotionEvent.AXIS_X, MotionEvent.AXIS_Y)
val (motionRightX, motionRightY) = getJoystickMotion(event, MotionEvent.AXIS_Z, MotionEvent.AXIS_RZ)
val (motionDPadX, motionDPadY) = getJoystickMotion(event, MotionEvent.AXIS_HAT_X, MotionEvent.AXIS_HAT_Y)
val motionLTrigger = getJoystickMotion(event, MotionEvent.AXIS_LTRIGGER)
val motionRTrigger = getJoystickMotion(event, MotionEvent.AXIS_RTRIGGER)
// i'm sure this is great for performance
val timestamp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
event.eventTimeNanos else event.eventTime * MS_TO_NS
queueEvent {
GeodeUtils.internalJoystickEvent(
timestamp, event.deviceId, event.source,
motionLeftX, motionLeftY,
motionRightX, motionRightY,
motionDPadX, motionDPadY,
motionLTrigger, motionRTrigger
)
}
return true return true
} }
@@ -671,40 +372,4 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
fun deleteBackward() { fun deleteBackward() {
queueEvent { cocos2dxRenderer.handleDeleteBackward() } queueEvent { cocos2dxRenderer.handleDeleteBackward() }
} }
fun enableControllerEvents() {
if (controllerEventsEnabled) {
return
}
controllerEventsEnabled = true
val inputManager = context.getSystemService(Context.INPUT_SERVICE) as InputManager
inputManager.registerInputDeviceListener(InputListener, null)
}
fun disableControllerEvents() {
if (!controllerEventsEnabled) {
return
}
controllerEventsEnabled = true
val inputManager = context.getSystemService(Context.INPUT_SERVICE) as InputManager
inputManager.unregisterInputDeviceListener(InputListener)
}
object InputListener : InputManager.InputDeviceListener {
override fun onInputDeviceAdded(deviceId: Int) {
GeodeUtils.inputDeviceAdded(deviceId)
}
override fun onInputDeviceChanged(deviceId: Int) {
GeodeUtils.inputDeviceChanged(deviceId)
}
override fun onInputDeviceRemoved(deviceId: Int) {
GeodeUtils.inputDeviceRemoved(deviceId)
}
}
} }
@@ -28,54 +28,33 @@ class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceVi
@JvmStatic @JvmStatic
private external fun nativeKeyDown(keyCode: Int): Boolean 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 @JvmStatic
private external fun nativeOnPause() private external fun nativeOnPause()
@JvmStatic @JvmStatic
private external fun nativeOnResume() private external fun nativeOnResume()
@JvmStatic
private external fun nativeScroll(x: Float, y: Float, scrollY: Float, scrollX: Float)
@JvmStatic @JvmStatic
private external fun nativeRender() private external fun nativeRender()
@JvmStatic @JvmStatic
private external fun nativeTextClosed() private external fun nativeTextClosed()
// timestamp was added in 2.208, but the jni method has no required type signature.
// it doesn't hurt to unconditionally send one
@JvmStatic @JvmStatic
private external fun nativeTouchesBegin(id: Int, x: Float, y: Float, timestamp: Double) private external fun nativeTouchesBegin(id: Int, x: Float, y: Float)
@JvmStatic @JvmStatic
private external fun nativeTouchesCancel( private external fun nativeTouchesCancel(
ids: IntArray, ids: IntArray,
xs: FloatArray, xs: FloatArray,
ys: FloatArray, ys: FloatArray
timestamp: Double,
) )
@JvmStatic @JvmStatic
private external fun nativeTouchesEnd(id: Int, x: Float, y: Float, timestamp: Double) private external fun nativeTouchesEnd(id: Int, x: Float, y: Float)
@JvmStatic @JvmStatic
private external fun nativeTouchesMove(ids: IntArray, xs: FloatArray, ys: FloatArray, timestamp: Double) private external fun nativeTouchesMove(ids: IntArray, xs: FloatArray, ys: FloatArray)
@JvmStatic @JvmStatic
private external fun nativeInit(width: Int, height: Int) private external fun nativeInit(width: Int, height: Int)
@@ -130,38 +109,26 @@ class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceVi
} }
} }
fun handleActionDown(id: Int, x: Float, y: Float, timestamp: Double) { fun handleActionDown(id: Int, x: Float, y: Float) {
nativeTouchesBegin(id, x, y, timestamp) nativeTouchesBegin(id, x, y)
} }
fun handleActionUp(id: Int, x: Float, y: Float, timestamp: Double) { fun handleActionUp(id: Int, x: Float, y: Float) {
nativeTouchesEnd(id, x, y, timestamp) nativeTouchesEnd(id, x, y)
} }
fun handleActionCancel(ids: IntArray, xs: FloatArray, ys: FloatArray, timestamp: Double) { fun handleActionCancel(ids: IntArray, xs: FloatArray, ys: FloatArray) {
nativeTouchesCancel(ids, xs, ys, timestamp) nativeTouchesCancel(ids, xs, ys)
} }
fun handleActionMove(ids: IntArray, xs: FloatArray, ys: FloatArray, timestamp: Double) { fun handleActionMove(ids: IntArray, xs: FloatArray, ys: FloatArray) {
nativeTouchesMove(ids, xs, ys, timestamp) nativeTouchesMove(ids, xs, ys)
} }
fun handleKeyDown(keyCode: Int) { fun handleKeyDown(keyCode: Int) {
nativeKeyDown(keyCode) 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() { fun handleOnPause() {
nativeOnPause() nativeOnPause()
} }
@@ -5,10 +5,8 @@ import android.text.TextWatcher
import android.widget.TextView.OnEditorActionListener import android.widget.TextView.OnEditorActionListener
import android.text.Editable import android.text.Editable
import android.view.KeyEvent import android.view.KeyEvent
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.TextView import android.widget.TextView
import com.customRobTop.JniToCpp
class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurfaceView) : TextWatcher, OnEditorActionListener { class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurfaceView) : TextWatcher, OnEditorActionListener {
private var originText: String? = null private var originText: String? = null
@@ -21,33 +19,21 @@ class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurf
this.originText = originText this.originText = originText
} }
override fun afterTextChanged(editable: Editable) { override fun afterTextChanged(s: Editable) {
if (isFullScreenEdit) { if (!isFullScreenEdit) {
return var nModified = s.length - text!!.length
} if (nModified > 0) {
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( cocos2dxGLSurfaceView.insertText(
str.substring(unchangedUntil) s.subSequence(text!!.length, s.length).toString()
) )
} else {
while (nModified < 0) {
cocos2dxGLSurfaceView.deleteBackward()
nModified++
}
}
text = s.toString()
} }
text = str
} }
override fun beforeTextChanged( override fun beforeTextChanged(
@@ -61,45 +47,24 @@ class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurf
override fun onTextChanged(pCharSequence: CharSequence, start: Int, before: Int, count: Int) {} override fun onTextChanged(pCharSequence: CharSequence, start: Int, before: Int, count: Int) {}
override fun onEditorAction(textView: TextView, actionID: Int, keyEvent: KeyEvent?): Boolean { override fun onEditorAction(textView: TextView, actionID: Int, keyEvent: KeyEvent?): Boolean {
val fullscreenIme = cocos2dxGLSurfaceView.cocos2dxEditText == textView && isFullScreenEdit if (cocos2dxGLSurfaceView.cocos2dxEditText == textView && isFullScreenEdit) {
if (fullscreenIme) { for (i in originText!!.length downTo 1) {
var text = textView.text.toString()
if (originText != text || !cocos2dxGLSurfaceView.sendNewCocosEvents) {
if (cocos2dxGLSurfaceView.sendNewCocosEvents) {
cocos2dxGLSurfaceView.queueEvent {
JniToCpp.textInputCaretToEnd()
}
}
repeat(originText!!.length) {
cocos2dxGLSurfaceView.deleteBackward() cocos2dxGLSurfaceView.deleteBackward()
} }
var text = textView.text.toString()
if (!cocos2dxGLSurfaceView.sendNewCocosEvents) { if (text.compareTo("") == 0) {
if (!text.endsWith('\n')) { text = "\n"
text += '\n'
} }
if (10 != text[text.length - 1].code) {
text += 10.toChar()
} }
if (text.isNotEmpty()) {
cocos2dxGLSurfaceView.insertText(text) cocos2dxGLSurfaceView.insertText(text)
} }
if (actionID != 6) {
return false
} }
}
if (fullscreenIme || actionID == EditorInfo.IME_ACTION_DONE) {
if (cocos2dxGLSurfaceView.sendNewCocosEvents) {
cocos2dxGLSurfaceView.queueEvent {
JniToCpp.textInputDone()
}
}
}
if (actionID == EditorInfo.IME_ACTION_DONE) {
cocos2dxGLSurfaceView.requestFocus() cocos2dxGLSurfaceView.requestFocus()
Cocos2dxGLSurfaceView.closeIMEKeyboard() Cocos2dxGLSurfaceView.closeIMEKeyboard()
}
return false return false
} }
} }
+5 -108
View File
@@ -7,7 +7,6 @@ import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.res.AssetManager import android.content.res.AssetManager
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo import android.media.AudioDeviceInfo
import android.media.AudioManager import android.media.AudioManager
import android.net.Uri import android.net.Uri
@@ -15,45 +14,27 @@ import android.os.Build
import android.util.Log import android.util.Log
import java.io.FileNotFoundException import java.io.FileNotFoundException
// i never agreed to your licenses! take that // i never agreed to your licenses! take that
@SuppressLint("StaticFieldLeak") @SuppressLint("StaticFieldLeak")
object FMOD { object FMOD {
private var gContext: Context? = null private var gContext: Context? = null
private var gPluginAudioDeviceCallback: PluginAudioDeviceCallback? = null
private val gPluginBroadcastReceiver = PluginBroadcastReceiver() private val gPluginBroadcastReceiver = PluginBroadcastReceiver()
external fun OutputAAudioHeadphonesChanged() external fun OutputAAudioHeadphonesChanged()
external fun SetInputEnumerationChanged()
external fun SetOutputEnumerationChanged()
@JvmStatic @JvmStatic
fun init(context: Context?) { fun init(context: Context?) {
gContext = context gContext = context
if (context == null) { gContext?.registerReceiver(
return gPluginBroadcastReceiver,
} IntentFilter(Intent.ACTION_HEADSET_PLUG)
)
val intentFilter = IntentFilter(Intent.ACTION_HEADSET_PLUG)
if (Build.VERSION.SDK_INT >= 34) {
context.registerReceiver(gPluginBroadcastReceiver, intentFilter, Context.RECEIVER_NOT_EXPORTED)
} else {
context.registerReceiver(gPluginBroadcastReceiver, intentFilter)
}
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
val pluginAudioDeviceCallback = PluginAudioDeviceCallback(audioManager.getDevices(3))
gPluginAudioDeviceCallback = pluginAudioDeviceCallback
audioManager.registerAudioDeviceCallback(pluginAudioDeviceCallback, null)
} }
@JvmStatic @JvmStatic
fun close() { fun close() {
val context = gContext val context = gContext
if (context != null) { context?.unregisterReceiver(gPluginBroadcastReceiver)
context.unregisterReceiver(gPluginBroadcastReceiver)
(context.getSystemService(Context.AUDIO_SERVICE) as AudioManager)
.unregisterAudioDeviceCallback(gPluginAudioDeviceCallback)
}
gContext = null gContext = null
} }
@@ -128,12 +109,6 @@ object FMOD {
} }
} }
@JvmStatic
fun getAudioDevices(i: Int): Array<AudioDeviceInfo?>? {
val context = gContext ?: return emptyArray()
return (context.getSystemService(Context.AUDIO_SERVICE) as AudioManager).getDevices(i)
}
@JvmStatic @JvmStatic
fun fileDescriptorFromUri(str: String?): Int { fun fileDescriptorFromUri(str: String?): Int {
gContext?.apply { gContext?.apply {
@@ -152,82 +127,4 @@ object FMOD {
OutputAAudioHeadphonesChanged() OutputAAudioHeadphonesChanged()
} }
} }
internal class PluginAudioDeviceCallback : AudioDeviceCallback {
val deviceSet: HashSet<Int> = HashSet()
constructor(audioDeviceInfoArr: Array<out AudioDeviceInfo?>?) {
if (audioDeviceInfoArr != null) {
deviceSet.addAll(audioDeviceInfoArr.mapNotNull { it?.id })
}
}
override fun onAudioDevicesAdded(audioDeviceInfoArr: Array<out AudioDeviceInfo?>?) {
var z: Boolean
var i = 0
if (audioDeviceInfoArr != null) {
var i2 = 0
z = false
while (i < audioDeviceInfoArr.size) {
if (!deviceSet.contains(audioDeviceInfoArr[i]!!.id)) {
if (audioDeviceInfoArr[i]!!.isSource) {
i2 = 1
}
if (audioDeviceInfoArr[i]!!.isSink) {
z = true
}
deviceSet.add(audioDeviceInfoArr[i]!!.id)
}
i++
}
i = i2
} else {
z = false
}
if (i != 0) {
try {
SetInputEnumerationChanged()
} catch (_: UnsatisfiedLinkError) { }
}
if (z) {
try {
SetOutputEnumerationChanged()
} catch (_: UnsatisfiedLinkError) { }
}
}
override fun onAudioDevicesRemoved(audioDeviceInfoArr: Array<out AudioDeviceInfo?>?) {
var z: Boolean
var i = 0
if (audioDeviceInfoArr != null) {
var i2 = 0
z = false
while (i < audioDeviceInfoArr.size) {
if (deviceSet.contains(audioDeviceInfoArr[i]!!.id)) {
if (audioDeviceInfoArr[i]!!.isSource) {
i2 = 1
}
if (audioDeviceInfoArr[i]!!.isSink) {
z = true
}
deviceSet.remove(audioDeviceInfoArr[i]!!.id)
}
i++
}
i = i2
} else {
z = false
}
if (i != 0) {
try {
SetInputEnumerationChanged()
} catch (_: UnsatisfiedLinkError) { }
}
if (z) {
try {
SetOutputEnumerationChanged()
} catch (_: UnsatisfiedLinkError) { }
}
}
}
} }
@@ -1,29 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<group android:scaleX="0.6689189"
android:scaleY="0.6689189"
android:translateX="169.51352"
android:translateY="182.1982">
<path
android:pathData="M553.7,189.9L536.9,177.1C522.2,165.8 501.8,165.8 487.1,177.1L207.6,390.5C194,400.9 188.3,418.8 193.3,435.2L300.9,783.2C306.2,800.3 322.1,812.1 340.1,812.1H683.9C701.9,812.1 717.8,800.3 723.1,783.2L830.7,435.2C835.7,418.8 830,400.9 816.4,390.5L792,371.9L762,466.3L680,733C676.5,744.4 666,752.1 654.2,752.1H368C356.1,752.1 345.7,744.4 342.2,733L252.8,442.3C249.5,431.5 253.2,419.8 262.1,413L494.6,234.5C495.6,233.7 496.6,233.1 497.6,232.5L553.7,189.9Z"
android:fillColor="#000000"
android:fillAlpha="0.25"
android:fillType="evenOdd"/>
<path
android:pathData="M500.6,320.6C508.5,314.5 519.5,314.5 527.4,320.6L686,442.2C693.3,447.8 696.3,457.3 693.6,466.1L632.6,664.4C629.7,673.6 621.2,679.9 611.6,679.9H416.4C406.8,679.9 398.3,673.6 395.4,664.4L385.4,632H439.4C440,632.1 440.7,632.2 441.4,632.2H583.2C588,632.2 592.3,629 593.7,624.4L638,477.5C639.3,473.2 637.8,468.5 634.3,465.7L519.1,375.6C515.1,372.5 509.5,372.5 505.5,375.6L399.1,458.8L344.5,499.1L334.4,466.1C331.7,457.3 334.7,447.8 342,442.2L500.6,320.6Z"
android:fillColor="#000000"
android:fillAlpha="0.25"
android:fillType="evenOdd"/>
<path
android:pathData="M557.1,165.4L536.9,150C522.2,138.8 501.8,138.8 487.1,150L180.9,383.8C167.3,394.2 161.6,412.1 166.7,428.5L284.4,809.4C289.7,826.6 305.6,838.3 323.6,838.3H700.4C718.4,838.3 734.3,826.6 739.6,809.4L857.3,428.5C862.4,412.1 856.7,394.2 843.1,383.8L814.8,362.3L782.4,464.4L693.2,754.4C689.7,765.7 679.2,773.5 667.4,773.5H354.6C342.8,773.5 332.3,765.7 328.8,754.4L231.2,437C227.9,426.2 231.6,414.5 240.6,407.6L494.6,212.6C495.6,211.8 496.6,211.1 497.7,210.5L557.1,165.4Z"
android:fillType="evenOdd"
android:fillColor="#ffffff" />
<path
android:pathData="M500.6,284.3C508.5,278.2 519.5,278.2 527.4,284.3L716.8,429.5C724.1,435.1 727.1,444.6 724.4,453.4L651.6,689.9C648.8,699.2 640.3,705.5 630.6,705.5H397.4C387.7,705.5 379.2,699.2 376.4,689.9L363.9,649.5H425.7C426.4,649.6 427.1,649.7 427.9,649.7H596.1C601,649.7 605.2,646.5 606.6,641.9L659.1,468C660.5,463.6 659,458.9 655.4,456.1L518.8,349.3C514.8,346.2 509.2,346.2 505.2,349.3L379.9,447.3L316.2,494.3L303.6,453.4C300.9,444.6 303.9,435.1 311.2,429.5L500.6,284.3Z"
android:fillType="evenOdd"
android:fillColor="#ffffff" />
</group>
</vector>
-43
View File
@@ -1,43 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="204dp"
android:height="205dp"
android:viewportWidth="204"
android:viewportHeight="205">
<path
android:pathData="M114.92,6.98L109,2.47C104.7,-0.82 98.72,-0.82 94.42,2.47L4.72,70.96C0.72,74.02 -0.95,79.25 0.54,84.06L35.03,195.65C36.59,200.69 41.24,204.12 46.51,204.12H156.91C162.18,204.12 166.83,200.69 168.38,195.65L202.88,84.06C204.37,79.25 202.7,74.02 198.7,70.96L190.43,64.65L180.92,94.56L154.79,179.53C153.77,182.85 150.71,185.12 147.23,185.12H55.6C52.13,185.12 49.06,182.85 48.04,179.53L19.44,86.54C18.47,83.38 19.57,79.95 22.19,77.94L96.6,20.81C96.9,20.58 97.21,20.38 97.53,20.19L114.92,6.98Z"
android:fillType="evenOdd">
<aapt:attr name="android:fillColor">
<gradient
android:startX="65.53"
android:startY="18.28"
android:endX="150.22"
android:endY="187.89"
android:type="linear">
<item android:offset="0" android:color="#FFFFFDFF"/>
<item android:offset="0.08" android:color="#FFF2EAF5"/>
<item android:offset="0.12" android:color="#FFEDE5EF"/>
<item android:offset="0.31" android:color="#FFCDB5CD"/>
<item android:offset="0.49" android:color="#FFBA9BBC"/>
<item android:offset="1" android:color="#FF8D7ACF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M98.37,41.8C100.69,40.03 103.9,40.03 106.22,41.8L161.71,84.35C163.85,85.98 164.74,88.78 163.95,91.36L142.61,160.64C141.78,163.35 139.28,165.19 136.45,165.19H68.14C65.31,165.19 62.81,163.35 61.98,160.64L58.33,148.8H76.42C76.63,148.84 76.85,148.87 77.07,148.87H126.35C127.77,148.87 129.03,147.93 129.44,146.57L144.82,95.62C145.2,94.35 144.76,92.97 143.72,92.15L103.69,60.85C102.53,59.94 100.89,59.94 99.72,60.85L63.01,89.56L44.33,103.33L40.65,91.36C39.85,88.78 40.75,85.98 42.88,84.35L98.37,41.8Z"
android:fillType="evenOdd">
<aapt:attr name="android:fillColor">
<gradient
android:startX="102.3"
android:startY="-83.96"
android:endX="102.3"
android:endY="165.06"
android:type="linear">
<item android:offset="0.48" android:color="#FFFFFFFF"/>
<item android:offset="0.63" android:color="#FFF5E4C2"/>
<item android:offset="0.73" android:color="#FFE5C7AD"/>
<item android:offset="1" android:color="#FFB790A9"/>
</gradient>
</aapt:attr>
</path>
</vector>
@@ -1,42 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="204dp"
android:height="205dp"
android:viewportWidth="204"
android:viewportHeight="205">
<path
android:pathData="M114.92,6.98L109,2.47C104.7,-0.82 98.72,-0.82 94.42,2.47L4.72,70.96C0.72,74.02 -0.95,79.25 0.54,84.06L35.03,195.65C36.59,200.69 41.24,204.12 46.51,204.12H156.91C162.18,204.12 166.83,200.69 168.38,195.65L202.88,84.06C204.37,79.25 202.7,74.02 198.7,70.96L190.43,64.65L180.92,94.56L154.79,179.53C153.77,182.85 150.71,185.12 147.23,185.12H55.6C52.13,185.12 49.06,182.85 48.04,179.53L19.44,86.54C18.47,83.38 19.57,79.95 22.19,77.94L96.6,20.81C96.9,20.58 97.21,20.38 97.53,20.19L114.92,6.98Z"
android:fillType="evenOdd">
<aapt:attr name="android:fillColor">
<gradient
android:startX="65.53"
android:startY="18.28"
android:endX="150.22"
android:endY="187.89"
android:type="linear">
<item android:offset="0.0" android:color="#fff5b11d"/>
<item android:offset="0.10" android:color="#fff0834d"/>
<item android:offset="0.24" android:color="#ffe85f6b"/>
<item android:offset="0.49" android:color="#ffc9659e"/>
<item android:offset="1" android:color="#ffb9588f"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M98.37,41.8C100.69,40.03 103.9,40.03 106.22,41.8L161.71,84.35C163.85,85.98 164.74,88.78 163.95,91.36L142.61,160.64C141.78,163.35 139.28,165.19 136.45,165.19H68.14C65.31,165.19 62.81,163.35 61.98,160.64L58.33,148.8H76.42C76.63,148.84 76.85,148.87 77.07,148.87H126.35C127.77,148.87 129.03,147.93 129.44,146.57L144.82,95.62C145.2,94.35 144.76,92.97 143.72,92.15L103.69,60.85C102.53,59.94 100.89,59.94 99.72,60.85L63.01,89.56L44.33,103.33L40.65,91.36C39.85,88.78 40.75,85.98 42.88,84.35L98.37,41.8Z"
android:fillType="evenOdd">
<aapt:attr name="android:fillColor">
<gradient
android:startX="102.3"
android:startY="-83.96"
android:endX="102.3"
android:endY="165.06"
android:type="linear">
<item android:offset="0.48" android:color="#fff2ce00"/>
<item android:offset="0.63" android:color="#fff4b97e"/>
<item android:offset="0.73" android:color="#fff7b66a"/>
<item android:offset="1" android:color="#ffeb8fac"/>
</gradient>
</aapt:attr>
</path>
</vector>
@@ -1,13 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="96dp"
android:height="96dp"
android:viewportWidth="696"
android:viewportHeight="698"
android:tint="?attr/colorControlNormal">
<path
android:pathData="M336.61,143.26C344.51,137.21 355.49,137.21 363.39,143.26L552.8,288.49C560.1,294.09 563.14,303.63 560.44,312.42L487.62,548.92C484.78,558.15 476.25,564.45 466.59,564.45H233.41C223.75,564.45 215.22,558.15 212.38,548.92L199.93,508.5H261.68C262.39,508.65 263.13,508.72 263.89,508.72H432.11C436.96,508.72 441.24,505.55 442.64,500.9L495.14,326.98C496.45,322.64 494.96,317.93 491.39,315.14L354.78,208.3C350.8,205.19 345.2,205.19 341.22,208.3L215.93,306.29L152.15,353.31L139.56,312.42C136.86,303.63 139.9,294.09 147.2,288.49L336.61,143.26Z"
android:fillType="evenOdd"
android:fillColor="@android:color/white">
</path>
</vector>
@@ -1,13 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="96dp"
android:height="96dp"
android:viewportWidth="696"
android:viewportHeight="698"
android:tint="?attr/colorControlNormal">
<path
android:pathData="M393.08,24.42L372.88,9C358.19,-2.22 337.81,-2.22 323.12,9L16.94,242.81C3.28,253.24 -2.42,271.09 2.66,287.51L120.41,668.43C125.72,685.61 141.6,697.33 159.58,697.33H536.42C554.4,697.33 570.28,685.61 575.59,668.43L693.34,287.51C698.42,271.09 692.72,253.24 679.06,242.81L650.84,221.26L618.37,323.39L529.2,613.39C525.71,624.72 515.24,632.46 503.39,632.46H190.61C178.76,632.46 168.29,624.72 164.8,613.39L67.2,295.97C63.88,285.19 67.62,273.49 76.56,266.62L330.56,71.62C331.58,70.84 332.64,70.14 333.74,69.52L393.08,24.42Z"
android:fillType="evenOdd"
android:fillColor="@android:color/white">
</path>
</vector>
@@ -1,28 +0,0 @@
<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="#F4D48E"/>
<path
android:pathData="M598,683m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
android:fillColor="#F4D48E"/>
<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="#F4A47B"/>
<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="#E27880"/>
<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="#B9588F"/>
<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="#714A9A"/>
</group>
</vector>
@@ -1,29 +0,0 @@
<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,29 +0,0 @@
<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="#CC7C7C"/>
<path
android:pathData="M598,683m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
android:fillColor="#CC7C7C"/>
<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="#C6B471"/>
<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="#59AF7F"/>
<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="#5F8CAB"/>
<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="#70558D"/>
</group>
</vector>
@@ -1,36 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:pathData="M0,0h1024v1024h-1024z"
android:fillColor="#070066"/>
<path
android:pathData="M512.5,521.1L915.2,638.7L1082,922.6L915.2,1206.4L512.5,1324L109.8,1206.4L-57,922.6L109.8,638.7L512.5,521.1Z"
android:fillColor="#6694C8"/>
<path
android:pathData="M915.2,638.7L1082,922.6V638.7H915.2Z"
android:fillColor="#4268B8"/>
<path
android:pathData="M109.8,638.7L512.5,521.1L109.8,397.2V638.7Z"
android:fillColor="#2D5EC7"/>
<path
android:pathData="M-57,922.6L109.8,638.7H-57V922.6Z"
android:fillColor="#4268B8"/>
<path
android:pathData="M512.5,521.1L915.2,638.7V397.2L512.5,521.1Z"
android:fillColor="#2D5EC7"/>
<path
android:pathData="M-57,638.7H109.8V397.2L-57,638.7Z"
android:fillColor="#2248AA"/>
<path
android:pathData="M109.8,397.2L512.5,521.1V194L109.8,397.2Z"
android:fillColor="#020F9D"/>
<path
android:pathData="M512.5,521.1L915.2,397.2L512.5,194V521.1Z"
android:fillColor="#020F9D"/>
<path
android:pathData="M915.2,638.7H1082L915.2,397.2V638.7Z"
android:fillColor="#2248AA"/>
</vector>
@@ -1,27 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="643"
android:viewportHeight="766">
<group android:scaleX="0.41038585"
android:scaleY="0.4888889"
android:translateX="191.0163"
android:translateY="195.75555">
<path
android:pathData="M299.94,33.78C313.01,25.85 329.41,25.85 342.48,33.78L370.9,51.03L268.17,116.49L268.23,116.51L92.68,223.61C84.64,228.51 79.74,237.25 79.74,246.66V509.84C79.74,519.26 84.64,527.99 92.68,532.89L306.22,663.17C314.85,668.44 325.71,668.44 334.34,663.17L547.89,532.89C555.92,527.99 560.82,519.26 560.82,509.85V246.66C560.82,246.2 560.81,245.74 560.78,245.28V166.26L599.75,189.9C612,197.34 619.48,210.63 619.48,224.96V540.81C619.48,555.14 612,568.42 599.75,575.86L342.48,731.98C329.41,739.91 313.01,739.91 299.94,731.98L42.66,575.86C30.41,568.42 22.93,555.14 22.93,540.81V224.96C22.93,210.63 30.41,197.34 42.66,189.9L299.94,33.78Z"
android:fillColor="#000000"
android:fillAlpha="0.15"/>
<path
android:pathData="M311.75,173.17C318.78,168.88 327.62,168.88 334.65,173.17L481.88,262.87C488.43,266.86 492.43,273.98 492.43,281.66V463.16C492.43,470.84 488.43,477.96 481.88,481.95L334.65,571.65C327.62,575.94 318.78,575.94 311.75,571.65L164.52,481.95C157.97,477.96 153.97,470.84 153.97,463.16V414.95L208.24,448.54C208.52,448.75 208.8,448.95 209.1,449.13L315.66,515.36C319.22,517.57 323.72,517.57 327.28,515.36L433.84,449.13C437.07,447.13 439.03,443.59 439.03,439.79V305.9C439.03,302.09 437.07,298.56 433.84,296.55L327.28,230.33C323.72,228.12 319.22,228.12 315.66,230.33L209.1,296.55C208.62,296.85 208.17,297.18 207.75,297.55L153.97,331.28V281.66C153.97,273.98 157.97,266.86 164.52,262.87L311.75,173.17Z"
android:fillColor="#000000"
android:fillAlpha="0.15"
android:fillType="evenOdd"/>
<path
android:pathData="M299.94,5.95C313.01,-1.98 329.41,-1.98 342.48,5.95L374.73,25.52L264.09,96.01L264.15,96.03L74.11,211.97C66.07,216.88 61.17,225.61 61.17,235.02V520.77C61.17,530.18 66.07,538.92 74.11,543.82L306.15,685.38C314.78,690.65 325.64,690.65 334.27,685.38L566.31,543.82C574.34,538.92 579.25,530.18 579.25,520.77V235.02C579.25,234.53 579.23,234.05 579.2,233.56V149.6L622.68,175.99C634.93,183.42 642.41,196.71 642.41,211.04V554.72C642.41,569.05 634.93,582.34 622.68,589.78L342.48,759.82C329.41,767.75 313.01,767.75 299.94,759.82L19.73,589.78C7.48,582.34 0,569.05 0,554.72V211.04C0,196.71 7.48,183.42 19.73,175.99L299.94,5.95Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M311.75,142.59C318.78,138.31 327.62,138.31 334.65,142.59L507.79,248.08C514.34,252.08 518.34,259.2 518.34,266.87V479.95C518.34,487.62 514.34,494.74 507.79,498.74L334.65,604.23C327.62,608.51 318.78,608.51 311.75,604.23L138.61,498.74C132.06,494.74 128.06,487.62 128.06,479.95V422.46L189.93,460.76C190.22,460.98 190.52,461.19 190.84,461.39L315.4,538.8C318.95,541.01 323.46,541.01 327.01,538.8L451.58,461.39C454.8,459.38 456.77,455.85 456.77,452.04V295.77C456.77,291.97 454.8,288.44 451.58,286.43L327.01,209.02C323.46,206.81 318.95,206.81 315.4,209.02L190.84,286.43C190.32,286.76 189.83,287.12 189.37,287.52L128.06,325.98V266.87C128.06,259.2 132.06,252.08 138.61,248.08L311.75,142.59Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
</group>
</vector>
@@ -1,18 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="643"
android:viewportHeight="766">
<group android:scaleX="0.41038585"
android:scaleY="0.4888889"
android:translateX="191.0163"
android:translateY="195.75555">
<path
android:pathData="M299.94,5.95C313.01,-1.98 329.41,-1.98 342.48,5.95L374.73,25.52L264.09,96.01L264.15,96.03L74.11,211.97C66.07,216.88 61.17,225.61 61.17,235.02V520.77C61.17,530.18 66.07,538.92 74.11,543.82L306.15,685.38C314.78,690.65 325.64,690.65 334.27,685.38L566.31,543.82C574.34,538.92 579.25,530.18 579.25,520.77V235.02C579.25,234.53 579.23,234.05 579.2,233.56V149.6L622.68,175.99C634.93,183.42 642.41,196.71 642.41,211.04V554.72C642.41,569.05 634.93,582.34 622.68,589.78L342.48,759.82C329.41,767.75 313.01,767.75 299.94,759.82L19.73,589.78C7.48,582.34 0,569.05 0,554.72V211.04C0,196.71 7.48,183.42 19.73,175.99L299.94,5.95Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M311.75,142.59C318.78,138.31 327.62,138.31 334.65,142.59L507.79,248.08C514.34,252.08 518.34,259.2 518.34,266.87V479.95C518.34,487.62 514.34,494.74 507.79,498.74L334.65,604.23C327.62,608.51 318.78,608.51 311.75,604.23L138.61,498.74C132.06,494.74 128.06,487.62 128.06,479.95V422.46L189.93,460.76C190.22,460.98 190.52,461.19 190.84,461.39L315.4,538.8C318.95,541.01 323.46,541.01 327.01,538.8L451.58,461.39C454.8,459.38 456.77,455.85 456.77,452.04V295.77C456.77,291.97 454.8,288.44 451.58,286.43L327.01,209.02C323.46,206.81 318.95,206.81 315.4,209.02L190.84,286.43C190.32,286.76 189.83,287.12 189.37,287.52L128.06,325.98V266.87C128.06,259.2 132.06,252.08 138.61,248.08L311.75,142.59Z"
android:fillColor="#ffffff"
android:fillType="evenOdd"/>
</group>
</vector>
@@ -1,29 +0,0 @@
<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="#65B8C2"/>
<path
android:pathData="M598,683m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
android:fillColor="#65B8C2"/>
<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="#DEA5CD"/>
<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="#DEE8EA"/>
<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="#C485BD"/>
<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="#259CB4"/>
</group>
</vector>
-10
View File
@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z"/>
</vector>
@@ -1,11 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M313,520L537,744L480,800L160,480L480,160L537,216L313,440L800,440L800,520L313,520Z"/>
</vector>
-10
View File
@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M382,720L154,492L211,435L382,606L749,239L806,296L382,720Z"/>
</vector>
@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M336,680L280,624L424,480L280,337L336,281L480,425L623,281L679,337L535,480L679,624L623,680L480,536L336,680Z"/>
</vector>
@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M260,800Q169,800 104.5,737Q40,674 40,583Q40,505 87,444Q134,383 210,366Q235,274 310,217Q385,160 480,160Q597,160 678.5,241.5Q760,323 760,440L760,440L760,440Q829,448 874.5,499.5Q920,551 920,620Q920,695 867.5,747.5Q815,800 740,800L260,800ZM260,720L740,720Q782,720 811,691Q840,662 840,620Q840,578 811,549Q782,520 740,520L680,520L680,440Q680,357 621.5,298.5Q563,240 480,240Q397,240 338.5,298.5Q280,357 280,440L280,440L260,440Q202,440 161,481Q120,522 120,580Q120,638 161,679Q202,720 260,720ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480ZM480,640Q497,640 508.5,628.5Q520,617 520,600Q520,583 508.5,571.5Q497,560 480,560Q463,560 451.5,571.5Q440,583 440,600Q440,617 451.5,628.5Q463,640 480,640ZM440,500L520,500L520,320L440,320L440,500Z"/>
</vector>
@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M260,800Q169,800 104.5,737Q40,674 40,583Q40,505 87,444Q134,383 210,366Q235,274 310,217Q385,160 480,160Q597,160 678.5,241.5Q760,323 760,440L760,440L760,440Q829,448 874.5,499.5Q920,551 920,620Q920,695 867.5,747.5Q815,800 740,800L520,800Q487,800 463.5,776.5Q440,753 440,720L440,514L376,576L320,520L480,360L640,520L584,576L520,514L520,720Q520,720 520,720Q520,720 520,720L740,720Q782,720 811,691Q840,662 840,620Q840,578 811,549Q782,520 740,520L680,520L680,440Q680,357 621.5,298.5Q563,240 480,240Q397,240 338.5,298.5Q280,357 280,440L260,440Q202,440 161,481Q120,522 120,580Q120,638 161,679Q202,720 260,720L360,720L360,800L260,800ZM480,520L480,520L480,520Q480,520 480,520Q480,520 480,520L480,520L480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520Q480,520 480,520L480,520Z"/>
</vector>
-10
View File
@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L400,160L480,240L800,240Q833,240 856.5,263.5Q880,287 880,320L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM160,720L800,720Q800,720 800,720Q800,720 800,720L800,320Q800,320 800,320Q800,320 800,320L447,320L367,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720ZM160,720Q160,720 160,720Q160,720 160,720L160,240Q160,240 160,240Q160,240 160,240L160,240L160,320L160,320Q160,320 160,320Q160,320 160,320L160,720Q160,720 160,720Q160,720 160,720Z"/>
</vector>
-10
View File
@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M440,680L520,680L520,440L440,440L440,680ZM480,360Q497,360 508.5,348.5Q520,337 520,320Q520,303 508.5,291.5Q497,280 480,280Q463,280 451.5,291.5Q440,303 440,320Q440,337 451.5,348.5Q463,360 480,360ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>
@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M280,840L280,840Q280,840 280,840Q280,840 280,840L280,120Q280,120 280,120Q280,120 280,120L280,120Q280,120 280,120Q280,120 280,120L280,520L280,520Q280,520 280,590Q280,660 280,760L280,840ZM508.5,228.5Q520,217 520,200Q520,183 508.5,171.5Q497,160 480,160Q463,160 451.5,171.5Q440,183 440,200Q440,217 451.5,228.5Q463,240 480,240Q497,240 508.5,228.5ZM280,920Q247,920 223.5,896.5Q200,873 200,840L200,120Q200,87 223.5,63.5Q247,40 280,40L680,40Q713,40 736.5,63.5Q760,87 760,120L760,244Q778,251 789,266Q800,281 800,300L800,380Q800,399 789,414Q778,429 760,436L760,520L680,520L680,120Q680,120 680,120Q680,120 680,120L280,120Q280,120 280,120Q280,120 280,120L280,840Q280,840 280,840Q280,840 280,840L360,840L360,920L280,920ZM584,904L440,760L584,616L640,673L553,760L640,847L584,904ZM776,904L720,847L807,760L720,673L776,616L920,760L776,904Z"/>
</vector>
@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,800Q447,800 423.5,776.5Q400,753 400,720Q400,687 423.5,663.5Q447,640 480,640Q513,640 536.5,663.5Q560,687 560,720Q560,753 536.5,776.5Q513,800 480,800ZM480,560Q447,560 423.5,536.5Q400,513 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,513 536.5,536.5Q513,560 480,560ZM480,320Q447,320 423.5,296.5Q400,273 400,240Q400,207 423.5,183.5Q447,160 480,160Q513,160 536.5,183.5Q560,207 560,240Q560,273 536.5,296.5Q513,320 480,320Z"/>
</vector>
@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M320,760L320,200L760,480L320,760ZM400,480L400,480L400,480ZM400,614L610,480L400,346L400,614Z"/>
</vector>
@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,800Q346,800 253,707Q160,614 160,480Q160,346 253,253Q346,160 480,160Q549,160 612,188.5Q675,217 720,270L720,160L800,160L800,440L520,440L520,360L688,360Q656,304 600.5,272Q545,240 480,240Q380,240 310,310Q240,380 240,480Q240,580 310,650Q380,720 480,720Q557,720 619,676Q681,632 706,560L790,560Q762,666 676,733Q590,800 480,800Z"/>
</vector>
@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M500,840L444,784L586,642L444,500L500,444L642,586L784,444L840,500L698,642L840,784L784,840L642,698L500,840ZM280,840L280,760L360,760L360,840L280,840ZM200,200L120,200Q120,167 143.5,143.5Q167,120 200,120L200,200ZM280,200L280,120L360,120L360,200L280,200ZM440,200L440,120L520,120L520,200L440,200ZM600,200L600,120L680,120L680,200L600,200ZM760,200L760,120Q793,120 816.5,143.5Q840,167 840,200L760,200ZM200,760L200,840Q167,840 143.5,816.5Q120,793 120,760L200,760ZM120,680L120,600L200,600L200,680L120,680ZM120,520L120,440L200,440L200,520L120,520ZM120,360L120,280L200,280L200,360L120,360ZM760,360L760,280L840,280L840,360L760,360Z"/>
</vector>
@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M370,880L354,752Q341,747 329.5,740Q318,733 307,725L188,775L78,585L181,507Q180,500 180,493.5Q180,487 180,480Q180,473 180,466.5Q180,460 181,453L78,375L188,185L307,235Q318,227 330,220Q342,213 354,208L370,80L590,80L606,208Q619,213 630.5,220Q642,227 653,235L772,185L882,375L779,453Q780,460 780,466.5Q780,473 780,480Q780,487 780,493.5Q780,500 778,507L881,585L771,775L653,725Q642,733 630,740Q618,747 606,752L590,880L370,880ZM440,800L519,800L533,694Q564,686 590.5,670.5Q617,655 639,633L738,674L777,606L691,541Q696,527 698,511.5Q700,496 700,480Q700,464 698,448.5Q696,433 691,419L777,354L738,286L639,328Q617,305 590.5,289.5Q564,274 533,266L520,160L441,160L427,266Q396,274 369.5,289.5Q343,305 321,327L222,286L183,354L269,418Q264,433 262,448Q260,463 260,480Q260,496 262,511Q264,526 269,541L183,606L222,674L321,632Q343,655 369.5,670.5Q396,686 427,694L440,800ZM482,620Q540,620 581,579Q622,538 622,480Q622,422 581,381Q540,340 482,340Q423,340 382.5,381Q342,422 342,480Q342,538 382.5,579Q423,620 482,620ZM480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480Z"/>
</vector>
-10
View File
@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M680,880Q630,880 595,845Q560,810 560,760Q560,754 563,732L282,568Q266,583 245,591.5Q224,600 200,600Q150,600 115,565Q80,530 80,480Q80,430 115,395Q150,360 200,360Q224,360 245,368.5Q266,377 282,392L563,228Q561,221 560.5,214.5Q560,208 560,200Q560,150 595,115Q630,80 680,80Q730,80 765,115Q800,150 800,200Q800,250 765,285Q730,320 680,320Q656,320 635,311.5Q614,303 598,288L317,452Q319,459 319.5,465.5Q320,472 320,480Q320,488 319.5,494.5Q319,501 317,508L598,672Q614,657 635,648.5Q656,640 680,640Q730,640 765,675Q800,710 800,760Q800,810 765,845Q730,880 680,880ZM680,800Q697,800 708.5,788.5Q720,777 720,760Q720,743 708.5,731.5Q697,720 680,720Q663,720 651.5,731.5Q640,743 640,760Q640,777 651.5,788.5Q663,800 680,800ZM200,520Q217,520 228.5,508.5Q240,497 240,480Q240,463 228.5,451.5Q217,440 200,440Q183,440 171.5,451.5Q160,463 160,480Q160,497 171.5,508.5Q183,520 200,520ZM680,240Q697,240 708.5,228.5Q720,217 720,200Q720,183 708.5,171.5Q697,160 680,160Q663,160 651.5,171.5Q640,183 640,200Q640,217 651.5,228.5Q663,240 680,240ZM680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760Q680,760 680,760ZM200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480Q200,480 200,480ZM680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Q680,200 680,200Z"/>
</vector>
-10
View File
@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,880Q341,845 250.5,720.5Q160,596 160,444L160,200L480,80L800,200L800,444Q800,596 709.5,720.5Q619,845 480,880ZM480,796Q584,763 652,664Q720,565 720,444L720,255L480,165L240,255L240,444Q240,565 308,664Q376,763 480,796ZM480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>
@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,640Q497,640 508.5,628.5Q520,617 520,600Q520,583 508.5,571.5Q497,560 480,560Q463,560 451.5,571.5Q440,583 440,600Q440,617 451.5,628.5Q463,640 480,640ZM440,480L520,480L520,280L440,280L440,480ZM480,880Q341,845 250.5,720.5Q160,596 160,444L160,200L480,80L800,200L800,444Q800,596 709.5,720.5Q619,845 480,880ZM480,796Q584,763 652,664Q720,565 720,444L720,255L480,165L240,255L240,444Q240,565 308,664Q376,763 480,796ZM480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>
-10
View File
@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M240,880L240,710Q201,693 171.5,664.5Q142,636 121.5,600Q101,564 90.5,523Q80,482 80,440Q80,282 192,181Q304,80 480,80Q656,80 768,181Q880,282 880,440Q880,482 869.5,523Q859,564 838.5,600Q818,636 788.5,664.5Q759,693 720,710L720,880L240,880ZM320,800L360,800L360,720L440,720L440,800L520,800L520,720L600,720L600,800L640,800L640,658Q678,649 707.5,628Q737,607 757.5,578Q778,549 789,514Q800,479 800,440Q800,315 711.5,237.5Q623,160 480,160Q337,160 248.5,237.5Q160,315 160,440Q160,479 171,514Q182,549 202.5,578Q223,607 253,628Q283,649 320,658L320,800ZM420,600L540,600L480,480L420,600ZM340,520Q373,520 396.5,496.5Q420,473 420,440Q420,407 396.5,383.5Q373,360 340,360Q307,360 283.5,383.5Q260,407 260,440Q260,473 283.5,496.5Q307,520 340,520ZM620,520Q653,520 676.5,496.5Q700,473 700,440Q700,407 676.5,383.5Q653,360 620,360Q587,360 563.5,383.5Q540,407 540,440Q540,473 563.5,496.5Q587,520 620,520ZM480,800L480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800Q480,800 480,800L480,800L480,800L480,800L480,800L480,800L480,800L480,800L480,800L480,800Z"/>
</vector>
-10
View File
@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,840Q405,840 339.5,811.5Q274,783 225.5,734.5Q177,686 148.5,620.5Q120,555 120,480Q120,405 148.5,339.5Q177,274 225.5,225.5Q274,177 339.5,148.5Q405,120 480,120Q562,120 635.5,155Q709,190 760,254L760,160L840,160L840,400L600,400L600,320L710,320Q669,264 609,232Q549,200 480,200Q363,200 281.5,281.5Q200,363 200,480Q200,597 281.5,678.5Q363,760 480,760Q585,760 663.5,692Q742,624 756,520L838,520Q823,657 720.5,748.5Q618,840 480,840ZM592,648L440,496L440,280L520,280L520,464L648,592L592,648Z"/>
</vector>
@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M40,840L480,80L920,840L40,840ZM178,760L782,760L480,240L178,760ZM480,720Q497,720 508.5,708.5Q520,697 520,680Q520,663 508.5,651.5Q497,640 480,640Q463,640 451.5,651.5Q440,663 440,680Q440,697 451.5,708.5Q463,720 480,720ZM440,600L520,600L520,400L440,400L440,600ZM480,500L480,500L480,500Z"/>
</vector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_geode_background"/>
<foreground android:drawable="@drawable/ic_launcher_monochrome_shadow"/>
</adaptive-icon>
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more