mirror of
https://github.com/geode-sdk/android-launcher.git
synced 2026-10-21 00:18:18 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1658072b1a | ||
|
|
b6e88b20cb | ||
|
|
f4af50b2cb | ||
|
|
00c47f4c70 | ||
|
|
11649a236a | ||
|
|
ff712196cc | ||
|
|
6c57f3f261 | ||
|
|
626f8d00d1 | ||
|
|
45ecc71749 | ||
|
|
3d60bd3d53 | ||
|
|
e40af4c49b | ||
|
|
e0a31694af | ||
|
|
c8994c2484 | ||
|
|
35c8428f08 | ||
|
|
bca761f26a | ||
|
|
79c06ca629 | ||
|
|
3194dff7ff | ||
|
|
9e86bbe355 | ||
|
|
eef9160970 | ||
|
|
3b4951def1 | ||
|
|
ef44d48ed0 | ||
|
|
aae2aa2290 | ||
|
|
0a62082519 | ||
|
|
758d5ad432 | ||
|
|
b3a776ce7c | ||
|
|
72f74fa084 | ||
|
|
0dba4f0606 | ||
|
|
2016aa4e90 | ||
|
|
38bc97b7b6 | ||
|
|
0a4f13789f | ||
|
|
3cb0998274 | ||
|
|
5d70cb484f | ||
|
|
a27e67b41e | ||
|
|
8bff33c013 | ||
|
|
c9c51f0032 | ||
|
|
82b6cf309f | ||
|
|
a7654cfc69 | ||
|
|
c1d136c0cc | ||
|
|
269f2fbd02 | ||
|
|
21c3374248 | ||
|
|
120f412986 | ||
|
|
633543f363 | ||
|
|
d6b4157cdc | ||
|
|
482e3bc5d0 | ||
|
|
15e24b566e | ||
|
|
0de077ec1d | ||
|
|
655587e1ee | ||
|
|
d3ad272ffc | ||
|
|
79632c9844 | ||
|
|
8f5b1b4218 | ||
|
|
23ed997640 | ||
|
|
acaff15d4d | ||
|
|
669ea87be0 | ||
|
|
b668e5032b | ||
|
|
4508abc155 | ||
|
|
38a994436f | ||
|
|
5ab71d6c62 | ||
|
|
3b88a32e8f | ||
|
|
0ceecbbc72 | ||
|
|
714f750f77 | ||
|
|
69188075a3 | ||
|
|
187d3f9819 | ||
|
|
559cedb0f0 | ||
|
|
232b0a52ec | ||
|
|
915c46f05d | ||
|
|
a6ceee55d8 | ||
|
|
f2697cfb3b | ||
|
|
1bb68349e5 | ||
|
|
a677cab912 | ||
|
|
5caa533a4a | ||
|
|
1a68cc5eb9 | ||
|
|
ce98734bb9 | ||
|
|
f17500ef7b | ||
|
|
ea3ed8415b | ||
|
|
41664e430c | ||
|
|
8b17e6591f | ||
|
|
1d47a54573 | ||
|
|
9307c74df4 | ||
|
|
df0cebaa89 | ||
|
|
7bab94bf40 | ||
|
|
6d5de931fc | ||
|
|
c404f977e5 | ||
|
|
3202851303 | ||
|
|
d1fdb26d44 | ||
|
|
4987aa00db | ||
|
|
97b1cc288f | ||
|
|
00367096e9 | ||
|
|
0a7a366056 | ||
|
|
60ce6ddee8 | ||
|
|
6c1cc68ad0 | ||
|
|
b4da272a35 | ||
|
|
4745a3d36c | ||
|
|
2bf633922e | ||
|
|
02c7399eea | ||
|
|
d8985e14cc | ||
|
|
c2125248b6 | ||
|
|
e6bac1e25a | ||
|
|
eab0de3a34 | ||
|
|
9b7d174704 | ||
|
|
4c0d9b88d2 | ||
|
|
1adae94859 | ||
|
|
5edd63f78f | ||
|
|
3a54c34580 | ||
|
|
f12f480a38 | ||
|
|
1d7cd9594d | ||
|
|
9d4c27ac92 | ||
|
|
2754cc4bb8 | ||
|
|
0d01f8c74b | ||
|
|
24dd71a96d | ||
|
|
1f1e0de523 |
No files matched your search
@@ -12,17 +12,23 @@ jobs:
|
||||
config:
|
||||
- name: Release
|
||||
lower: release
|
||||
flavor: standard
|
||||
|
||||
- name: Debug
|
||||
lower: debug
|
||||
flavor: standard
|
||||
|
||||
- name: Release
|
||||
lower: release
|
||||
flavor: googlePlay
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Java JDK
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: 21
|
||||
distribution: temurin
|
||||
@@ -34,9 +40,19 @@ jobs:
|
||||
- name: Fix gradle permissions
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
- name: Build project
|
||||
- name: Build project (standard)
|
||||
if: ${{ matrix.config.flavor == 'standard' }}
|
||||
run: |
|
||||
./gradlew app:assemble${{ matrix.config.name }} \
|
||||
./gradlew app:assembleStandard${{ matrix.config.name }} \
|
||||
-Pandroid.injected.signing.store.file="$HOME/release.keystore" \
|
||||
-Pandroid.injected.signing.store.password="${{secrets.KEYSTORE_PASSWORD}}" \
|
||||
-Pandroid.injected.signing.key.alias="${{secrets.KEY_ALIAS}}" \
|
||||
-Pandroid.injected.signing.key.password="${{secrets.KEY_PASSWORD}}"
|
||||
|
||||
- name: Build project (Google Play)
|
||||
if: ${{ matrix.config.flavor == 'googlePlay' }}
|
||||
run: |
|
||||
./gradlew app:bundleGooglePlayRelease -PdisableAbiSplits \
|
||||
-Pandroid.injected.signing.store.file="$HOME/release.keystore" \
|
||||
-Pandroid.injected.signing.store.password="${{secrets.KEYSTORE_PASSWORD}}" \
|
||||
-Pandroid.injected.signing.key.alias="${{secrets.KEY_ALIAS}}" \
|
||||
@@ -44,26 +60,78 @@ jobs:
|
||||
|
||||
- name: Rename universal apk
|
||||
id: rename_apk_universal
|
||||
if: ${{ matrix.config.flavor == 'standard' }}
|
||||
run: |
|
||||
SHORT_SHA=${GITHUB_SHA::7}
|
||||
APK_PATH=${{github.workspace}}/app/build/outputs/apk/${{ matrix.config.lower }}/app-universal-${{ matrix.config.lower }}.apk
|
||||
APK_PATH=${{github.workspace}}/app/build/outputs/apk/standard/${{ matrix.config.lower }}/app-standard-universal-${{ matrix.config.lower }}.apk
|
||||
OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-$SHORT_SHA.apk
|
||||
mv $APK_PATH $OUT_PATH
|
||||
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Rename apk
|
||||
id: rename_apk
|
||||
if: ${{ matrix.config.flavor == 'standard' }}
|
||||
run: |
|
||||
SHORT_SHA=${GITHUB_SHA::7}
|
||||
APK_PATH=${{github.workspace}}/app/build/outputs/apk/${{ matrix.config.lower }}/app-armeabi-v7a-${{ matrix.config.lower }}.apk
|
||||
APK_PATH=${{github.workspace}}/app/build/outputs/apk/standard/${{ matrix.config.lower }}/app-standard-armeabi-v7a-${{ matrix.config.lower }}.apk
|
||||
OUT_PATH=${{github.workspace}}/geode-launcher-${{ matrix.config.lower }}-android32-$SHORT_SHA.apk
|
||||
mv $APK_PATH $OUT_PATH
|
||||
echo "path=$OUT_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload development build
|
||||
uses: actions/upload-artifact@v4
|
||||
- name: Rename mappings
|
||||
id: rename_mappings
|
||||
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:
|
||||
name: geode-launcher-${{ matrix.config.lower }}
|
||||
path: |
|
||||
${{ steps.rename_apk_universal.outputs.path }}
|
||||
${{ steps.rename_apk.outputs.path }}
|
||||
${{ steps.rename_mappings.outputs.path }}
|
||||
|
||||
- name: Upload debug build
|
||||
uses: actions/upload-artifact@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 }}
|
||||
+290
-28
@@ -1,6 +1,7 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
val composeBOM: String by rootProject.extra
|
||||
import groovy.json.JsonSlurper
|
||||
import java.net.URI
|
||||
import java.security.MessageDigest
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
@@ -14,15 +15,241 @@ plugins {
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
}
|
||||
|
||||
val currentGeodeVersion = "5.11.2"
|
||||
|
||||
abstract class DownloadVersionMetadataTask : DefaultTask() {
|
||||
@get:Input
|
||||
abstract val geodeVersion: Property<String>
|
||||
|
||||
@get:OutputFile
|
||||
abstract val targetFile: RegularFileProperty
|
||||
|
||||
@TaskAction
|
||||
fun execute() {
|
||||
val downloadGeodeVersion = geodeVersion.get()
|
||||
val destinationFile = targetFile.get().asFile
|
||||
|
||||
val releaseUrl = "https://api.geode-sdk.org/v1/loader/versions/$downloadGeodeVersion"
|
||||
URI.create(releaseUrl).toURL().openStream().use { response ->
|
||||
destinationFile.outputStream().use { destination ->
|
||||
response.copyTo(destination)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class DownloadGeodeFileTask : DefaultTask() {
|
||||
@get:Input
|
||||
abstract val platform: Property<String>
|
||||
|
||||
@get:InputFile
|
||||
abstract val metadata: RegularFileProperty
|
||||
|
||||
private fun downloadAndVerifyFile(filename: String, url: String, hash: String?): File {
|
||||
logger.info("Downloading $filename from $url")
|
||||
|
||||
val tempFile = File(temporaryDir, filename)
|
||||
|
||||
URI.create(url).toURL().openStream().use { response ->
|
||||
tempFile.outputStream().use { output ->
|
||||
response.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
if (hash != null) {
|
||||
val downloadedHash = MessageDigest.getInstance("SHA-256")
|
||||
.digest(tempFile.readBytes())
|
||||
.toHexString()
|
||||
|
||||
if (downloadedHash != hash) {
|
||||
throw GradleException("Hash check failed for file $filename, expected $hash but found $downloadedHash.")
|
||||
}
|
||||
}
|
||||
|
||||
return tempFile
|
||||
}
|
||||
|
||||
fun downloadZip(fallbackUrl: String?): File {
|
||||
val downloadPlatform = platform.get()
|
||||
|
||||
val metadataFile = metadata.get().asFile
|
||||
val json = JsonSlurper().parse(metadataFile) as Map<*, *>
|
||||
|
||||
val payload = json["payload"] as? Map<*, *>
|
||||
|
||||
val downloads = payload?.get("downloads") as? Map<*, *>
|
||||
val platformInfo = downloads?.get(downloadPlatform) as? Map<*, *>
|
||||
|
||||
val downloadUrl = platformInfo?.get("url") as? String ?: fallbackUrl
|
||||
if (downloadUrl == null) {
|
||||
throw GradleException("Failed to fetch download url for platform $platform.")
|
||||
}
|
||||
|
||||
val expectedHash = platformInfo?.get("hash") as? String
|
||||
|
||||
return downloadAndVerifyFile("$downloadPlatform.zip", downloadUrl, expectedHash)
|
||||
}
|
||||
}
|
||||
|
||||
abstract class DownloadAndUnzipGeodeTask : DownloadGeodeFileTask() {
|
||||
@get:Input
|
||||
abstract val geodeVersion: Property<String>
|
||||
|
||||
@get:OutputFile
|
||||
abstract val targetFile: RegularFileProperty
|
||||
|
||||
@TaskAction
|
||||
fun execute() {
|
||||
val downloadGeodeVersion = geodeVersion.get()
|
||||
val downloadPlatform = platform.get()
|
||||
val destinationFile = targetFile.get().asFile
|
||||
|
||||
val tempOutput = this.downloadZip("https://github.com/geode-sdk/geode/releases/download/v$downloadGeodeVersion/geode-v$downloadGeodeVersion-$downloadPlatform.zip")
|
||||
|
||||
val expectedFile = "Geode.$downloadPlatform.so"
|
||||
|
||||
ZipInputStream(tempOutput.inputStream()).use { zip ->
|
||||
var entry = zip.nextEntry
|
||||
while (entry != null) {
|
||||
if (entry.name == expectedFile) {
|
||||
destinationFile.outputStream().use {
|
||||
zip.copyTo(it)
|
||||
}
|
||||
return@execute
|
||||
}
|
||||
|
||||
entry = zip.nextEntry
|
||||
}
|
||||
|
||||
throw GradleException("Could not find Geode release.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class DownloadAndUnzipGeodeResourcesTask : DownloadGeodeFileTask() {
|
||||
init {
|
||||
platform.set("resources")
|
||||
}
|
||||
|
||||
@get:Input
|
||||
abstract val geodeVersion: Property<String>
|
||||
|
||||
@get:OutputDirectory
|
||||
abstract val outputDir: DirectoryProperty
|
||||
|
||||
@TaskAction
|
||||
fun execute() {
|
||||
val downloadGeodeVersion = geodeVersion.get()
|
||||
|
||||
val tempOutput = this.downloadZip("https://github.com/geode-sdk/geode/releases/download/v$downloadGeodeVersion/resources.zip")
|
||||
|
||||
val baseOutput = outputDir.get().asFile
|
||||
if (baseOutput.exists()) baseOutput.deleteRecursively()
|
||||
|
||||
val output = File(baseOutput, "geode.loader")
|
||||
output.mkdirs()
|
||||
|
||||
val canonicalOutput = output.canonicalPath
|
||||
|
||||
ZipInputStream(tempOutput.inputStream()).use { zipStream ->
|
||||
var entry = zipStream.nextEntry
|
||||
while (entry != null) {
|
||||
val destination = File(output, entry.name)
|
||||
if (!destination.canonicalPath.startsWith(canonicalOutput)) {
|
||||
throw GradleException("attempted copy to outside of output directory: ${entry.name}")
|
||||
}
|
||||
|
||||
if (destination.isDirectory) {
|
||||
destination.mkdirs()
|
||||
} else {
|
||||
destination.parentFile?.mkdirs()
|
||||
|
||||
destination.outputStream().use { destinationStream ->
|
||||
zipStream.copyTo(destinationStream)
|
||||
}
|
||||
}
|
||||
|
||||
entry = zipStream.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val fetchGeodeMetadataTask = tasks.register<DownloadVersionMetadataTask>("fetchGeodeMetadata") {
|
||||
description = "Fetches and saves a Geode version's metadata"
|
||||
inputs.property("geodeVersion", currentGeodeVersion)
|
||||
|
||||
geodeVersion = currentGeodeVersion
|
||||
targetFile = temporaryDir.resolve("metadata.json")
|
||||
}
|
||||
|
||||
val downloadGeode32Task = tasks.register<DownloadAndUnzipGeodeTask>("downloadGeode32Binary") {
|
||||
description = "Downloads the 32bit Geode release archive"
|
||||
inputs.property("geodeVersion", currentGeodeVersion)
|
||||
|
||||
geodeVersion = currentGeodeVersion
|
||||
platform = "android32"
|
||||
metadata = fetchGeodeMetadataTask.flatMap { it.targetFile }
|
||||
targetFile = temporaryDir.resolve("libgeode.so")
|
||||
}
|
||||
|
||||
val downloadGeode64Task = tasks.register<DownloadAndUnzipGeodeTask>("downloadGeode64Binary") {
|
||||
description = "Downloads the 64bit Geode release archive"
|
||||
inputs.property("geodeVersion", currentGeodeVersion)
|
||||
|
||||
geodeVersion = currentGeodeVersion
|
||||
platform = "android64"
|
||||
metadata = fetchGeodeMetadataTask.flatMap { it.targetFile }
|
||||
targetFile = temporaryDir.resolve("libgeode.so")
|
||||
}
|
||||
|
||||
val downloadGeodeResourcesTask = tasks.register<DownloadAndUnzipGeodeResourcesTask>("downloadGeodeResources") {
|
||||
description = "Downloads Geode resources"
|
||||
inputs.property("geodeVersion", currentGeodeVersion)
|
||||
|
||||
geodeVersion = currentGeodeVersion
|
||||
metadata = fetchGeodeMetadataTask.flatMap { it.targetFile }
|
||||
outputDir = layout.buildDirectory.dir("geode-resources")
|
||||
}
|
||||
|
||||
abstract class MergeGeodeFilesTask : DefaultTask() {
|
||||
@get:InputFile
|
||||
abstract val binary32: RegularFileProperty
|
||||
|
||||
@get:InputFile
|
||||
abstract val binary64: RegularFileProperty
|
||||
|
||||
@get:OutputDirectory
|
||||
abstract val outputDir: DirectoryProperty
|
||||
|
||||
@TaskAction
|
||||
fun execute() {
|
||||
val out = outputDir.get().asFile
|
||||
out.deleteRecursively()
|
||||
out.mkdirs()
|
||||
|
||||
binary32.get().asFile.copyTo(outputDir.file("armeabi-v7a/libgeode.so").get().asFile)
|
||||
binary64.get().asFile.copyTo(outputDir.file("arm64-v8a/libgeode.so").get().asFile)
|
||||
}
|
||||
}
|
||||
|
||||
val fullDownloadTask = tasks.register<MergeGeodeFilesTask>("downloadGeodeFull") {
|
||||
description = "Downloads both Geode releases"
|
||||
|
||||
binary32 = downloadGeode32Task.flatMap { it.targetFile }
|
||||
binary64 = downloadGeode64Task.flatMap { it.targetFile }
|
||||
outputDir = layout.buildDirectory.dir("geode-prebuilt")
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.geode.launcher"
|
||||
minSdk = 23
|
||||
targetSdk = 36
|
||||
versionCode = 23
|
||||
versionName = "1.7.0"
|
||||
targetSdk = 37
|
||||
versionCode = 31
|
||||
versionName = "1.9.0"
|
||||
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
@@ -31,7 +258,7 @@ android {
|
||||
@Suppress("UnstableApiUsage")
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
arguments("-DUSE_TULIPHOOK:BOOL=OFF", "-DANDROID_STL=c++_shared")
|
||||
arguments("-DANDROID_STL=c++_shared")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +268,7 @@ android {
|
||||
|
||||
splits {
|
||||
abi {
|
||||
isEnable = true
|
||||
isEnable = !project.hasProperty("disableAbiSplits")
|
||||
reset()
|
||||
|
||||
//noinspection ChromeOsAbiSupport. i'm sorry!
|
||||
@@ -51,6 +278,24 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
flavorDimensions += "distribution"
|
||||
|
||||
productFlavors {
|
||||
create("standard") {
|
||||
dimension = "distribution"
|
||||
isDefault = true
|
||||
buildConfigField("boolean", "GOOGLE_PLAY_BUILD", "false")
|
||||
buildConfigField("String", "PREBUNDLED_GEODE", "null")
|
||||
}
|
||||
|
||||
create("googlePlay") {
|
||||
dimension = "distribution"
|
||||
applicationId = "com.geode.launcher.play"
|
||||
buildConfigField("boolean", "GOOGLE_PLAY_BUILD", "true")
|
||||
buildConfigField("String", "PREBUNDLED_GEODE", "\"$currentGeodeVersion\"")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
@@ -66,8 +311,8 @@ android {
|
||||
// enables a polyfill for java Instant on api levels < 26 (used for updater)
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
targetCompatibility = JavaVersion.VERSION_11
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
@@ -80,34 +325,51 @@ android {
|
||||
}
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
version = "3.25.0+"
|
||||
version = "3.21.0+"
|
||||
path = file("src/main/cpp/CMakeLists.txt")
|
||||
}
|
||||
}
|
||||
namespace = "com.geode.launcher"
|
||||
ndkVersion = "29.0.14206865"
|
||||
ndkVersion = "30.0.16248370"
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants(selector().withFlavor("distribution" to "googlePlay")) { variant ->
|
||||
variant.sources.jniLibs?.addGeneratedSourceDirectory(
|
||||
fullDownloadTask,
|
||||
MergeGeodeFilesTask::outputDir
|
||||
)
|
||||
variant.sources.assets?.addGeneratedSourceDirectory(
|
||||
downloadGeodeResourcesTask,
|
||||
DownloadAndUnzipGeodeResourcesTask::outputDir
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation (platform("androidx.compose:compose-bom:$composeBOM"))
|
||||
implementation ("androidx.core:core-ktx:1.17.0")
|
||||
implementation (platform("androidx.compose:compose-bom:2026.09.00"))
|
||||
implementation ("androidx.core:core-ktx:1.19.1")
|
||||
implementation ("androidx.compose.ui:ui")
|
||||
implementation ("androidx.compose.material3:material3")
|
||||
implementation ("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
|
||||
implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
|
||||
implementation ("androidx.activity:activity-compose:1.12.3")
|
||||
implementation ("androidx.activity:activity-ktx:1.12.3")
|
||||
implementation ("androidx.appcompat:appcompat:1.7.1")
|
||||
implementation ("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0")
|
||||
implementation ("androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0")
|
||||
implementation ("androidx.activity:activity-compose:1.13.0")
|
||||
implementation ("androidx.activity:activity-ktx:1.13.0")
|
||||
implementation ("androidx.appcompat:appcompat:1.8.0")
|
||||
implementation ("androidx.documentfile:documentfile:1.1.0")
|
||||
implementation ("com.squareup.okio:okio:3.16.4")
|
||||
implementation ("com.squareup.okhttp3:okhttp:5.3.2")
|
||||
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.10.0")
|
||||
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.10.0")
|
||||
implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1")
|
||||
implementation ("com.mikepenz:multiplatform-markdown-renderer-android:0.39.1")
|
||||
implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.39.1")
|
||||
implementation ("androidx.browser:browser:1.9.0")
|
||||
|
||||
implementation ("com.squareup.okio:okio:3.18.2")
|
||||
implementation ("com.squareup.okhttp3:okhttp:5.5.0")
|
||||
implementation ("com.squareup.okhttp3:okhttp-coroutines:5.5.0")
|
||||
|
||||
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
|
||||
implementation ("org.jetbrains.kotlinx:kotlinx-serialization-json-okio:1.11.0")
|
||||
implementation ("org.jetbrains.kotlinx:kotlinx-datetime:0.8.0")
|
||||
|
||||
implementation ("com.mikepenz:multiplatform-markdown-renderer-android:0.46.0")
|
||||
implementation ("com.mikepenz:multiplatform-markdown-renderer-m3:0.46.0")
|
||||
implementation ("androidx.browser:browser:1.10.0")
|
||||
debugImplementation ("androidx.compose.ui:ui-tooling")
|
||||
coreLibraryDesugaring ("com.android.tools:desugar_jdk_libs:2.1.5")
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission
|
||||
android:name="android.permission.REQUEST_INSTALL_PACKAGES"
|
||||
tools:node="remove" />
|
||||
|
||||
</manifest>
|
||||
@@ -17,11 +17,8 @@
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.LIGHTS" />
|
||||
|
||||
@@ -90,6 +87,24 @@
|
||||
<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"
|
||||
@@ -110,7 +125,7 @@
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".GeometryDashActivity"
|
||||
android:configChanges="orientation|screenSize|smallestScreenSize"
|
||||
android:configChanges="fontScale|layoutDirection|density|smallestScreenSize|screenSize|uiMode|screenLayout|orientation|navigation|keyboardHidden|keyboard|locale"
|
||||
android:exported="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:screenOrientation="landscape"
|
||||
@@ -178,6 +193,26 @@
|
||||
<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"
|
||||
@@ -198,10 +233,30 @@
|
||||
<data android:scheme="geode-launcher" android:host="main" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
<activity-alias
|
||||
android:name=".MainActivityGreen"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_green"
|
||||
android:label="@string/app_name"
|
||||
android:targetActivity=".MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="geode-launcher" android:host="main" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<provider
|
||||
android:name=".UserDirectoryProvider"
|
||||
android:authorities="com.geode.launcher.user"
|
||||
android:authorities="${applicationId}.user"
|
||||
android:exported="true"
|
||||
android:grantUriPermissions="true"
|
||||
android:permission="android.permission.MANAGE_DOCUMENTS">
|
||||
@@ -211,7 +266,7 @@
|
||||
</provider>
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="com.geode.launcher.fileprovider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
|
||||
@@ -1,47 +1,17 @@
|
||||
cmake_minimum_required(VERSION 3.18.1)
|
||||
cmake_minimum_required(VERSION 3.21)
|
||||
|
||||
project(launcherfix)
|
||||
|
||||
add_library(launcherfix SHARED launcher-fix.cpp)
|
||||
add_library(launcherfix SHARED launcher-fix.cpp patcher.cpp)
|
||||
|
||||
target_compile_features(launcherfix PUBLIC cxx_std_20)
|
||||
set_target_properties(launcherfix PROPERTIES CXX_EXTENSIONS OFF)
|
||||
|
||||
include(FetchContent)
|
||||
|
||||
option(USE_TULIPHOOK "enables choice of hooking library" OFF)
|
||||
if (USE_TULIPHOOK)
|
||||
FetchContent_Declare(TulipHook
|
||||
GIT_REPOSITORY https://github.com/geode-sdk/TulipHook.git
|
||||
GIT_TAG v3.1.4
|
||||
)
|
||||
|
||||
set(TULIP_LINK_SOURCE ON CACHE INTERNAL "")
|
||||
FetchContent_MakeAvailable(TulipHook)
|
||||
|
||||
target_compile_definitions(launcherfix PRIVATE -DUSE_TULIPHOOK=1)
|
||||
|
||||
target_link_libraries(launcherfix PRIVATE TulipHook TulipHookInclude)
|
||||
else()
|
||||
FetchContent_Declare(dobby
|
||||
# this is the last version that builds on android at all
|
||||
GIT_REPOSITORY https://github.com/jmpews/Dobby
|
||||
GIT_TAG 0932d69c320e786672361ab53825ba8f4245e9d3
|
||||
)
|
||||
|
||||
FetchContent_GetProperties(dobby)
|
||||
if(NOT dobby_POPULATED)
|
||||
FetchContent_Populate(dobby)
|
||||
|
||||
# fixes Dobby build
|
||||
# add_compile_definitions(typeof=__typeof__)
|
||||
set(DOBBY_DEBUG OFF CACHE INTERNAL "" FORCE)
|
||||
set(DOBBY_GENERATE_SHARED OFF CACHE INTERNAL "" FORCE)
|
||||
|
||||
add_subdirectory("${dobby_SOURCE_DIR}" ${dobby_BINARY_DIR} EXCLUDE_FROM_ALL)
|
||||
target_include_directories(launcherfix PRIVATE ${dobby_SOURCE_DIR}/include)
|
||||
target_link_libraries(launcherfix PRIVATE dobby)
|
||||
endif()
|
||||
endif()
|
||||
set_target_properties(launcherfix PROPERTIES
|
||||
INTERPROCEDURAL_OPTIMIZATION ON
|
||||
UNITY_BUILD ON
|
||||
C_VISIBILITY_PRESET hidden
|
||||
CXX_VISIBILITY_PRESET hidden
|
||||
)
|
||||
|
||||
target_link_libraries(launcherfix PRIVATE log)
|
||||
@@ -0,0 +1,42 @@
|
||||
#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);
|
||||
@@ -1,36 +1,13 @@
|
||||
#include <dlfcn.h>
|
||||
#include <jni.h>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
#include <android/log.h>
|
||||
#include <link.h>
|
||||
|
||||
#ifndef DISABLE_LAUNCHER_FIX
|
||||
#include "base.h"
|
||||
#include "log.hpp"
|
||||
|
||||
#ifdef USE_TULIPHOOK
|
||||
#include <tulip/TulipHook.hpp>
|
||||
#else
|
||||
#include <dobby.h>
|
||||
#endif
|
||||
std::string DataPaths::original_data_path{};
|
||||
std::string DataPaths::data_path{};
|
||||
|
||||
#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() {}
|
||||
};
|
||||
bool DataPaths::patch_exceptions{};
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setDataPath(
|
||||
@@ -41,25 +18,11 @@ JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setDataPath(
|
||||
auto is_copy = jboolean();
|
||||
auto data_path_str = env->GetStringUTFChars(data_path, &is_copy);
|
||||
|
||||
DataPaths::get_instance().data_path = std::string(data_path_str);
|
||||
DataPaths::data_path = std::string(data_path_str);
|
||||
|
||||
env->ReleaseStringUTFChars(data_path, data_path_str);
|
||||
}
|
||||
|
||||
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"
|
||||
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setOriginalDataPath(
|
||||
JNIEnv *env,
|
||||
@@ -69,227 +32,52 @@ JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_setOriginalDataPath(
|
||||
auto is_copy = jboolean();
|
||||
auto data_path_str = env->GetStringUTFChars(data_path, &is_copy);
|
||||
|
||||
DataPaths::get_instance().original_data_path = std::string(data_path_str);
|
||||
DataPaths::original_data_path = std::string(data_path_str);
|
||||
|
||||
env->ReleaseStringUTFChars(data_path, data_path_str);
|
||||
}
|
||||
|
||||
// i copied this function from the android linker
|
||||
std::uint32_t elfhash(const char *_name) {
|
||||
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;
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_enableExceptionsRenaming(JNIEnv*, jobject) {
|
||||
DataPaths::patch_exceptions = true;
|
||||
}
|
||||
|
||||
// this should be called after gd is loaded but before geode
|
||||
extern "C"
|
||||
JNIEXPORT int JNICALL Java_com_geode_launcher_LauncherFix_performExceptionsRenaming(JNIEnv*, jobject) {
|
||||
return dl_iterate_phdr(on_dl_iterate, nullptr);
|
||||
extern "C" JNIEXPORT void JNICALL Java_com_geode_launcher_LauncherFix_performPatches(JNIEnv*, jobject) {
|
||||
dl_iterate_phdr(on_dl_iterate, nullptr);
|
||||
}
|
||||
|
||||
std::optional<std::string> redirect_path(const char* pathname) {
|
||||
auto& data_path = DataPaths::get_instance().data_path;
|
||||
auto& original_data_path = DataPaths::get_instance().original_data_path;
|
||||
std::string_view data_path = DataPaths::data_path;
|
||||
std::string_view original_data_path = DataPaths::original_data_path;
|
||||
|
||||
if (!data_path.empty() && !original_data_path.empty()) {
|
||||
// call this a c string optimization
|
||||
if (std::strncmp(pathname, original_data_path.c_str(), original_data_path.size()) == 0) {
|
||||
auto path = data_path + (pathname + original_data_path.size());
|
||||
if (data_path.empty() || original_data_path.empty()) [[unlikely]] {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
// call this a c string optimization
|
||||
if (std::strncmp(pathname, original_data_path.data(), original_data_path.size()) == 0) {
|
||||
auto remaining_path = (pathname + original_data_path.size());
|
||||
auto remaining_len = strlen(remaining_path);
|
||||
|
||||
std::string x;
|
||||
x.reserve(data_path.size() + remaining_len);
|
||||
x.append(data_path);
|
||||
x.append(remaining_path, remaining_len);
|
||||
|
||||
return x;
|
||||
}
|
||||
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
FILE* (*fopen_original)(const char *pathname, const char *mode);
|
||||
FILE* fopen_hook(const char* pathname, const char* mode) {
|
||||
auto path_str = redirect_path(pathname);
|
||||
auto redirect = path_str.has_value() ? path_str->c_str() : pathname;
|
||||
|
||||
return fopen_original(redirect, mode);
|
||||
return fopen(redirect, mode);
|
||||
}
|
||||
|
||||
int (*rename_original)(const char* old_path, const char* new_path);
|
||||
int rename_hook(const char* old_path, const char* new_path) {
|
||||
auto old_path_str = redirect_path(old_path);
|
||||
auto old_redirect = old_path_str ? old_path_str->c_str() : old_path;
|
||||
@@ -297,63 +85,5 @@ int rename_hook(const char* old_path, const char* new_path) {
|
||||
auto new_path_str = redirect_path(new_path);
|
||||
auto new_redirect = new_path_str ? new_path_str->c_str() : new_path;
|
||||
|
||||
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
|
||||
return rename(old_redirect, new_redirect);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#define LOG_TAG "GeodeLauncher-Fix"
|
||||
|
||||
#include <android/log.h>
|
||||
#include <format>
|
||||
|
||||
namespace log {
|
||||
template<typename... Args>
|
||||
void log_internal(android_LogPriority prio, std::format_string<Args...> fmt, Args&&... args) {
|
||||
std::string str = std::format(fmt, std::forward<Args>(args)...);
|
||||
__android_log_write(prio, LOG_TAG, str.c_str());
|
||||
}
|
||||
|
||||
template<typename... Args>
|
||||
inline void debug(std::format_string<Args...> fmt, Args&&... args) {
|
||||
log_internal(ANDROID_LOG_DEBUG, fmt, std::forward<Args>(args)...);
|
||||
}
|
||||
|
||||
template<typename... Args>
|
||||
inline void info(std::format_string<Args...> fmt, Args&&... args) {
|
||||
log_internal(ANDROID_LOG_INFO, fmt, std::forward<Args>(args)...);
|
||||
}
|
||||
|
||||
template<typename... Args>
|
||||
inline void warn(std::format_string<Args...> fmt, Args&&... args) {
|
||||
log_internal(ANDROID_LOG_WARN, fmt, std::forward<Args>(args)...);
|
||||
}
|
||||
|
||||
template<typename... Args>
|
||||
inline void error(std::format_string<Args...> fmt, Args&&... args) {
|
||||
log_internal(ANDROID_LOG_ERROR, fmt, std::forward<Args>(args)...);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
|
||||
#include <link.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/mman.h>
|
||||
|
||||
#include "log.hpp"
|
||||
#include "base.h"
|
||||
|
||||
struct ElfDynamicState {
|
||||
std::uint32_t* hash_table{};
|
||||
char* str_table{};
|
||||
ElfW(Sym)* sym_table{};
|
||||
|
||||
void* plt_table{};
|
||||
std::uint64_t plt_table_size_bytes{};
|
||||
bool plt_table_rela{};
|
||||
|
||||
std::uint64_t soname_idx{};
|
||||
|
||||
bool is_empty() const {
|
||||
return !hash_table || !str_table || !sym_table || !plt_table;
|
||||
}
|
||||
};
|
||||
|
||||
// copied from tuliphook source :)
|
||||
// tuliphook sets WRX perms, but we write to pages with W^X protection
|
||||
bool set_mprotect(std::uintptr_t address, size_t size, int protection) {
|
||||
auto const page_size = getpagesize();
|
||||
auto const page_mask = page_size - 1;
|
||||
|
||||
auto const ptr = reinterpret_cast<uintptr_t>(address);
|
||||
auto const aligned_ptr = ptr & (~page_mask);
|
||||
auto const begin_size = ptr - aligned_ptr;
|
||||
auto const page_count = (begin_size + size + page_mask) / page_size;
|
||||
auto const aligned_size = page_count * page_size;
|
||||
|
||||
auto status = mprotect(reinterpret_cast<void*>(aligned_ptr), aligned_size, protection);
|
||||
|
||||
return status == 0;
|
||||
}
|
||||
|
||||
// i copied this function from the android linker
|
||||
constexpr std::uint32_t elfhash(std::string_view name) {
|
||||
std::uint32_t h = 0, g;
|
||||
|
||||
for (const auto c : name) {
|
||||
h = (h << 4) + static_cast<unsigned char>(c);
|
||||
g = h & 0xf0000000;
|
||||
h ^= g;
|
||||
h ^= g >> 24;
|
||||
}
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
constexpr std::array<const std::uint8_t, 3> SYMBOL_PATCH_VALUE{":3"};
|
||||
|
||||
bool patch_symbol(std::uint32_t* hash_table, char* str_table, ElfW(Sym)* sym_table, const std::string_view orig_name) {
|
||||
auto hash = elfhash(orig_name);
|
||||
|
||||
auto nbucket = hash_table[0];
|
||||
auto nchain = hash_table[1];
|
||||
auto bucket = &hash_table[2];
|
||||
auto chain = &bucket[nbucket];
|
||||
|
||||
for (auto i = bucket[hash % nbucket]; i != 0; i = chain[i]) {
|
||||
auto sym = sym_table + i;
|
||||
auto sym_name = str_table + sym->st_name;
|
||||
|
||||
if (orig_name == sym_name) {
|
||||
// there's probably no point to checking this, honestly
|
||||
if (ELF_ST_BIND(sym->st_info) == STB_GLOBAL && ELF_ST_TYPE(sym->st_info) == STT_FUNC) {
|
||||
// we found it! now go rename the symbol
|
||||
|
||||
log::debug("Disabling symbol at {} ({})", reinterpret_cast<void*>(sym_name), sym_name);
|
||||
|
||||
if (!set_mprotect(reinterpret_cast<std::uintptr_t>(sym_name), SYMBOL_PATCH_VALUE.size(), PROT_READ | PROT_WRITE)) {
|
||||
log::warn("failed to patch symbol: could not set writable flag");
|
||||
return false;
|
||||
}
|
||||
|
||||
memcpy(sym_name, SYMBOL_PATCH_VALUE.data(), SYMBOL_PATCH_VALUE.size());
|
||||
|
||||
if (!set_mprotect(reinterpret_cast<std::uintptr_t>(sym_name), SYMBOL_PATCH_VALUE.size(), PROT_READ)) {
|
||||
log::warn("failed to patch symbol: could not remove writable flag");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::warn("could not find symbol {} to patch (hash: {})", orig_name, hash);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
bool perform_symbol_patches(dl_phdr_info* info, const ElfDynamicState& state) {
|
||||
// patch symbol names
|
||||
auto has_error = false;
|
||||
for (const auto symbol : remove_symbols) {
|
||||
if (!patch_symbol(state.hash_table, state.str_table, state.sym_table, symbol)) {
|
||||
has_error = true;
|
||||
}
|
||||
}
|
||||
|
||||
return !has_error;
|
||||
}
|
||||
|
||||
#if defined(__LP64__)
|
||||
#define ELF_R_SYM(i) ELF64_R_SYM(i)
|
||||
#define ELF_R_TYPE(i) ELF64_R_TYPE(i)
|
||||
#else
|
||||
#define ELF_R_SYM(i) ELF32_R_SYM(i)
|
||||
#define ELF_R_TYPE(i) ELF32_R_TYPE(i)
|
||||
#endif
|
||||
|
||||
template <typename T, typename... Types>
|
||||
concept is_one_of = (std::same_as<T, Types> || ...);
|
||||
|
||||
template <typename T> requires is_one_of<T, ElfW(Rela), ElfW(Rel)>
|
||||
bool rewrite_symbol_reloc(std::uintptr_t addr, T& r, ElfW(Sym)* sym, void* func) {
|
||||
auto type = ELF_R_TYPE(r.r_info);
|
||||
|
||||
// we're only interested in JUMP_SLOT symbols
|
||||
if (type != R_AARCH64_JUMP_SLOT && type != R_ARM_JUMP_SLOT) {
|
||||
log::warn("asked to patch unknown symbol type: {:#x}", type);
|
||||
return false;
|
||||
}
|
||||
|
||||
auto write_addr = reinterpret_cast<std::uintptr_t*>(addr);
|
||||
auto val = reinterpret_cast<std::uintptr_t>(func);
|
||||
if constexpr (std::is_same_v<T, ElfW(Rela)>) {
|
||||
// reloc = S + A
|
||||
val += r.r_addend;
|
||||
}
|
||||
|
||||
log::debug("Patching relocation at vaddr {:#x} (offset={:#x}): {:#x} => {:#x}", addr, r.r_offset, *write_addr, val);
|
||||
|
||||
if (!set_mprotect(addr, sizeof(std::uintptr_t), PROT_WRITE | PROT_READ)) {
|
||||
log::warn("Failed to patch relocation: failed to add writable flag");
|
||||
return false;
|
||||
}
|
||||
|
||||
// why use memcpy when you can do this :)
|
||||
*write_addr = val;
|
||||
|
||||
if (!set_mprotect(addr, sizeof(std::uintptr_t), PROT_READ)) {
|
||||
log::warn("Failed to patch relocation: failed to remove writable flag");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
template <typename T> requires is_one_of<T, ElfW(Rela), ElfW(Rel)>
|
||||
bool perform_reloc_patches(dl_phdr_info* info, const ElfDynamicState& state) {
|
||||
auto table_size = state.plt_table_size_bytes / sizeof(T);
|
||||
|
||||
auto failed = false;
|
||||
|
||||
for (auto& plt : std::span{
|
||||
reinterpret_cast<T*>(state.plt_table),
|
||||
static_cast<std::size_t>(table_size)
|
||||
}) {
|
||||
auto sym = ELF_R_SYM(plt.r_info);
|
||||
if (sym == 0) continue;
|
||||
|
||||
auto sym_data = state.sym_table + sym;
|
||||
auto sym_name = state.str_table + sym_data->st_name;
|
||||
|
||||
auto reloc_addr = info->dlpi_addr + plt.r_offset;
|
||||
|
||||
if (strcmp(sym_name, "fopen") == 0) {
|
||||
if (!rewrite_symbol_reloc(reloc_addr, plt, sym_data, reinterpret_cast<void*>(&fopen_hook))) {
|
||||
failed = true;
|
||||
}
|
||||
} else if (strcmp(sym_name, "rename") == 0) {
|
||||
if (!rewrite_symbol_reloc(reloc_addr, plt, sym_data, reinterpret_cast<void*>(&rename_hook))) {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return !failed;
|
||||
}
|
||||
|
||||
int on_dl_iterate(dl_phdr_info* info, size_t size, void* data) {
|
||||
// this is probably going to be gd
|
||||
if (info->dlpi_name != nullptr && strstr(info->dlpi_name, "libcocos2dcpp.so") != nullptr) {
|
||||
log::debug("Base address of libcocos2dcpp: {:#x}", info->dlpi_addr);
|
||||
|
||||
// step 1: get the dynamic table
|
||||
std::uintptr_t dyn_addr = 0u;
|
||||
|
||||
for (auto& phdr : std::span{ info->dlpi_phdr, info->dlpi_phnum}) {
|
||||
if (phdr.p_type == PT_DYNAMIC) {
|
||||
dyn_addr = info->dlpi_addr + phdr.p_vaddr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (dyn_addr == 0u) {
|
||||
log::warn("failed to find libcocos dynamic section");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// step 2: get the symbol table
|
||||
auto dyn_entry = reinterpret_cast<ElfW(Dyn)*>(dyn_addr);
|
||||
auto dyn_end_reached = false;
|
||||
|
||||
ElfDynamicState state{};
|
||||
|
||||
while (!dyn_end_reached) {
|
||||
auto tag = dyn_entry->d_tag;
|
||||
|
||||
switch (dyn_entry->d_tag) {
|
||||
case DT_SYMTAB:
|
||||
state.sym_table = reinterpret_cast<ElfW(Sym)*>(info->dlpi_addr + dyn_entry->d_un.d_val);
|
||||
break;
|
||||
case DT_STRTAB:
|
||||
state.str_table = reinterpret_cast<char*>(info->dlpi_addr + dyn_entry->d_un.d_val);
|
||||
break;
|
||||
case DT_HASH:
|
||||
state.hash_table = reinterpret_cast<std::uint32_t*>(info->dlpi_addr + dyn_entry->d_un.d_val);
|
||||
break;
|
||||
case DT_SONAME:
|
||||
state.soname_idx = dyn_entry->d_un.d_val;
|
||||
break;
|
||||
case DT_JMPREL:
|
||||
state.plt_table = reinterpret_cast<void*>(info->dlpi_addr + dyn_entry->d_un.d_val);
|
||||
break;
|
||||
case DT_PLTREL: {
|
||||
// for Android, arm64 = DT_RELA, arm32 = DT_REL
|
||||
// could just gate this based on architecture but whatever
|
||||
|
||||
auto val = dyn_entry->d_un.d_val;
|
||||
state.plt_table_rela = val == DT_RELA;
|
||||
if (val != DT_RELA && val != DT_REL) {
|
||||
log::warn("unrecognized PLTREL type {:#x}", val);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case DT_PLTRELSZ:
|
||||
state.plt_table_size_bytes = dyn_entry->d_un.d_val;
|
||||
break;
|
||||
case DT_NULL:
|
||||
dyn_end_reached = true;
|
||||
break;
|
||||
}
|
||||
|
||||
dyn_entry++;
|
||||
}
|
||||
|
||||
if (state.is_empty()) {
|
||||
log::error("failed to parse dynamic section (at least one table is null)");
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool failed = false;
|
||||
|
||||
auto so_name = state.str_table + state.soname_idx;
|
||||
if (strcmp(so_name, "libcocos2dcpp.so") != 0) {
|
||||
log::warn("DT_SONAME gives us {}! that's not good", so_name);
|
||||
failed = true;
|
||||
}
|
||||
|
||||
auto reloc_failed = state.plt_table_rela
|
||||
? !perform_reloc_patches<ElfW(Rela)>(info, state)
|
||||
: !perform_reloc_patches<ElfW(Rel)>(info, state);
|
||||
if (reloc_failed) {
|
||||
failed = true;
|
||||
}
|
||||
|
||||
if (DataPaths::patch_exceptions) {
|
||||
if (!perform_symbol_patches(info, state)) {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
log::info(
|
||||
"symbol patch diagnostics\nlibrary path: {}\naddrs: base={:#x} sym={} str={} hash={} plt={}",
|
||||
info->dlpi_name,
|
||||
info->dlpi_addr,
|
||||
reinterpret_cast<void*>(state.sym_table),
|
||||
reinterpret_cast<void*>(state.str_table),
|
||||
reinterpret_cast<void*>(state.hash_table),
|
||||
state.plt_table
|
||||
);
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -2,33 +2,106 @@ package com.customRobTop
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.*
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.OpenableColumns
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.net.toUri
|
||||
import com.customRobTop.contracts.ImportFileActivityContract
|
||||
import com.customRobTop.contracts.ShareFileActivityContract
|
||||
import com.geode.launcher.R
|
||||
import com.geode.launcher.utils.DownloadUtils
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import org.cocos2dx.lib.Cocos2dxGLSurfaceView
|
||||
import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.closeIMEKeyboard
|
||||
import org.cocos2dx.lib.Cocos2dxGLSurfaceView.Companion.openIMEKeyboard
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.IOException
|
||||
import java.io.OutputStream
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.*
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("unused", "UNUSED_PARAMETER")
|
||||
object BaseRobTopActivity {
|
||||
const val KEYBOARD_INPUT_MULTILINE = 1
|
||||
const val KEYBOARD_INPUT_NUMERIC = 4
|
||||
const val KEYBOARD_INPUT_PASSWORD = 2
|
||||
const val LEVEL_EXTENSION: String = ".gdlvl"
|
||||
|
||||
private var isLoaded = false
|
||||
var blockBackButton = false
|
||||
private var keyboardActive = false
|
||||
var curKeyboardInputFlags = 0
|
||||
|
||||
var isPaused = false
|
||||
|
||||
lateinit var me: WeakReference<Activity>
|
||||
private var shouldResumeSound = true
|
||||
|
||||
fun setCurrentActivity(currentActivity: Activity) {
|
||||
private var pendingExportPaths = emptyList<String>()
|
||||
|
||||
private lateinit var importFileActivityContract: ActivityResultLauncher<Unit>
|
||||
private lateinit var shareFileActivityContract: ActivityResultLauncher<String>
|
||||
private lateinit var shareFolderActivityContract: ActivityResultLauncher<Uri?>
|
||||
|
||||
fun setCurrentActivity(currentActivity: AppCompatActivity) {
|
||||
me = WeakReference(currentActivity)
|
||||
|
||||
importFileActivityContract = currentActivity.registerForActivityResult(ImportFileActivityContract()) { uris ->
|
||||
val filePaths = copyPickedFiles(uris)
|
||||
|
||||
Cocos2dxGLSurfaceView.cocos2dxGLSurfaceView.queueEvent {
|
||||
JniToCpp.filesPicked(filePaths.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
shareFileActivityContract = currentActivity.registerForActivityResult(ShareFileActivityContract()) { uri ->
|
||||
val currentlyPending = pendingExportPaths
|
||||
pendingExportPaths = emptyList()
|
||||
|
||||
val exportPath = currentlyPending.firstOrNull()
|
||||
if (exportPath == null || uri == null) {
|
||||
Cocos2dxGLSurfaceView.cocos2dxGLSurfaceView.queueEvent {
|
||||
JniToCpp.fileShareFinished(false)
|
||||
}
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
val result = saveToDocument(exportPath, uri)
|
||||
Cocos2dxGLSurfaceView.cocos2dxGLSurfaceView.queueEvent {
|
||||
JniToCpp.fileShareFinished(result)
|
||||
}
|
||||
}
|
||||
|
||||
shareFolderActivityContract = currentActivity.registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
|
||||
val currentlyPending = pendingExportPaths
|
||||
pendingExportPaths = emptyList()
|
||||
|
||||
if (uri == null) {
|
||||
Cocos2dxGLSurfaceView.cocos2dxGLSurfaceView.queueEvent {
|
||||
JniToCpp.fileShareFinished(false)
|
||||
}
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
val result = saveToFolder(currentlyPending, uri) > 0
|
||||
Cocos2dxGLSurfaceView.cocos2dxGLSurfaceView.queueEvent {
|
||||
JniToCpp.fileShareFinished(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("HardwareIds")
|
||||
@@ -87,6 +160,18 @@ object BaseRobTopActivity {
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getClipboardString(): String {
|
||||
me.get()?.run {
|
||||
val manager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
val clip = manager.primaryClip
|
||||
if (clip != null && clip.itemCount > 0) {
|
||||
return clip.getItemAt(0).coerceToText(this).toString()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun sendMail(subject: String, body: String, to: String) {
|
||||
me.get()?.runOnUiThread {
|
||||
@@ -264,4 +349,164 @@ object BaseRobTopActivity {
|
||||
level = DeprecationLevel.HIDDEN
|
||||
)
|
||||
fun logEvent(event: String) {}
|
||||
|
||||
@JvmStatic
|
||||
fun setKeyboardInputFlags(flags: Int) {
|
||||
curKeyboardInputFlags = flags
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun pickImportFiles() {
|
||||
try {
|
||||
importFileActivityContract.launch(Unit)
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
me.get()?.apply {
|
||||
runOnUiThread {
|
||||
Toast.makeText(
|
||||
this,
|
||||
R.string.no_activity_found,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyPickedFiles(uris: List<Uri>): List<String> {
|
||||
val me = me.get() ?: return emptyList()
|
||||
|
||||
val importsDir = File(LaunchUtils.getSaveDirectory(me), "imports")
|
||||
importsDir.mkdirs()
|
||||
|
||||
return uris.mapIndexedNotNull { index, uri ->
|
||||
val displayName = displayName(uri)
|
||||
?: "import_$index$LEVEL_EXTENSION"
|
||||
if (!displayName.endsWith(LEVEL_EXTENSION)) {
|
||||
return@mapIndexedNotNull null
|
||||
}
|
||||
|
||||
var outputFile = File(importsDir, displayName)
|
||||
var counter = 2
|
||||
while (outputFile.exists()) {
|
||||
outputFile = File(importsDir, "${counter}_$displayName")
|
||||
counter++
|
||||
}
|
||||
|
||||
try {
|
||||
me.contentResolver.openInputStream(uri)?.use { input ->
|
||||
outputFile.outputStream().use { output ->
|
||||
DownloadUtils.copyFile(input, output)
|
||||
}
|
||||
|
||||
outputFile.absolutePath
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
outputFile.delete()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayName(uri: Uri): String? {
|
||||
me.get()?.contentResolver?.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursorQuery ->
|
||||
if (cursorQuery.moveToFirst()) {
|
||||
val displayName = cursorQuery.getString(0)
|
||||
if (displayName.isNotEmpty()) {
|
||||
return File(displayName).name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun shareFiles(files: Array<String>) {
|
||||
pendingExportPaths = files.toList()
|
||||
|
||||
try {
|
||||
if (files.size == 1) {
|
||||
shareFileActivityContract.launch(files.first())
|
||||
return
|
||||
} else if (files.size > 1) {
|
||||
shareFolderActivityContract.launch(null)
|
||||
return
|
||||
}
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
me.get()?.apply {
|
||||
runOnUiThread {
|
||||
Toast.makeText(
|
||||
this,
|
||||
R.string.no_activity_found,
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
Cocos2dxGLSurfaceView.cocos2dxGLSurfaceView.queueEvent {
|
||||
JniToCpp.fileShareFinished(false)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private fun saveToFolder(exportPaths: List<String>, treeUri: Uri): Int {
|
||||
val uri = DocumentsContract.buildDocumentUriUsingTree(treeUri, DocumentsContract.getTreeDocumentId(treeUri))
|
||||
|
||||
val me = me.get() ?: return 0
|
||||
|
||||
return exportPaths.count { path ->
|
||||
val filename = File(path).name
|
||||
val documentUri = DocumentsContract.createDocument(me.contentResolver, uri, "application/octet-stream", filename)
|
||||
?: return@count false
|
||||
|
||||
saveToDocument(path, documentUri)
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveToDocument(exportPath: String, uri: Uri): Boolean {
|
||||
try {
|
||||
FileInputStream(exportPath).use { inputStream ->
|
||||
openOutput(uri).use { outputStream ->
|
||||
if (outputStream != null) {
|
||||
DownloadUtils.copyFile(inputStream, outputStream)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ensureLevelExtension(uri)
|
||||
return true
|
||||
} catch (_: IOException) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private fun openOutput(uri: Uri): OutputStream? {
|
||||
val me = me.get() ?: return null
|
||||
return try {
|
||||
me.contentResolver.openOutputStream(uri, "wt")
|
||||
} catch (_: IOException) {
|
||||
me.contentResolver.openOutputStream(uri)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureLevelExtension(uri: Uri) {
|
||||
val displayName = displayName(uri)
|
||||
if (displayName == null || displayName.endsWith(LEVEL_EXTENSION)) {
|
||||
return
|
||||
}
|
||||
val me = me.get() ?: return
|
||||
|
||||
val extensionIdx = displayName.lastIndexOf(".gdlvl.")
|
||||
val substring = if (extensionIdx < 0) {
|
||||
displayName + LEVEL_EXTENSION
|
||||
} else {
|
||||
displayName.substring(0, extensionIdx + 6)
|
||||
}
|
||||
|
||||
try {
|
||||
DocumentsContract.renameDocument(me.contentResolver, uri, substring)
|
||||
} catch (_: IOException) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,4 +55,16 @@ object JniToCpp {
|
||||
|
||||
@JvmStatic
|
||||
external fun videoAdShowed()
|
||||
|
||||
@JvmStatic
|
||||
external fun filesPicked(files: Array<String>)
|
||||
|
||||
@JvmStatic
|
||||
external fun fileShareFinished(success: Boolean)
|
||||
|
||||
@JvmStatic
|
||||
external fun textInputCaretToEnd()
|
||||
|
||||
@JvmStatic
|
||||
external fun textInputDone()
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.customRobTop.contracts
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
|
||||
class ImportFileActivityContract : ActivityResultContract<Unit, List<Uri>>() {
|
||||
override fun createIntent(
|
||||
context: Context,
|
||||
input: Unit
|
||||
): Intent {
|
||||
return Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "*/*"
|
||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun parseResult(
|
||||
resultCode: Int,
|
||||
intent: Intent?
|
||||
): List<Uri> {
|
||||
if (resultCode != Activity.RESULT_OK || intent == null) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val clipData = intent.clipData
|
||||
return if (clipData != null) {
|
||||
List(clipData.itemCount) { i -> clipData.getItemAt(i).uri }
|
||||
} else {
|
||||
val intentData = intent.data
|
||||
if (intentData != null)
|
||||
listOf(intentData)
|
||||
else
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.customRobTop.contracts
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.DocumentsContract
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import java.io.File
|
||||
|
||||
class ShareFileActivityContract : ActivityResultContract<String, Uri?>() {
|
||||
override fun createIntent(
|
||||
context: Context,
|
||||
input: String
|
||||
): Intent {
|
||||
return Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "application/octet-stream"
|
||||
putExtra(Intent.EXTRA_TITLE, File(input).name)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
putExtra(DocumentsContract.EXTRA_INITIAL_URI,
|
||||
DocumentsContract.buildDocumentUri("com.android.externalstorage.documents", "primary:Download")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun parseResult(
|
||||
resultCode: Int,
|
||||
intent: Intent?
|
||||
): Uri? {
|
||||
if (resultCode != Activity.RESULT_OK || intent == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return intent.data
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,7 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
||||
returnMessage: String? = null,
|
||||
returnExtendedMessage: String? = null
|
||||
) {
|
||||
val launchIntent = Intent(this, MainActivity::class.java).apply {
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)!!.apply {
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
|
||||
if (error != null && !returnMessage.isNullOrEmpty()) {
|
||||
@@ -171,23 +171,21 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
||||
tryLoadLibrary(gdPackageInfo, Constants.FMOD_LIB_NAME)
|
||||
tryLoadLibrary(gdPackageInfo, Constants.COCOS_LIB_NAME)
|
||||
|
||||
if (GamePackageUtils.getGameVersionCode(packageManager) >= 39L) {
|
||||
/*
|
||||
val customSymbols = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.CUSTOM_SYMBOL_LIST)
|
||||
if (customSymbols) {
|
||||
val symbolFile = File(LaunchUtils.getBaseDirectory(this), "exception_symbols.txt")
|
||||
LauncherFix.enableCustomSymbolList(symbolFile.path)
|
||||
}
|
||||
*/
|
||||
|
||||
val gameVersionCode = GamePackageUtils.getGameVersionCode(packageManager)
|
||||
if (gameVersionCode >= 39L) {
|
||||
// this fix requires geode v3, which is 2.206+
|
||||
// there is a short period in which 2.206 users will still have geode v2, but whatever. ig
|
||||
LauncherFix.performExceptionsRenaming()
|
||||
LauncherFix.enableExceptionsRenaming()
|
||||
}
|
||||
|
||||
LauncherFix.performPatches()
|
||||
|
||||
loadInternalMods()
|
||||
|
||||
val baseView = createView()
|
||||
if (gameVersionCode >= 42L) {
|
||||
mGLSurfaceView?.sendNewCocosEvents = true
|
||||
}
|
||||
|
||||
if (!GeodeUtils.handleSafeArea && Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) {
|
||||
val frameLayoutParams = ViewGroup.LayoutParams(
|
||||
@@ -206,24 +204,55 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
||||
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)
|
||||
|
||||
try {
|
||||
val geodeFailed = try {
|
||||
loadGeodeLibrary()
|
||||
false
|
||||
} catch (e: UnsatisfiedLinkError) {
|
||||
handleGeodeException(e)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,53 +393,73 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
||||
return "$sourceDir!/lib/$architecture/"
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||
private fun loadGeodeLibrary() {
|
||||
// Load Geode if exists
|
||||
// bundling the object with the application allows for nicer backtraces
|
||||
try {
|
||||
// put libgeode.so in jniLibs/armeabi-v7a to get this
|
||||
System.loadLibrary("geode")
|
||||
return
|
||||
} catch (e: UnsatisfiedLinkError) {
|
||||
// but users may prefer it stored with data
|
||||
val geodeFilename = LaunchUtils.geodeFilename
|
||||
val geodePath = File(filesDir.path, "launcher/$geodeFilename")
|
||||
if (geodePath.exists()) {
|
||||
System.load(geodePath.path)
|
||||
@Suppress("SENSELESS_COMPARISON")
|
||||
if (BuildConfig.PREBUNDLED_GEODE != null) {
|
||||
// attempt to load an externally provided Geode first
|
||||
if (!loadExternalGeode()) {
|
||||
copyGeodeResources()
|
||||
System.loadLibrary("geode")
|
||||
}
|
||||
} else {
|
||||
// Load Geode if exists
|
||||
// bundling the object with the application allows for nicer backtraces
|
||||
try {
|
||||
// put libgeode.so in jniLibs/armeabi-v7a to get this
|
||||
System.loadLibrary("geode")
|
||||
return
|
||||
}
|
||||
|
||||
// you know zmx i have 0 clue what this does so im
|
||||
// just gonna like copy the binary from external
|
||||
// also i get 20 million permission denied errors
|
||||
val externalGeodePath = LaunchUtils.getInstalledGeodePath(this)!!
|
||||
|
||||
val copiedPath = File(filesDir.path, "copied")
|
||||
if (copiedPath.exists()) {
|
||||
copiedPath.deleteRecursively()
|
||||
}
|
||||
copiedPath.mkdir()
|
||||
|
||||
val copiedGeodePath = File(copiedPath.path, "Geode.so")
|
||||
|
||||
if (externalGeodePath.exists()) {
|
||||
DownloadUtils.copyFile(
|
||||
FileInputStream(externalGeodePath),
|
||||
FileOutputStream(copiedGeodePath)
|
||||
)
|
||||
|
||||
if (copiedGeodePath.exists()) {
|
||||
println("Loading Geode from ${externalGeodePath.name}")
|
||||
System.load(copiedGeodePath.path)
|
||||
return
|
||||
} catch (e: UnsatisfiedLinkError) {
|
||||
if (!loadExternalGeode()) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("UnsafeDynamicallyLoadedCode")
|
||||
private fun loadExternalGeode(): Boolean {
|
||||
// but users may prefer it stored with data
|
||||
val geodeFilename = LaunchUtils.geodeFilename
|
||||
val geodePath = File(filesDir.path, "launcher/$geodeFilename")
|
||||
if (geodePath.exists()) {
|
||||
println("Loading Geode from ${geodePath.path}")
|
||||
System.load(geodePath.path)
|
||||
return true
|
||||
}
|
||||
|
||||
// you know zmx i have 0 clue what this does so im
|
||||
// just gonna like copy the binary from external
|
||||
// also i get 20 million permission denied errors
|
||||
val externalGeodePath = LaunchUtils.getInstalledGeodePath(this)
|
||||
?: return false
|
||||
|
||||
val copiedPath = File(filesDir.path, "copied")
|
||||
if (copiedPath.exists()) {
|
||||
copiedPath.deleteRecursively()
|
||||
}
|
||||
copiedPath.mkdir()
|
||||
|
||||
val copiedGeodePath = File(copiedPath.path, "Geode.so")
|
||||
|
||||
if (externalGeodePath.exists()) {
|
||||
DownloadUtils.copyFile(
|
||||
FileInputStream(externalGeodePath),
|
||||
FileOutputStream(copiedGeodePath)
|
||||
)
|
||||
|
||||
if (copiedGeodePath.exists()) {
|
||||
// android 17 fix
|
||||
copiedGeodePath.setWritable(false)
|
||||
|
||||
println("Loading Geode from ${externalGeodePath.name}")
|
||||
System.load(copiedGeodePath.path)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private fun createView(): FrameLayout {
|
||||
val frameLayoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
@@ -444,19 +493,6 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
||||
this.mGLSurfaceView = glSurfaceView
|
||||
frameLayout.addView(this.mGLSurfaceView)
|
||||
|
||||
val disableNotification = PreferenceUtils.get(this).getBoolean(PreferenceUtils.Key.DISABLE_REDESIGN)
|
||||
if (!disableNotification) {
|
||||
val hasCards = determineDisplayedCards(this)
|
||||
if (hasCards.isNotEmpty()) {
|
||||
val notificationView = ComposeView(this)
|
||||
frameLayout.addView(notificationView)
|
||||
|
||||
notificationView.setContent {
|
||||
LaunchNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
glSurfaceView.setEGLContextClientVersion(2)
|
||||
glSurfaceView.setEGLConfigChooser(EGLConfigChooser())
|
||||
|
||||
@@ -475,6 +511,10 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
||||
editText.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
||||
glSurfaceView.cocos2dxEditText = editText
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
glSurfaceView.defaultFocusHighlightEnabled = false
|
||||
}
|
||||
|
||||
return frameLayout
|
||||
}
|
||||
|
||||
@@ -621,6 +661,10 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
||||
mGLSurfaceView?.enableControllerEvents()
|
||||
true
|
||||
}
|
||||
GeodeUtils.CAPABILITY_RESIZE_CALLBACK -> {
|
||||
mGLSurfaceView?.supportsResizeEvents = true
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
@@ -657,6 +701,56 @@ class GeometryDashActivity : AppCompatActivity(), Cocos2dxHelper.Cocos2dxHelperL
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyGeodeResources() {
|
||||
@Suppress("SENSELESS_COMPARISON")
|
||||
if (BuildConfig.PREBUNDLED_GEODE == null) {
|
||||
return
|
||||
}
|
||||
|
||||
val resourcesDirectory = LaunchUtils.getGeodeResourcesDirectory(this)
|
||||
|
||||
val pathBase = "geode.loader"
|
||||
val versionFilename = "version"
|
||||
|
||||
val bundledVersion = try {
|
||||
assets.open("$pathBase/$versionFilename").bufferedReader().use {
|
||||
it.readText()
|
||||
}.trimEnd()
|
||||
} catch (_: IOException) {
|
||||
return
|
||||
}
|
||||
|
||||
val versionFile = File(resourcesDirectory, versionFilename)
|
||||
val resourcesVersion = if (versionFile.exists()) {
|
||||
versionFile.readText().trimEnd()
|
||||
} else null
|
||||
|
||||
if (bundledVersion == resourcesVersion) {
|
||||
println("copyGeodeResources: found $resourcesVersion, expected $bundledVersion. No work needs to be done!")
|
||||
return
|
||||
}
|
||||
|
||||
if (resourcesDirectory.exists()) {
|
||||
resourcesDirectory.deleteRecursively()
|
||||
}
|
||||
resourcesDirectory.mkdirs()
|
||||
|
||||
println("copyGeodeResources: found $resourcesVersion, expected $bundledVersion. Copying assets")
|
||||
|
||||
val fileListing = try {
|
||||
assets.list(pathBase)
|
||||
} catch (_: IOException) {
|
||||
return
|
||||
}
|
||||
|
||||
fileListing?.forEach { fileName ->
|
||||
val resourceOutput = File(resourcesDirectory, fileName)
|
||||
|
||||
val resource = assets.open("$pathBase/$fileName")
|
||||
DownloadUtils.copyFile(resource, resourceOutput.outputStream())
|
||||
}
|
||||
}
|
||||
|
||||
class EGLConfigChooser : GLSurfaceView.EGLConfigChooser {
|
||||
// this comes from EGL14, but is unavailable on EGL10
|
||||
// also EGL14 is incompatible with EGL10. so whatever
|
||||
|
||||
@@ -13,7 +13,7 @@ object LauncherFix {
|
||||
|
||||
external fun setOriginalDataPath(dataPath: String)
|
||||
|
||||
external fun performExceptionsRenaming()
|
||||
external fun enableExceptionsRenaming()
|
||||
|
||||
external fun enableCustomSymbolList(symbolsPath: String)
|
||||
external fun performPatches()
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import android.text.format.Formatter
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
@@ -70,6 +72,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -82,7 +85,9 @@ import com.geode.launcher.ui.theme.Theme
|
||||
import com.geode.launcher.ui.theme.Theme.DARK
|
||||
import com.geode.launcher.ui.theme.Theme.LIGHT
|
||||
import com.geode.launcher.ui.theme.launcherTitleStyle
|
||||
import com.geode.launcher.ui.theme.sapphireTitleStyle
|
||||
import com.geode.launcher.updater.ReleaseManager
|
||||
import com.geode.launcher.utils.ApplicationIcon
|
||||
import com.geode.launcher.utils.Constants
|
||||
import com.geode.launcher.utils.GamePackageUtils
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
@@ -99,13 +104,25 @@ class MainActivity : ComponentActivity() {
|
||||
val gdInstalled = GamePackageUtils.isGameInstalled(packageManager)
|
||||
val geodeInstalled = LaunchUtils.isGeodeInstalled(this)
|
||||
|
||||
if (gdInstalled && geodeInstalled && intent.getBooleanExtra("restarted", false)) {
|
||||
onLaunch(this)
|
||||
val restartImmediately = intent.getBooleanExtra("restarted", false)
|
||||
if (gdInstalled && geodeInstalled && restartImmediately) {
|
||||
val withSafeMode = intent.getBooleanExtra("safe-mode", false)
|
||||
onLaunch(this, withSafeMode)
|
||||
}
|
||||
|
||||
enableEdgeToEdge()
|
||||
clearDownloadedApks(this)
|
||||
|
||||
// remove old Geode for the like 4 people that downloaded the Play Store build
|
||||
@Suppress("SENSELESS_COMPARISON")
|
||||
if (BuildConfig.PREBUNDLED_GEODE != null) {
|
||||
val preferences = PreferenceUtils.get(this)
|
||||
if (!preferences.getBoolean(PreferenceUtils.Key.PREBUNDLED_GEODE_MIGRATION_PERFORMED)) {
|
||||
LaunchUtils.getInstalledGeodePath(this)?.delete()
|
||||
preferences.setBoolean(PreferenceUtils.Key.PREBUNDLED_GEODE_MIGRATION_PERFORMED, true)
|
||||
}
|
||||
}
|
||||
|
||||
val returnMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_MESSAGE)
|
||||
val returnExtendedMessage = intent.extras?.getString(LaunchUtils.LAUNCHER_KEY_RETURN_EXTENDED_MESSAGE)
|
||||
|
||||
@@ -151,6 +168,8 @@ class MainActivity : ComponentActivity() {
|
||||
launchViewModel.launchArguments = launchArguments
|
||||
}
|
||||
|
||||
launchViewModel.beginLaunchFlow()
|
||||
|
||||
CompositionLocalProvider(LocalTheme provides theme) {
|
||||
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||
Surface(
|
||||
@@ -176,6 +195,7 @@ fun LaunchCancelledIcon(cancelReason: LaunchViewModel.LaunchCancelReason, modifi
|
||||
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED,
|
||||
LaunchViewModel.LaunchCancelReason.GAME_MISSING -> Icon(painterResource(R.drawable.icon_error), contentDescription = null, modifier)
|
||||
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED,
|
||||
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED_AUTOMATIC,
|
||||
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> Icon(painterResource(R.drawable.icon_warning), contentDescription = null, modifier)
|
||||
else -> Icon(painterResource(R.drawable.icon_info), contentDescription = null, modifier)
|
||||
}
|
||||
@@ -187,6 +207,9 @@ fun mapCancelReasonToInfo(cancelReason: LaunchViewModel.LaunchCancelReason): Lau
|
||||
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED -> LaunchStatusInfo(
|
||||
title = stringResource(R.string.launcher_cancelled_crash)
|
||||
)
|
||||
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED_AUTOMATIC -> LaunchStatusInfo(
|
||||
title = stringResource(R.string.launcher_cancelled_crash_instant)
|
||||
)
|
||||
LaunchViewModel.LaunchCancelReason.GEODE_NOT_FOUND -> LaunchStatusInfo(
|
||||
title = stringResource(R.string.geode_download_title)
|
||||
)
|
||||
@@ -196,7 +219,7 @@ fun mapCancelReasonToInfo(cancelReason: LaunchViewModel.LaunchCancelReason): Lau
|
||||
)
|
||||
LaunchViewModel.LaunchCancelReason.GAME_OUTDATED -> LaunchStatusInfo(
|
||||
title = stringResource(R.string.launcher_cancelled_error),
|
||||
details = stringResource(R.string.launcher_cancelled_outdated)
|
||||
details = stringResource(R.string.launcher_cancelled_outdated, GamePackageUtils.getSupportedUnifiedVersion())
|
||||
)
|
||||
LaunchViewModel.LaunchCancelReason.AUTOMATIC,
|
||||
LaunchViewModel.LaunchCancelReason.MANUAL-> LaunchStatusInfo(
|
||||
@@ -245,7 +268,7 @@ fun mapLaunchStatusToInfo(state: LaunchViewModel.LaunchUIState, inSafeMode: Bool
|
||||
)
|
||||
}
|
||||
is LaunchViewModel.LaunchUIState.Cancelled -> {
|
||||
return mapCancelReasonToInfo(state.reason)
|
||||
mapCancelReasonToInfo(state.reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -323,6 +346,7 @@ fun RetryButtonContents(reason: LaunchViewModel.LaunchCancelReason) {
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(stringResource(R.string.launcher_cancelled_resume))
|
||||
}
|
||||
LaunchViewModel.LaunchCancelReason.LAST_LAUNCH_CRASHED_AUTOMATIC,
|
||||
LaunchViewModel.LaunchCancelReason.MANUAL,
|
||||
LaunchViewModel.LaunchCancelReason.AUTOMATIC -> {
|
||||
Icon(
|
||||
@@ -445,35 +469,75 @@ fun ExtraOptions(onSettings: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
val lightOuterColorGradient = arrayOf(
|
||||
0.0f to Color(0xfff5b11d),
|
||||
0.10f to Color(0xfff0834d),
|
||||
0.24f to Color(0xffe85f6b),
|
||||
0.49f to Color(0xffc9659e),
|
||||
1.0f to Color(0xffb9588f)
|
||||
data class ColorPalette(
|
||||
val outerColors: List<Pair<Float, Color>>,
|
||||
val innerColors: List<Pair<Float, Color>>,
|
||||
)
|
||||
|
||||
val lightInnerColorGradient = arrayOf(
|
||||
0.48f to Color(0xfff2ce00),
|
||||
0.63f to Color(0xfff4b97e),
|
||||
0.73f to Color(0xfff7b66a),
|
||||
1.0f to Color(0xffeb8fac)
|
||||
data class AnimatedIconSpec(
|
||||
val lightPalette: ColorPalette,
|
||||
val darkPalette: ColorPalette,
|
||||
|
||||
@param:DrawableRes val innerId: Int,
|
||||
@param:DrawableRes val outerId: Int,
|
||||
)
|
||||
|
||||
val darkOuterColorGradient = arrayOf(
|
||||
0.0f to Color(0xfffffdff),
|
||||
0.08f to Color(0xffF2EAF5),
|
||||
0.12f to Color(0xffEDE5EF),
|
||||
0.31f to Color(0xffCDB5CD),
|
||||
0.49f to Color(0xffBA9BBC),
|
||||
1.0f to Color(0xff8D7ACF),
|
||||
data class BrandPalette(
|
||||
@param:DrawableRes val darkLogo: Int,
|
||||
@param:DrawableRes val lightLogo: Int,
|
||||
|
||||
@param:StringRes val title: Int,
|
||||
val titleFont: TextStyle,
|
||||
val animatedIcon: AnimatedIconSpec? = null,
|
||||
)
|
||||
|
||||
val darkInnerColorGradient = arrayOf(
|
||||
0.48f to Color(0xffffffff),
|
||||
0.63f to Color(0xfff5e4c2),
|
||||
0.73f to Color(0xffe5c7ad),
|
||||
1.0f to Color(0xffb790a9),
|
||||
val geodeColorPalette = BrandPalette(
|
||||
animatedIcon = AnimatedIconSpec(
|
||||
lightPalette = ColorPalette(
|
||||
outerColors = listOf(
|
||||
0.0f to Color(0xfff5b11d),
|
||||
0.10f to Color(0xfff0834d),
|
||||
0.24f to Color(0xffe85f6b),
|
||||
0.49f to Color(0xffc9659e),
|
||||
1.0f to Color(0xffb9588f)
|
||||
),
|
||||
innerColors = listOf(
|
||||
0.48f to Color(0xfff2ce00),
|
||||
0.63f to Color(0xfff4b97e),
|
||||
0.73f to Color(0xfff7b66a),
|
||||
1.0f to Color(0xffeb8fac)
|
||||
)
|
||||
),
|
||||
darkPalette = ColorPalette(
|
||||
outerColors = listOf(
|
||||
0.0f to Color(0xfffffdff),
|
||||
0.08f to Color(0xffF2EAF5),
|
||||
0.12f to Color(0xffEDE5EF),
|
||||
0.31f to Color(0xffCDB5CD),
|
||||
0.49f to Color(0xffBA9BBC),
|
||||
1.0f to Color(0xff8D7ACF),
|
||||
),
|
||||
innerColors = listOf(
|
||||
0.48f to Color(0xffffffff),
|
||||
0.63f to Color(0xfff5e4c2),
|
||||
0.73f to Color(0xffe5c7ad),
|
||||
1.0f to Color(0xffb790a9),
|
||||
)
|
||||
),
|
||||
innerId = R.drawable.geode_monochrome_inner,
|
||||
outerId = R.drawable.geode_monochrome_outer,
|
||||
),
|
||||
darkLogo = R.drawable.geode_base,
|
||||
lightLogo = R.drawable.geode_base_light,
|
||||
title = R.string.launcher_title,
|
||||
titleFont = launcherTitleStyle
|
||||
)
|
||||
|
||||
val sapphireColorPalette = BrandPalette(
|
||||
darkLogo = R.drawable.sapphire_full,
|
||||
lightLogo = R.drawable.sapphire_full,
|
||||
title = R.string.application_icon_sapphire,
|
||||
titleFont = sapphireTitleStyle
|
||||
)
|
||||
|
||||
@Preview(name = "non-animated, light")
|
||||
@@ -498,22 +562,49 @@ fun GeodeLogoAnimatedPreview() {
|
||||
CompositionLocalProvider(LocalTheme provides theme) {
|
||||
GeodeLauncherTheme(theme = theme) {
|
||||
Surface {
|
||||
GeodeLogo(true)
|
||||
GeodeLogo(shouldAnimate = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "sapphire, non-animated, light")
|
||||
@Preview(name = "sapphire, non-animated, dark", uiMode = UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun SapphireLogoPreview() {
|
||||
val theme = if (isSystemInDarkTheme()) DARK else LIGHT
|
||||
CompositionLocalProvider(LocalTheme provides theme) {
|
||||
GeodeLauncherTheme(theme = theme) {
|
||||
Surface {
|
||||
GeodeLogo(shouldAnimate = false, basePalette = sapphireColorPalette)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(name = "sapphire, animated, light")
|
||||
@Preview(name = "sapphire, animated, dark", uiMode = UI_MODE_NIGHT_YES)
|
||||
@Composable
|
||||
fun SapphireLogoAnimatedPreview() {
|
||||
val theme = if (isSystemInDarkTheme()) DARK else LIGHT
|
||||
CompositionLocalProvider(LocalTheme provides theme) {
|
||||
GeodeLauncherTheme(theme = theme) {
|
||||
Surface {
|
||||
GeodeLogo(shouldAnimate = true, basePalette = sapphireColorPalette)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AnimatedLogo(modifier: Modifier = Modifier) {
|
||||
fun AnimatedLogo(modifier: Modifier = Modifier, spec: AnimatedIconSpec) {
|
||||
val theme = LocalTheme.current
|
||||
val innerColorPalette = remember(theme) {
|
||||
if (theme == LIGHT) lightInnerColorGradient else darkInnerColorGradient
|
||||
}
|
||||
|
||||
val outerColorPalette = remember(theme) {
|
||||
if (theme == LIGHT) lightOuterColorGradient else darkOuterColorGradient
|
||||
}
|
||||
val colorPalette = if (theme == LIGHT) spec.lightPalette
|
||||
else spec.darkPalette
|
||||
|
||||
val innerColorPalette = colorPalette.innerColors
|
||||
val outerColorPalette = colorPalette.outerColors
|
||||
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "infinite transition")
|
||||
val offset by infiniteTransition.animateFloat(
|
||||
@@ -527,14 +618,14 @@ fun AnimatedLogo(modifier: Modifier = Modifier) {
|
||||
)
|
||||
|
||||
val innerBrush = Brush.linearGradient(
|
||||
colorStops = innerColorPalette,
|
||||
colorStops = innerColorPalette.toTypedArray(),
|
||||
start = Offset(0.0f, 0.0f + (165.06f * offset)),
|
||||
end = Offset(0.0f, 165.06f + (165.06f * offset)),
|
||||
tileMode = TileMode.Mirror
|
||||
)
|
||||
|
||||
val outerBrush = Brush.linearGradient(
|
||||
colorStops = outerColorPalette,
|
||||
colorStops = outerColorPalette.toTypedArray(),
|
||||
start = Offset(0.0f + (84.69f * offset), 0.0f + (165.06f * offset)),
|
||||
end = Offset(84.69f + (84.69f * offset), 169.61f + (165.06f * offset)),
|
||||
tileMode = TileMode.Mirror
|
||||
@@ -542,7 +633,7 @@ fun AnimatedLogo(modifier: Modifier = Modifier) {
|
||||
|
||||
Box(modifier = modifier) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.geode_monochrome_inner),
|
||||
painter = painterResource(spec.innerId),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
|
||||
@@ -553,9 +644,10 @@ fun AnimatedLogo(modifier: Modifier = Modifier) {
|
||||
blendMode = BlendMode.SrcIn
|
||||
)
|
||||
}
|
||||
.size(64.dp, 64.dp)
|
||||
)
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.geode_monochrome_outer),
|
||||
painter = painterResource(spec.outerId),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
|
||||
@@ -566,39 +658,50 @@ fun AnimatedLogo(modifier: Modifier = Modifier) {
|
||||
blendMode = BlendMode.SrcIn
|
||||
)
|
||||
}
|
||||
.size(64.dp, 64.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GeodeLogo(shouldAnimate: Boolean = false, modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
fun GeodeLogo(modifier: Modifier = Modifier, shouldAnimate: Boolean = false, basePalette: BrandPalette = geodeColorPalette) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
itemVerticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
) {
|
||||
val theme = LocalTheme.current
|
||||
|
||||
Crossfade(
|
||||
targetState = shouldAnimate,
|
||||
animationSpec = tween(durationMillis = 1000, easing = FastOutSlowInEasing),
|
||||
label="logo fade"
|
||||
) { screen ->
|
||||
when (screen) {
|
||||
true -> AnimatedLogo(modifier = Modifier.size(64.dp, 64.dp))
|
||||
false -> Image(
|
||||
painterResource(if (theme == LIGHT)
|
||||
R.drawable.geode_base_light else R.drawable.geode_base
|
||||
),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp, 64.dp)
|
||||
)
|
||||
if (basePalette.animatedIcon != null) {
|
||||
Crossfade(
|
||||
targetState = shouldAnimate,
|
||||
animationSpec = tween(durationMillis = 1000, easing = FastOutSlowInEasing),
|
||||
label="logo fade"
|
||||
) { screen ->
|
||||
when (screen) {
|
||||
true -> AnimatedLogo(modifier = Modifier.size(64.dp, 64.dp), spec = basePalette.animatedIcon)
|
||||
false -> Image(
|
||||
painterResource(if (theme == LIGHT)
|
||||
basePalette.lightLogo else basePalette.darkLogo
|
||||
),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp, 64.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Image(
|
||||
painterResource(if (theme == LIGHT)
|
||||
basePalette.lightLogo else basePalette.darkLogo
|
||||
),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(84.dp, 84.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
stringResource(R.string.launcher_title),
|
||||
style = launcherTitleStyle,
|
||||
stringResource(basePalette.title),
|
||||
style = basePalette.titleFont,
|
||||
fontSize = 64.sp,
|
||||
modifier = Modifier
|
||||
.padding(12.dp)
|
||||
@@ -625,6 +728,9 @@ fun GeodeUpdateIndicator(snackbarHostState: SnackbarHostState, onRetry: () -> Un
|
||||
ReleaseManager.UpdateException.Reason.EXTERNAL_FILE_IN_USE -> stringResource(
|
||||
R.string.release_fetch_external_in_use_short
|
||||
)
|
||||
ReleaseManager.UpdateException.Reason.HASH_VALIDATION_FAILED -> stringResource(
|
||||
R.string.release_fetch_hash_validation_failed_short
|
||||
)
|
||||
else -> stringResource(R.string.release_fetch_generic_short)
|
||||
}
|
||||
}
|
||||
@@ -659,10 +765,6 @@ fun AltMainScreen(
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
launchViewModel.beginLaunchFlow()
|
||||
}
|
||||
|
||||
val launchUIState by launchViewModel.uiState.collectAsState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
var launchInSafeMode by remember {
|
||||
@@ -690,7 +792,14 @@ fun AltMainScreen(
|
||||
.padding(8.dp)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
GeodeLogo(shouldAnimate = launchUIState.isInProgress())
|
||||
val sapphireLogo = remember {
|
||||
PreferenceUtils.get(context)
|
||||
.getString(PreferenceUtils.Key.SELECTED_ICON) == ApplicationIcon.SAPPHIRE.toId()
|
||||
}
|
||||
|
||||
val basePalette = if (sapphireLogo) sapphireColorPalette
|
||||
else geodeColorPalette
|
||||
GeodeLogo(shouldAnimate = launchUIState.isInProgress(), basePalette = basePalette)
|
||||
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
@@ -731,10 +840,11 @@ fun AltMainScreen(
|
||||
)
|
||||
|
||||
// only show launcher update in a case where a user won't see it ingame
|
||||
if (launchUIState is LaunchViewModel.LaunchUIState.Cancelled) {
|
||||
val currentUIState = launchUIState
|
||||
if (currentUIState is LaunchViewModel.LaunchUIState.Cancelled) {
|
||||
val gameVersion = remember { GamePackageUtils.getGameVersionCodeOrNull(context.packageManager) }
|
||||
|
||||
if (gameVersion != null && gameVersion < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
|
||||
if (gameVersion != null && gameVersion < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING && currentUIState.reason != LaunchViewModel.LaunchCancelReason.GAME_OUTDATED) {
|
||||
UnsupportedVersionWarning()
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@ import android.provider.DocumentsContract
|
||||
import android.provider.DocumentsProvider
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.geode.launcher.utils.ApplicationIcon
|
||||
import com.geode.launcher.utils.IconUtils
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import java.io.File
|
||||
|
||||
private val DEFAULT_ROOT_PROJECTION: Array<String> = arrayOf(
|
||||
@@ -60,8 +63,17 @@ class UserDirectoryProvider : DocumentsProvider() {
|
||||
DocumentsContract.Root.FLAG_SUPPORTS_RECENTS or
|
||||
DocumentsContract.Root.FLAG_SUPPORTS_SEARCH
|
||||
)
|
||||
add(DocumentsContract.Root.COLUMN_TITLE, context.getString(R.string.app_name))
|
||||
add(DocumentsContract.Root.COLUMN_ICON, R.drawable.geode_logo)
|
||||
|
||||
val selectedIconKey = PreferenceUtils.get(context).getString(PreferenceUtils.Key.SELECTED_ICON)
|
||||
?: "geode"
|
||||
val selectedIcon = ApplicationIcon.fromId(selectedIconKey)
|
||||
val iconDetails = IconUtils.getIconDetails(selectedIcon)
|
||||
|
||||
val nameId = if (selectedIcon == ApplicationIcon.SAPPHIRE) R.string.application_icon_sapphire else R.string.app_name
|
||||
val logoId = if (selectedIcon == ApplicationIcon.GEODE) R.drawable.geode_logo else iconDetails.iconId
|
||||
|
||||
add(DocumentsContract.Root.COLUMN_TITLE, context.getString(nameId))
|
||||
add(DocumentsContract.Root.COLUMN_ICON, logoId)
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.geode.launcher.log
|
||||
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
abstract class BaseDirectoryViewModel<T>(protected val baseDirectory: File) : ViewModel() {
|
||||
private val _lineState = mutableStateListOf<T>()
|
||||
val lineState = _lineState
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading = _isLoading.asStateFlow()
|
||||
|
||||
private var logJob: Job? = null
|
||||
|
||||
abstract fun getFileList(): List<T>
|
||||
|
||||
abstract fun getItemKey(item: T): Any
|
||||
|
||||
open fun clearAllFiles() {
|
||||
logJob?.cancel()
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
if (baseDirectory.exists()) {
|
||||
val files = baseDirectory.listFiles()
|
||||
files?.forEach {
|
||||
it.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_lineState.clear()
|
||||
}
|
||||
|
||||
private fun loadLogs() {
|
||||
_isLoading.value = true
|
||||
|
||||
logJob?.cancel()
|
||||
logJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
val lines = getFileList()
|
||||
|
||||
_lineState.clear()
|
||||
_lineState.addAll(lines)
|
||||
|
||||
_isLoading.value = false
|
||||
|
||||
logJob = null
|
||||
}
|
||||
}
|
||||
|
||||
fun removeFile(filename: String) {
|
||||
logJob?.cancel()
|
||||
|
||||
val crashPath = File(baseDirectory, filename)
|
||||
if (crashPath.exists()) {
|
||||
crashPath.delete()
|
||||
loadLogs()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
loadLogs()
|
||||
}
|
||||
}
|
||||
@@ -1,115 +1,89 @@
|
||||
package com.geode.launcher.log
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
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.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlin.time.Instant
|
||||
|
||||
data class CrashDump @OptIn(ExperimentalTime::class) constructor(
|
||||
data class CrashDump(
|
||||
val filename: String,
|
||||
val lastModified: Instant
|
||||
val lastModified: Instant,
|
||||
val fullPath: String,
|
||||
)
|
||||
|
||||
class CrashViewModel(private val application: Application) : ViewModel() {
|
||||
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(
|
||||
application = application
|
||||
)
|
||||
CrashViewModel(crashDirectory)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val _lineState = mutableStateListOf<CrashDump>()
|
||||
val lineState = _lineState
|
||||
var hasIndicator = false
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading = _isLoading.asStateFlow()
|
||||
override fun getItemKey(item: CrashDump) = item.filename
|
||||
|
||||
private var failedToLoad = false
|
||||
override fun getFileList(): List<CrashDump> {
|
||||
val crashDirectory = baseDirectory
|
||||
|
||||
private var logJob: Job? = null
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
private fun getCrashList(): List<CrashDump> {
|
||||
val crashDirectory = LaunchUtils.getCrashDirectory(application)
|
||||
if (!crashDirectory.exists()) {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val children = crashDirectory.listFiles {
|
||||
// ignore indicator files (including old file)
|
||||
_, name -> name != LaunchUtils.CRASH_INDICATOR_NAME && name != "last-pid"
|
||||
_, 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())
|
||||
lastModified = Instant.fromEpochMilliseconds(it.lastModified()),
|
||||
fullPath = it.path,
|
||||
)
|
||||
}
|
||||
.sortedByDescending { it.lastModified }
|
||||
}
|
||||
|
||||
fun clearCrashes() {
|
||||
logJob?.cancel()
|
||||
|
||||
fun clearIndicator() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val crashDirectory = LaunchUtils.getCrashDirectory(application)
|
||||
if (crashDirectory.exists()) {
|
||||
val files = crashDirectory.listFiles()
|
||||
files?.forEach {
|
||||
it.delete()
|
||||
LaunchUtils.CRASH_INDICATOR_NAMES.forEach {
|
||||
val crashFile = File(baseDirectory, it)
|
||||
if (crashFile.exists()) {
|
||||
crashFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_lineState.clear()
|
||||
}
|
||||
|
||||
private fun loadLogs() {
|
||||
_isLoading.value = true
|
||||
|
||||
logJob?.cancel()
|
||||
logJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
val lines = getCrashList()
|
||||
|
||||
_lineState.clear()
|
||||
_lineState.addAll(lines)
|
||||
|
||||
_isLoading.value = false
|
||||
|
||||
logJob = null
|
||||
hasIndicator = false
|
||||
}
|
||||
}
|
||||
|
||||
fun removeCrash(filename: String) {
|
||||
logJob?.cancel()
|
||||
fun createIndicator() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val crashFile = File(baseDirectory, LaunchUtils.CRASH_INDICATOR_NAMES.first())
|
||||
crashFile.createNewFile()
|
||||
|
||||
val crashPath = File(LaunchUtils.getCrashDirectory(application), filename)
|
||||
if (crashPath.exists()) {
|
||||
crashPath.delete()
|
||||
loadLogs()
|
||||
hasIndicator = true
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
loadLogs()
|
||||
override fun clearAllFiles() {
|
||||
super.clearAllFiles()
|
||||
hasIndicator = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ 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.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
@@ -38,12 +39,10 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
@@ -56,11 +55,14 @@ import com.geode.launcher.preferences.ApplicationLogsActivity
|
||||
import com.geode.launcher.BuildConfig
|
||||
import com.geode.launcher.R
|
||||
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.utils.GamePackageUtils
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
data class LoadFailureInfo(
|
||||
val title: LaunchUtils.LauncherError,
|
||||
@@ -138,14 +140,28 @@ fun ErrorInfoTitle(failureReason: LaunchUtils.LauncherError) {
|
||||
|
||||
}
|
||||
|
||||
fun onShareCrash(context: Context) {
|
||||
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) {
|
||||
val lastCrash = findSomethingForCrash(context)
|
||||
if (lastCrash == null) {
|
||||
Toast.makeText(context, R.string.launcher_error_export_missing, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
val baseDirectory = LaunchUtils.getBaseDirectory(context)
|
||||
val baseDirectory = LaunchUtils.getBaseDirectory(context, true)
|
||||
|
||||
val providerLocation = lastCrash.toRelativeString(baseDirectory)
|
||||
val documentsPath = "${UserDirectoryProvider.ROOT}${providerLocation}"
|
||||
@@ -164,6 +180,19 @@ 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) {
|
||||
val launchIntent = Intent(context, ApplicationLogsActivity::class.java)
|
||||
context.startActivity(launchIntent)
|
||||
@@ -173,40 +202,46 @@ fun onShowLogs(context: Context) {
|
||||
fun ErrorInfoDescription(description: String, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val currentRelease = remember {
|
||||
PreferenceUtils.get(context).getString(PreferenceUtils.Key.CURRENT_VERSION_TAG)
|
||||
@Suppress("SENSELESS_COMPARISON")
|
||||
if (BuildConfig.PREBUNDLED_GEODE != null)
|
||||
"${BuildConfig.PREBUNDLED_GEODE}*"
|
||||
else
|
||||
PreferenceUtils.get(context).getString(PreferenceUtils.Key.CURRENT_VERSION_TAG)
|
||||
}
|
||||
|
||||
val gameVersion = remember {
|
||||
GamePackageUtils.getUnifiedVersionName(context.packageManager)
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
stringResource(R.string.launcher_error_details),
|
||||
style = Typography.headlineSmall
|
||||
)
|
||||
SelectionContainer {
|
||||
Column(modifier = modifier) {
|
||||
Text(
|
||||
stringResource(R.string.launcher_error_details),
|
||||
style = Typography.headlineSmall
|
||||
)
|
||||
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
Text(
|
||||
description,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
style = Typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
description,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
style = Typography.bodyMedium
|
||||
)
|
||||
|
||||
Spacer(Modifier.size(4.dp))
|
||||
Spacer(Modifier.size(4.dp))
|
||||
|
||||
Text(
|
||||
stringResource(R.string.launcher_error_device_info,
|
||||
Build.MODEL,
|
||||
Build.VERSION.RELEASE,
|
||||
currentRelease ?: "unknown",
|
||||
gameVersion,
|
||||
LaunchUtils.applicationArchitecture,
|
||||
BuildConfig.VERSION_NAME
|
||||
),
|
||||
style = Typography.labelMedium
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.launcher_error_device_info,
|
||||
Build.MODEL,
|
||||
Build.VERSION.RELEASE,
|
||||
currentRelease ?: "unknown",
|
||||
gameVersion,
|
||||
LaunchUtils.applicationArchitecture,
|
||||
BuildConfig.VERSION_NAME
|
||||
),
|
||||
style = Typography.labelMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,20 +289,34 @@ fun ErrorInfoActions(extraDetails: String?, modifier: Modifier = Modifier) {
|
||||
Text(stringResource(R.string.launcher_error_copy))
|
||||
}
|
||||
} else {
|
||||
Button(onClick = { onShareCrash(context) }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_share),
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(stringResource(R.string.launcher_error_export_crash))
|
||||
}
|
||||
val crashFile = remember { findSomethingForCrash(context) }
|
||||
val readableCrash = remember { crashFile?.extension == "log" }
|
||||
|
||||
val developerSettingsEnabled = remember {
|
||||
PreferenceUtils.get(context).getBoolean(PreferenceUtils.Key.DEVELOPER_MODE)
|
||||
}
|
||||
if (developerSettingsEnabled) {
|
||||
FilledTonalButton(onClick = { onShowLogs(context) }) {
|
||||
if (crashFile != null) {
|
||||
Button(onClick = { onShareCrash(context) }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_share),
|
||||
contentDescription = null
|
||||
)
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(stringResource(R.string.launcher_error_export_crash))
|
||||
}
|
||||
|
||||
if (readableCrash) {
|
||||
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(
|
||||
painterResource(R.drawable.icon_description),
|
||||
contentDescription = null
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.geode.launcher.utils.GeodeUtils
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
|
||||
@Composable
|
||||
@@ -142,7 +143,7 @@ fun onSettings(context: Context) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LongPressButton(onClick: () -> Unit, onLongPress: () -> Unit, enabled: Boolean = true, modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) {
|
||||
fun LongPressButton(onClick: () -> Unit, onLongPress: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, content: @Composable RowScope.() -> Unit) {
|
||||
// compose apis don't provide a good way of adding long press to a button
|
||||
var isLongPress by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -154,11 +155,11 @@ fun LongPressButton(onClick: () -> Unit, onLongPress: () -> Unit, enabled: Boole
|
||||
interactionSource.interactions.collectLatest { interaction ->
|
||||
when (interaction) {
|
||||
is PressInteraction.Press -> {
|
||||
delay(viewConfiguration.longPressTimeoutMillis)
|
||||
delay(viewConfiguration.longPressTimeoutMillis.milliseconds)
|
||||
|
||||
// perform a second delay to make the action more obvious
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
delay(viewConfiguration.longPressTimeoutMillis)
|
||||
delay(viewConfiguration.longPressTimeoutMillis.milliseconds)
|
||||
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
|
||||
@@ -186,6 +187,8 @@ fun LongPressButton(onClick: () -> Unit, onLongPress: () -> Unit, enabled: Boole
|
||||
|
||||
@Composable
|
||||
fun UnsupportedVersionWarning(modifier: Modifier = Modifier) {
|
||||
val gameVersion = GamePackageUtils.getSupportedUnifiedVersion()
|
||||
|
||||
InlineDialog(
|
||||
headline = {
|
||||
Icon(
|
||||
@@ -201,7 +204,7 @@ fun UnsupportedVersionWarning(modifier: Modifier = Modifier) {
|
||||
},
|
||||
body = {
|
||||
Text(
|
||||
stringResource(R.string.launcher_game_update_required),
|
||||
stringResource(R.string.launcher_game_update_required, gameVersion),
|
||||
)
|
||||
},
|
||||
modifier = modifier.padding(8.dp)
|
||||
|
||||
@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.displayCutoutPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
@@ -27,7 +26,6 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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.unit.dp
|
||||
@@ -41,12 +39,14 @@ import com.geode.launcher.utils.GamePackageUtils
|
||||
import com.geode.launcher.utils.GeodeUtils
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
enum class LaunchNotificationType {
|
||||
GEODE_UPDATED, LAUNCHER_UPDATE_AVAILABLE, UPDATE_FAILED, UNSUPPORTED_VERSION;
|
||||
GEODE_UPDATED, LAUNCHER_UPDATE_AVAILABLE, UPDATE_FAILED, UNSUPPORTED_VERSION, FAILED_LOAD;
|
||||
}
|
||||
|
||||
fun determineDisplayedCards(context: Context): List<LaunchNotificationType> {
|
||||
fun determineDisplayedCards(context: Context, geodeFailed: Boolean = false): List<LaunchNotificationType> {
|
||||
val cards = mutableListOf<LaunchNotificationType>()
|
||||
|
||||
if (GamePackageUtils.getGameVersionCode(context.packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN_WARNING) {
|
||||
@@ -54,10 +54,8 @@ fun determineDisplayedCards(context: Context): List<LaunchNotificationType> {
|
||||
}
|
||||
|
||||
val releaseManager = ReleaseManager.get(context)
|
||||
val loadAutomatically = PreferenceUtils.get(context).getBoolean(PreferenceUtils.Key.LOAD_AUTOMATICALLY)
|
||||
|
||||
val availableUpdate = releaseManager.availableLauncherUpdate.value
|
||||
if (loadAutomatically && availableUpdate != null) {
|
||||
val availableUpdate = releaseManager.availableLauncherUpdateTag.value
|
||||
if (availableUpdate != null && !releaseManager.dismissedLauncherUpdate) {
|
||||
cards.add(LaunchNotificationType.LAUNCHER_UPDATE_AVAILABLE)
|
||||
}
|
||||
|
||||
@@ -71,6 +69,10 @@ fun determineDisplayedCards(context: Context): List<LaunchNotificationType> {
|
||||
cards.add(LaunchNotificationType.UPDATE_FAILED)
|
||||
}
|
||||
|
||||
if (geodeFailed) {
|
||||
cards.add(LaunchNotificationType.FAILED_LOAD)
|
||||
}
|
||||
|
||||
return cards
|
||||
}
|
||||
|
||||
@@ -87,7 +89,7 @@ fun NotificationCardFromType(type: LaunchNotificationType) {
|
||||
}
|
||||
|
||||
AnimatedNotificationCard(
|
||||
displayLength = 5000L,
|
||||
displayLength = 5.seconds,
|
||||
onClick = {
|
||||
showInfoDialog = true
|
||||
}
|
||||
@@ -105,21 +107,22 @@ fun NotificationCardFromType(type: LaunchNotificationType) {
|
||||
OutdatedVersionContent()
|
||||
}
|
||||
}
|
||||
LaunchNotificationType.FAILED_LOAD -> {
|
||||
AnimatedNotificationCard {
|
||||
ForwardCompatibilityContent()
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LaunchNotification() {
|
||||
val context = LocalContext.current
|
||||
|
||||
fun LaunchNotification(cards: List<LaunchNotificationType>) {
|
||||
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||
val theme = Theme.fromInt(themeOption)
|
||||
|
||||
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||
|
||||
val cards = determineDisplayedCards(context)
|
||||
|
||||
CompositionLocalProvider(LocalTheme provides theme) {
|
||||
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption) {
|
||||
// surface is not in use, so this is unfortunately not provided
|
||||
@@ -139,15 +142,15 @@ fun LaunchNotification() {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AnimatedNotificationCard(modifier: Modifier = Modifier, visibilityDelay: Long = 0L, displayLength: Long = 3000L, onClick: (() -> Unit)? = null, contents: @Composable () -> Unit) {
|
||||
fun AnimatedNotificationCard(modifier: Modifier = Modifier, visibilityDelay: Duration = Duration.ZERO, displayLength: Duration = 3.seconds, onClick: (() -> Unit)? = null, contents: @Composable () -> Unit) {
|
||||
val state = remember {
|
||||
MutableTransitionState(false).apply {
|
||||
targetState = visibilityDelay <= 0
|
||||
targetState = visibilityDelay <= Duration.ZERO
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(true) {
|
||||
if (visibilityDelay > 0) {
|
||||
if (visibilityDelay > Duration.ZERO) {
|
||||
delay(visibilityDelay)
|
||||
state.targetState = true
|
||||
}
|
||||
@@ -207,6 +210,7 @@ fun LauncherUpdateContent(modifier: Modifier = Modifier) {
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
@Composable
|
||||
fun UpdateNotificationContent(modifier: Modifier = Modifier) {
|
||||
ListItem(
|
||||
@@ -223,6 +227,7 @@ fun UpdateNotificationContent(modifier: Modifier = Modifier) {
|
||||
modifier = modifier.width(IntrinsicSize.Max)
|
||||
)
|
||||
}
|
||||
*/
|
||||
|
||||
@Composable
|
||||
fun OutdatedVersionContent(modifier: Modifier = Modifier) {
|
||||
@@ -257,4 +262,23 @@ fun UpdateFailedContent(modifier: Modifier = Modifier) {
|
||||
},
|
||||
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,6 +7,7 @@ import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.geode.launcher.BuildConfig
|
||||
import com.geode.launcher.updater.ReleaseManager
|
||||
import com.geode.launcher.utils.Constants
|
||||
import com.geode.launcher.utils.GamePackageUtils
|
||||
@@ -65,17 +66,25 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
||||
}
|
||||
|
||||
enum class LaunchCancelReason {
|
||||
MANUAL, AUTOMATIC, LAST_LAUNCH_CRASHED, GEODE_NOT_FOUND, GAME_MISSING, GAME_OUTDATED;
|
||||
MANUAL, AUTOMATIC, LAST_LAUNCH_CRASHED, LAST_LAUNCH_CRASHED_AUTOMATIC,
|
||||
GEODE_NOT_FOUND, GAME_MISSING, GAME_OUTDATED;
|
||||
|
||||
fun allowsRetry() = when (this) {
|
||||
MANUAL,
|
||||
AUTOMATIC,
|
||||
LAST_LAUNCH_CRASHED,
|
||||
LAST_LAUNCH_CRASHED_AUTOMATIC,
|
||||
GEODE_NOT_FOUND -> true
|
||||
GAME_MISSING,
|
||||
GAME_OUTDATED -> false
|
||||
}
|
||||
|
||||
fun isCrash() = when (this) {
|
||||
LAST_LAUNCH_CRASHED,
|
||||
LAST_LAUNCH_CRASHED_AUTOMATIC -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
fun isGameInstallIssue() = when (this) {
|
||||
GAME_OUTDATED,
|
||||
GAME_MISSING -> true
|
||||
@@ -100,23 +109,10 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
private val _uiState: MutableStateFlow<LaunchUIState>
|
||||
|
||||
init {
|
||||
val preferenceUtils = PreferenceUtils.get(application)
|
||||
val initialCancel = !preferenceUtils.getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
|
||||
&& !preferenceUtils.getBoolean(PreferenceUtils.Key.LOAD_AUTOMATICALLY)
|
||||
|
||||
// fixes a single frame of progress being displayed when we're not actually going to do work
|
||||
_uiState = MutableStateFlow<LaunchUIState>(
|
||||
if (initialCancel) LaunchUIState.Cancelled(LaunchCancelReason.AUTOMATIC)
|
||||
else LaunchUIState.Initial
|
||||
)
|
||||
}
|
||||
|
||||
private val _uiState = MutableStateFlow<LaunchUIState>(LaunchUIState.Initial)
|
||||
val uiState = _uiState.asStateFlow()
|
||||
|
||||
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdate
|
||||
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdateTag
|
||||
|
||||
var loadFailure: LoadFailureInfo? = null
|
||||
var launchArguments: LaunchArguments? = null
|
||||
@@ -153,7 +149,7 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
||||
|
||||
fun currentCrashInfo(): LoadFailureInfo? {
|
||||
val currentState = _uiState.value
|
||||
if (currentState is LaunchUIState.Cancelled && currentState.reason == LaunchCancelReason.LAST_LAUNCH_CRASHED) {
|
||||
if (currentState is LaunchUIState.Cancelled && currentState.reason.isCrash()) {
|
||||
return loadFailure
|
||||
}
|
||||
|
||||
@@ -162,12 +158,12 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
||||
|
||||
fun clearCrashInfo() {
|
||||
val currentState = _uiState.value
|
||||
if (currentState is LaunchUIState.Cancelled && currentState.reason == LaunchCancelReason.LAST_LAUNCH_CRASHED) {
|
||||
if (currentState is LaunchUIState.Cancelled && currentState.reason.isCrash()) {
|
||||
loadFailure = null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun preReadyCheck() {
|
||||
private fun preReadyCheck() {
|
||||
if (isCancelling) {
|
||||
// don't let the game start if cancelled
|
||||
return
|
||||
@@ -176,7 +172,8 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
||||
val packageManager = application.packageManager
|
||||
|
||||
if (!GamePackageUtils.isGameInstalled(packageManager)) {
|
||||
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
|
||||
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
|
||||
return
|
||||
}
|
||||
|
||||
if (ReleaseManager.get(application).isInUpdate) {
|
||||
@@ -186,21 +183,32 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
||||
return
|
||||
}
|
||||
|
||||
if (loadFailure != null) {
|
||||
// last launch crashed, so cancel
|
||||
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.LAST_LAUNCH_CRASHED))
|
||||
}
|
||||
|
||||
if (GamePackageUtils.getGameVersionCode(packageManager) < Constants.SUPPORTED_VERSION_CODE_MIN) {
|
||||
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_OUTDATED))
|
||||
// play store build only comes with the one build of geode
|
||||
val minVersionCode = if (BuildConfig.GOOGLE_PLAY_BUILD) Constants.SUPPORTED_VERSION_CODE else Constants.SUPPORTED_VERSION_CODE_MIN
|
||||
if (GamePackageUtils.getGameVersionCode(packageManager) < minVersionCode) {
|
||||
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_OUTDATED))
|
||||
return
|
||||
}
|
||||
|
||||
if (!LaunchUtils.isGeodeInstalled(application)) {
|
||||
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GEODE_NOT_FOUND))
|
||||
_uiState.tryEmit(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
|
||||
}
|
||||
@@ -212,13 +220,14 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
||||
}
|
||||
|
||||
if (!readyTimerPassed && !hasManuallyStarted) {
|
||||
return _uiState.emit(LaunchUIState.Working)
|
||||
_uiState.tryEmit(LaunchUIState.Working)
|
||||
return
|
||||
}
|
||||
|
||||
_uiState.emit(LaunchUIState.Ready)
|
||||
_uiState.tryEmit(LaunchUIState.Ready)
|
||||
}
|
||||
|
||||
suspend fun beginLaunchFlow(isRestart: Boolean = false) {
|
||||
fun beginLaunchFlow(isRestart: Boolean = false) {
|
||||
if (isRestart) {
|
||||
hasManuallyStarted = true
|
||||
}
|
||||
@@ -234,23 +243,39 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
||||
isCancelling = false
|
||||
|
||||
if (!GamePackageUtils.isGameInstalled(application.packageManager)) {
|
||||
return _uiState.emit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
|
||||
_uiState.tryEmit(LaunchUIState.Cancelled(LaunchCancelReason.GAME_MISSING))
|
||||
return
|
||||
}
|
||||
|
||||
if (readyTimer == null) {
|
||||
initReadyTimer()
|
||||
}
|
||||
|
||||
val hasGeode = LaunchUtils.isGeodeInstalled(application)
|
||||
val shouldUpdate = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
|
||||
if ((shouldUpdate && !hasCheckedForUpdates) || !hasGeode) {
|
||||
ReleaseManager.get(application).checkForUpdates(false)
|
||||
if (!BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||
val releaseManager = ReleaseManager.get(application)
|
||||
|
||||
val hasGeode = LaunchUtils.isGeodeInstalled(application)
|
||||
val shouldUpdate = PreferenceUtils.get(application).getBoolean(PreferenceUtils.Key.UPDATE_AUTOMATICALLY)
|
||||
|
||||
if ((shouldUpdate && !hasCheckedForUpdates) || !hasGeode) {
|
||||
releaseManager.checkForUpdates(false)
|
||||
} else {
|
||||
releaseManager.afterUseCachedUpdate()
|
||||
}
|
||||
|
||||
if (isRestart) {
|
||||
releaseManager.dismissedLauncherUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
preReadyCheck()
|
||||
}
|
||||
|
||||
fun retryUpdate() {
|
||||
if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||
return
|
||||
}
|
||||
|
||||
ReleaseManager.get(application).checkForUpdates(true)
|
||||
if (ReleaseManager.get(application).isInUpdate) {
|
||||
restoreUpdateState = _uiState.value
|
||||
@@ -261,7 +286,7 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun cancelLaunch(isAutomatic: Boolean = false) {
|
||||
fun cancelLaunch(isAutomatic: Boolean = false) {
|
||||
// no need to double cancel
|
||||
if (_uiState.value is LaunchUIState.Cancelled) {
|
||||
return
|
||||
@@ -273,11 +298,14 @@ class LaunchViewModel(private val application: Application): ViewModel() {
|
||||
|
||||
val releaseManager = ReleaseManager.get(application)
|
||||
if (releaseManager.isInUpdate) {
|
||||
_uiState.emit(LaunchUIState.Cancelled(reason, true))
|
||||
releaseManager.cancelUpdate()
|
||||
_uiState.tryEmit(LaunchUIState.Cancelled(reason, true))
|
||||
|
||||
viewModelScope.launch {
|
||||
releaseManager.cancelUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
_uiState.emit(LaunchUIState.Cancelled(reason, false))
|
||||
_uiState.tryEmit(LaunchUIState.Cancelled(reason, false))
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
|
||||
@@ -4,22 +4,20 @@ import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -29,7 +27,6 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
@@ -51,10 +48,9 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextLinkStyles
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.geode.launcher.InstallReceiver
|
||||
import com.geode.launcher.R
|
||||
import com.geode.launcher.main.LauncherUpdater.downloadUpdate
|
||||
import com.geode.launcher.ui.theme.Typography
|
||||
import com.geode.launcher.updater.ReleaseManager
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
@@ -63,9 +59,12 @@ import com.mikepenz.markdown.compose.LocalBulletListHandler
|
||||
import com.mikepenz.markdown.m3.Markdown
|
||||
import com.mikepenz.markdown.m3.markdownTypography
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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 java.io.File
|
||||
@@ -101,6 +100,7 @@ fun clearDownloadedApks(context: Context) {
|
||||
object LauncherUpdater {
|
||||
enum class LauncherUpdateState {
|
||||
Began,
|
||||
Downloading,
|
||||
Active,
|
||||
Inactive,
|
||||
Finished;
|
||||
@@ -113,12 +113,16 @@ object LauncherUpdater {
|
||||
_uiState.emit(LauncherUpdateState.Inactive)
|
||||
}
|
||||
|
||||
suspend fun installPackage(context: Context, file: File) {
|
||||
if (!file.exists()) {
|
||||
_uiState.emit(LauncherUpdateState.Finished)
|
||||
return
|
||||
}
|
||||
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) {
|
||||
_uiState.emit(LauncherUpdateState.Began)
|
||||
|
||||
val installer = context.packageManager.packageInstaller
|
||||
@@ -147,7 +151,7 @@ object LauncherUpdater {
|
||||
}
|
||||
})
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
withContext(Dispatchers.IO) { runInterruptible {
|
||||
file.inputStream().use { apkStream ->
|
||||
val length = file.length()
|
||||
|
||||
@@ -155,105 +159,94 @@ object LauncherUpdater {
|
||||
PackageInstaller.SessionParams.MODE_FULL_INSTALL,
|
||||
)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||
}
|
||||
|
||||
val sessionId = installer.createSession(params)
|
||||
val session = installer.openSession(sessionId)
|
||||
installer.openSession(sessionId).use { session ->
|
||||
session.openWrite("package", 0, length).use { sessionStream ->
|
||||
apkStream.copyTo(sessionStream)
|
||||
session.fsync(sessionStream)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
session.close()
|
||||
}
|
||||
}
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
suspend fun installLauncherUpdate(context: Context) {
|
||||
val nextUpdate = ReleaseManager.get(context).availableLauncherUpdate.value
|
||||
val launcherDownload = nextUpdate?.getDownload()
|
||||
|
||||
if (launcherDownload != null) {
|
||||
val outputFile = launcherDownload.filename
|
||||
val baseDirectory = context.filesDir
|
||||
|
||||
val outputPathFile = File(baseDirectory, outputFile)
|
||||
if (!outputPathFile.exists()) {
|
||||
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
|
||||
}
|
||||
|
||||
PreferenceUtils.get(context).setBoolean(PreferenceUtils.Key.CLEANUP_APKS, true)
|
||||
if (!launcherDownload.exists()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
LauncherUpdater.installPackage(context, outputPathFile)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
val msg = context.getString(R.string.launcher_self_update_failed, e.message)
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
PreferenceUtils.get(context).setBoolean(PreferenceUtils.Key.CLEANUP_APKS, true)
|
||||
|
||||
LauncherUpdater.resetState()
|
||||
}
|
||||
} else {
|
||||
Toast.makeText(context, context.getString(R.string.release_fetch_no_releases), Toast.LENGTH_SHORT).show()
|
||||
try {
|
||||
LauncherUpdater.installPackage(context, launcherDownload)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
val msg = context.getString(R.string.launcher_self_update_failed, e.message)
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
|
||||
LauncherUpdater.resetState()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LauncherUpdateDialog(onDismiss: () -> Unit) {
|
||||
fun LauncherUpdateDialog(isCancelling: Boolean, onDismiss: () -> Unit) {
|
||||
val updateState by LauncherUpdater.uiState.collectAsState()
|
||||
|
||||
val cancellable = updateState != LauncherUpdater.LauncherUpdateState.Active
|
||||
|
||||
LaunchedEffect(updateState) {
|
||||
if (updateState == LauncherUpdater.LauncherUpdateState.Finished) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
if (cancellable) {
|
||||
return
|
||||
}
|
||||
|
||||
Dialog(
|
||||
onDismissRequest = {
|
||||
if (cancellable) onDismiss()
|
||||
AlertDialog(
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_update),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||
) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||
modifier = Modifier.padding(24.dp)
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
|
||||
Text(
|
||||
stringResource(R.string.launcher_self_update_progress),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
title = {
|
||||
if (isCancelling) {
|
||||
Text(stringResource(R.string.release_fetch_cancelling))
|
||||
} else if (updateState == LauncherUpdater.LauncherUpdateState.Downloading) {
|
||||
Text(stringResource(R.string.launcher_downloading_update))
|
||||
} else {
|
||||
Text(stringResource(R.string.launcher_self_update_progress))
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
text = {
|
||||
LinearProgressIndicator()
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss, enabled = !isCancelling) {
|
||||
Text(stringResource(R.string.message_box_cancel))
|
||||
}
|
||||
},
|
||||
onDismissRequest = {}
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -261,84 +254,121 @@ fun LauncherUpdateDialog(onDismiss: () -> Unit) {
|
||||
fun LauncherUpdateInformation(onDismiss: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
|
||||
val nextRelease = remember { ReleaseManager.get(context).availableLauncherUpdate.value }
|
||||
val releaseManager = remember { ReleaseManager.get(context) }
|
||||
|
||||
val nextReleaseName by releaseManager.availableLauncherUpdateTag.collectAsState()
|
||||
val nextRelease by releaseManager.availableLauncherUpdateDetails.collectAsState()
|
||||
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var updateJob by remember { mutableStateOf<Job?>(null) }
|
||||
var cancellingUpdate by remember { mutableStateOf(false) }
|
||||
|
||||
var beginUpdate by remember { mutableStateOf(false) }
|
||||
if (beginUpdate) {
|
||||
LauncherUpdateDialog(onDismiss = {
|
||||
beginUpdate = false
|
||||
LauncherUpdateDialog(cancellingUpdate, onDismiss = {
|
||||
cancellingUpdate = true
|
||||
|
||||
coroutineScope.launch {
|
||||
updateJob?.cancelAndJoin()
|
||||
|
||||
beginUpdate = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (nextRelease != null) {
|
||||
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) {
|
||||
Box(Modifier.verticalScroll(rememberScrollState())) {
|
||||
Column(modifier = Modifier.padding(horizontal = 24.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.launcher_update_name, nextRelease.release.tagName),
|
||||
stringResource(R.string.launcher_update_name, nextReleaseNameSaved),
|
||||
style = Typography.headlineSmall,
|
||||
)
|
||||
|
||||
val releasedTime = remember {
|
||||
DateFormat.getDateInstance().format(
|
||||
Date.from(nextRelease.release.publishedAt.toJavaInstant())
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
stringResource(R.string.launcher_update_released, "$releasedTime"),
|
||||
style = Typography.labelLarge
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.padding(vertical = 12.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
installLauncherUpdate(context)
|
||||
beginUpdate = true
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(painterResource(R.drawable.icon_download), contentDescription = null)
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(stringResource(R.string.launcher_install))
|
||||
}
|
||||
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
IconButton(onClick = { uriHandler.openUri(nextRelease.release.htmlUrl) }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_link),
|
||||
stringResource(R.string.launcher_update_external_link)
|
||||
val nextRelease = nextRelease
|
||||
if (nextRelease != null) {
|
||||
val releasedTime = remember {
|
||||
DateFormat.getDateInstance().format(
|
||||
Date.from(nextRelease.release.publishedAt.toJavaInstant())
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (nextRelease.release.body != null) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
Text(
|
||||
stringResource(R.string.launcher_update_changelog),
|
||||
style = Typography.titleLarge
|
||||
stringResource(R.string.launcher_update_released, "$releasedTime"),
|
||||
style = Typography.labelLarge
|
||||
)
|
||||
|
||||
// every new release of the markdown library adds another arg to it
|
||||
CompositionLocalProvider(LocalBulletListHandler provides { _, _, _, _, _ -> "• " }) {
|
||||
Markdown(
|
||||
content = nextRelease.release.body.replace("\r", ""),
|
||||
typography = markdownTypography(
|
||||
textLink = TextLinkStyles(
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
).toSpanStyle()
|
||||
),
|
||||
),
|
||||
flavour = GFMFlavourDescriptor(),
|
||||
Row(modifier = Modifier.padding(vertical = 12.dp)) {
|
||||
Button(
|
||||
onClick = {
|
||||
updateJob = coroutineScope.launch {
|
||||
cancellingUpdate = false
|
||||
beginUpdate = true
|
||||
|
||||
installLauncherUpdate(context)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(painterResource(R.drawable.icon_download), contentDescription = null)
|
||||
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Text(stringResource(R.string.launcher_install))
|
||||
}
|
||||
|
||||
val uriHandler = LocalUriHandler.current
|
||||
|
||||
IconButton(onClick = { uriHandler.openUri(nextRelease.release.htmlUrl) }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_link),
|
||||
stringResource(R.string.launcher_update_external_link)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (nextRelease.release.body != null) {
|
||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||
|
||||
Text(
|
||||
stringResource(R.string.launcher_update_changelog),
|
||||
style = Typography.titleLarge
|
||||
)
|
||||
|
||||
// every new release of the markdown library adds another arg to it
|
||||
CompositionLocalProvider(LocalBulletListHandler provides { _, _, _, _, _ -> "• " }) {
|
||||
Markdown(
|
||||
content = nextRelease.release.body.replace("\r", ""),
|
||||
typography = markdownTypography(
|
||||
textLink = TextLinkStyles(
|
||||
style = MaterialTheme.typography.bodyLarge.copy(
|
||||
textDecoration = TextDecoration.Underline,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
).toSpanStyle()
|
||||
),
|
||||
),
|
||||
flavour = GFMFlavourDescriptor(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp)
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,14 +392,24 @@ fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
var updateJob by remember { mutableStateOf<Job?>(null) }
|
||||
var cancellingUpdate by remember { mutableStateOf(false) }
|
||||
|
||||
var beginUpdate by remember { mutableStateOf(false) }
|
||||
if (beginUpdate) {
|
||||
LauncherUpdateDialog {
|
||||
beginUpdate = false
|
||||
LauncherUpdateDialog(cancellingUpdate) {
|
||||
cancellingUpdate = true
|
||||
coroutineScope.launch {
|
||||
updateJob?.cancelAndJoin()
|
||||
updateJob = null
|
||||
|
||||
beginUpdate = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ElevatedCard(modifier) {
|
||||
// there's like a single pixel of the shadow that gets clipped without this...
|
||||
ElevatedCard(modifier = modifier.padding(bottom = 1.dp)) {
|
||||
Column(
|
||||
// buttons add enough padding already, lower to compensate
|
||||
modifier = Modifier.padding(
|
||||
@@ -394,9 +434,11 @@ fun LauncherUpdateIndicator(modifier: Modifier = Modifier) {
|
||||
|
||||
TextButton(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
installLauncherUpdate(context)
|
||||
updateJob = coroutineScope.launch {
|
||||
cancellingUpdate = false
|
||||
beginUpdate = true
|
||||
|
||||
installLauncherUpdate(context)
|
||||
}
|
||||
},
|
||||
) {
|
||||
|
||||
@@ -129,7 +129,7 @@ fun onSelectIcon(context: Context, selectedIcon: ApplicationIconDetails) {
|
||||
val iconData = IconUtils.getIconDetails(it)
|
||||
|
||||
packageManager.setComponentEnabledSetting(
|
||||
ComponentName(context, "${context.packageName}.${iconData.component}"),
|
||||
ComponentName(context, "${context.packageName.removeSuffix(".play")}.${iconData.component}"),
|
||||
if (iconData.id == selectedIcon.id)
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
else PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
|
||||
@@ -237,7 +237,6 @@ fun ApplicationIconScreen(
|
||||
) {
|
||||
ApplicationIcon.entries.forEach {
|
||||
val iconData = IconUtils.getIconDetails(it)
|
||||
|
||||
IconCard(iconData, selectedIcon == iconData.id)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.geode.launcher.preferences
|
||||
|
||||
import android.content.ClipData
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -9,7 +8,6 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -18,6 +16,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -42,13 +41,8 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -61,6 +55,8 @@ import com.geode.launcher.R
|
||||
import com.geode.launcher.log.LogLine
|
||||
import com.geode.launcher.log.LogPriority
|
||||
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.LocalTheme
|
||||
import com.geode.launcher.ui.theme.Theme
|
||||
@@ -144,46 +140,24 @@ fun SelectLogLevelDialog(
|
||||
|
||||
@Composable
|
||||
fun LogCard(logLine: LogLine, modifier: Modifier = Modifier) {
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val clipboard = LocalClipboard.current
|
||||
val resources = LocalResources.current
|
||||
SelectionContainer {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"[${logLine.priority.toChar()}] ${logLine.formattedTime}",
|
||||
style = Typography.labelSmall,
|
||||
color = getLogColor(logLine.priority),
|
||||
fontFamily = robotoMonoFamily
|
||||
)
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(8.dp)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onLongPress = {
|
||||
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
|
||||
val clipboardLabel = resources.getString(R.string.application_log_copy_label)
|
||||
val clipboardData = logLine.asSimpleString
|
||||
|
||||
coroutineScope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText(
|
||||
clipboardLabel, clipboardData
|
||||
))
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
) {
|
||||
Text(
|
||||
"[${logLine.priority.toChar()}] ${logLine.formattedTime}",
|
||||
style = Typography.labelSmall,
|
||||
color = getLogColor(logLine.priority),
|
||||
fontFamily = robotoMonoFamily
|
||||
)
|
||||
|
||||
Text(
|
||||
"[${logLine.tag}]: ${logLine.messageTrimmed}",
|
||||
fontFamily = robotoMonoFamily,
|
||||
style = Typography.bodyMedium
|
||||
)
|
||||
Text(
|
||||
"[${logLine.tag}]: ${logLine.messageTrimmed}",
|
||||
fontFamily = robotoMonoFamily,
|
||||
style = Typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,46 +12,32 @@ 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.Spacer
|
||||
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.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.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.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.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.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -60,16 +46,15 @@ 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.Typography
|
||||
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.ExperimentalTime
|
||||
import kotlin.time.toJavaInstant
|
||||
|
||||
class CrashLogsActivity : ComponentActivity() {
|
||||
@@ -99,7 +84,7 @@ class CrashLogsActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
fun shareCrash(context: Context, filename: String) {
|
||||
val baseDirectory = LaunchUtils.getBaseDirectory(context)
|
||||
val baseDirectory = LaunchUtils.getBaseDirectory(context, true)
|
||||
val crashPath = File(LaunchUtils.getCrashDirectory(context), filename)
|
||||
|
||||
if (!crashPath.exists()) {
|
||||
@@ -124,14 +109,22 @@ fun shareCrash(context: Context, filename: String) {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@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)
|
||||
) {
|
||||
@@ -169,7 +162,7 @@ fun CrashCard(crashDump: CrashDump, crashViewModel: CrashViewModel, modifier: Mo
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
crashViewModel.removeCrash(crashDump.filename)
|
||||
crashViewModel.removeFile(crashDump.filename)
|
||||
}) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_delete),
|
||||
@@ -180,119 +173,58 @@ fun CrashCard(crashDump: CrashDump, crashViewModel: CrashViewModel, modifier: Mo
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CrashLogsScreen(
|
||||
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||
crashViewModel: CrashViewModel = viewModel(factory = CrashViewModel.Factory)
|
||||
) {
|
||||
val logLines = crashViewModel.lineState
|
||||
val isLoading by crashViewModel.isLoading.collectAsState()
|
||||
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
|
||||
var showMoreOptions by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
Column {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||
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_arrow_back),
|
||||
contentDescription = stringResource(R.string.back_icon_alt)
|
||||
painterResource(R.drawable.icon_remove_selection),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(stringResource(R.string.application_crashes_clear_indicator))
|
||||
}, onClick = {
|
||||
crashViewModel.clearIndicator()
|
||||
onDismiss()
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { showMoreOptions = !showMoreOptions }) {
|
||||
)
|
||||
} else {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_more_vert),
|
||||
contentDescription = stringResource(R.string.application_logs_more)
|
||||
painterResource(R.drawable.icon_skull),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(stringResource(R.string.application_crashes_create_indicator))
|
||||
}, onClick = {
|
||||
crashViewModel.createIndicator()
|
||||
onDismiss()
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showMoreOptions,
|
||||
onDismissRequest = { showMoreOptions = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_delete),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(stringResource(R.string.application_logs_delete))
|
||||
}, onClick = {
|
||||
crashViewModel.clearCrashes()
|
||||
showMoreOptions = false
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
stringResource(R.string.title_activity_crash_logs),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
if (!isLoading && logLines.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.application_crashes_no_dumps),
|
||||
textAlign = TextAlign.Center,
|
||||
style = Typography.bodyLarge,
|
||||
modifier = Modifier.fillMaxSize().padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val itemsSize = logLines.size
|
||||
|
||||
itemsIndexed(
|
||||
logLines,
|
||||
key = { index, line -> line.filename }
|
||||
) { index, line ->
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (index == 0) 16.dp else 4.dp,
|
||||
topEnd = if (index == 0) 16.dp else 4.dp,
|
||||
bottomStart = if (index + 1 == itemsSize) 16.dp else 4.dp,
|
||||
bottomEnd = if (index + 1 == itemsSize) 16.dp else 4.dp
|
||||
)
|
||||
|
||||
CrashCard(
|
||||
line,
|
||||
crashViewModel,
|
||||
modifier = Modifier.clip(shape)
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
) { line, shape ->
|
||||
CrashCard(
|
||||
line,
|
||||
crashViewModel,
|
||||
modifier = Modifier.clip(shape)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,4 +234,4 @@ fun CrashLogsPreview() {
|
||||
GeodeLauncherTheme {
|
||||
CrashLogsScreen(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,15 @@ import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geode.launcher.BuildConfig
|
||||
import com.geode.launcher.R
|
||||
import com.geode.launcher.preferences.components.OptionsButton
|
||||
import com.geode.launcher.preferences.components.OptionsGroup
|
||||
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.LocalTheme
|
||||
import com.geode.launcher.ui.theme.Theme
|
||||
@@ -151,10 +159,14 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
|
||||
title = stringResource(R.string.preference_launch_arguments_name),
|
||||
dialogTitle = stringResource(R.string.preference_launch_arguments_set_title),
|
||||
preferenceKey = PreferenceUtils.Key.LAUNCH_ARGUMENTS,
|
||||
filterInput = { it.filter { c ->
|
||||
// if only there was a better way to define this!
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*(){}<>[]?:;'\"~`-_+=\\| ".contains(c)
|
||||
}}
|
||||
filterInput = {
|
||||
it.filter { c ->
|
||||
// if only there was a better way to define this!
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890!@#$%^&*(){}<>[]?:;'\"~`-_+=\\| ".contains(
|
||||
c
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/*
|
||||
@@ -179,7 +191,7 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
|
||||
title = stringResource(R.string.preferences_view_logs),
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_description),
|
||||
painterResource(R.drawable.icon_mobile_code),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
@@ -193,17 +205,30 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
|
||||
)
|
||||
}
|
||||
|
||||
OptionsGroup(title = stringResource(R.string.preference_category_updater)) {
|
||||
SettingsSelectCard(
|
||||
title = stringResource(R.string.preference_release_channel_tag_name),
|
||||
dialogTitle = stringResource(R.string.preference_release_channel_select),
|
||||
preferenceKey = PreferenceUtils.Key.RELEASE_CHANNEL_TAG,
|
||||
options = linkedMapOf(
|
||||
0 to stringResource(R.string.preference_release_channel_stable),
|
||||
1 to stringResource(R.string.preference_release_channel_beta),
|
||||
2 to stringResource(R.string.preference_release_channel_nightly),
|
||||
if (!BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||
OptionsGroup(title = stringResource(R.string.preference_category_updater)) {
|
||||
val context = LocalContext.current
|
||||
|
||||
SettingsSelectCard(
|
||||
title = stringResource(R.string.preference_release_channel_tag_name),
|
||||
dialogTitle = stringResource(R.string.preference_release_channel_select),
|
||||
preferenceKey = PreferenceUtils.Key.RELEASE_CHANNEL_TAG,
|
||||
options = linkedMapOf(
|
||||
0 to stringResource(R.string.preference_release_channel_stable),
|
||||
1 to stringResource(R.string.preference_release_channel_beta),
|
||||
2 to stringResource(R.string.preference_release_channel_nightly),
|
||||
),
|
||||
extraSelectBehavior = {
|
||||
PreferenceUtils.get(context)
|
||||
.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, 0L)
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
SettingsCard(
|
||||
title = stringResource(R.string.preference_disable_update_cache),
|
||||
preferenceKey = PreferenceUtils.Key.DISABLE_UPDATE_CACHE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
OptionsGroup(title = stringResource(R.string.preference_category_testing)) {
|
||||
@@ -213,9 +238,21 @@ fun DeveloperSettingsScreen(onBackPressedDispatcher: OnBackPressedDispatcher?) {
|
||||
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),
|
||||
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
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package com.geode.launcher.preferences
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.provider.DocumentsContract
|
||||
import android.text.format.Formatter
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedDispatcher
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.geode.launcher.R
|
||||
import com.geode.launcher.UserDirectoryProvider
|
||||
import com.geode.launcher.log.GeodeLog
|
||||
import com.geode.launcher.log.GeodeLogsViewModel
|
||||
import com.geode.launcher.preferences.components.DirectoryListingScreen
|
||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||
import com.geode.launcher.ui.theme.LocalTheme
|
||||
import com.geode.launcher.ui.theme.Theme
|
||||
import com.geode.launcher.ui.theme.robotoMonoFamily
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import java.io.File
|
||||
|
||||
class GeodeLogsListingActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
val themeOption by PreferenceUtils.useIntPreference(PreferenceUtils.Key.THEME)
|
||||
val theme = Theme.fromInt(themeOption)
|
||||
|
||||
val backgroundOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.BLACK_BACKGROUND)
|
||||
val dynamicColorOption by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DISABLE_USER_THEME)
|
||||
|
||||
CompositionLocalProvider(LocalTheme provides theme) {
|
||||
GeodeLauncherTheme(theme = theme, blackBackground = backgroundOption, dynamicColor = !dynamicColorOption) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
GeodeLogsListingScreen(onBackPressedDispatcher)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun shareLog(context: Context, filename: String) {
|
||||
val baseDirectory = LaunchUtils.getBaseDirectory(context, 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.geode.launcher.preferences
|
||||
|
||||
import android.app.UiModeManager
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ClipData
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
@@ -19,10 +20,31 @@ import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
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.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -31,9 +53,12 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -45,24 +70,39 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.geode.launcher.BuildConfig
|
||||
import com.geode.launcher.MainActivity
|
||||
import com.geode.launcher.R
|
||||
import com.geode.launcher.UserDirectoryProvider
|
||||
import com.geode.launcher.updater.ReleaseViewModel
|
||||
import com.geode.launcher.preferences.components.BackupButton
|
||||
import com.geode.launcher.preferences.components.OptionsButton
|
||||
import com.geode.launcher.preferences.components.OptionsCard
|
||||
import com.geode.launcher.preferences.components.OptionsGroup
|
||||
import com.geode.launcher.preferences.components.OptionsLabel
|
||||
import com.geode.launcher.preferences.components.OptionsTitle
|
||||
import com.geode.launcher.preferences.components.SafeModeDialog
|
||||
import com.geode.launcher.preferences.components.SettingsCard
|
||||
import com.geode.launcher.preferences.components.SettingsFPSCard
|
||||
import com.geode.launcher.preferences.components.SettingsRangeCard
|
||||
import com.geode.launcher.preferences.components.SettingsSelectCard
|
||||
import com.geode.launcher.preferences.components.SettingsStringSelectCard
|
||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||
import com.geode.launcher.ui.theme.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.updater.ReleaseViewModel
|
||||
import com.geode.launcher.utils.ApplicationIcon
|
||||
import com.geode.launcher.utils.Constants
|
||||
import com.geode.launcher.utils.GamePackageUtils
|
||||
import com.geode.launcher.utils.IconUtils
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.ConnectException
|
||||
import java.net.UnknownHostException
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.time.Clock
|
||||
|
||||
|
||||
class SettingsActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
@@ -119,6 +159,53 @@ fun onOpenFileManager(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UpdateErrorDialog(
|
||||
error: Exception,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val clipboard = LocalClipboard.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val clipboardLabel = stringResource(R.string.application_log_copy_label)
|
||||
|
||||
AlertDialog(
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_warning),
|
||||
contentDescription = null,
|
||||
)
|
||||
},
|
||||
title = { Text(stringResource(R.string.preference_check_for_updates_help_title)) },
|
||||
text = {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
error.stackTraceToString(),
|
||||
fontFamily = robotoMonoFamily,
|
||||
style = Typography.bodyMedium,
|
||||
modifier = Modifier.verticalScroll(rememberScrollState())
|
||||
)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = {
|
||||
coroutineScope.launch {
|
||||
clipboard.setClipEntry(
|
||||
ClipEntry(ClipData.newPlainText(clipboardLabel, error.stackTraceToString()))
|
||||
)
|
||||
}
|
||||
}) {
|
||||
Text(stringResource(R.string.preference_check_for_updates_help_copy))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.message_box_accept))
|
||||
}
|
||||
},
|
||||
onDismissRequest = onDismiss
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UpdateIndicator(
|
||||
snackbarHostState: SnackbarHostState,
|
||||
@@ -128,6 +215,17 @@ fun UpdateIndicator(
|
||||
val resources = LocalResources.current
|
||||
|
||||
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) {
|
||||
is ReleaseViewModel.ReleaseUIState.InUpdateCheck -> {
|
||||
@@ -165,7 +263,12 @@ fun UpdateIndicator(
|
||||
else -> resources.getString(R.string.preference_check_for_updates_failed)
|
||||
}
|
||||
|
||||
snackbarHostState.showSnackbar(message)
|
||||
currentError = updateStatus.exception
|
||||
|
||||
val snackbarResult = snackbarHostState.showSnackbar(message, resources.getString(R.string.preference_check_for_updates_failed_action))
|
||||
if (snackbarResult == SnackbarResult.ActionPerformed) {
|
||||
enableHelp = true
|
||||
}
|
||||
}
|
||||
is ReleaseViewModel.ReleaseUIState.Finished -> {
|
||||
if (updateStatus.hasUpdated) {
|
||||
@@ -374,11 +477,42 @@ fun GameplaySettingsGroup() {
|
||||
preferenceKey = PreferenceUtils.Key.LIMIT_FRAME_RATE,
|
||||
maxFrameRate = maxFrameRate
|
||||
)
|
||||
|
||||
var showSafeModeDialog by remember { mutableStateOf(false) }
|
||||
|
||||
OptionsButton(
|
||||
title = stringResource(R.string.preference_launch_safe_mode),
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_shield),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
) {
|
||||
showSafeModeDialog = true
|
||||
}
|
||||
|
||||
if (showSafeModeDialog) {
|
||||
SafeModeDialog(onDismiss = {
|
||||
showSafeModeDialog = false
|
||||
}) {
|
||||
val launchIntent = 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)) {
|
||||
@@ -412,7 +546,10 @@ fun UpdateSettingsGroup(releaseViewModel: ReleaseViewModel, snackbarHostState: S
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
description = stringResource(R.string.preference_check_for_updates_description, checkAgoString)
|
||||
description = stringResource(
|
||||
R.string.preference_check_for_updates_description,
|
||||
checkAgoString
|
||||
)
|
||||
.takeIf { lastUpdateCheck != 0L }
|
||||
)
|
||||
},
|
||||
@@ -430,7 +567,7 @@ fun UpdateSettingsGroup(releaseViewModel: ReleaseViewModel, snackbarHostState: S
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AdvancedSettingsGroup(developerModeEnabled: Boolean) {
|
||||
fun AdvancedSettingsGroup(developerModeEnabled: Boolean, snackbarHostState: SnackbarHostState) {
|
||||
val context = LocalContext.current
|
||||
|
||||
OptionsGroup(stringResource(R.string.preference_category_developer)) {
|
||||
@@ -447,6 +584,20 @@ fun AdvancedSettingsGroup(developerModeEnabled: Boolean) {
|
||||
)
|
||||
}
|
||||
|
||||
OptionsButton(
|
||||
title = stringResource(R.string.preferences_view_geode_logs),
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_description),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
val launchIntent = Intent(context, GeodeLogsListingActivity::class.java)
|
||||
context.startActivity(launchIntent)
|
||||
}
|
||||
)
|
||||
|
||||
OptionsButton(
|
||||
title = stringResource(R.string.preferences_view_crashes),
|
||||
icon = {
|
||||
@@ -461,32 +612,7 @@ fun AdvancedSettingsGroup(developerModeEnabled: Boolean) {
|
||||
}
|
||||
)
|
||||
|
||||
var showSafeModeDialog by remember { mutableStateOf(false) }
|
||||
|
||||
OptionsButton(
|
||||
title = stringResource(R.string.preference_launch_safe_mode),
|
||||
icon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_shield),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
) {
|
||||
showSafeModeDialog = true
|
||||
}
|
||||
|
||||
if (showSafeModeDialog) {
|
||||
SafeModeDialog(onDismiss = {
|
||||
showSafeModeDialog = false
|
||||
}) {
|
||||
val launchIntent = Intent(context, MainActivity::class.java).apply {
|
||||
// hardcode it, why not
|
||||
action = Intent.ACTION_VIEW
|
||||
data = "geode-launcher://main/launch?safe-mode=true".toUri()
|
||||
}
|
||||
context.startActivity(launchIntent)
|
||||
}
|
||||
}
|
||||
BackupButton(snackbarHostState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -502,14 +628,21 @@ fun AboutSettingsGroup(developerModeEnabled: Boolean) {
|
||||
|
||||
OptionsButton(
|
||||
stringResource(R.string.preference_launcher_version_name),
|
||||
stringResource(R.string.preference_launcher_version_description, BuildConfig.VERSION_NAME),
|
||||
stringResource(
|
||||
R.string.preference_launcher_version_description,
|
||||
BuildConfig.VERSION_NAME
|
||||
),
|
||||
displayInline = true
|
||||
) {
|
||||
launcherButtonTapped += 1
|
||||
|
||||
if (launcherButtonTapped >= 7) {
|
||||
if (developerModeEnabled) {
|
||||
Toast.makeText(context, R.string.preference_enable_developer_again, Toast.LENGTH_SHORT)
|
||||
Toast.makeText(
|
||||
context,
|
||||
R.string.preference_enable_developer_again,
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
} else {
|
||||
showDeveloperDialog = true
|
||||
@@ -524,15 +657,22 @@ fun AboutSettingsGroup(developerModeEnabled: Boolean) {
|
||||
showDeveloperDialog = false
|
||||
},
|
||||
onEnable = {
|
||||
PreferenceUtils.get(context).setBoolean(PreferenceUtils.Key.DEVELOPER_MODE, true)
|
||||
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_description, currentRelease ?: "unknown"),
|
||||
stringResource(
|
||||
R.string.preference_loader_version_description,
|
||||
if (BuildConfig.PREBUNDLED_GEODE != null)
|
||||
"${BuildConfig.PREBUNDLED_GEODE}*"
|
||||
else (currentRelease ?: "unknown")
|
||||
),
|
||||
displayInline = true
|
||||
)
|
||||
|
||||
@@ -546,22 +686,32 @@ fun AboutSettingsGroup(developerModeEnabled: Boolean) {
|
||||
}
|
||||
|
||||
val gameSource = remember {
|
||||
resources.getString(when (GamePackageUtils.identifyGameSource(context.packageManager)) {
|
||||
GamePackageUtils.GameSource.GOOGLE -> R.string.preference_game_source_google
|
||||
GamePackageUtils.GameSource.AMAZON -> R.string.preference_game_source_amazon
|
||||
else -> R.string.preference_game_source_unknown
|
||||
})
|
||||
resources.getString(
|
||||
when (GamePackageUtils.identifyGameSource(context.packageManager)) {
|
||||
GamePackageUtils.GameSource.GOOGLE -> R.string.preference_game_source_google
|
||||
GamePackageUtils.GameSource.AMAZON -> R.string.preference_game_source_amazon
|
||||
else -> R.string.preference_game_source_unknown
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
OptionsLabel(
|
||||
stringResource(R.string.preference_game_version_name),
|
||||
stringResource(R.string.preference_game_version_description, gameVersion, gameSource),
|
||||
stringResource(
|
||||
R.string.preference_game_version_description,
|
||||
gameVersion,
|
||||
gameSource
|
||||
),
|
||||
displayInline = true
|
||||
)
|
||||
|
||||
OptionsLabel(
|
||||
stringResource(R.string.preference_architecture_name),
|
||||
stringResource(R.string.preference_architecture_description, Build.MODEL, LaunchUtils.applicationArchitecture),
|
||||
stringResource(
|
||||
R.string.preference_architecture_description,
|
||||
Build.MODEL,
|
||||
LaunchUtils.applicationArchitecture
|
||||
),
|
||||
displayInline = true
|
||||
)
|
||||
}
|
||||
@@ -644,7 +794,7 @@ fun SettingsScreen(
|
||||
|
||||
val developerModeEnabled by PreferenceUtils.useBooleanPreference(PreferenceUtils.Key.DEVELOPER_MODE)
|
||||
|
||||
AdvancedSettingsGroup(developerModeEnabled)
|
||||
AdvancedSettingsGroup(developerModeEnabled, snackbarHostState)
|
||||
AboutSettingsGroup(developerModeEnabled)
|
||||
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package com.geode.launcher.preferences.components
|
||||
|
||||
import androidx.activity.OnBackPressedDispatcher
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.geode.launcher.R
|
||||
import com.geode.launcher.log.BaseDirectoryViewModel
|
||||
import com.geode.launcher.ui.theme.Typography
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CrashLogsTopBar(
|
||||
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||
directoryViewModel: BaseDirectoryViewModel<*>,
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
@StringRes titleId: Int,
|
||||
additionalOptions: (@Composable (() -> Unit) -> Unit)? = null,
|
||||
) {
|
||||
val isLoading by directoryViewModel.isLoading.collectAsState()
|
||||
var showMoreOptions by remember { mutableStateOf(false) }
|
||||
|
||||
Column {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { onBackPressedDispatcher?.onBackPressed() }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_arrow_back),
|
||||
contentDescription = stringResource(R.string.back_icon_alt)
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(onClick = { showMoreOptions = !showMoreOptions }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_more_vert),
|
||||
contentDescription = stringResource(R.string.application_logs_more)
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = showMoreOptions,
|
||||
onDismissRequest = { showMoreOptions = false }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_delete),
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(stringResource(R.string.application_logs_delete))
|
||||
}, onClick = {
|
||||
directoryViewModel.clearAllFiles()
|
||||
showMoreOptions = false
|
||||
}
|
||||
)
|
||||
|
||||
additionalOptions?.invoke {
|
||||
showMoreOptions = false
|
||||
}
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
stringResource(titleId),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> CrashLogsListing(
|
||||
modifier: Modifier = Modifier,
|
||||
directoryViewModel: BaseDirectoryViewModel<T>,
|
||||
@StringRes noneAvailableId: Int,
|
||||
logCard: @Composable (T, Shape) -> Unit,
|
||||
) {
|
||||
val logLines = directoryViewModel.lineState
|
||||
val isLoading by directoryViewModel.isLoading.collectAsState()
|
||||
|
||||
LazyColumn(
|
||||
modifier,
|
||||
verticalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
if (!isLoading && logLines.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(noneAvailableId),
|
||||
textAlign = TextAlign.Center,
|
||||
style = Typography.bodyLarge,
|
||||
modifier = Modifier.fillMaxSize().padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val itemsSize = logLines.size
|
||||
|
||||
itemsIndexed(
|
||||
logLines,
|
||||
key = { index, line -> directoryViewModel.getItemKey(line) }
|
||||
) { index, line ->
|
||||
val shape = RoundedCornerShape(
|
||||
topStart = if (index == 0) 16.dp else 4.dp,
|
||||
topEnd = if (index == 0) 16.dp else 4.dp,
|
||||
bottomStart = if (index + 1 == itemsSize) 16.dp else 4.dp,
|
||||
bottomEnd = if (index + 1 == itemsSize) 16.dp else 4.dp
|
||||
)
|
||||
|
||||
logCard(line, shape)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun <T> DirectoryListingScreen(
|
||||
onBackPressedDispatcher: OnBackPressedDispatcher?,
|
||||
directoryViewModel: BaseDirectoryViewModel<T>,
|
||||
@StringRes titleId: Int,
|
||||
@StringRes noneAvailableId: Int,
|
||||
additionalOptions: (@Composable (() -> Unit) -> Unit)? = null,
|
||||
logCard: @Composable (T, Shape) -> Unit,
|
||||
) {
|
||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
topBar = {
|
||||
CrashLogsTopBar(
|
||||
onBackPressedDispatcher,
|
||||
directoryViewModel,
|
||||
scrollBehavior,
|
||||
titleId,
|
||||
additionalOptions = additionalOptions
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
CrashLogsListing(
|
||||
modifier = Modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxWidth(),
|
||||
directoryViewModel,
|
||||
noneAvailableId = noneAvailableId,
|
||||
logCard = logCard
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+3
-456
@@ -1,4 +1,4 @@
|
||||
package com.geode.launcher.preferences
|
||||
package com.geode.launcher.preferences.components
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -8,41 +8,28 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.defaultMinSize
|
||||
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.selection.toggleable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.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.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
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.draw.clip
|
||||
@@ -51,21 +38,17 @@ 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.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.geode.launcher.R
|
||||
import com.geode.launcher.ui.theme.GeodeLauncherTheme
|
||||
import com.geode.launcher.ui.theme.Typography
|
||||
import com.geode.launcher.utils.LabelledText
|
||||
import com.geode.launcher.utils.PreferenceUtils
|
||||
import com.geode.launcher.utils.Profile
|
||||
import com.geode.launcher.utils.ProfileManager
|
||||
import kotlin.collections.get
|
||||
import kotlin.math.log10
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
@@ -237,244 +220,6 @@ fun SettingsStringCard(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StringDialog(
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
onSelect: (String) -> Unit,
|
||||
initialValue: String,
|
||||
filterInput: ((String) -> String)? = null
|
||||
) {
|
||||
var enteredValue by remember { mutableStateOf(initialValue) }
|
||||
|
||||
BaseDialog(
|
||||
onDismissRequest = { onDismissRequest() },
|
||||
title = title,
|
||||
content = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = enteredValue,
|
||||
onValueChange = {
|
||||
enteredValue = filterInput?.invoke(it) ?: it
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
autoCorrectEnabled = false
|
||||
),
|
||||
trailingIcon = {
|
||||
if (enteredValue.isNotEmpty()) {
|
||||
IconButton(onClick = { enteredValue = "" }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_close_small),
|
||||
contentDescription = stringResource(R.string.preference_text_clear)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onSelect(enteredValue) }) {
|
||||
Text(stringResource(R.string.message_box_accept))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.message_box_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RangeDialog(
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
onSelect: (Int) -> Unit,
|
||||
labelSuffix: String,
|
||||
initialValue: Int,
|
||||
range: IntRange,
|
||||
scale: Int,
|
||||
step: Int,
|
||||
children: @Composable () -> Unit
|
||||
) {
|
||||
var enteredValue by remember {
|
||||
mutableFloatStateOf(initialValue / scale.toFloat())
|
||||
}
|
||||
|
||||
BaseDialog(
|
||||
onDismissRequest = { onDismissRequest() },
|
||||
title = title,
|
||||
content = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
) {
|
||||
val precision = log10(scale.toFloat()).roundToInt()
|
||||
|
||||
Slider(
|
||||
value = enteredValue,
|
||||
onValueChange = { enteredValue = it },
|
||||
valueRange = (range.first.toFloat()/scale)..(range.last.toFloat()/scale),
|
||||
steps = ((range.last - range.first) - 1) / step,
|
||||
modifier = Modifier.weight(1.0f)
|
||||
)
|
||||
|
||||
Text("%.${precision}f$labelSuffix".format(enteredValue))
|
||||
}
|
||||
|
||||
children()
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onSelect((enteredValue * scale).roundToInt()) }) {
|
||||
Text(stringResource(R.string.message_box_accept))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.message_box_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FrameRateDialog(
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
onSelect: (Int) -> Unit,
|
||||
initialValue: Int,
|
||||
maxFrameRate: Int,
|
||||
) {
|
||||
var enteredValue by remember {
|
||||
mutableStateOf(
|
||||
if (initialValue == 0) maxFrameRate.toString()
|
||||
else initialValue.toString()
|
||||
)
|
||||
}
|
||||
|
||||
val minFrameRate = 5
|
||||
val currentValue = enteredValue.toIntOrNull()
|
||||
val maximumReached = currentValue != null && currentValue > maxFrameRate
|
||||
val minimumReached = currentValue != null && currentValue < minFrameRate
|
||||
|
||||
BaseDialog(
|
||||
onDismissRequest = { onDismissRequest() },
|
||||
title = title,
|
||||
content = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
val prevValue = max((currentValue ?: 0) - 5, minFrameRate)
|
||||
enteredValue = prevValue.toString()
|
||||
}, enabled = currentValue == null || currentValue > minFrameRate) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_remove),
|
||||
contentDescription = stringResource(R.string.preference_limit_framerate_subtract)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = enteredValue,
|
||||
onValueChange = {
|
||||
enteredValue = it
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
),
|
||||
suffix = {
|
||||
Text(stringResource(R.string.preference_limit_framerate_suffix))
|
||||
},
|
||||
label = {
|
||||
Text(stringResource(R.string.preference_limit_framerate_label))
|
||||
},
|
||||
isError = minimumReached || maximumReached,
|
||||
modifier = Modifier.weight(1.0f),
|
||||
)
|
||||
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
IconButton(onClick = {
|
||||
val nextValue = min((currentValue ?: 0) + 5, maxFrameRate)
|
||||
enteredValue = nextValue.toString()
|
||||
}, enabled = currentValue == null || currentValue < maxFrameRate) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_add),
|
||||
contentDescription = stringResource(R.string.preference_limit_framerate_add)
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = { onSelect(0) }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_delete),
|
||||
contentDescription = stringResource(R.string.preference_limit_framerate_reset)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (minimumReached) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
Text(
|
||||
stringResource(R.string.preference_limit_framerate_error_min, minFrameRate),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
} else if (maximumReached) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.preference_limit_framerate_error_max,
|
||||
maxFrameRate
|
||||
),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
// automatically disable if already max
|
||||
if (currentValue == maxFrameRate) {
|
||||
onSelect(0)
|
||||
} else {
|
||||
onSelect(currentValue ?: 0)
|
||||
}
|
||||
},
|
||||
enabled = !minimumReached && !maximumReached && currentValue != null
|
||||
) {
|
||||
Text(stringResource(R.string.message_box_accept))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.message_box_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsFPSCard(
|
||||
title: String,
|
||||
@@ -570,120 +315,6 @@ fun SettingsRangeCard(
|
||||
}
|
||||
}
|
||||
|
||||
internal val LocalSelectValue = compositionLocalOf<Any> { 0 }
|
||||
internal val LocalSelectSetValue = staticCompositionLocalOf<(Any) -> Unit> { {} }
|
||||
|
||||
@Composable
|
||||
fun <T> SelectOption(name: String, value: T, enabled: Boolean = true, leadingContent: @Composable (Boolean) -> Unit = {}) {
|
||||
val currentValue = LocalSelectValue.current
|
||||
val setValue = LocalSelectSetValue.current
|
||||
|
||||
val isSelected = currentValue.equals(value)
|
||||
|
||||
// do not give the row or column padding!! it messes up the selection effect
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
onClick = { setValue(value as Any) },
|
||||
role = Role.RadioButton,
|
||||
enabled = enabled
|
||||
)
|
||||
.padding(horizontal = 12.dp)
|
||||
) {
|
||||
Row(modifier = Modifier.weight(1.0f, true), verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(
|
||||
selected = isSelected,
|
||||
onClick = { setValue(value as Any) },
|
||||
enabled = enabled
|
||||
)
|
||||
Text(name, style = Typography.bodyMedium)
|
||||
}
|
||||
|
||||
leadingContent(isSelected)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileCreateDialog(onDismissRequest: () -> Unit) {
|
||||
var enteredValue by remember { mutableStateOf("") }
|
||||
|
||||
val filename = enteredValue.take(16)
|
||||
.lowercase()
|
||||
.map {
|
||||
if ("qwertyuiopasdfghjklzxcvbnm1234567890-_.".contains(it))
|
||||
it else '_'
|
||||
}
|
||||
.joinToString("")
|
||||
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val minimumReached = enteredValue.isEmpty() || filename.isEmpty()
|
||||
|
||||
val profileManager = ProfileManager.get(context)
|
||||
val currentProfiles = remember {
|
||||
profileManager.getProfiles().map { it.path }.toSet()
|
||||
}
|
||||
|
||||
val isDuplicate = currentProfiles.contains(filename)
|
||||
|
||||
BaseDialog(
|
||||
onDismissRequest = { onDismissRequest() },
|
||||
title = stringResource(R.string.preference_profiles_create),
|
||||
content = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = enteredValue,
|
||||
onValueChange = {
|
||||
enteredValue = it
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
),
|
||||
label = {
|
||||
Text(stringResource(R.string.preference_profiles_create_name))
|
||||
},
|
||||
isError = isDuplicate,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
|
||||
if (isDuplicate) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
Text(stringResource(R.string.preference_profiles_create_duplicate))
|
||||
} else if (!minimumReached) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
Text(stringResource(R.string.preference_profiles_create_info, filename))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
ProfileManager.get(context).storeProfile(Profile(filename, enteredValue))
|
||||
onDismissRequest()
|
||||
},
|
||||
enabled = !minimumReached && !isDuplicate
|
||||
) {
|
||||
Text(stringResource(R.string.preference_profiles_create_action))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.message_box_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ProfileCreateCard() {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
@@ -713,7 +344,7 @@ fun ProfileSelectCard() {
|
||||
profileManager.getCurrentProfile()
|
||||
}
|
||||
|
||||
var currentProfileName = remember {
|
||||
val currentProfileName = remember {
|
||||
val savedProfile = currentProfileId
|
||||
if (savedProfile == null) {
|
||||
"Default"
|
||||
@@ -803,90 +434,6 @@ fun ProfileSelectCard() {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BaseDialog(
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
dismissButton: @Composable () -> Unit,
|
||||
confirmButton: @Composable () -> Unit,
|
||||
content: @Composable (ColumnScope.() -> Unit),
|
||||
) {
|
||||
Dialog(onDismissRequest = onDismissRequest) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
)
|
||||
) {
|
||||
// styling a dialog is actually a little hard if you're doing what i'm doing
|
||||
// maybe there's a better way to make these padding values...
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(
|
||||
title,
|
||||
style = Typography.titleLarge,
|
||||
modifier = Modifier.padding(
|
||||
start = 28.dp,
|
||||
top = 24.dp,
|
||||
bottom = 12.dp
|
||||
)
|
||||
)
|
||||
|
||||
content()
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
bottom = 16.dp,
|
||||
end = 16.dp,
|
||||
top = 4.dp
|
||||
)
|
||||
) {
|
||||
dismissButton()
|
||||
confirmButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> SelectDialog(
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
onSelect: (T) -> Unit,
|
||||
initialValue: T,
|
||||
options: @Composable () -> Unit,
|
||||
) {
|
||||
val (selectedValue, setSelectedValue) = remember { mutableStateOf(initialValue) }
|
||||
|
||||
BaseDialog(
|
||||
title = title,
|
||||
onDismissRequest = onDismissRequest,
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.message_box_cancel))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onSelect(selectedValue) }) {
|
||||
Text(stringResource(R.string.message_box_accept))
|
||||
}
|
||||
}
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalSelectValue provides (selectedValue as Any),
|
||||
LocalSelectSetValue provides ({
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
setSelectedValue(it as T)
|
||||
})
|
||||
) {
|
||||
options()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OptionsButton(title: String, description: String? = null, icon: (@Composable () -> Unit)? = null, displayInline: Boolean = false, onClick: () -> Unit) {
|
||||
OptionsCard(
|
||||
@@ -0,0 +1,490 @@
|
||||
package com.geode.launcher.preferences.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.geode.launcher.R
|
||||
import com.geode.launcher.ui.theme.Typography
|
||||
import com.geode.launcher.utils.Profile
|
||||
import com.geode.launcher.utils.ProfileManager
|
||||
import kotlin.math.log10
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
||||
@Composable
|
||||
fun StringDialog(
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
onSelect: (String) -> Unit,
|
||||
initialValue: String,
|
||||
filterInput: ((String) -> String)? = null
|
||||
) {
|
||||
var enteredValue by remember { mutableStateOf(initialValue) }
|
||||
|
||||
BaseDialog(
|
||||
onDismissRequest = { onDismissRequest() },
|
||||
title = title,
|
||||
content = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = enteredValue,
|
||||
onValueChange = {
|
||||
enteredValue = filterInput?.invoke(it) ?: it
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Ascii,
|
||||
autoCorrectEnabled = false
|
||||
),
|
||||
trailingIcon = {
|
||||
if (enteredValue.isNotEmpty()) {
|
||||
IconButton(onClick = { enteredValue = "" }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_close_small),
|
||||
contentDescription = stringResource(R.string.preference_text_clear)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onSelect(enteredValue) }) {
|
||||
Text(stringResource(R.string.message_box_accept))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.message_box_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RangeDialog(
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
onSelect: (Int) -> Unit,
|
||||
labelSuffix: String,
|
||||
initialValue: Int,
|
||||
range: IntRange,
|
||||
scale: Int,
|
||||
step: Int,
|
||||
children: @Composable () -> Unit
|
||||
) {
|
||||
var enteredValue by remember {
|
||||
mutableFloatStateOf(initialValue / scale.toFloat())
|
||||
}
|
||||
|
||||
BaseDialog(
|
||||
onDismissRequest = { onDismissRequest() },
|
||||
title = title,
|
||||
content = {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
) {
|
||||
val precision = log10(scale.toFloat()).roundToInt()
|
||||
|
||||
Slider(
|
||||
value = enteredValue,
|
||||
onValueChange = { enteredValue = it },
|
||||
valueRange = (range.first.toFloat()/scale)..(range.last.toFloat()/scale),
|
||||
steps = ((range.last - range.first) - 1) / step,
|
||||
modifier = Modifier.weight(1.0f)
|
||||
)
|
||||
|
||||
Text("%.${precision}f$labelSuffix".format(enteredValue))
|
||||
}
|
||||
|
||||
children()
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onSelect((enteredValue * scale).roundToInt()) }) {
|
||||
Text(stringResource(R.string.message_box_accept))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.message_box_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FrameRateDialog(
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
onSelect: (Int) -> Unit,
|
||||
initialValue: Int,
|
||||
maxFrameRate: Int,
|
||||
) {
|
||||
var enteredValue by remember {
|
||||
mutableStateOf(
|
||||
if (initialValue == 0) maxFrameRate.toString()
|
||||
else initialValue.toString()
|
||||
)
|
||||
}
|
||||
|
||||
val minFrameRate = 5
|
||||
val currentValue = enteredValue.toIntOrNull()
|
||||
val maximumReached = currentValue != null && currentValue > maxFrameRate
|
||||
val minimumReached = currentValue != null && currentValue < minFrameRate
|
||||
|
||||
BaseDialog(
|
||||
onDismissRequest = { onDismissRequest() },
|
||||
title = title,
|
||||
content = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
val prevValue = max((currentValue ?: 0) - 5, minFrameRate)
|
||||
enteredValue = prevValue.toString()
|
||||
}, enabled = currentValue == null || currentValue > minFrameRate) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_remove),
|
||||
contentDescription = stringResource(R.string.preference_limit_framerate_subtract)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = enteredValue,
|
||||
onValueChange = {
|
||||
enteredValue = it
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Number,
|
||||
),
|
||||
suffix = {
|
||||
Text(stringResource(R.string.preference_limit_framerate_suffix))
|
||||
},
|
||||
label = {
|
||||
Text(stringResource(R.string.preference_limit_framerate_label))
|
||||
},
|
||||
isError = minimumReached || maximumReached,
|
||||
modifier = Modifier.weight(1.0f),
|
||||
)
|
||||
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
IconButton(onClick = {
|
||||
val nextValue = min((currentValue ?: 0) + 5, maxFrameRate)
|
||||
enteredValue = nextValue.toString()
|
||||
}, enabled = currentValue == null || currentValue < maxFrameRate) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_add),
|
||||
contentDescription = stringResource(R.string.preference_limit_framerate_add)
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = { onSelect(0) }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.icon_delete),
|
||||
contentDescription = stringResource(R.string.preference_limit_framerate_reset)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (minimumReached) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
Text(
|
||||
stringResource(R.string.preference_limit_framerate_error_min, minFrameRate),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
} else if (maximumReached) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.preference_limit_framerate_error_max,
|
||||
maxFrameRate
|
||||
),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
// automatically disable if already max
|
||||
if (currentValue == maxFrameRate) {
|
||||
onSelect(0)
|
||||
} else {
|
||||
onSelect(currentValue ?: 0)
|
||||
}
|
||||
},
|
||||
enabled = !minimumReached && !maximumReached && currentValue != null
|
||||
) {
|
||||
Text(stringResource(R.string.message_box_accept))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.message_box_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun ProfileCreateDialog(onDismissRequest: () -> Unit) {
|
||||
var enteredValue by remember { mutableStateOf("") }
|
||||
|
||||
val filename = enteredValue.take(16)
|
||||
.lowercase()
|
||||
.map {
|
||||
if ("qwertyuiopasdfghjklzxcvbnm1234567890-_.".contains(it))
|
||||
it else '_'
|
||||
}
|
||||
.joinToString("")
|
||||
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
val minimumReached = enteredValue.isEmpty() || filename.isEmpty()
|
||||
|
||||
val profileManager = ProfileManager.get(context)
|
||||
val currentProfiles = remember {
|
||||
profileManager.getProfiles().map { it.path }.toSet()
|
||||
}
|
||||
|
||||
val isDuplicate = currentProfiles.contains(filename)
|
||||
|
||||
BaseDialog(
|
||||
onDismissRequest = { onDismissRequest() },
|
||||
title = stringResource(R.string.preference_profiles_create),
|
||||
content = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = enteredValue,
|
||||
onValueChange = {
|
||||
enteredValue = it
|
||||
},
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
),
|
||||
label = {
|
||||
Text(stringResource(R.string.preference_profiles_create_name))
|
||||
},
|
||||
isError = isDuplicate,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||
)
|
||||
|
||||
if (isDuplicate) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
Text(stringResource(R.string.preference_profiles_create_duplicate))
|
||||
} else if (!minimumReached) {
|
||||
Spacer(Modifier.size(8.dp))
|
||||
|
||||
Text(stringResource(R.string.preference_profiles_create_info, filename))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
ProfileManager.get(context).storeProfile(Profile(filename, enteredValue))
|
||||
onDismissRequest()
|
||||
},
|
||||
enabled = !minimumReached && !isDuplicate
|
||||
) {
|
||||
Text(stringResource(R.string.preference_profiles_create_action))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.message_box_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun BaseDialog(
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
dismissButton: @Composable () -> Unit,
|
||||
confirmButton: @Composable () -> Unit,
|
||||
content: @Composable (ColumnScope.() -> Unit),
|
||||
) {
|
||||
Dialog(onDismissRequest = onDismissRequest) {
|
||||
Card(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
)
|
||||
) {
|
||||
// styling a dialog is actually a little hard if you're doing what i'm doing
|
||||
// maybe there's a better way to make these padding values...
|
||||
Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
|
||||
Text(
|
||||
title,
|
||||
style = Typography.titleLarge,
|
||||
modifier = Modifier.padding(
|
||||
start = 28.dp,
|
||||
top = 24.dp,
|
||||
bottom = 12.dp
|
||||
)
|
||||
)
|
||||
|
||||
content()
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
bottom = 16.dp,
|
||||
end = 16.dp,
|
||||
top = 4.dp
|
||||
)
|
||||
) {
|
||||
dismissButton()
|
||||
confirmButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal val LocalSelectValue = compositionLocalOf<Any> { 0 }
|
||||
internal val LocalSelectSetValue = staticCompositionLocalOf<(Any) -> Unit> { {} }
|
||||
|
||||
@Composable
|
||||
fun <T> SelectOption(name: String, value: T, enabled: Boolean = true, leadingContent: @Composable (Boolean) -> Unit = {}) {
|
||||
val currentValue = LocalSelectValue.current
|
||||
val setValue = LocalSelectSetValue.current
|
||||
|
||||
val isSelected = currentValue.equals(value)
|
||||
|
||||
// do not give the row or column padding!! it messes up the selection effect
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
onClick = { setValue(value as Any) },
|
||||
role = Role.RadioButton,
|
||||
enabled = enabled
|
||||
)
|
||||
.padding(horizontal = 12.dp)
|
||||
) {
|
||||
Row(modifier = Modifier.weight(1.0f, true), verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(
|
||||
selected = isSelected,
|
||||
onClick = { setValue(value as Any) },
|
||||
enabled = enabled
|
||||
)
|
||||
Text(name, style = Typography.bodyMedium)
|
||||
}
|
||||
|
||||
leadingContent(isSelected)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T> SelectDialog(
|
||||
title: String,
|
||||
onDismissRequest: () -> Unit,
|
||||
onSelect: (T) -> Unit,
|
||||
initialValue: T,
|
||||
options: @Composable () -> Unit,
|
||||
) {
|
||||
val (selectedValue, setSelectedValue) = remember { mutableStateOf(initialValue) }
|
||||
|
||||
BaseDialog(
|
||||
title = title,
|
||||
onDismissRequest = onDismissRequest,
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismissRequest) {
|
||||
Text(stringResource(R.string.message_box_cancel))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onSelect(selectedValue) }) {
|
||||
Text(stringResource(R.string.message_box_accept))
|
||||
}
|
||||
}
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalSelectValue provides (selectedValue as Any),
|
||||
LocalSelectSetValue provides ({
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
setSelectedValue(it as T)
|
||||
})
|
||||
) {
|
||||
options()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,20 @@ 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
|
||||
)
|
||||
@@ -3,10 +3,9 @@ package com.geode.launcher.updater
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import kotlin.time.Instant
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
@Serializable
|
||||
data class Asset @OptIn(ExperimentalTime::class) constructor(
|
||||
data class Asset(
|
||||
val url: String,
|
||||
val id: Int,
|
||||
val name: String,
|
||||
@@ -14,10 +13,11 @@ data class Asset @OptIn(ExperimentalTime::class) constructor(
|
||||
val createdAt: Instant,
|
||||
val updatedAt: Instant,
|
||||
val browserDownloadUrl: String,
|
||||
val digest: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Release @OptIn(ExperimentalTime::class) constructor(
|
||||
data class Release(
|
||||
val url: String,
|
||||
val id: Int,
|
||||
val targetCommitish: String,
|
||||
@@ -31,12 +31,29 @@ data class Release @OptIn(ExperimentalTime::class) constructor(
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoaderVersion @OptIn(ExperimentalTime::class) constructor(
|
||||
data class LoaderPlatformDownload(
|
||||
val url: String,
|
||||
val hash: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoaderDownload(
|
||||
val win: LoaderPlatformDownload,
|
||||
val mac: LoaderPlatformDownload,
|
||||
val android32: LoaderPlatformDownload,
|
||||
val android64: LoaderPlatformDownload,
|
||||
val ios: LoaderPlatformDownload,
|
||||
val resources: LoaderPlatformDownload,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LoaderVersion(
|
||||
val tag: String,
|
||||
val version: String,
|
||||
val createdAt: Instant,
|
||||
val commitHash: String,
|
||||
val prerelease: Boolean
|
||||
val prerelease: Boolean,
|
||||
val downloads: LoaderDownload,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -45,12 +62,46 @@ data class LoaderPayload<T>(
|
||||
val error: String
|
||||
)
|
||||
|
||||
data class DownloadableAsset(val url: String, val filename: String, val size: Long? = null)
|
||||
data class DownloadableAsset(
|
||||
val url: String,
|
||||
val filename: String,
|
||||
val size: Long? = null,
|
||||
val hash: String? = null,
|
||||
)
|
||||
|
||||
private fun mapDownload(download: Asset): DownloadableAsset {
|
||||
val hash = if (download.digest?.startsWith("sha256:") == true)
|
||||
download.digest.removePrefix("sha256:")
|
||||
else null
|
||||
|
||||
return DownloadableAsset(
|
||||
url = download.browserDownloadUrl,
|
||||
filename = download.name,
|
||||
size = download.size.toLong(),
|
||||
hash = hash,
|
||||
)
|
||||
}
|
||||
|
||||
abstract class Downloadable {
|
||||
/**
|
||||
* Human-readable representation of the current version.
|
||||
*/
|
||||
abstract fun getDescription(): String
|
||||
|
||||
/**
|
||||
* Unique value that refers to the current version, used for comparison.
|
||||
*/
|
||||
abstract fun getDescriptor(): Long
|
||||
|
||||
/**
|
||||
* Release download link for the current platform
|
||||
*/
|
||||
abstract fun getDownload(): DownloadableAsset?
|
||||
|
||||
/**
|
||||
* Resources download link for current platform.
|
||||
*/
|
||||
abstract fun getResourcesDownload(): DownloadableAsset?
|
||||
}
|
||||
|
||||
class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadable() {
|
||||
@@ -67,7 +118,6 @@ class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadab
|
||||
return release.tagName
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
override fun getDescriptor(): Long {
|
||||
return release.createdAt.epochSeconds
|
||||
}
|
||||
@@ -84,11 +134,15 @@ class DownloadableGitHubLoaderRelease(private val release: Release) : Downloadab
|
||||
|
||||
override fun getDownload(): DownloadableAsset? {
|
||||
val download = getGitHubDownload() ?: return null
|
||||
return DownloadableAsset(
|
||||
url = download.browserDownloadUrl,
|
||||
filename = download.name,
|
||||
size = download.size.toLong()
|
||||
)
|
||||
return mapDownload(download)
|
||||
}
|
||||
|
||||
override fun getResourcesDownload(): DownloadableAsset? {
|
||||
val download = release.assets.find {
|
||||
it.name == "resources.zip"
|
||||
} ?: return null
|
||||
|
||||
return mapDownload(download)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +151,6 @@ class DownloadableLauncherRelease(val release: Release) : Downloadable() {
|
||||
return release.tagName
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
override fun getDescriptor(): Long {
|
||||
return release.createdAt.epochSeconds
|
||||
}
|
||||
@@ -115,18 +168,17 @@ class DownloadableLauncherRelease(val release: Release) : Downloadable() {
|
||||
* 0 | 0 | 1
|
||||
* surprise! it's an xnor
|
||||
*/
|
||||
|
||||
(asset.name.contains("android32")) == use32BitPlatform
|
||||
asset.name.endsWith("apk") && asset.name.contains("android32") == use32BitPlatform
|
||||
}
|
||||
}
|
||||
|
||||
override fun getDownload(): DownloadableAsset? {
|
||||
val download = getGitHubDownload() ?: return null
|
||||
return DownloadableAsset(
|
||||
url = download.browserDownloadUrl,
|
||||
filename = download.name,
|
||||
size = download.size.toLong()
|
||||
)
|
||||
return mapDownload(download)
|
||||
}
|
||||
|
||||
override fun getResourcesDownload(): DownloadableAsset? {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,16 +187,27 @@ class DownloadableLoaderRelease(private val version: LoaderVersion) : Downloadab
|
||||
return version.tag
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
override fun getDescriptor(): Long {
|
||||
return version.createdAt.epochSeconds
|
||||
}
|
||||
|
||||
override fun getDownload(): DownloadableAsset? {
|
||||
override fun getDownload(): DownloadableAsset {
|
||||
val data = if (LaunchUtils.is64bit) version.downloads.android64 else version.downloads.android32
|
||||
val filename = "geode-${version.tag}-${LaunchUtils.platformName}.zip"
|
||||
return DownloadableAsset(
|
||||
url = "https://github.com/geode-sdk/geode/releases/download/${version.tag}/$filename",
|
||||
filename = filename
|
||||
url = data.url,
|
||||
filename = filename,
|
||||
hash = data.hash,
|
||||
)
|
||||
}
|
||||
|
||||
override fun getResourcesDownload(): DownloadableAsset {
|
||||
val data = version.downloads.resources
|
||||
val filename = "resources.zip"
|
||||
return DownloadableAsset(
|
||||
url = data.url,
|
||||
filename = filename,
|
||||
hash = data.hash,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,16 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
import okio.HashingSink
|
||||
import okio.blackholeSink
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import okio.source
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.InterruptedIOException
|
||||
import java.io.IOException
|
||||
import kotlin.math.max
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
@@ -69,7 +75,8 @@ class ReleaseManager private constructor(
|
||||
|
||||
class UpdateException(reason: Reason? = null, cause: Throwable? = null) : Exception(reason?.name, cause) {
|
||||
enum class Reason {
|
||||
EXTERNAL_FILE_IN_USE
|
||||
EXTERNAL_FILE_IN_USE,
|
||||
HASH_VALIDATION_FAILED
|
||||
}
|
||||
|
||||
var reason: Reason? = reason
|
||||
@@ -84,10 +91,18 @@ class ReleaseManager private constructor(
|
||||
val isInUpdate: Boolean
|
||||
get() = _uiState.value !is ReleaseManagerState.Failure && _uiState.value !is ReleaseManagerState.Finished
|
||||
|
||||
private val _availableLauncherUpdate = MutableStateFlow<DownloadableLauncherRelease?>(null)
|
||||
val availableLauncherUpdate = _availableLauncherUpdate.asStateFlow()
|
||||
private val _availableLauncherUpdateTag = MutableStateFlow<String?>(null)
|
||||
val availableLauncherUpdateTag = _availableLauncherUpdateTag.asStateFlow()
|
||||
|
||||
private val _availableLauncherUpdateDetails = MutableStateFlow<DownloadableLauncherRelease?>(null)
|
||||
val availableLauncherUpdateDetails = _availableLauncherUpdateDetails.asStateFlow()
|
||||
|
||||
var dismissedLauncherUpdate = false
|
||||
|
||||
var skipStateUpdate = false
|
||||
|
||||
private fun sendError(e: Exception) {
|
||||
skipStateUpdate = true
|
||||
_uiState.value = ReleaseManagerState.Failure(e)
|
||||
|
||||
// ignore cancellation, it's good actually
|
||||
@@ -133,35 +148,96 @@ class ReleaseManager private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadLauncherUpdate(release: Downloadable) {
|
||||
val download = release.getDownload() ?: return
|
||||
private suspend fun downloadLauncherUpdate(release: Downloadable): Result<File> {
|
||||
val download = release.getDownload()
|
||||
?: return Result.failure(FileNotFoundException())
|
||||
|
||||
val outputDirectory = applicationContext.filesDir
|
||||
val outputFile = File(outputDirectory, download.filename)
|
||||
|
||||
if (outputFile.exists()) {
|
||||
// only download the apk once
|
||||
return
|
||||
try {
|
||||
val hash = DownloadUtils.downloadFile(
|
||||
httpClient,
|
||||
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)
|
||||
}
|
||||
|
||||
_uiState.value = ReleaseManagerState.InDownload(0, download.size)
|
||||
return Result.success(outputFile)
|
||||
}
|
||||
|
||||
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 {
|
||||
val fileStream = DownloadUtils.downloadStream(
|
||||
httpClient,
|
||||
download.url
|
||||
) { progress, outOf ->
|
||||
_uiState.value = ReleaseManagerState.InDownload(progress, outOf)
|
||||
if (!outputDir.exists()) {
|
||||
outputDir.mkdirs()
|
||||
}
|
||||
|
||||
fileStream.copyTo(outputFile.outputStream())
|
||||
val hash = DownloadUtils.downloadFile(
|
||||
httpClient,
|
||||
resourceAsset.url,
|
||||
downloadFile,
|
||||
onProgress = { 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 ->
|
||||
DownloadUtils.copyZipStreamToDirectory(
|
||||
zip,
|
||||
outputDir
|
||||
)
|
||||
}
|
||||
|
||||
if (finalDir.exists()) {
|
||||
finalDir.deleteRecursively()
|
||||
}
|
||||
|
||||
if (!outputDir.renameTo(finalDir)) {
|
||||
println("Failed to rename temporary directory!")
|
||||
DownloadUtils.copyDirectory(outputDir, finalDir)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
sendError(e)
|
||||
return
|
||||
return false
|
||||
} 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) {
|
||||
skipStateUpdate = false
|
||||
|
||||
val releaseAsset = release.getDownload()
|
||||
if (releaseAsset == null) {
|
||||
val noAssetException = Exception("missing Android download")
|
||||
@@ -170,51 +246,92 @@ class ReleaseManager private constructor(
|
||||
return
|
||||
}
|
||||
|
||||
// set an initial download size
|
||||
_uiState.value = ReleaseManagerState.InDownload(0, releaseAsset.size)
|
||||
val resourcesAsset = release.getResourcesDownload()
|
||||
|
||||
val outputFile = getTempFile()
|
||||
var releaseSize = releaseAsset.size ?: 0L
|
||||
val resourcesSize = resourcesAsset?.size ?: 0L
|
||||
|
||||
// set an initial download size
|
||||
val initialSize = if (releaseAsset.size == null && resourcesAsset?.size == null) null else releaseSize + resourcesSize
|
||||
_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 {
|
||||
val fileStream = DownloadUtils.downloadStream(
|
||||
httpClient,
|
||||
releaseAsset.url
|
||||
) { progress, outOf ->
|
||||
_uiState.value = ReleaseManagerState.InDownload(progress, outOf)
|
||||
// tempFile should be in same path as geodeFile
|
||||
val geodeParent = geodeFile.parentFile
|
||||
if (geodeParent != null && !geodeParent.exists()) {
|
||||
geodeParent.mkdirs()
|
||||
}
|
||||
|
||||
outputFile.parentFile?.mkdirs()
|
||||
val hash = DownloadUtils.downloadFile(
|
||||
httpClient,
|
||||
releaseAsset.url,
|
||||
downloadFile,
|
||||
onProgress = { progress, outOf ->
|
||||
if (!skipStateUpdate)
|
||||
_uiState.value = ReleaseManagerState.InDownload(progress, outOf + resourcesSize)
|
||||
|
||||
val geodeFile = getGeodeOutputPath()
|
||||
|
||||
DownloadUtils.extractFileFromZipStream(
|
||||
fileStream,
|
||||
outputFile.outputStream(),
|
||||
geodeFile.name
|
||||
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 ->
|
||||
DownloadUtils.extractFileFromZipStream(
|
||||
zip,
|
||||
outputFile,
|
||||
geodeFile.name
|
||||
)
|
||||
}
|
||||
|
||||
// work around a permission issue from adb push
|
||||
if (geodeFile.exists()) {
|
||||
geodeFile.delete()
|
||||
}
|
||||
|
||||
outputFile.renameTo(geodeFile)
|
||||
val renameSuccessful = runCatching {
|
||||
outputFile.renameTo(geodeFile)
|
||||
}.getOrDefault(false)
|
||||
|
||||
if (!renameSuccessful) {
|
||||
// attempt a manual copy if rename fails for whatever reason
|
||||
DownloadUtils.copyFile(outputFile, geodeFile)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
sendError(e)
|
||||
return
|
||||
} finally {
|
||||
val tempFileClone = File(tempFilePath)
|
||||
try {
|
||||
if (downloadFile.exists()) downloadFile.delete()
|
||||
if (tempFileClone.exists()) tempFileClone.delete()
|
||||
} catch (_: Exception) { }
|
||||
} catch (_: IOException) {}
|
||||
}
|
||||
|
||||
downloadLauncherUpdateIfNecessary()
|
||||
if (resourcesAsset != null) {
|
||||
if (!performResourceDownload(resourcesAsset, releaseSize)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// extraction performed
|
||||
updatePreferences(release)
|
||||
|
||||
val currentTime = Clock.System.now().toEpochMilliseconds()
|
||||
PreferenceUtils.get(applicationContext)
|
||||
.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
|
||||
|
||||
_uiState.value = ReleaseManagerState.Finished(true)
|
||||
}
|
||||
|
||||
@@ -228,35 +345,104 @@ class ReleaseManager private constructor(
|
||||
val sharedPreferences = PreferenceUtils.get(applicationContext)
|
||||
|
||||
val originalFileHash = sharedPreferences.getString(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED)
|
||||
?: return false
|
||||
?: return true
|
||||
|
||||
val currentFileHash = computeFileHash(geodeFile)
|
||||
|
||||
return originalFileHash != currentFileHash
|
||||
}
|
||||
|
||||
private fun checkLauncherUpdate(launcherUpdate: DownloadableLauncherRelease) {
|
||||
if (launcherUpdate.release.tagName != BuildConfig.VERSION_NAME) {
|
||||
_availableLauncherUpdate.value = launcherUpdate
|
||||
private suspend fun getLatestLauncherUpdate(): DownloadableLauncherRelease? {
|
||||
if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||
// don't check for updates on Google Play builds
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
releaseRepository.getLatestLauncherRelease()
|
||||
} catch (e: Exception) {
|
||||
sendError(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadLauncherUpdateIfNecessary() {
|
||||
val update = _availableLauncherUpdate.value ?: return
|
||||
downloadLauncherUpdate(update)
|
||||
suspend fun checkLauncherUpdate() {
|
||||
if (BuildConfig.GOOGLE_PLAY_BUILD) return
|
||||
|
||||
val launcherRelease = getLatestLauncherUpdate() ?: return
|
||||
|
||||
// unconditionally track it, we're not using it for checks and it might be good to have
|
||||
_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
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun checkForNewRelease(isManual: Boolean = false) {
|
||||
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()
|
||||
if (!isManual && lastCheckTime > checkMinTime && !fileWasExternallyModified()) {
|
||||
return lastCheckTime > checkMinTime && !fileWasExternallyModified()
|
||||
}
|
||||
|
||||
fun afterUseCachedUpdate() {
|
||||
checkCachedLauncherUpdate()
|
||||
_uiState.tryEmit(ReleaseManagerState.Finished())
|
||||
}
|
||||
|
||||
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 {
|
||||
getLatestRelease()
|
||||
} catch (e: Exception) {
|
||||
@@ -264,22 +450,11 @@ class ReleaseManager private constructor(
|
||||
return
|
||||
}
|
||||
|
||||
val launcherRelease = try {
|
||||
releaseRepository.getLatestLauncherRelease()
|
||||
} catch (e: Exception) {
|
||||
sendError(e)
|
||||
return
|
||||
}
|
||||
|
||||
if (launcherRelease != null) {
|
||||
checkLauncherUpdate(launcherRelease)
|
||||
}
|
||||
|
||||
val currentTime = Clock.System.now().toEpochMilliseconds()
|
||||
sharedPreferences.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
|
||||
checkLauncherUpdate()
|
||||
|
||||
if (release == null) {
|
||||
downloadLauncherUpdateIfNecessary()
|
||||
val currentTime = Clock.System.now().toEpochMilliseconds()
|
||||
sharedPreferences.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
|
||||
|
||||
_uiState.value = ReleaseManagerState.Finished()
|
||||
return
|
||||
@@ -290,7 +465,8 @@ class ReleaseManager private constructor(
|
||||
|
||||
// check if an update is needed
|
||||
if (latestVersion == currentVersion && !fileWasExternallyModified()) {
|
||||
downloadLauncherUpdateIfNecessary()
|
||||
val currentTime = Clock.System.now().toEpochMilliseconds()
|
||||
sharedPreferences.setLong(PreferenceUtils.Key.LAST_UPDATE_CHECK_TIME, currentTime)
|
||||
|
||||
_uiState.value = ReleaseManagerState.Finished()
|
||||
return
|
||||
@@ -300,8 +476,6 @@ class ReleaseManager private constructor(
|
||||
|
||||
// check if the file was externally modified
|
||||
if (!allowOverwriting && fileWasExternallyModified(true)) {
|
||||
downloadLauncherUpdateIfNecessary()
|
||||
|
||||
sendError(UpdateException(UpdateException.Reason.EXTERNAL_FILE_IN_USE))
|
||||
return
|
||||
}
|
||||
@@ -329,8 +503,14 @@ class ReleaseManager private constructor(
|
||||
sharedPreferences.setString(PreferenceUtils.Key.CURRENT_RELEASE_MODIFIED, fileHash)
|
||||
}
|
||||
|
||||
private fun computeFileHash(file: File): String =
|
||||
file.source().buffer().readByteString().md5().hex()
|
||||
private fun computeFileHash(file: File): String {
|
||||
val sink = HashingSink.md5(blackholeSink())
|
||||
file.source().buffer().use {
|
||||
it.readAll(sink)
|
||||
}
|
||||
|
||||
return sink.hash.hex()
|
||||
}
|
||||
|
||||
private fun getGeodeOutputPath(): File {
|
||||
val geodeName = LaunchUtils.geodeFilename
|
||||
@@ -339,15 +519,40 @@ class ReleaseManager private constructor(
|
||||
return File(geodeDirectory, geodeName)
|
||||
}
|
||||
|
||||
private fun getTempFile(): File {
|
||||
val geodeName = LaunchUtils.geodeFilename
|
||||
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)
|
||||
|
||||
val tempFile = File.createTempFile(geodeName, null, geodeDirectory)
|
||||
// warning!! while File::createTempFile may look tempting, a certain brand of phones has a messed up implementation of it
|
||||
// so we're making a temp file manually (as long as it doesn't collide with the geode download, it's okay)
|
||||
|
||||
val 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.
|
||||
*/
|
||||
@@ -365,9 +570,13 @@ class ReleaseManager private constructor(
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
fun checkForUpdates(isManual: Boolean = false): StateFlow<ReleaseManagerState> {
|
||||
if (!isInUpdate) {
|
||||
_uiState.value = ReleaseManagerState.InUpdateCheck
|
||||
updateJob = GlobalScope.launch {
|
||||
checkForNewRelease(isManual)
|
||||
if (isManual || !shouldUseCache()) {
|
||||
_uiState.value = ReleaseManagerState.InUpdateCheck
|
||||
updateJob = GlobalScope.launch {
|
||||
checkForNewRelease(isManual)
|
||||
}
|
||||
} else {
|
||||
afterUseCachedUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.geode.launcher.updater
|
||||
|
||||
import com.geode.launcher.utils.DownloadUtils.executeCoroutine
|
||||
import com.geode.launcher.BuildConfig
|
||||
import com.geode.launcher.utils.LaunchUtils
|
||||
import kotlin.time.Clock
|
||||
import kotlinx.datetime.DateTimeUnit
|
||||
import kotlin.time.Instant
|
||||
@@ -10,8 +11,10 @@ import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNamingStrategy
|
||||
import kotlinx.serialization.json.okio.decodeFromBufferedSource
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttp
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.coroutines.executeAsync
|
||||
import java.io.IOException
|
||||
import java.net.URL
|
||||
import kotlin.time.ExperimentalTime
|
||||
@@ -28,6 +31,8 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
|
||||
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? {
|
||||
val releasePath = "$GITHUB_API_BASE/repos/geode-sdk/android-launcher/releases/latest"
|
||||
|
||||
@@ -59,48 +64,50 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
|
||||
.url(url)
|
||||
.addHeader("Accept", "application/json")
|
||||
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
|
||||
.addHeader("User-Agent", userAgent)
|
||||
.build()
|
||||
|
||||
val call = httpClient.newCall(request)
|
||||
val response = call.executeCoroutine()
|
||||
|
||||
return when (response.code) {
|
||||
200 -> {
|
||||
val format = Json {
|
||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||
ignoreUnknownKeys = true
|
||||
return call.executeAsync().use { response ->
|
||||
when (response.code) {
|
||||
200 -> {
|
||||
val format = Json {
|
||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
val release = format.decodeFromBufferedSource<List<Release>>(
|
||||
response.body.source()
|
||||
)
|
||||
|
||||
(release.find { it.tagName != "nightly" })
|
||||
?.let(::DownloadableGitHubLoaderRelease)
|
||||
}
|
||||
403 -> {
|
||||
// determine if the error code is a ratelimit
|
||||
// (github docs say it sends 429 too, but haven't seen that)
|
||||
|
||||
val release = format.decodeFromBufferedSource<List<Release>>(
|
||||
response.body!!.source()
|
||||
)
|
||||
val limitRemaining = response.headers.get(GITHUB_RATELIMIT_REMAINING)?.toInt()
|
||||
val limitReset = response.headers.get(GITHUB_RATELIMIT_RESET)?.toLong()
|
||||
|
||||
(release.find { it.tagName != "nightly" })
|
||||
?.let(::DownloadableGitHubLoaderRelease)
|
||||
}
|
||||
403 -> {
|
||||
// determine if the error code is a ratelimit
|
||||
// (github docs say it sends 429 too, but haven't seen that)
|
||||
if (limitRemaining == 0 && limitReset != null) {
|
||||
// handle ratelimit with a custom error
|
||||
// there's also a retry-after header but again, haven't seen
|
||||
val resetTime = Instant.fromEpochSeconds(limitReset, 0L)
|
||||
val msg = generateRateLimitMessage(resetTime)
|
||||
|
||||
val limitRemaining = response.headers.get(GITHUB_RATELIMIT_REMAINING)?.toInt()
|
||||
val limitReset = response.headers.get(GITHUB_RATELIMIT_RESET)?.toLong()
|
||||
throw IOException(msg)
|
||||
}
|
||||
|
||||
if (limitRemaining == 0 && limitReset != null) {
|
||||
// handle ratelimit with a custom error
|
||||
// there's also a retry-after header but again, haven't seen
|
||||
val resetTime = Instant.fromEpochSeconds(limitReset, 0L)
|
||||
val msg = generateRateLimitMessage(resetTime)
|
||||
|
||||
throw IOException(msg)
|
||||
throw IOException("response 403: ${response.body.string()}")
|
||||
}
|
||||
404 -> {
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
throw IOException("unknown response ${response.code}")
|
||||
}
|
||||
|
||||
throw IOException("response 403: ${response.body!!.string()}")
|
||||
}
|
||||
404 -> {
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
throw IOException("unknown response ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,47 +128,48 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
|
||||
.url(url)
|
||||
.addHeader("Accept", "application/json")
|
||||
.addHeader(GITHUB_API_HEADER, GITHUB_API_VERSION)
|
||||
.addHeader("User-Agent", userAgent)
|
||||
.build()
|
||||
|
||||
val call = httpClient.newCall(request)
|
||||
val response = call.executeCoroutine()
|
||||
return call.executeAsync().use { response ->
|
||||
when (response.code) {
|
||||
200 -> {
|
||||
val format = Json {
|
||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
return when (response.code) {
|
||||
200 -> {
|
||||
val format = Json {
|
||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||
ignoreUnknownKeys = true
|
||||
val release = format.decodeFromBufferedSource<Release>(
|
||||
response.body.source()
|
||||
)
|
||||
|
||||
release
|
||||
}
|
||||
403 -> {
|
||||
// determine if the error code is a ratelimit
|
||||
// (github docs say it sends 429 too, but haven't seen that)
|
||||
|
||||
val release = format.decodeFromBufferedSource<Release>(
|
||||
response.body!!.source()
|
||||
)
|
||||
val limitRemaining = response.headers[GITHUB_RATELIMIT_REMAINING]?.toInt()
|
||||
val limitReset = response.headers[GITHUB_RATELIMIT_RESET]?.toLong()
|
||||
|
||||
release
|
||||
}
|
||||
403 -> {
|
||||
// determine if the error code is a ratelimit
|
||||
// (github docs say it sends 429 too, but haven't seen that)
|
||||
if (limitRemaining == 0 && limitReset != null) {
|
||||
// handle ratelimit with a custom error
|
||||
// there's also a retry-after header but again, haven't seen
|
||||
val resetTime = Instant.fromEpochSeconds(limitReset, 0L)
|
||||
val msg = generateRateLimitMessage(resetTime)
|
||||
|
||||
val limitRemaining = response.headers[GITHUB_RATELIMIT_REMAINING]?.toInt()
|
||||
val limitReset = response.headers[GITHUB_RATELIMIT_RESET]?.toLong()
|
||||
throw IOException(msg)
|
||||
}
|
||||
|
||||
if (limitRemaining == 0 && limitReset != null) {
|
||||
// handle ratelimit with a custom error
|
||||
// there's also a retry-after header but again, haven't seen
|
||||
val resetTime = Instant.fromEpochSeconds(limitReset, 0L)
|
||||
val msg = generateRateLimitMessage(resetTime)
|
||||
|
||||
throw IOException(msg)
|
||||
throw IOException("response 403: ${response.body.string()}")
|
||||
}
|
||||
404 -> {
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
throw IOException("unknown response ${response.code}")
|
||||
}
|
||||
|
||||
throw IOException("response 403: ${response.body!!.string()}")
|
||||
}
|
||||
404 -> {
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
throw IOException("unknown response ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,31 +186,32 @@ class ReleaseRepository(private val httpClient: OkHttpClient) {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("Accept", "application/json")
|
||||
.addHeader("User-Agent", userAgent)
|
||||
.build()
|
||||
|
||||
val call = httpClient.newCall(request)
|
||||
val response = call.executeCoroutine()
|
||||
return call.executeAsync().use { response ->
|
||||
when (response.code) {
|
||||
200 -> {
|
||||
val format = Json {
|
||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
return when (response.code) {
|
||||
200 -> {
|
||||
val format = Json {
|
||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||
ignoreUnknownKeys = true
|
||||
val body = format.decodeFromBufferedSource<LoaderPayload<LoaderVersion>>(
|
||||
response.body.source()
|
||||
)
|
||||
|
||||
val release = body.payload ?: throw IOException("index error: ${body.error}")
|
||||
|
||||
DownloadableLoaderRelease(release)
|
||||
}
|
||||
404 -> {
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
throw IOException("unknown response ${response.code}")
|
||||
}
|
||||
|
||||
val body = format.decodeFromBufferedSource<LoaderPayload<LoaderVersion>>(
|
||||
response.body!!.source()
|
||||
)
|
||||
|
||||
val release = body.payload ?: throw IOException("index error: ${body.error}")
|
||||
|
||||
DownloadableLoaderRelease(release)
|
||||
}
|
||||
404 -> {
|
||||
null
|
||||
}
|
||||
else -> {
|
||||
throw IOException("unknown response ${response.code}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.AP
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.geode.launcher.BuildConfig
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -53,7 +54,7 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
|
||||
val isInUpdate
|
||||
get() = ReleaseManager.get(application).isInUpdate
|
||||
|
||||
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdate
|
||||
val nextLauncherUpdate = ReleaseManager.get(application).availableLauncherUpdateTag
|
||||
|
||||
var hasPerformedCheck = false
|
||||
private set
|
||||
@@ -89,6 +90,10 @@ class ReleaseViewModel(private val application: Application): ViewModel() {
|
||||
}
|
||||
|
||||
fun runReleaseCheck(isManual: Boolean = false) {
|
||||
if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||
return
|
||||
}
|
||||
|
||||
hasPerformedCheck = true
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
|
||||
@@ -17,10 +17,10 @@ object Constants {
|
||||
const val SUPPORTED_VERSION_CODE_MIN = 37L
|
||||
|
||||
// anything below this version code shows a warning on the main menu
|
||||
const val SUPPORTED_VERSION_CODE_MIN_WARNING = 40L
|
||||
const val SUPPORTED_VERSION_CODE_MIN_WARNING = 41L
|
||||
|
||||
// anything above this version code will show a warning when starting the game
|
||||
const val SUPPORTED_VERSION_CODE = 40L
|
||||
const val SUPPORTED_VERSION_CODE = 41L
|
||||
|
||||
const val SUPPORTED_VERSION_STRING = "2.2.143"
|
||||
const val SUPPORTED_VERSION_STRING = "2.2.144"
|
||||
}
|
||||
@@ -2,37 +2,63 @@ package com.geode.launcher.utils
|
||||
|
||||
import android.os.Build
|
||||
import android.os.FileUtils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runInterruptible
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.coroutines.executeAsync
|
||||
import okio.Buffer
|
||||
import okio.BufferedSink
|
||||
import okio.BufferedSource
|
||||
import okio.FileSystem
|
||||
import okio.ForwardingSink
|
||||
import okio.ForwardingSource
|
||||
import okio.HashingSink
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import okio.Sink
|
||||
import okio.Source
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.zip.ZipInputStream
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
|
||||
typealias ProgressCallback = (progress: Long, outOf: Long) -> Unit
|
||||
|
||||
object DownloadUtils {
|
||||
suspend fun downloadFile(
|
||||
httpClient: OkHttpClient,
|
||||
url: String,
|
||||
outputFile: File,
|
||||
onProgress: ProgressCallback? = null,
|
||||
): String = downloadStream(
|
||||
httpClient,
|
||||
url,
|
||||
onProgress = onProgress
|
||||
).use { body ->
|
||||
val hashingSink = HashingSink.sha256(outputFile.sink().buffer())
|
||||
hashingSink.use { sink ->
|
||||
body.source().use { source ->
|
||||
source.readAll(sink)
|
||||
}
|
||||
}
|
||||
|
||||
hashingSink.hash.hex()
|
||||
}
|
||||
|
||||
suspend fun downloadStream(
|
||||
httpClient: OkHttpClient,
|
||||
url: String,
|
||||
onProgress: ProgressCallback? = null
|
||||
): InputStream {
|
||||
onProgress: ProgressCallback? = null,
|
||||
): ResponseBody {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.build()
|
||||
@@ -50,7 +76,7 @@ object DownloadUtils {
|
||||
val originalResponse = chain.proceed(chain.request())
|
||||
originalResponse.newBuilder()
|
||||
.body(ProgressResponseBody(
|
||||
originalResponse.body!!, onProgress
|
||||
originalResponse.body, onProgress
|
||||
))
|
||||
.build()
|
||||
}
|
||||
@@ -59,50 +85,67 @@ object DownloadUtils {
|
||||
val progressClient = progressClientBuilder.build()
|
||||
|
||||
val call = progressClient.newCall(request)
|
||||
val response = call.executeCoroutine()
|
||||
val response = call.executeAsync()
|
||||
|
||||
return response.body!!.byteStream()
|
||||
val responseCode = response.code
|
||||
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 {
|
||||
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) {
|
||||
destination.mkdirs()
|
||||
} else {
|
||||
destination.parentFile?.mkdirs()
|
||||
|
||||
destination.outputStream().use { destinationStream ->
|
||||
zipStream.copyTo(destinationStream)
|
||||
}
|
||||
|
||||
continuation.resumeWithException(e)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
continuation.resume(response)
|
||||
}
|
||||
})
|
||||
|
||||
continuation.invokeOnCancellation {
|
||||
this.cancel()
|
||||
entry = zipStream.nextEntry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// (runInterruptible allows it to cancel, otherwise it waits for the stream to finish)
|
||||
runInterruptible {
|
||||
inputStream.use {
|
||||
outputStream.use {
|
||||
inputStream.use { input ->
|
||||
output.outputStream().use { out ->
|
||||
// nice indentation
|
||||
val zip = ZipInputStream(inputStream)
|
||||
zip.use {
|
||||
var entry = it.nextEntry
|
||||
ZipInputStream(input).use { zip ->
|
||||
var entry = zip.nextEntry
|
||||
while (entry != null) {
|
||||
if (entry.name == zipPath) {
|
||||
it.copyTo(outputStream)
|
||||
zip.copyTo(out)
|
||||
return@runInterruptible
|
||||
}
|
||||
|
||||
entry = it.nextEntry
|
||||
entry = zip.nextEntry
|
||||
}
|
||||
|
||||
// no matching entries found, throw exception
|
||||
@@ -113,6 +156,22 @@ object DownloadUtils {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun copyFile(from: File, to: File) {
|
||||
val fs = FileSystem.SYSTEM
|
||||
|
||||
val fromPath = from.toOkioPath()
|
||||
val toPath = to.toOkioPath()
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
fs.copy(fromPath, toPath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies data from inputStream to outputStream
|
||||
*
|
||||
* Both streams are closed upon the conclusion of this function.
|
||||
*/
|
||||
fun copyFile(inputStream: InputStream, outputStream: OutputStream) {
|
||||
// gotta love copying
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
@@ -125,6 +184,13 @@ 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>
|
||||
@@ -166,4 +232,39 @@ 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,6 +8,7 @@ import android.os.Environment
|
||||
import android.provider.DocumentsContract
|
||||
import android.provider.MediaStore
|
||||
import java.io.File
|
||||
import androidx.core.net.toUri
|
||||
|
||||
class FileUtils {
|
||||
// copied from https://stackoverflow.com/questions/17546101/get-real-path-for-uri-android
|
||||
@@ -86,7 +87,7 @@ class FileUtils {
|
||||
val file = File(id)
|
||||
if (file.exists()) return id
|
||||
}
|
||||
val contentUri = ContentUris.withAppendedId(Uri.parse("content://downloads/public_downloads"), java.lang.Long.valueOf(id))
|
||||
val contentUri = ContentUris.withAppendedId("content://downloads/public_downloads".toUri(), java.lang.Long.valueOf(id))
|
||||
return getDataColumn(context, contentUri, null, null)
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ object GamePackageUtils {
|
||||
39L to "2.206",
|
||||
40L to "2.2074", // ok
|
||||
41L to "2.2081",
|
||||
42L to "2.209",
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -63,14 +64,25 @@ object GamePackageUtils {
|
||||
return gameVersionMap[versionCode] ?: getGameVersionString(packageManager)
|
||||
}
|
||||
|
||||
fun getSupportedUnifiedVersion(): String {
|
||||
return gameVersionMap[Constants.SUPPORTED_VERSION_CODE] ?: Constants.SUPPORTED_VERSION_STRING
|
||||
}
|
||||
|
||||
fun detectAbiMismatch(context: Context, packageInfo: PackageInfo, loadException: Error): Boolean {
|
||||
val abi = LaunchUtils.applicationArchitecture
|
||||
val isExtracted =
|
||||
packageInfo.applicationInfo!!.flags and ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS == ApplicationInfo.FLAG_EXTRACT_NATIVE_LIBS
|
||||
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 =
|
||||
"Geometry Dash metadata:\nSplit sources: $isSplit\nExtracted libraries: $isExtracted\nLauncher architecture: $abi"
|
||||
"Geometry Dash metadata:\nSplit sources: $isSplit\nExtracted libraries: $isExtracted\nLauncher architecture: $abi\nGame version: $versionCode"
|
||||
Log.i("GeodeLauncher", metadata)
|
||||
|
||||
// easiest check! these messages are hardcoded in bionic/linker/linker_phdr.cpp
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.content.pm.PackageManager
|
||||
import android.hardware.lights.Light
|
||||
import android.hardware.lights.LightState
|
||||
import android.hardware.lights.LightsRequest
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
@@ -31,7 +32,6 @@ import androidx.core.net.toUri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.geode.launcher.BuildConfig
|
||||
import com.geode.launcher.R
|
||||
import com.geode.launcher.UserDirectoryProvider
|
||||
import com.geode.launcher.activityresult.GeodeOpenFileActivityResult
|
||||
import com.geode.launcher.activityresult.GeodeOpenFilesActivityResult
|
||||
import com.geode.launcher.activityresult.GeodeSaveFileActivityResult
|
||||
@@ -191,10 +191,16 @@ object GeodeUtils {
|
||||
|
||||
@JvmStatic
|
||||
fun restartGame() {
|
||||
restartGame(false)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun restartGame(safeMode: Boolean) {
|
||||
activity.get()?.run {
|
||||
packageManager.getLaunchIntentForPackage(packageName)?.also {
|
||||
val mainIntent = Intent.makeRestartActivityTask(it.component)
|
||||
mainIntent.putExtra("restarted", true)
|
||||
mainIntent.putExtra("safe-mode", safeMode)
|
||||
startActivity(mainIntent)
|
||||
exitProcess(0)
|
||||
}
|
||||
@@ -207,24 +213,22 @@ object GeodeUtils {
|
||||
val context = activity.get()!!
|
||||
|
||||
val pathFile = File(path)
|
||||
val baseDirectory = LaunchUtils.getBaseDirectory(context)
|
||||
val baseDirectory = LaunchUtils.getBaseDirectory(context, true)
|
||||
val isInternalPath = pathFile.startsWith(baseDirectory)
|
||||
|
||||
val intent = if (isInternalPath) {
|
||||
// TODO: figure out how to get this to point to the path it should be pointing at
|
||||
// (the best i got was pointing at a file)
|
||||
// val relativePath = pathFile.relativeTo(baseDirectory)
|
||||
val relativePath = pathFile.relativeTo(baseDirectory)
|
||||
val uri = DocumentsContract.buildDocumentUri(
|
||||
"com.android.externalstorage.documents",
|
||||
if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||
"primary:Android/data/${context.packageName}/$relativePath"
|
||||
} else {
|
||||
"primary:Android/media/${context.packageName}/$relativePath"
|
||||
}
|
||||
)
|
||||
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
data = DocumentsContract.buildRootUri(
|
||||
"${context.packageName}.user", UserDirectoryProvider.ROOT
|
||||
)
|
||||
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION or
|
||||
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
||||
)
|
||||
setDataAndType(uri, DocumentsContract.Document.MIME_TYPE_DIR)
|
||||
}
|
||||
} else {
|
||||
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
@@ -394,6 +398,15 @@ object GeodeUtils {
|
||||
return ""
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getGameVersionCode(): Long {
|
||||
activity.get()?.run {
|
||||
return GamePackageUtils.getGameVersionCode(packageManager)
|
||||
}
|
||||
|
||||
return 0L
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current version code of the launcher.
|
||||
*/
|
||||
@@ -403,7 +416,7 @@ object GeodeUtils {
|
||||
}
|
||||
|
||||
fun isGeodeUri(uri: Uri): Boolean {
|
||||
return "com.geode.launcher.user" == uri.authority
|
||||
return "${BuildConfig.APPLICATION_ID}.user" == uri.authority
|
||||
}
|
||||
|
||||
private const val INTERNAL_PERMISSION_PREFIX = "geode.permission_internal"
|
||||
@@ -422,7 +435,7 @@ object GeodeUtils {
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
)
|
||||
|
||||
return permissions.all {
|
||||
permissions.all {
|
||||
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
@@ -551,6 +564,7 @@ object GeodeUtils {
|
||||
const val CAPABILITY_EXTENDED_INPUT = "extended_input"
|
||||
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)
|
||||
|
||||
@@ -762,6 +776,11 @@ object GeodeUtils {
|
||||
return true
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getConnectivityManager(): ConnectivityManager? = activity.get()?.run {
|
||||
getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
}
|
||||
|
||||
external fun nativeKeyUp(keyCode: Int, modifiers: Int)
|
||||
external fun nativeKeyDown(keyCode: Int, modifiers: Int, isRepeating: Boolean)
|
||||
external fun nativeActionScroll(scrollX: Float, scrollY: Float)
|
||||
@@ -781,7 +800,7 @@ object GeodeUtils {
|
||||
|
||||
external fun internalKeyEvent(timestamp: Long, deviceId: Int, eventSource: Int, keyCode: Int, modifiers: Int, isDown: Boolean, repeatCount: Int)
|
||||
external fun internalTouchEvent(timestamp: Long, deviceId: Int, eventSource: Int, eventType: Int, ids: IntArray, xs: FloatArray, ys: FloatArray)
|
||||
external fun internalScrollEvent(timestamp: Long, deviceId: Int, eventSource: Int, scrollX: Float, scrollY: Float)
|
||||
external fun internalScrollEvent(timestamp: Long, deviceId: Int, eventSource: Int, scrollX: Float, scrollY: Float, mouseX: Float, mouseY: Float)
|
||||
external fun internalJoystickEvent(timestamp: Long, deviceId: Int, eventSource: Int, leftX: FloatArray, leftY: FloatArray, rightX: FloatArray, rightY: FloatArray, hatX: FloatArray, hatY: FloatArray, leftTrigger: FloatArray, rightTrigger: FloatArray)
|
||||
|
||||
external fun inputDeviceAdded(deviceId: Int)
|
||||
|
||||
@@ -43,23 +43,29 @@ enum class ApplicationIcon {
|
||||
DEFAULT,
|
||||
GEODE,
|
||||
PRIDE,
|
||||
TRANS;
|
||||
TRANS,
|
||||
GREEN,
|
||||
SAPPHIRE;
|
||||
|
||||
companion object {
|
||||
fun fromId(id: String) = when (id) {
|
||||
"default" -> ApplicationIcon.DEFAULT
|
||||
"geode" -> ApplicationIcon.GEODE
|
||||
"pride" -> ApplicationIcon.PRIDE
|
||||
"trans" -> ApplicationIcon.TRANS
|
||||
else -> ApplicationIcon.DEFAULT
|
||||
"default" -> DEFAULT
|
||||
"geode" -> GEODE
|
||||
"pride" -> PRIDE
|
||||
"trans" -> TRANS
|
||||
"sapphire" -> SAPPHIRE
|
||||
"green" -> GREEN
|
||||
else -> DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
fun toId() = when (this) {
|
||||
ApplicationIcon.PRIDE -> "pride"
|
||||
ApplicationIcon.GEODE -> "geode"
|
||||
ApplicationIcon.DEFAULT -> "default"
|
||||
ApplicationIcon.TRANS -> "trans"
|
||||
PRIDE -> "pride"
|
||||
GEODE -> "geode"
|
||||
DEFAULT -> "default"
|
||||
TRANS -> "trans"
|
||||
SAPPHIRE -> "sapphire"
|
||||
GREEN -> "green"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,5 +102,17 @@ object IconUtils {
|
||||
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,6 +2,7 @@ package com.geode.launcher.utils
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.geode.launcher.BuildConfig
|
||||
import java.io.File
|
||||
import java.io.Serializable
|
||||
|
||||
@@ -41,6 +42,9 @@ object LaunchUtils {
|
||||
}
|
||||
|
||||
fun isGeodeInstalled(context: Context): Boolean {
|
||||
@Suppress("SENSELESS_COMPARISON")
|
||||
if (BuildConfig.PREBUNDLED_GEODE != null) return true
|
||||
|
||||
return getInstalledGeodePath(context) != null
|
||||
}
|
||||
|
||||
@@ -48,10 +52,14 @@ object LaunchUtils {
|
||||
* Returns the directory that Geode/the game should base itself off of.
|
||||
*/
|
||||
fun getBaseDirectory(context: Context, ignoreProfile: Boolean = false): File {
|
||||
// deprecated, but seems to be the best choice of directory (i forced mat to test it)
|
||||
// also, is getting the first item the correct choice here?? what do they mean
|
||||
@Suppress("DEPRECATION")
|
||||
val dir = context.externalMediaDirs.first()
|
||||
val dir = if (BuildConfig.GOOGLE_PLAY_BUILD) {
|
||||
context.getExternalFilesDir(null)!!.parentFile!!
|
||||
} else {
|
||||
// deprecated, but seems to be the best choice of directory (i forced mat to test it)
|
||||
// also, is getting the first item the correct choice here?? what do they mean
|
||||
@Suppress("DEPRECATION")
|
||||
context.externalMediaDirs.first()
|
||||
}
|
||||
|
||||
// prevent having resources added to system gallery
|
||||
// accessing this file every time the directory is read may be a little wasteful...
|
||||
@@ -73,13 +81,23 @@ object LaunchUtils {
|
||||
return File(getBaseDirectory(context), "save")
|
||||
}
|
||||
|
||||
const val CRASH_INDICATOR_NAME = "lastSessionDidCrash"
|
||||
val CRASH_INDICATOR_NAMES = setOf("lastSessionDidCrash", "last-pid", "last-crashed")
|
||||
|
||||
fun getCrashDirectory(context: Context): File {
|
||||
val base = getBaseDirectory(context)
|
||||
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? {
|
||||
val crashDirectory = getCrashDirectory(context)
|
||||
if (!crashDirectory.exists()) {
|
||||
@@ -88,7 +106,7 @@ object LaunchUtils {
|
||||
|
||||
val children = crashDirectory.listFiles {
|
||||
// ignore indicator files (including old file)
|
||||
_, name -> name != CRASH_INDICATOR_NAME && name != "last-pid"
|
||||
_, name -> !CRASH_INDICATOR_NAMES.contains(name)
|
||||
}
|
||||
|
||||
return children?.maxByOrNull { it.lastModified() }
|
||||
@@ -96,9 +114,9 @@ object LaunchUtils {
|
||||
|
||||
fun lastSessionCrashed(context: Context): Boolean {
|
||||
val base = getCrashDirectory(context)
|
||||
val crashIndicatorFile = File(base, CRASH_INDICATOR_NAME)
|
||||
|
||||
return crashIndicatorFile.exists()
|
||||
return CRASH_INDICATOR_NAMES.any {
|
||||
File(base, it).exists()
|
||||
}
|
||||
}
|
||||
|
||||
enum class LauncherError : Serializable {
|
||||
|
||||
@@ -127,36 +127,34 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
||||
enum class Key {
|
||||
LOAD_AUTOMATICALLY,
|
||||
UPDATE_AUTOMATICALLY,
|
||||
RELEASE_CHANNEL,
|
||||
CURRENT_VERSION_TAG,
|
||||
CURRENT_VERSION_TIMESTAMP,
|
||||
THEME,
|
||||
BLACK_BACKGROUND,
|
||||
CURRENT_RELEASE_MODIFIED,
|
||||
LAST_DISMISSED_UPDATE,
|
||||
DISMISSED_GJ_UPDATE,
|
||||
LAUNCH_ARGUMENTS,
|
||||
LIMIT_ASPECT_RATIO,
|
||||
DISPLAY_MODE,
|
||||
FORCE_HRR,
|
||||
RELEASE_CHANNEL_TAG,
|
||||
DEVELOPER_MODE,
|
||||
CLEANUP_APKS,
|
||||
CUSTOM_SYMBOL_LIST,
|
||||
CUSTOM_ASPECT_RATIO,
|
||||
SCREEN_ZOOM,
|
||||
SCREEN_ZOOM_FIT,
|
||||
LIMIT_FRAME_RATE,
|
||||
DISABLE_USER_THEME,
|
||||
SELECTED_ICON,
|
||||
DISABLE_REDESIGN,
|
||||
WAIT_PERIOD,
|
||||
LAST_UPDATE_CHECK_TIME
|
||||
LAST_UPDATE_CHECK_TIME,
|
||||
LAST_LAUNCHER_UPDATE,
|
||||
DISABLE_UPDATE_CACHE,
|
||||
PREBUNDLED_GEODE_MIGRATION_PERFORMED,
|
||||
}
|
||||
|
||||
private fun defaultValueForBooleanKey(key: Key): Boolean {
|
||||
return when (key) {
|
||||
Key.UPDATE_AUTOMATICALLY, Key.FORCE_HRR -> true
|
||||
Key.UPDATE_AUTOMATICALLY -> true
|
||||
Key.DEVELOPER_MODE -> BuildConfig.DEBUG
|
||||
else -> false
|
||||
}
|
||||
@@ -180,23 +178,18 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
||||
return when (key) {
|
||||
Key.LOAD_AUTOMATICALLY -> "PreferenceLoadAutomatically"
|
||||
Key.UPDATE_AUTOMATICALLY -> "PreferenceUpdateAutomatically"
|
||||
Key.RELEASE_CHANNEL -> "PreferenceReleaseChannel"
|
||||
Key.CURRENT_VERSION_TAG -> "PreferenceCurrentVersionName"
|
||||
Key.CURRENT_VERSION_TIMESTAMP -> "PreferenceCurrentVersionDescriptor"
|
||||
Key.THEME -> "PreferenceTheme"
|
||||
Key.BLACK_BACKGROUND -> "PreferenceBlackBackground"
|
||||
Key.CURRENT_RELEASE_MODIFIED -> "PreferenceReleaseModifiedHash"
|
||||
Key.LAST_DISMISSED_UPDATE -> "PreferenceLastDismissedUpdate"
|
||||
Key.DISMISSED_GJ_UPDATE -> "PreferenceDismissedGJUpdate"
|
||||
Key.LAUNCH_ARGUMENTS -> "PreferenceLaunchArguments"
|
||||
Key.LIMIT_ASPECT_RATIO -> "PreferenceLimitAspectRatio"
|
||||
Key.DISPLAY_MODE -> "PreferenceDisplayMode"
|
||||
Key.FORCE_HRR -> "PreferenceForceHighRefreshRate"
|
||||
Key.DISABLE_REDESIGN -> "PreferenceDisableRedesign"
|
||||
Key.RELEASE_CHANNEL_TAG -> "PreferenceReleaseChannelTag"
|
||||
Key.DEVELOPER_MODE -> "PreferenceDeveloperMode"
|
||||
Key.CLEANUP_APKS -> "PreferenceCleanupPackages"
|
||||
Key.CUSTOM_SYMBOL_LIST -> "PreferenceCustomSymbolList"
|
||||
Key.CUSTOM_ASPECT_RATIO -> "PreferenceCustomAspectRatio"
|
||||
Key.SCREEN_ZOOM -> "PreferenceScreenZoom"
|
||||
Key.SCREEN_ZOOM_FIT -> "PreferenceScreenZoomFit"
|
||||
@@ -205,6 +198,14 @@ class PreferenceUtils(private val sharedPreferences: SharedPreferences) {
|
||||
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,17 +6,19 @@ import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
const val MS_TO_SEC = 1000L
|
||||
|
||||
@Composable
|
||||
fun useCountdownTimer(
|
||||
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
|
||||
time: Long,
|
||||
time: Duration,
|
||||
onCountdownFinish: () -> Unit
|
||||
): Long {
|
||||
var millisUntilFinished by remember {
|
||||
mutableLongStateOf(time)
|
||||
mutableStateOf(time)
|
||||
}
|
||||
|
||||
var shouldBeCounting by remember {
|
||||
@@ -28,9 +30,9 @@ fun useCountdownTimer(
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
if (millisUntilFinished > 0) {
|
||||
delay(MS_TO_SEC)
|
||||
millisUntilFinished -= MS_TO_SEC
|
||||
if (millisUntilFinished > Duration.ZERO) {
|
||||
delay(1.seconds)
|
||||
millisUntilFinished -= 1.seconds
|
||||
} else {
|
||||
onCountdownFinish()
|
||||
}
|
||||
@@ -58,5 +60,5 @@ fun useCountdownTimer(
|
||||
}
|
||||
}
|
||||
|
||||
return millisUntilFinished / MS_TO_SEC
|
||||
return millisUntilFinished.inWholeSeconds
|
||||
}
|
||||
@@ -1,31 +1,45 @@
|
||||
package org.cocos2dx.lib
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.*
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Paint.FontMetricsInt
|
||||
import android.graphics.Rect
|
||||
import android.graphics.Typeface
|
||||
import android.text.TextPaint
|
||||
import android.text.TextUtils
|
||||
import android.util.Log
|
||||
import android.view.MotionEvent
|
||||
import androidx.core.graphics.createBitmap
|
||||
import java.lang.ref.WeakReference
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.*
|
||||
import java.util.LinkedList
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ceil
|
||||
|
||||
private const val HORIZONTALALIGN_LEFT = 1
|
||||
private const val HORIZONTALALIGN_RIGHT = 2
|
||||
private const val HORIZONTALALIGN_CENTER = 3
|
||||
private const val VERTICALALIGN_TOP = 1
|
||||
private const val VERTICALALIGN_BOTTOM = 2
|
||||
private const val VERTICALALIGN_CENTER = 3
|
||||
|
||||
@Suppress("KotlinJniMissingFunction")
|
||||
object Cocos2dxBitmap {
|
||||
private lateinit var context: WeakReference<Context>
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeInitBitmapDC(pWidth: Int, pHeight: Int, pPixels: ByteArray)
|
||||
external fun nativeInitBitmapDC(pWidth: Int, pHeight: Int, pPixels: ByteArray)
|
||||
|
||||
fun setContext(context: Context) {
|
||||
this.context = WeakReference(context)
|
||||
}
|
||||
|
||||
/*
|
||||
@JvmStatic
|
||||
@Suppress("unused")
|
||||
fun createTextBitmap(
|
||||
string: String,
|
||||
fontName: String,
|
||||
@@ -55,7 +69,6 @@ object Cocos2dxBitmap {
|
||||
1.0f
|
||||
)
|
||||
}
|
||||
*/
|
||||
|
||||
@JvmStatic
|
||||
@Suppress("unused")
|
||||
@@ -79,19 +92,18 @@ object Cocos2dxBitmap {
|
||||
strokeB: Float,
|
||||
strokeSize: Float
|
||||
) {
|
||||
val bitmapTotalHeight: Int
|
||||
val horizontalAlignment = alignment and 15
|
||||
val verticalAlignment = alignment shr 4 and 15
|
||||
val horizontalAlignment = alignment and 0x0F
|
||||
val verticalAlignment = (alignment shr 4) and 0x0F
|
||||
val pString2 = refactorString(string)
|
||||
val paint = newPaint(fontName, fontSize, horizontalAlignment)
|
||||
paint.setARGB(
|
||||
MotionEvent.ACTION_MASK,
|
||||
(255.0 * fontTintR.toDouble()).toInt(),
|
||||
(255.0 * fontTintG.toDouble()).toInt(),
|
||||
(255.0 * fontTintB.toDouble()).toInt()
|
||||
255,
|
||||
(255.0 * fontTintR).toInt(),
|
||||
(255.0 * fontTintG).toInt(),
|
||||
(255.0 * fontTintB).toInt()
|
||||
)
|
||||
val textProperty = computeTextProperty(pString2, width, height, paint)
|
||||
bitmapTotalHeight = if (height == 0) {
|
||||
val bitmapTotalHeight = if (height == 0) {
|
||||
textProperty.totalHeight
|
||||
} else {
|
||||
height
|
||||
@@ -101,7 +113,8 @@ object Cocos2dxBitmap {
|
||||
var renderTextDeltaX = 0.0f
|
||||
var renderTextDeltaY = 0.0f
|
||||
if (shadow) {
|
||||
paint.setShadowLayer(shadowBlur, shadowDX, shadowDY, -8553091)
|
||||
val shadowColor = 0xff7d7d7du.toInt()
|
||||
paint.setShadowLayer(shadowBlur, shadowDX, shadowDY, shadowColor)
|
||||
bitmapPaddingX = abs(shadowDX)
|
||||
bitmapPaddingY = abs(shadowDY)
|
||||
if (shadowDX.toDouble() < 0.0) {
|
||||
@@ -111,56 +124,47 @@ object Cocos2dxBitmap {
|
||||
renderTextDeltaY = bitmapPaddingY
|
||||
}
|
||||
}
|
||||
val bitmap = Bitmap.createBitmap(
|
||||
textProperty.maxWidth + bitmapPaddingX.toInt(),
|
||||
bitmapPaddingY.toInt() + bitmapTotalHeight,
|
||||
Bitmap.Config.ARGB_8888
|
||||
)
|
||||
|
||||
val bitmap = try {
|
||||
createBitmap(
|
||||
textProperty.maxWidth + bitmapPaddingX.toInt(),
|
||||
bitmapTotalHeight + bitmapPaddingY.toInt()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// bandaid fix, hopefully someone checks logs to see this
|
||||
Log.e("Cocos2dxBitmap", "Error creating bitmap: $e")
|
||||
nativeInitBitmapDC(0, 0, ByteArray(0))
|
||||
return
|
||||
}
|
||||
|
||||
val canvas = Canvas(bitmap)
|
||||
val fontMetricsInt = paint.fontMetricsInt
|
||||
var y = computeY(fontMetricsInt, height, textProperty.totalHeight, verticalAlignment)
|
||||
val lines = textProperty.lines
|
||||
val length = lines.size
|
||||
for (i in 0 until length) {
|
||||
val line = lines[i]
|
||||
canvas.drawText(
|
||||
line!!,
|
||||
computeX(
|
||||
line,
|
||||
textProperty.maxWidth,
|
||||
horizontalAlignment
|
||||
).toFloat() + renderTextDeltaX,
|
||||
y.toFloat() + renderTextDeltaY,
|
||||
paint
|
||||
)
|
||||
|
||||
for (line in lines) {
|
||||
val x = computeX(line, textProperty.maxWidth, horizontalAlignment)
|
||||
canvas.drawText(line, x + renderTextDeltaX, y + renderTextDeltaY, paint)
|
||||
y += textProperty.heightPerLine
|
||||
}
|
||||
|
||||
if (stroke) {
|
||||
val paintStroke = newPaint(fontName, fontSize, horizontalAlignment)
|
||||
paintStroke.style = Paint.Style.STROKE
|
||||
paintStroke.strokeWidth = 0.5f * strokeSize
|
||||
paintStroke.strokeWidth = strokeSize * 0.5f
|
||||
paintStroke.setARGB(
|
||||
MotionEvent.ACTION_MASK,
|
||||
255,
|
||||
strokeR.toInt() * MotionEvent.ACTION_MASK,
|
||||
strokeG.toInt() * MotionEvent.ACTION_MASK,
|
||||
strokeB.toInt() * MotionEvent.ACTION_MASK
|
||||
)
|
||||
var y2 = computeY(fontMetricsInt, height, textProperty.totalHeight, verticalAlignment)
|
||||
val lines2 = textProperty.lines
|
||||
val length2 = lines2.size
|
||||
for (i2 in 0 until length2) {
|
||||
val line2 = lines2[i2]
|
||||
canvas.drawText(
|
||||
line2!!,
|
||||
computeX(
|
||||
line2,
|
||||
textProperty.maxWidth,
|
||||
horizontalAlignment
|
||||
).toFloat() + renderTextDeltaX,
|
||||
y2.toFloat() + renderTextDeltaY,
|
||||
paintStroke
|
||||
)
|
||||
y2 += textProperty.heightPerLine
|
||||
var y = computeY(fontMetricsInt, height, textProperty.totalHeight, verticalAlignment)
|
||||
val lines = textProperty.lines
|
||||
|
||||
for (line in lines) {
|
||||
val x = computeX(line, textProperty.maxWidth, horizontalAlignment)
|
||||
canvas.drawText(line, x + renderTextDeltaX, y + renderTextDeltaY, paintStroke)
|
||||
y += textProperty.heightPerLine
|
||||
}
|
||||
}
|
||||
initNativeObject(bitmap)
|
||||
@@ -168,24 +172,24 @@ object Cocos2dxBitmap {
|
||||
|
||||
private fun newPaint(fontName: String, fontSize: Int, horizontalAlignment: Int): Paint {
|
||||
val paint = Paint()
|
||||
paint.color = -1
|
||||
paint.color = Color.WHITE
|
||||
paint.textSize = fontSize.toFloat()
|
||||
paint.isAntiAlias = true
|
||||
if (fontName.endsWith(".ttf")) {
|
||||
try {
|
||||
context.get()?.apply {
|
||||
context.get()!!.apply {
|
||||
paint.typeface = Cocos2dxTypefaces[this, fontName]
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e("Cocos2dxBitmap", "error to create ttf type face: $fontName")
|
||||
Log.e("Cocos2dxBitmap", "error to create ttf type face: $fontName - $e")
|
||||
paint.typeface = Typeface.create(fontName, Typeface.NORMAL)
|
||||
}
|
||||
} else {
|
||||
paint.typeface = Typeface.create(fontName, Typeface.NORMAL)
|
||||
}
|
||||
when (horizontalAlignment) {
|
||||
2 -> paint.textAlign = Paint.Align.RIGHT
|
||||
3 -> paint.textAlign = Paint.Align.CENTER
|
||||
HORIZONTALALIGN_RIGHT -> paint.textAlign = Paint.Align.RIGHT
|
||||
HORIZONTALALIGN_CENTER -> paint.textAlign = Paint.Align.CENTER
|
||||
else -> paint.textAlign = Paint.Align.LEFT
|
||||
}
|
||||
return paint
|
||||
@@ -198,14 +202,14 @@ object Cocos2dxBitmap {
|
||||
paint: Paint
|
||||
): TextProperty {
|
||||
val fm = paint.fontMetricsInt
|
||||
val h = ceil((fm.bottom - fm.top).toDouble()).toInt()
|
||||
val h = fm.bottom - fm.top
|
||||
var maxContentWidth = 0
|
||||
val lines = splitString(string, width, height, paint)
|
||||
if (width != 0) {
|
||||
maxContentWidth = width
|
||||
} else {
|
||||
for (line in lines) {
|
||||
val temp = ceil(paint.measureText(line, 0, line!!.length).toDouble()).toInt()
|
||||
val temp = ceil(paint.measureText(line, 0, line.length)).toInt()
|
||||
if (temp > maxContentWidth) {
|
||||
maxContentWidth = temp
|
||||
}
|
||||
@@ -214,10 +218,10 @@ object Cocos2dxBitmap {
|
||||
return TextProperty(maxContentWidth, h, lines)
|
||||
}
|
||||
|
||||
private fun computeX(@Suppress("UNUSED_PARAMETER") text: String?, maxWidth: Int, horizontalAlignment: Int): Int {
|
||||
private fun computeX(@Suppress("UNUSED_PARAMETER") text: String, maxWidth: Int, horizontalAlignment: Int): Int {
|
||||
return when (horizontalAlignment) {
|
||||
2 -> maxWidth
|
||||
3 -> maxWidth / 2
|
||||
HORIZONTALALIGN_RIGHT -> maxWidth
|
||||
HORIZONTALALIGN_CENTER -> maxWidth / 2
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
@@ -229,13 +233,15 @@ object Cocos2dxBitmap {
|
||||
verticalAlignment: Int
|
||||
): Int {
|
||||
val y = -fontMetricsInt.top
|
||||
return if (constrainHeight <= totalHeight) {
|
||||
return if (constrainHeight > totalHeight) {
|
||||
when (verticalAlignment) {
|
||||
VERTICALALIGN_TOP -> -fontMetricsInt.top
|
||||
VERTICALALIGN_CENTER -> -fontMetricsInt.top + (constrainHeight - totalHeight) / 2
|
||||
VERTICALALIGN_BOTTOM -> -fontMetricsInt.top + (constrainHeight - totalHeight)
|
||||
else -> y
|
||||
}
|
||||
} else {
|
||||
y
|
||||
} else when (verticalAlignment) {
|
||||
1 -> -fontMetricsInt.top
|
||||
2 -> -fontMetricsInt.top + (constrainHeight - totalHeight)
|
||||
3 -> -fontMetricsInt.top + (constrainHeight - totalHeight) / 2
|
||||
else -> y
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,56 +250,56 @@ object Cocos2dxBitmap {
|
||||
maxWidth: Int,
|
||||
maxHeight: Int,
|
||||
paint: Paint
|
||||
): Array<String?> {
|
||||
val lines: Array<String?> = string.split("\\n").toTypedArray()
|
||||
): List<String> {
|
||||
val lines = string.split("\n")
|
||||
val fm = paint.fontMetricsInt
|
||||
val maxLines = maxHeight / ceil((fm.bottom - fm.top).toDouble()).toInt()
|
||||
val heightPerLine = fm.bottom - fm.top
|
||||
val maxLines = maxHeight / heightPerLine
|
||||
return if (maxWidth != 0) {
|
||||
val strList = LinkedList<String?>()
|
||||
val strList = LinkedList<String>()
|
||||
for (line in lines) {
|
||||
if (ceil(paint.measureText(line).toDouble()).toInt() > maxWidth) {
|
||||
val lineWidth = ceil(paint.measureText(line)).toInt()
|
||||
|
||||
if (lineWidth > maxWidth) {
|
||||
strList.addAll(divideStringWithMaxWidth(line, maxWidth, paint))
|
||||
} else {
|
||||
strList.add(line)
|
||||
}
|
||||
|
||||
if (maxLines > 0 && strList.size >= maxLines) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (maxLines > 0 && strList.size > maxLines) {
|
||||
while (strList.size > maxLines) {
|
||||
strList.removeLast()
|
||||
}
|
||||
}
|
||||
val ret = arrayOfNulls<String>(strList.size)
|
||||
strList.toArray(ret)
|
||||
ret
|
||||
} else if (maxHeight == 0 || lines.size <= maxLines) {
|
||||
lines
|
||||
|
||||
strList
|
||||
} else if (maxHeight != 0 && lines.size > maxLines) {
|
||||
lines.take(maxLines)
|
||||
} else {
|
||||
val strList2 = LinkedList<String?>()
|
||||
for (i in 0 until maxLines) {
|
||||
strList2.add(lines[i])
|
||||
}
|
||||
val ret2 = arrayOfNulls<String>(strList2.size)
|
||||
strList2.toArray(ret2)
|
||||
ret2
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
private fun divideStringWithMaxWidth(
|
||||
string: String?,
|
||||
string: String,
|
||||
maxWidth: Int,
|
||||
paint: Paint
|
||||
): LinkedList<String?> {
|
||||
val charLength = string!!.length
|
||||
): LinkedList<String> {
|
||||
val charLength = string.length
|
||||
var start = 0
|
||||
val strList = LinkedList<String?>()
|
||||
val strList = LinkedList<String>()
|
||||
|
||||
var i = 1
|
||||
while (i <= charLength) {
|
||||
val tempWidth = ceil(paint.measureText(string, start, i).toDouble()).toInt()
|
||||
val tempWidth = ceil(paint.measureText(string, start, i)).toInt()
|
||||
if (tempWidth >= maxWidth) {
|
||||
val lastIndexOfSpace = string.substring(0, i).lastIndexOf(" ")
|
||||
|
||||
if (lastIndexOfSpace != -1 && lastIndexOfSpace > start) {
|
||||
strList.add(string.substring(start, lastIndexOfSpace))
|
||||
i = lastIndexOfSpace + 1
|
||||
@@ -303,16 +309,20 @@ object Cocos2dxBitmap {
|
||||
} else {
|
||||
strList.add(string.substring(start, i))
|
||||
}
|
||||
|
||||
while (i < charLength && string[i] == ' ') {
|
||||
i++
|
||||
}
|
||||
|
||||
start = i
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
if (start < charLength) {
|
||||
strList.add(string.substring(start))
|
||||
}
|
||||
|
||||
return strList
|
||||
}
|
||||
|
||||
@@ -320,19 +330,22 @@ object Cocos2dxBitmap {
|
||||
if (pString.compareTo("") == 0) {
|
||||
return " "
|
||||
}
|
||||
|
||||
val strBuilder = StringBuilder(pString)
|
||||
var start = 0
|
||||
var index = strBuilder.indexOf("\n")
|
||||
while (index != -1) {
|
||||
start = if (index == 0 || strBuilder[index - 1].code == 10) {
|
||||
start = if (index == 0 || strBuilder[index - 1] == '\n') {
|
||||
strBuilder.insert(start, " ")
|
||||
index + 2
|
||||
} else {
|
||||
index + 1
|
||||
}
|
||||
|
||||
if (start > strBuilder.length || index == strBuilder.length) {
|
||||
break
|
||||
}
|
||||
|
||||
index = strBuilder.indexOf("\n", start)
|
||||
}
|
||||
return strBuilder.toString()
|
||||
@@ -340,15 +353,10 @@ object Cocos2dxBitmap {
|
||||
|
||||
private fun initNativeObject(pBitmap: Bitmap) {
|
||||
val pixels = getPixels(pBitmap)
|
||||
if (pixels != null) {
|
||||
nativeInitBitmapDC(pBitmap.width, pBitmap.height, pixels)
|
||||
}
|
||||
nativeInitBitmapDC(pBitmap.width, pBitmap.height, pixels)
|
||||
}
|
||||
|
||||
private fun getPixels(pBitmap: Bitmap?): ByteArray? {
|
||||
if (pBitmap == null) {
|
||||
return null
|
||||
}
|
||||
private fun getPixels(pBitmap: Bitmap): ByteArray {
|
||||
val pixels = ByteArray(pBitmap.width * pBitmap.height * 4)
|
||||
val buf = ByteBuffer.wrap(pixels)
|
||||
buf.order(ByteOrder.nativeOrder())
|
||||
@@ -361,13 +369,18 @@ object Cocos2dxBitmap {
|
||||
private fun getFontSizeAccordingHeight(height: Int): Int {
|
||||
val paint = Paint()
|
||||
val bounds = Rect()
|
||||
|
||||
paint.typeface = Typeface.DEFAULT
|
||||
var incrTextSize = 1
|
||||
var foundDesiredSize = false
|
||||
|
||||
while (!foundDesiredSize) {
|
||||
paint.textSize = incrTextSize.toFloat()
|
||||
paint.getTextBounds("SghMNy", 0, "SghMNy".length, bounds)
|
||||
val text = "SghMNy"
|
||||
paint.getTextBounds(text, 0, text.length, bounds)
|
||||
|
||||
incrTextSize++
|
||||
|
||||
if (height - bounds.height() <= 2) {
|
||||
foundDesiredSize = true
|
||||
}
|
||||
@@ -382,23 +395,19 @@ object Cocos2dxBitmap {
|
||||
if (TextUtils.isEmpty(string)) {
|
||||
return ""
|
||||
}
|
||||
|
||||
val paint = TextPaint()
|
||||
paint.typeface = Typeface.DEFAULT
|
||||
paint.textSize = fontSize
|
||||
|
||||
return TextUtils.ellipsize(string, paint, width, TextUtils.TruncateAt.END).toString()
|
||||
}
|
||||
|
||||
private class TextProperty constructor(
|
||||
private class TextProperty(
|
||||
val maxWidth: Int,
|
||||
val heightPerLine: Int, pLines: Array<String?>
|
||||
val heightPerLine: Int,
|
||||
val lines: Collection<String>
|
||||
) {
|
||||
val lines: Array<String?>
|
||||
|
||||
val totalHeight: Int
|
||||
|
||||
init {
|
||||
totalHeight = pLines.size * heightPerLine
|
||||
lines = pLines
|
||||
}
|
||||
val totalHeight: Int = lines.size * heightPerLine
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,13 @@ import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.Message
|
||||
import android.util.Log
|
||||
import android.text.InputType
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.Surface
|
||||
import android.view.WindowInsets
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.widget.AppCompatEditText
|
||||
@@ -25,12 +27,54 @@ private const val HANDLER_CLOSE_IME_KEYBOARD = 3
|
||||
private const val MS_TO_NS = 1_000_000
|
||||
private const val SEC_TO_NS = 1_000_000_000.0
|
||||
|
||||
fun inputTypeForFlags(i: Int): Int {
|
||||
if ((i and BaseRobTopActivity.KEYBOARD_INPUT_NUMERIC) != 0) {
|
||||
return InputType.TYPE_NUMBER_FLAG_DECIMAL or InputType.TYPE_NUMBER_FLAG_SIGNED or InputType.TYPE_CLASS_NUMBER
|
||||
}
|
||||
|
||||
if ((i and BaseRobTopActivity.KEYBOARD_INPUT_PASSWORD) != 0) {
|
||||
return InputType.TYPE_TEXT_VARIATION_PASSWORD or InputType.TYPE_CLASS_TEXT
|
||||
}
|
||||
|
||||
if ((i and BaseRobTopActivity.KEYBOARD_INPUT_MULTILINE) != 0) {
|
||||
return InputType.TYPE_TEXT_FLAG_MULTI_LINE or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or InputType.TYPE_CLASS_TEXT
|
||||
}
|
||||
|
||||
return InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS or InputType.TYPE_CLASS_TEXT
|
||||
}
|
||||
|
||||
fun usesInlineKeyboard(): Boolean {
|
||||
return Build.VERSION.SDK_INT >= 30
|
||||
}
|
||||
|
||||
fun imeOptionsForFlags(i: Int): Int {
|
||||
val i2 = if ((i and BaseRobTopActivity.KEYBOARD_INPUT_MULTILINE) == 0 || !usesInlineKeyboard())
|
||||
EditorInfo.IME_ACTION_DONE else EditorInfo.IME_ACTION_NONE
|
||||
|
||||
return if (usesInlineKeyboard())
|
||||
EditorInfo.IME_FLAG_NO_FULLSCREEN or EditorInfo.IME_FLAG_NO_EXTRACT_UI or i2
|
||||
else i2
|
||||
}
|
||||
|
||||
class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
companion object {
|
||||
lateinit var cocos2dxGLSurfaceView: Cocos2dxGLSurfaceView
|
||||
private lateinit var handler: Handler
|
||||
private lateinit var cocos2dxTextInputWrapper: Cocos2dxTextInputWrapper
|
||||
|
||||
private var keyboardVisible = false
|
||||
private var keyboardHeight = 0
|
||||
|
||||
private var keyboardHideCheck = Runnable {
|
||||
if (keyboardVisible) {
|
||||
return@Runnable
|
||||
}
|
||||
|
||||
cocos2dxGLSurfaceView.queueEvent {
|
||||
cocos2dxGLSurfaceView.cocos2dxRenderer.handleTextClosed()
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun openIMEKeyboard() {
|
||||
val msg = Message()
|
||||
@@ -50,6 +94,30 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
cocos2dxGLSurfaceView.queueEvent {
|
||||
Cocos2dxAccelerometer.onSensorChanged(x, y, z, timestamp)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
fun onKeyboardInsets(windowInsets: WindowInsets) {
|
||||
if (!cocos2dxGLSurfaceView.sendNewCocosEvents) {
|
||||
return
|
||||
}
|
||||
|
||||
val visible = windowInsets.isVisible(WindowInsets.Type.ime())
|
||||
val offsetHeight = if (visible) windowInsets.getInsets(WindowInsets.Type.ime()).bottom else 0
|
||||
|
||||
if (visible == keyboardVisible && offsetHeight == keyboardHeight) {
|
||||
return
|
||||
}
|
||||
|
||||
keyboardVisible = visible
|
||||
keyboardHeight = offsetHeight
|
||||
|
||||
handler.removeCallbacks(keyboardHideCheck)
|
||||
if (!visible) {
|
||||
handler.postDelayed(keyboardHideCheck, 150L)
|
||||
} else {
|
||||
cocos2dxGLSurfaceView.cocos2dxRenderer.handleKeyboardWillShow(keyboardHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var cocos2dxRenderer: Cocos2dxRenderer
|
||||
@@ -63,6 +131,10 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
var manualBackEvents = false
|
||||
var controllerEventsEnabled = false
|
||||
private set
|
||||
var supportsResizeEvents = false
|
||||
|
||||
// 2.209+ events for keyboard and such
|
||||
var sendNewCocosEvents = false
|
||||
|
||||
var cocos2dxEditText: AppCompatEditText? = null
|
||||
set(value) {
|
||||
@@ -84,9 +156,13 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
HANDLER_OPEN_IME_KEYBOARD -> {
|
||||
if (cocos2dxEditText?.requestFocus() == true) {
|
||||
cocos2dxEditText?.apply {
|
||||
removeTextChangedListener(
|
||||
cocos2dxTextInputWrapper
|
||||
)
|
||||
removeTextChangedListener(cocos2dxTextInputWrapper)
|
||||
|
||||
if (sendNewCocosEvents) {
|
||||
inputType = inputTypeForFlags(BaseRobTopActivity.curKeyboardInputFlags)
|
||||
imeOptions = imeOptionsForFlags(BaseRobTopActivity.curKeyboardInputFlags)
|
||||
}
|
||||
|
||||
setText("")
|
||||
val text = msg.obj as String
|
||||
append(text)
|
||||
@@ -99,16 +175,13 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
cocos2dxEditText,
|
||||
0
|
||||
)
|
||||
Log.d("GLSurfaceView", "showSoftInput")
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
HANDLER_CLOSE_IME_KEYBOARD -> {
|
||||
cocos2dxEditText?.apply {
|
||||
removeTextChangedListener(
|
||||
cocos2dxTextInputWrapper
|
||||
)
|
||||
removeTextChangedListener(cocos2dxTextInputWrapper)
|
||||
(context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(
|
||||
windowToken,
|
||||
0
|
||||
@@ -116,8 +189,10 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
}
|
||||
|
||||
requestFocus()
|
||||
Log.d("GLSurfaceView", "HideSoftInput")
|
||||
cocos2dxRenderer.handleTextClosed()
|
||||
|
||||
queueEvent {
|
||||
cocos2dxRenderer.handleTextClosed()
|
||||
}
|
||||
return
|
||||
}
|
||||
else -> return
|
||||
@@ -288,6 +363,103 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
if (!isInEditMode) {
|
||||
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 {
|
||||
@@ -347,65 +519,19 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||
if (!useKeyboardEvents && !controllerEventsEnabled) {
|
||||
if (!queueKeyboardEvent(keyCode, event, true)) {
|
||||
return legacyKeyDown(keyCode, event)
|
||||
}
|
||||
|
||||
return when (keyCode) {
|
||||
// ignore system keys
|
||||
KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_MUTE -> {
|
||||
super.onKeyDown(keyCode, event)
|
||||
}
|
||||
else -> {
|
||||
if (BaseRobTopActivity.blockBackButton) {
|
||||
return true
|
||||
}
|
||||
|
||||
queueEvent {
|
||||
sendNextEventTimestamp(event.eventTime * MS_TO_NS)
|
||||
|
||||
if (useKeyboardEvents) {
|
||||
GeodeUtils.nativeKeyDown(keyCode, event.modifiers, event.repeatCount != 0)
|
||||
}
|
||||
|
||||
if (controllerEventsEnabled) {
|
||||
GeodeUtils.internalKeyEvent(event.eventTime * MS_TO_NS, event.deviceId, event.source, event.keyCode, event.modifiers, true, event.repeatCount)
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onKeyUp(keyCode: Int, event: KeyEvent): Boolean {
|
||||
if (!useKeyboardEvents && !controllerEventsEnabled) {
|
||||
if (!queueKeyboardEvent(keyCode, event, false)) {
|
||||
return super.onKeyUp(keyCode, event)
|
||||
}
|
||||
|
||||
return when (keyCode) {
|
||||
KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_MUTE -> {
|
||||
super.onKeyUp(keyCode, event)
|
||||
}
|
||||
else -> {
|
||||
if (event.repeatCount != 0 || BaseRobTopActivity.blockBackButton) {
|
||||
return true
|
||||
}
|
||||
|
||||
queueEvent {
|
||||
sendNextEventTimestamp(event.eventTime * MS_TO_NS)
|
||||
|
||||
if (useKeyboardEvents) {
|
||||
GeodeUtils.nativeKeyUp(keyCode, event.modifiers)
|
||||
}
|
||||
|
||||
if (controllerEventsEnabled) {
|
||||
GeodeUtils.internalKeyEvent(event.eventTime * MS_TO_NS, event.deviceId, event.source, event.keyCode, event.modifiers, false, event.repeatCount)
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// i'm lazy.
|
||||
@@ -468,11 +594,15 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
}
|
||||
|
||||
override fun onGenericMotionEvent(event: MotionEvent?): Boolean {
|
||||
if (!useKeyboardEvents && !controllerEventsEnabled) {
|
||||
val hasGeodeEvents = useKeyboardEvents || controllerEventsEnabled
|
||||
if (!hasGeodeEvents && !sendNewCocosEvents) {
|
||||
return super.onGenericMotionEvent(event)
|
||||
}
|
||||
|
||||
if (event?.action == MotionEvent.ACTION_SCROLL) {
|
||||
val mouseX = event.x
|
||||
val mouseY = event.y
|
||||
|
||||
val scrollX = event.getAxisValue(MotionEvent.AXIS_HSCROLL)
|
||||
val scrollY = event.getAxisValue(MotionEvent.AXIS_VSCROLL)
|
||||
val timestamp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||
@@ -486,7 +616,11 @@ class Cocos2dxGLSurfaceView(context: Context) : GLSurfaceView(context) {
|
||||
}
|
||||
|
||||
if (controllerEventsEnabled) {
|
||||
GeodeUtils.internalScrollEvent(timestamp, event.deviceId, event.source, scrollX, scrollY)
|
||||
GeodeUtils.internalScrollEvent(timestamp, event.deviceId, event.source, scrollX, scrollY, mouseX, mouseY)
|
||||
}
|
||||
|
||||
if (!hasGeodeEvents && sendNewCocosEvents) {
|
||||
cocos2dxRenderer.handleScroll(mouseX, mouseY, scrollY, scrollX)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,12 +28,29 @@ class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceVi
|
||||
@JvmStatic
|
||||
private external fun nativeKeyDown(keyCode: Int): Boolean
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeKeyboardEvent(
|
||||
keyCode: Int,
|
||||
down: Boolean,
|
||||
isRepeat: Boolean,
|
||||
timestamp: Double,
|
||||
shiftPressed: Boolean,
|
||||
ctrlPressed: Boolean,
|
||||
altPressed: Boolean
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeKeyboardWillShow(offset: Int)
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeOnPause()
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeOnResume()
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeScroll(x: Float, y: Float, scrollY: Float, scrollX: Float)
|
||||
|
||||
@JvmStatic
|
||||
private external fun nativeRender()
|
||||
|
||||
@@ -133,6 +150,18 @@ class Cocos2dxRenderer(private var handler: Cocos2dxGLSurfaceView) : GLSurfaceVi
|
||||
nativeKeyDown(keyCode)
|
||||
}
|
||||
|
||||
fun handleKeyboardWillShow(offset: Int) {
|
||||
nativeKeyboardWillShow(offset)
|
||||
}
|
||||
|
||||
fun handleKeyboardEvent(keyCode: Int, down: Boolean, isRepeat: Boolean, timestamp: Double, shiftPressed: Boolean, ctrlPressed: Boolean, altPressed: Boolean) {
|
||||
nativeKeyboardEvent(keyCode, down, isRepeat, timestamp, shiftPressed, ctrlPressed, altPressed)
|
||||
}
|
||||
|
||||
fun handleScroll(x: Float, y: Float, scrollY: Float, scrollX: Float) {
|
||||
nativeScroll(x, y, scrollY, scrollX)
|
||||
}
|
||||
|
||||
fun handleOnPause() {
|
||||
nativeOnPause()
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import android.text.TextWatcher
|
||||
import android.widget.TextView.OnEditorActionListener
|
||||
import android.text.Editable
|
||||
import android.view.KeyEvent
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.TextView
|
||||
import com.customRobTop.JniToCpp
|
||||
|
||||
class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurfaceView) : TextWatcher, OnEditorActionListener {
|
||||
private var originText: String? = null
|
||||
@@ -19,21 +21,33 @@ class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurf
|
||||
this.originText = originText
|
||||
}
|
||||
|
||||
override fun afterTextChanged(s: Editable) {
|
||||
if (!isFullScreenEdit) {
|
||||
var nModified = s.length - text!!.length
|
||||
if (nModified > 0) {
|
||||
cocos2dxGLSurfaceView.insertText(
|
||||
s.subSequence(text!!.length, s.length).toString()
|
||||
)
|
||||
} else {
|
||||
while (nModified < 0) {
|
||||
cocos2dxGLSurfaceView.deleteBackward()
|
||||
nModified++
|
||||
}
|
||||
}
|
||||
text = s.toString()
|
||||
override fun afterTextChanged(editable: Editable) {
|
||||
if (isFullScreenEdit) {
|
||||
return
|
||||
}
|
||||
|
||||
val cText = text ?: return
|
||||
|
||||
val str = editable.toString()
|
||||
val unchanged = str.commonPrefixWith(cText)
|
||||
|
||||
var unchangedUntil = unchanged.length
|
||||
if (unchangedUntil > 0 && unchangedUntil < str.length && Character.isLowSurrogate(str[unchangedUntil])) {
|
||||
unchangedUntil--
|
||||
}
|
||||
|
||||
val codePointLength = cText.codePointCount(unchangedUntil, cText.length)
|
||||
repeat(codePointLength) {
|
||||
cocos2dxGLSurfaceView.deleteBackward()
|
||||
}
|
||||
|
||||
if (unchangedUntil < str.length) {
|
||||
cocos2dxGLSurfaceView.insertText(
|
||||
str.substring(unchangedUntil)
|
||||
)
|
||||
}
|
||||
|
||||
text = str
|
||||
}
|
||||
|
||||
override fun beforeTextChanged(
|
||||
@@ -47,22 +61,45 @@ class Cocos2dxTextInputWrapper(private val cocos2dxGLSurfaceView: Cocos2dxGLSurf
|
||||
|
||||
override fun onTextChanged(pCharSequence: CharSequence, start: Int, before: Int, count: Int) {}
|
||||
override fun onEditorAction(textView: TextView, actionID: Int, keyEvent: KeyEvent?): Boolean {
|
||||
if (cocos2dxGLSurfaceView.cocos2dxEditText == textView && isFullScreenEdit) {
|
||||
for (i in originText!!.length downTo 1) {
|
||||
cocos2dxGLSurfaceView.deleteBackward()
|
||||
}
|
||||
val fullscreenIme = cocos2dxGLSurfaceView.cocos2dxEditText == textView && isFullScreenEdit
|
||||
if (fullscreenIme) {
|
||||
var text = textView.text.toString()
|
||||
if (!text.endsWith('\n')) {
|
||||
text += '\n'
|
||||
}
|
||||
if (originText != text || !cocos2dxGLSurfaceView.sendNewCocosEvents) {
|
||||
if (cocos2dxGLSurfaceView.sendNewCocosEvents) {
|
||||
cocos2dxGLSurfaceView.queueEvent {
|
||||
JniToCpp.textInputCaretToEnd()
|
||||
}
|
||||
}
|
||||
|
||||
cocos2dxGLSurfaceView.insertText(text)
|
||||
repeat(originText!!.length) {
|
||||
cocos2dxGLSurfaceView.deleteBackward()
|
||||
}
|
||||
|
||||
if (!cocos2dxGLSurfaceView.sendNewCocosEvents) {
|
||||
if (!text.endsWith('\n')) {
|
||||
text += '\n'
|
||||
}
|
||||
}
|
||||
|
||||
if (text.isNotEmpty()) {
|
||||
cocos2dxGLSurfaceView.insertText(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (actionID != 6) {
|
||||
return false
|
||||
|
||||
if (fullscreenIme || actionID == EditorInfo.IME_ACTION_DONE) {
|
||||
if (cocos2dxGLSurfaceView.sendNewCocosEvents) {
|
||||
cocos2dxGLSurfaceView.queueEvent {
|
||||
JniToCpp.textInputDone()
|
||||
}
|
||||
}
|
||||
}
|
||||
cocos2dxGLSurfaceView.requestFocus()
|
||||
Cocos2dxGLSurfaceView.closeIMEKeyboard()
|
||||
|
||||
if (actionID == EditorInfo.IME_ACTION_DONE) {
|
||||
cocos2dxGLSurfaceView.requestFocus()
|
||||
Cocos2dxGLSurfaceView.closeIMEKeyboard()
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,28 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="1024"
|
||||
android:viewportHeight="1024">
|
||||
<group android:scaleX="0.75" android:scaleY="0.75" android:translateX="128" android:translateY="128">
|
||||
android:viewportWidth="1366"
|
||||
android:viewportHeight="1366">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M0,0h1024v1024h-1024z"/>
|
||||
android:pathData="M0,0h1366v1366h-1366z"/>
|
||||
<path
|
||||
android:pathData="M0,0h1024v1024h-1024z"
|
||||
android:pathData="M0,0h1366v1366h-1366z"
|
||||
android:fillColor="#F4D48E"/>
|
||||
<path
|
||||
android:pathData="M512,512m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
|
||||
android:pathData="M598,683m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
|
||||
android:fillColor="#F4D48E"/>
|
||||
<path
|
||||
android:pathData="M719.5,289C618,241.7 526,152.5 320.5,152.5C115,152.5 3.5,248 -8.5,293C-20.5,338 -8.2,391.5 -13.5,390.5L-33,706.5H1145L1137.5,460C1115.5,426.8 1043.5,399 1007,391.5C970.5,384 822.5,337 719.5,289Z"
|
||||
android:pathData="M873.8,459.7C752.5,412.3 642.7,323 397.2,323C151.8,323 18.6,418.7 4.3,463.8C-10.1,508.8 4.7,562.4 -1.7,561.4L-25,878H1382L1373,631.1C1346.8,597.8 1260.8,569.9 1217.2,562.4C1173.6,554.9 996.8,507.8 873.8,459.7Z"
|
||||
android:fillColor="#F4A47B"/>
|
||||
<path
|
||||
android:pathData="M561.5,514.5C488.5,476.5 341,402 214.5,402C88,402 30.3,440.7 -8.5,461.5C-47.3,482.3 -54,485.2 -59,486.5L-68.5,808L563.5,814.5L561.5,514.5Z"
|
||||
android:pathData="M647.9,685.4C569.9,647.4 412.4,573 277.3,573C142.1,573 80.5,611.7 39.1,632.4C-2.4,653.2 -9.5,656.1 -14.9,657.4L-25,978.5L650,985L647.9,685.4Z"
|
||||
android:fillColor="#E27880"/>
|
||||
<path
|
||||
android:pathData="M1020.5,551C994,521.2 893.7,454.1 662.5,452.5C539.2,451.6 363,505.6 259,577.5C163.9,643.2 43,680 -13,686L-124,934.5H1187.5L1020.5,551Z"
|
||||
android:pathData="M1362.1,728.7C1327,696.7 1194.3,624.7 888.5,623C725.4,622.1 492.3,679.9 354.7,757.1C228.9,827.6 68.9,867 -5.2,873.5L-152,1140H1583L1362.1,728.7Z"
|
||||
android:fillColor="#B9588F"/>
|
||||
<path
|
||||
android:pathData="M819.5,856C638,856 518,772.5 359,772.5C200,772.5 100,860.5 65,869L-94,949.5V1041.5L1177.5,1025.5L1032.5,738C1028,758 967.4,856 819.5,856Z"
|
||||
android:pathData="M1107,1027.2C885.5,1027.2 739,943.6 544.9,943.6C350.9,943.6 228.8,1031.7 186.1,1040.2L-8,1120.8V1385L1376,1379L1367,909C1361.5,929 1287.6,1027.2 1107,1027.2Z"
|
||||
android:fillColor="#714A9A"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,29 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="1366"
|
||||
android:viewportHeight="1366">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M0,0h1366v1366h-1366z"/>
|
||||
<path
|
||||
android:pathData="M0,0h1366v1366h-1366z"
|
||||
android:fillColor="#9EF4C3"/>
|
||||
<path
|
||||
android:pathData="M598,683m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
|
||||
android:fillColor="#9EF4C3"/>
|
||||
<path
|
||||
android:pathData="M873.8,459.7C752.5,412.3 642.7,323 397.2,323C151.8,323 18.6,418.7 4.3,463.8C-10.1,508.8 4.7,562.4 -1.7,561.4L-25,878H1382L1373,631.1C1346.8,597.8 1260.8,569.9 1217.2,562.4C1173.6,554.9 996.8,507.8 873.8,459.7Z"
|
||||
android:fillColor="#6DDBAD"/>
|
||||
<path
|
||||
android:pathData="M647.9,685.4C569.9,647.4 412.4,573 277.3,573C142.1,573 80.5,611.7 39.1,632.4C-2.4,653.2 -9.5,656.1 -14.9,657.4L-25,978.5L650,985L647.9,685.4Z"
|
||||
android:fillColor="#64C780"/>
|
||||
<path
|
||||
android:pathData="M1362.1,728.7C1327,696.7 1194.3,624.7 888.5,623C725.4,622.1 492.3,679.9 354.7,757.1C228.9,827.6 68.9,867 -5.2,873.5L-152,1140H1583L1362.1,728.7Z"
|
||||
android:fillColor="#46A47D"/>
|
||||
<path
|
||||
android:pathData="M1107,1027.2C885.5,1027.2 739,943.6 544.9,943.6C350.9,943.6 228.8,1031.7 186.1,1040.2L-8,1120.8V1385L1376,1379L1367,909C1361.5,929 1287.6,1027.2 1107,1027.2Z"
|
||||
android:fillColor="#417563"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="1024"
|
||||
android:viewportHeight="1024">
|
||||
<group android:scaleX="0.75" android:scaleY="0.75" android:translateX="128" android:translateY="128">
|
||||
android:viewportWidth="1366"
|
||||
android:viewportHeight="1366">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M0,0h1024v1024h-1024z"/>
|
||||
android:pathData="M0,0h1366v1366h-1366z"/>
|
||||
<path
|
||||
android:pathData="M0,0h1024v1024h-1024z"
|
||||
android:pathData="M0,0h1366v1366h-1366z"
|
||||
android:fillColor="#CC7C7C"/>
|
||||
<path
|
||||
android:pathData="M512,512m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
|
||||
android:pathData="M598,683m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
|
||||
android:fillColor="#CC7C7C"/>
|
||||
<path
|
||||
android:pathData="M719.5,289C618,241.7 526,152.5 320.5,152.5C115,152.5 3.5,248 -8.5,293C-20.5,338 -8.2,391.5 -13.5,390.5L-33,706.5H1145L1137.5,460C1115.5,426.8 1043.5,399 1007,391.5C970.5,384 822.5,337 719.5,289Z"
|
||||
android:pathData="M873.8,459.7C752.5,412.3 642.7,323 397.2,323C151.8,323 18.6,418.7 4.3,463.8C-10.1,508.8 4.7,562.4 -1.7,561.4L-25,878H1382L1373,631.1C1346.8,597.8 1260.8,569.9 1217.2,562.4C1173.6,554.9 996.8,507.8 873.8,459.7Z"
|
||||
android:fillColor="#C6B471"/>
|
||||
<path
|
||||
android:pathData="M561.5,514.5C488.5,476.5 341,402 214.5,402C88,402 30.3,440.7 -8.5,461.5C-47.3,482.3 -54,485.2 -59,486.5L-68.5,808L563.5,814.5L561.5,514.5Z"
|
||||
android:pathData="M647.9,685.4C569.9,647.4 412.4,573 277.3,573C142.1,573 80.5,611.7 39.1,632.4C-2.4,653.2 -9.5,656.1 -14.9,657.4L-25,978.5L650,985L647.9,685.4Z"
|
||||
android:fillColor="#59AF7F"/>
|
||||
<path
|
||||
android:pathData="M1020.5,551C994,521.2 893.7,454.1 662.5,452.5C539.2,451.6 363,505.6 259,577.5C163.9,643.2 43,680 -13,686L-124,934.5H1187.5L1020.5,551Z"
|
||||
android:pathData="M1362.1,728.7C1327,696.7 1194.3,624.7 888.5,623C725.4,622.1 492.3,679.9 354.7,757.1C228.9,827.6 68.9,867 -5.2,873.5L-152,1140H1583L1362.1,728.7Z"
|
||||
android:fillColor="#5F8CAB"/>
|
||||
<path
|
||||
android:pathData="M819.5,856C638,856 518,772.5 359,772.5C200,772.5 100,860.5 65,869L-94,949.5V1041.5L1177.5,1025.5L1032.5,738C1028,758 967.4,856 819.5,856Z"
|
||||
android:pathData="M1107,1027.2C885.5,1027.2 739,943.6 544.9,943.6C350.9,943.6 228.8,1031.7 186.1,1040.2L-8,1120.8V1385L1376,1379L1367,909C1361.5,929 1287.6,1027.2 1107,1027.2Z"
|
||||
android:fillColor="#70558D"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<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>
|
||||
@@ -0,0 +1,27 @@
|
||||
<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>
|
||||
@@ -0,0 +1,18 @@
|
||||
<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,28 +1,29 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="1024"
|
||||
android:viewportHeight="1024">
|
||||
<group android:scaleX="0.75" android:scaleY="0.75" android:translateX="128" android:translateY="128">
|
||||
android:viewportWidth="1366"
|
||||
android:viewportHeight="1366">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M0,0h1024v1024h-1024z"/>
|
||||
android:pathData="M0,0h1366v1366h-1366z"/>
|
||||
<path
|
||||
android:pathData="M0,0h1024v1024h-1024z"
|
||||
android:pathData="M0,0h1366v1366h-1366z"
|
||||
android:fillColor="#65B8C2"/>
|
||||
<path
|
||||
android:pathData="M512,512m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
|
||||
android:pathData="M598,683m-512,0a512,512 0,1 1,1024 0a512,512 0,1 1,-1024 0"
|
||||
android:fillColor="#65B8C2"/>
|
||||
<path
|
||||
android:pathData="M719.5,289C618,241.7 526,152.5 320.5,152.5C115,152.5 3.5,248 -8.5,293C-20.5,338 -8.2,391.5 -13.5,390.5L-33,706.5H1145L1137.5,460C1115.5,426.8 1043.5,399 1007,391.5C970.5,384 822.5,337 719.5,289Z"
|
||||
android:pathData="M873.8,459.7C752.5,412.3 642.7,323 397.2,323C151.8,323 18.6,418.7 4.3,463.8C-10.1,508.8 4.7,562.4 -1.7,561.4L-25,878H1382L1373,631.1C1346.8,597.8 1260.8,569.9 1217.2,562.4C1173.6,554.9 996.8,507.8 873.8,459.7Z"
|
||||
android:fillColor="#DEA5CD"/>
|
||||
<path
|
||||
android:pathData="M561.5,514.5C488.5,476.5 341,402 214.5,402C88,402 30.3,440.7 -8.5,461.5C-47.3,482.3 -54,485.2 -59,486.5L-68.5,808L563.5,814.5L561.5,514.5Z"
|
||||
android:pathData="M647.9,685.4C569.9,647.4 412.4,573 277.3,573C142.1,573 80.5,611.7 39.1,632.4C-2.4,653.2 -9.5,656.1 -14.9,657.4L-25,978.5L650,985L647.9,685.4Z"
|
||||
android:fillColor="#DEE8EA"/>
|
||||
<path
|
||||
android:pathData="M1020.5,551C994,521.2 893.7,454.1 662.5,452.5C539.2,451.6 363,505.6 259,577.5C163.9,643.2 43,680 -13,686L-124,934.5H1187.5L1020.5,551Z"
|
||||
android:pathData="M1362.1,728.7C1327,696.7 1194.3,624.7 888.5,623C725.4,622.1 492.3,679.9 354.7,757.1C228.9,827.6 68.9,867 -5.2,873.5L-152,1140H1583L1362.1,728.7Z"
|
||||
android:fillColor="#C485BD"/>
|
||||
<path
|
||||
android:pathData="M819.5,856C638,856 518,772.5 359,772.5C200,772.5 100,860.5 65,869L-94,949.5V1041.5L1177.5,1025.5L1032.5,738C1028,758 967.4,856 819.5,856Z"
|
||||
android:pathData="M1107,1027.2C885.5,1027.2 739,943.6 544.9,943.6C350.9,943.6 228.8,1031.7 186.1,1040.2L-8,1120.8V1385L1376,1379L1367,909C1361.5,929 1287.6,1027.2 1107,1027.2Z"
|
||||
android:fillColor="#259CB4"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="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>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="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>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M280,840L280,840Q280,840 280,840Q280,840 280,840L280,120Q280,120 280,120Q280,120 280,120L280,120Q280,120 280,120Q280,120 280,120L280,520L280,520Q280,520 280,590Q280,660 280,760L280,840ZM508.5,228.5Q520,217 520,200Q520,183 508.5,171.5Q497,160 480,160Q463,160 451.5,171.5Q440,183 440,200Q440,217 451.5,228.5Q463,240 480,240Q497,240 508.5,228.5ZM280,920Q247,920 223.5,896.5Q200,873 200,840L200,120Q200,87 223.5,63.5Q247,40 280,40L680,40Q713,40 736.5,63.5Q760,87 760,120L760,244Q778,251 789,266Q800,281 800,300L800,380Q800,399 789,414Q778,429 760,436L760,520L680,520L680,120Q680,120 680,120Q680,120 680,120L280,120Q280,120 280,120Q280,120 280,120L280,840Q280,840 280,840Q280,840 280,840L360,840L360,920L280,920ZM584,904L440,760L584,616L640,673L553,760L640,847L584,904ZM776,904L720,847L807,760L720,673L776,616L920,760L776,904Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="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>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="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>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_green_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_monochrome_shadow"/>
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_sapphire_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_sapphire_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_sapphire_monochrome" />
|
||||
</adaptive-icon>
|
||||
@@ -35,7 +35,7 @@
|
||||
</string>
|
||||
|
||||
<string name="launcher_game_update_required_short">A Geometry Dash update is required.</string>
|
||||
<string name="launcher_game_update_required">The currently installed version of Geometry Dash is no longer supported. Please update Geometry Dash to continue receiving Geode updates.</string>
|
||||
<string name="launcher_game_update_required">The currently installed version of Geometry Dash is no longer supported. Please update Geometry Dash to version %1$s to continue receiving Geode updates.</string>
|
||||
|
||||
<string name="launcher_update_available">A new launcher update is available!</string>
|
||||
<string name="launcher_update_dismiss">Dismiss</string>
|
||||
@@ -53,6 +53,7 @@
|
||||
<string name="launcher_error_view_logs">View logs</string>
|
||||
<string name="launcher_error_copy_error_alt">Copy</string>
|
||||
<string name="launcher_error_copy">Copy details</string>
|
||||
<string name="launcher_error_view_details">View details</string>
|
||||
<string name="launcher_error_export_crash">Share crash</string>
|
||||
<string name="launcher_error_export_missing">No crash dump was found!</string>
|
||||
<string name="launcher_error_device_info">Android %2$s, %1$s (%5$s)\nGD %4$s, Geode %3$s + v%6$s</string>
|
||||
@@ -62,12 +63,16 @@
|
||||
<string name="launcher_notification_update_success">Geode updated!</string>
|
||||
<string name="launcher_notification_compatibility">Unsupported Geometry Dash version.</string>
|
||||
<string name="launcher_notification_compatibility_description">An update is required to receive Geode updates.</string>
|
||||
<string name="launcher_notification_forwards_compatibility_description">Geode is currently unavailable.</string>
|
||||
<string name="launcher_notification_forwards_compatibility">Unsupported Geometry Dash version.</string>
|
||||
|
||||
<string name="launcher_notification_update_cta">Tap for more information.</string>
|
||||
|
||||
<string name="launcher_self_update_failed_title">Update failed</string>
|
||||
|
||||
<string name="launcher_self_update_failed">Failed to install launcher update: %1$s.</string>
|
||||
<string name="launcher_self_update_progress">Updating Geode Launcher…</string>
|
||||
<string name="launcher_update_download_failed">Downloading update failed: %1$s</string>
|
||||
<string name="launcher_self_update_progress">Installing update…</string>
|
||||
|
||||
<string name="launcher_starting_game">Starting Geometry Dash…</string>
|
||||
<string name="launcher_downloading_update">Downloading update…</string>
|
||||
@@ -75,8 +80,9 @@
|
||||
<string name="launcher_cancelled_manual">Launch paused.</string>
|
||||
<string name="launcher_cancelled_navigated_away">Launch paused.</string>
|
||||
<string name="launcher_cancelled_crash">Launch paused due to a crash.</string>
|
||||
<string name="launcher_cancelled_crash_instant">Geode crashed.</string>
|
||||
<string name="launcher_cancelled_error">Geode cannot be used.</string>
|
||||
<string name="launcher_cancelled_outdated">A Geometry Dash update is required.</string>
|
||||
<string name="launcher_cancelled_outdated">Geometry Dash version %1$s is required.</string>
|
||||
<string name="launcher_cancelled_restart">Restart</string>
|
||||
<string name="launcher_cancelled_resume">Resume</string>
|
||||
<string name="launcher_cancelled_retry">Retry</string>
|
||||
@@ -99,6 +105,7 @@
|
||||
<string name="release_fetch_manual_check_required">Externally managed library in use, please manually check for updates if you want to overwrite it.</string>
|
||||
|
||||
<string name="release_fetch_external_in_use_short">Update failed. External library in use.</string>
|
||||
<string name="release_fetch_hash_validation_failed_short">Update failed. Integrity check failed.</string>
|
||||
<string name="release_fetch_no_internet_short">Update failed. Internet not connected.</string>
|
||||
<string name="release_fetch_generic_short">Update failed. Please try again later.</string>
|
||||
|
||||
@@ -113,10 +120,10 @@
|
||||
<string name="load_failed_crashed_description">A crash was reported during the last session. Try to:</string>
|
||||
<string name="load_failed_recommendation_reinstall">Reinstall Geometry Dash.</string>
|
||||
<string name="load_failed_recommendation_switch_32bit_abi">Install the 32-bit version of the launcher.</string>
|
||||
<string name="load_failed_recommendation_switch_64bit_abi">Install the non 32-bit version of the launcher.</string>
|
||||
<string name="load_failed_recommendation_switch_64bit_abi">Install the non-32-bit version of the launcher.</string>
|
||||
<string name="load_failed_recommendation_update">Verify that the latest versions of the launcher and Geometry Dash are installed.</string>
|
||||
<string name="load_failed_recommendation_report">Report this issue to the developers.</string>
|
||||
<string name="load_failed_recommendation_safe_mode">Enable the safe mode button in Settings.</string>
|
||||
<string name="load_failed_recommendation_safe_mode">Use the safe mode button in Settings.</string>
|
||||
<string name="load_failed_recommendation_dev_stl">Devs: If this is a locally built Geode version, please use the c++_shared ANDROID_STL!</string>
|
||||
|
||||
<string name="message_box_cancel">Cancel</string>
|
||||
@@ -140,6 +147,7 @@
|
||||
<string name="preferences_open_file_manager">Open file manager</string>
|
||||
<string name="preferences_view_logs">View application logs</string>
|
||||
<string name="preferences_view_crashes">View crash dumps</string>
|
||||
<string name="preferences_view_geode_logs">View Geode logs</string>
|
||||
|
||||
<string name="preference_theme_name">Theme</string>
|
||||
<string name="preference_theme_select">Select theme</string>
|
||||
@@ -229,7 +237,12 @@
|
||||
|
||||
<string name="preference_check_for_updates_button">Check for updates</string>
|
||||
<string name="preference_check_for_updates_description">Last checked %1$s</string>
|
||||
<string name="preference_check_for_updates_failed">Update failed. See logs for more details.</string>
|
||||
<string name="preference_check_for_updates_failed">Update failed.</string>
|
||||
<string name="preference_check_for_updates_failed_action">Help</string>
|
||||
|
||||
<string name="preference_check_for_updates_help_title">Update failed.</string>
|
||||
<string name="preference_check_for_updates_help_copy">Copy</string>
|
||||
|
||||
<string name="preference_check_for_updates_none_found">No updates found.</string>
|
||||
<string name="preference_check_for_updates_success">Geode has been updated!</string>
|
||||
<string name="preference_check_for_updates_downloading">Downloading update…</string>
|
||||
@@ -241,10 +254,34 @@
|
||||
<string name="preference_launch_safe_mode">Launch in safe mode</string>
|
||||
<string name="preference_launch_safe_mode_confirm_title">Start in safe mode?</string>
|
||||
|
||||
<string name="preference_backup_cloud">Backup save to cloud</string>
|
||||
<string name="preference_backup_confirm_title">Backup save data?</string>
|
||||
<string name="preference_backup_in_progress">Backing up data…</string>
|
||||
<string name="preference_backup_cancelling">Cancelling backup…</string>
|
||||
|
||||
<string name="preference_backup_confirm_description">This will backup your save data using your in-game account. You can then access it on an unmodified version of the game through the account backup menu.\nContinuing will overwrite any save data you currently have on the Geometry Dash servers!</string>
|
||||
|
||||
<string name="preference_backup_save_missing">Failed to find save data.</string>
|
||||
<string name="preference_backup_save_decode_failed">Failed to decode save file.</string>
|
||||
<string name="preference_backup_save_too_large">Save file is too large.</string>
|
||||
<string name="preference_backup_save_details_missing">Failed to find account information.</string>
|
||||
|
||||
<string name="preference_backup_success">Backup success!</string>
|
||||
<string name="preference_backup_cancelled">Backup cancelled.</string>
|
||||
<string name="preference_backup_failed_generic">An unknown error has occurred.</string>
|
||||
<string name="preference_backup_failed_invalid_login">Invalid login information.</string>
|
||||
<string name="preference_backup_failed_network">A network error has occurred. Please try again later.</string>
|
||||
|
||||
<string name="preference_backup_failed_title">Backup failed</string>
|
||||
|
||||
<string name="preference_backup_failed">Backup failed with error code %1$d.</string>
|
||||
|
||||
<string name="preference_launch_arguments_name">Launch arguments</string>
|
||||
<string name="preference_launch_arguments_set_title">Set launch arguments</string>
|
||||
<string name="preference_text_clear">Clear</string>
|
||||
|
||||
<string name="preference_disable_update_cache">Disable update cache</string>
|
||||
|
||||
<string name="preference_open_developer_options">Open developer options</string>
|
||||
<string name="preference_developer_options_dialog_title">Enable developer options?</string>
|
||||
|
||||
@@ -290,6 +327,8 @@
|
||||
<string name="title_activity_crash_logs">Crash dumps</string>
|
||||
<string name="application_crashes_no_dumps">No crash dumps available.</string>
|
||||
<string name="application_crashes_delete_one">Delete</string>
|
||||
<string name="application_crashes_clear_indicator">Ignore last crash</string>
|
||||
<string name="application_crashes_create_indicator">Fake crash</string>
|
||||
|
||||
<!-- ApplicationIconActivity strings -->
|
||||
<string name="title_activity_application_icon">Application icons</string>
|
||||
@@ -298,6 +337,13 @@
|
||||
<string name="application_icon_geode">Geode</string>
|
||||
<string name="application_icon_pride">Pride</string>
|
||||
<string name="application_icon_trans">Transgender</string>
|
||||
<string name="application_icon_sapphire">Sapphire</string>
|
||||
<string name="application_icon_green">Android</string>
|
||||
<string name="application_icon_confirm">Change icon to %1$s?</string>
|
||||
<string name="application_icon_confirm_description">Geode Launcher will close itself to change the application icon.</string>
|
||||
|
||||
<!-- GeodeLogsListingActivity strings -->
|
||||
<string name="title_activity_geode_logs">Geode logs</string>
|
||||
<string name="application_geode_logs_no_logs">No logs available.</string>
|
||||
<string name="application_geode_logs_empty">File was empty.</string>
|
||||
</resources>
|
||||
+4
-8
@@ -1,13 +1,9 @@
|
||||
buildscript {
|
||||
val composeBOM by extra("2026.01.01")
|
||||
}
|
||||
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
plugins {
|
||||
id("com.android.application") version "9.0.0" apply false
|
||||
id("com.android.library") version "9.0.0" apply false
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.3.0" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.3.0" apply false
|
||||
id("com.android.application") version "9.4.1" apply false
|
||||
id("com.android.library") version "9.4.1" apply false
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.21" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.4.21" apply false
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
|
||||
Vendored
BIN
Binary file not shown.
+3
-2
@@ -1,8 +1,9 @@
|
||||
#Wed Jan 28 04:12:33 MST 2026
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
# gradlew start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
@@ -29,7 +29,7 @@
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
# ksh gradlew
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
@@ -57,7 +57,7 @@
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
@@ -114,7 +114,6 @@ case "$( uname )" in #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
@@ -172,7 +171,6 @@ fi
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
@@ -212,8 +210,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
|
||||
Vendored
+12
-24
@@ -19,12 +19,12 @@
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem gradlew startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@@ -51,7 +51,7 @@ echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
@@ -65,30 +65,18 @@ echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
@rem Execute gradlew
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
Reference in new issue
Block a user