Initial commit: Escape penacony standalone Android Native App

This commit is contained in:
CAtlas0784 committed 2026-10-07 21:42:41 +07:00
commit 43c80237ec
48 files changed
+4282

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
/app/build
+95
View File
@@ -0,0 +1,95 @@
# Escape penacony standalone 📱✨
## ppSR Scene & Map Teleporter - Native Android Application
แอปพลิเคชัน Android Standalone สำหรับจัดการและเทเลพอร์ตแผนที่ในเซิร์ฟเวอร์จำลอง **ppSR (Project Star Rail)** บนระบบปฏิบัติการ Android โดยตรง พร้อมระบบสัมผัส (Touch UI) ที่ทันสมัย และระบบอัปเดตอัตโนมัติในตัว (In-App Auto-Updater)
- **ชื่อแอปพลิเคชันเต็ม (Full Name)**: `Escape penacony standalone`
- **ชื่อแอปพลิเคชันที่แสดงบนอุปกรณ์ (Display Name)**: `Escape penacony`
- **Application ID / Package**: `com.catlas.escapepenacony`
- **สถาปัตยกรรม**: Native Android (Kotlin + Jetpack Compose + Material Design 3)
---
## 🌟 ฟีเจอร์หลัก (Key Features)
1. **Native Touch UI (Jetpack Compose & Material Design 3)**
- อินเทอร์เฟซมืด (Dark Cyberpunk Theme) ดีไซน์พิเศษ
- รองรับแถบเลือกดวงดาว (Planet Tabs) แบบเลื่อนแนวนอน และระบบค้นหาฉาก/พิกัดแบบเรียลไทม์
2. **ฐานข้อมูลแผนที่ครบถ้วน (Full Data Parity)**
- ครอบคลุม **8 ดวงดาว 85 ฉาก/แผนที่** จาก `scenes.json`
- แยกระบบหมวดหมู่ด้วยแท็กสีชัดเจน:
- ⚔️ **Combat Ready**: ฉากที่รองรับเสาต่อสู้ Calyx ดั้งเดิมในไคลเอนต์ Official
- 🗺️ **Safe Scenery**: ฉากสำหรับเดินชมวิว เสา Calyx ซ่อนใต้ดินที่ระดับความลึก Y=-999999 ปลอดภัยจากการแครช
- 🧪 **4.7 Beta Exclusive**: ฉากพิเศษสำหรับไคลเอนต์เบต้า 4.7
3. **ระบบจัดการไฟล์ผ่าน Android Storage Access Framework (SAF)**
- เลือกระบุไฟล์ `persistent.json` ผ่าน Document Picker รองรับ Android 10, 11, 12, 13, 14, และ 15+
- กดปุ่ม **Teleport** เพียงครั้งเดียวเพื่ออัปเดตพิกัดลงไฟล์ `persistent.json` ทันที พร้อมระบบสำรองไฟล์อัตโนมัติ
- มีปุ่ม **Copy JSON** สำหรับคัดลอก JSON พิกัดไปใช้งานต่อได้ทันที
4. **Custom Coordinates Input**
- ปุ่มลอย (+) สำหรับกรอก Plane ID, X, Y, Z, และ Map Layer ที่กำหนดเองได้อิสระ
5. **In-App Auto-Updater & OTA Hot Sync**
- **ตรวจจับเวอร์ชันใหม่ผ่าน GitHub Releases API**: เปรียบเทียบ `tag_name` กับเวอร์ชันในเครื่อง ดาวน์โหลด `.apk` และเรียกตัวติดตั้งระบบอัตโนมัติผ่าน `FileProvider`
- **OTA Map Update**: ปุ่มซิงค์ดาวน์โหลด `scenes.json` ตัวล่าสุดจาก GitHub Raw ได้ทันทีโดยไม่ต้องลง APK ใหม่
6. **รองรับ 3 ภาษา (Trilingual Support)**
- สลับภาษาระหว่าง **English (EN)**, **ภาษาไทย (TH)**, และ **简体中文 (ZH)** ได้ทันทีผ่านปุ่มบนแถบเมนูด้านบน
---
## 🏗️ โครงสร้างโปรเจกต์ (Project Structure)
```text
Android APK/
├── app/
│ ├── build.gradle.kts
│ ├── proguard-rules.pro
│ └── src/main/
│ ├── AndroidManifest.xml
│ ├── assets/
│ │ └── scenes.json # ฐานข้อมูลแผนที่ 8 โลก 85 แมพ
│ ├── java/com/catlas/escapepenacony/
│ │ ├── MainActivity.kt # Activity หลักและ SAF Document Picker
│ │ ├── model/
│ │ │ ├── SceneModels.kt # โมเดลข้อมูล Planet & Scene & Language
│ │ │ └── PersistentConfig.kt # โครงสร้าง JSON ของ persistent.json
│ │ ├── data/
│ │ │ ├── SceneRepository.kt # โหลดและบันทึก scenes.json (รองรับ OTA)
│ │ │ ├── StorageManager.kt # อ่าน/เขียน persistent.json ผ่าน SAF
│ │ │ └── UpdateManager.kt # GitHub Releases API & APK Self-Updater
│ │ └── ui/
│ │ ├── MainScreen.kt # หน้าจอหลัก Compose
│ │ ├── Components.kt # SceneCard, Badges, Dialogs
│ │ └── theme/ # Color, Theme, Type
│ └── res/
│ ├── values/ # strings.xml, colors.xml, themes.xml
│ └── xml/file_paths.xml # FileProvider path สำหรับติดตั้ง APK
├── gradle/
│ ├── libs.versions.toml # Version Catalog
│ └── wrapper/ # Gradle Wrapper 8.9
├── build.gradle.kts
├── settings.gradle.kts
└── gradlew / gradlew.bat
```
---
## 🛠️ วิธีการเปิดและคอมไพล์โปรเจกต์ (How to Build)
### 1. เปิดผ่าน Android Studio (แนะนำ)
1. เปิดโปรแกรม **Android Studio**
2. เลือก **Open** แล้วชี้ไปที่โฟลเดอร์ `Android APK`
3. รอ Android Studio ซิงค์ Gradle ให้เสร็จสิ้น
4. เลือกเมนู **Build** > **Build Bundle(s) / APK(s)** > **Build APK(s)** หรือกด Run ลงบนโทรศัพท์ Android / Emulator
### 2. คอมไพล์ผ่าน Command Line (CLI)
เปิด Terminal หรือ PowerShell ที่โฟลเดอร์ `Android APK`:
```bash
# คอมไพล์ Debug APK
./gradlew assembleDebug
# คอมไพล์ Release APK
./gradlew assembleRelease
```
ไฟล์ APK ที่สร้างเสร็จจะอยู่ที่:
`app/build/outputs/apk/debug/app-debug.apk`
+56
View File
@@ -0,0 +1,56 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.catlas.escapepenacony"
compileSdk = 35
defaultConfig {
applicationId = "com.catlas.escapepenacony"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "v1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
buildConfig = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.extended)
implementation(libs.kotlinx.coroutines.android)
debugImplementation(libs.androidx.ui.tooling)
}
+2
View File
@@ -0,0 +1,2 @@
# Escape penacony ProGuard Rules
-keepattributes *Annotation*
+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.EscapePenacony">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,52 @@
package com.catlas.escapepenacony
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.*
import com.catlas.escapepenacony.data.StorageManager
import com.catlas.escapepenacony.ui.MainScreen
import com.catlas.escapepenacony.ui.theme.EscapePenaconyTheme
class MainActivity : ComponentActivity() {
private var currentPersistentUri by mutableStateOf<Uri?>(null)
// Storage Access Framework Document Picker for persistent.json
private val openDocumentLauncher = registerForActivityResult(
ActivityResultContracts.OpenDocument()
) { uri: Uri? ->
if (uri != null) {
try {
contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
} catch (_: Exception) {}
StorageManager.savePersistentUri(this, uri)
currentPersistentUri = uri
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Restore previously selected persistent.json if available
currentPersistentUri = StorageManager.getPersistentUri(this)
setContent {
EscapePenaconyTheme {
MainScreen(
currentPersistentUri = currentPersistentUri,
onPickPersistentFile = {
openDocumentLauncher.launch(arrayOf("application/json", "*/*"))
}
)
}
}
}
}
@@ -0,0 +1,131 @@
package com.catlas.escapepenacony.data
import android.content.Context
import com.catlas.escapepenacony.model.PlanetItem
import com.catlas.escapepenacony.model.SceneItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
object SceneRepository {
private const val CACHED_SCENES_FILENAME = "scenes_custom.json"
suspend fun loadScenes(context: Context): List<PlanetItem> = withContext(Dispatchers.IO) {
val cachedFile = File(context.filesDir, CACHED_SCENES_FILENAME)
val jsonContent = if (cachedFile.exists()) {
cachedFile.readText()
} else {
context.assets.open("scenes.json").bufferedReader().use { it.readText() }
}
parseJson(jsonContent)
}
suspend fun saveUpdatedScenesJson(context: Context, jsonString: String): Boolean = withContext(Dispatchers.IO) {
try {
// Validate JSON first
JSONObject(jsonString)
val cachedFile = File(context.filesDir, CACHED_SCENES_FILENAME)
cachedFile.writeText(jsonString)
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
suspend fun resetToDefaultScenes(context: Context): List<PlanetItem> = withContext(Dispatchers.IO) {
val cachedFile = File(context.filesDir, CACHED_SCENES_FILENAME)
if (cachedFile.exists()) {
cachedFile.delete()
}
val defaultJson = context.assets.open("scenes.json").bufferedReader().use { it.readText() }
parseJson(defaultJson)
}
private fun parseJson(jsonString: String): List<PlanetItem> {
val planets = mutableListOf<PlanetItem>()
val root = JSONObject(jsonString)
val planetsArray = root.optJSONArray("planets") ?: return emptyList()
for (i in 0 until planetsArray.length()) {
val pObj = planetsArray.getJSONObject(i)
val pId = pObj.optString("id", "planet_$i")
val pNameEn = pObj.optString("name", "Unknown Planet")
val pNameTh = pObj.optString("name_th", pNameEn)
val pNameZh = pObj.optString("name_zh", pNameEn)
val scenesArray = pObj.optJSONArray("scenes")
val scenes = mutableListOf<SceneItem>()
if (scenesArray != null) {
for (j in 0 until scenesArray.length()) {
val sObj = scenesArray.getJSONObject(j)
val sNameEn = sObj.optString("name", "Scene $j")
val sNameTh = sObj.optString("name_th", sNameEn)
val sNameZh = sObj.optString("name_zh", sNameEn)
val planeId = sObj.optLong("plane_id", 0)
val x = sObj.optLong("x", 0)
val y = sObj.optLong("y", 0)
val z = sObj.optLong("z", 0)
val calyxX = if (sObj.has("calyx_x")) sObj.getLong("calyx_x") else null
val calyxY = if (sObj.has("calyx_y")) sObj.getLong("calyx_y") else null
val calyxZ = if (sObj.has("calyx_z")) sObj.getLong("calyx_z") else null
val mapLayer = sObj.optInt("map_layer", 1)
val calyxGroupId = sObj.optLong("calyx_group_id", 186)
val calyxInstId = sObj.optLong("calyx_inst_id", 300001)
val calyxEntityId = sObj.optLong("calyx_entity_id", 1337)
val calyxPropId = sObj.optLong("calyx_prop_id", 808)
val combatReady = sObj.optBoolean("calyx_supported", false)
val isBeta = sObj.optBoolean("is_beta", false)
val descEn = sObj.optString("desc", "")
val descTh = sObj.optString("desc_th", descEn)
val descZh = sObj.optString("desc_zh", descEn)
scenes.add(
SceneItem(
id = "${pId}_${planeId}_$j",
nameEn = sNameEn,
nameTh = sNameTh,
nameZh = sNameZh,
planeId = planeId,
x = x,
y = y,
z = z,
calyxX = calyxX,
calyxY = calyxY,
calyxZ = calyxZ,
mapLayer = mapLayer,
calyxGroupId = calyxGroupId,
calyxInstId = calyxInstId,
calyxEntityId = calyxEntityId,
calyxPropId = calyxPropId,
combatReady = combatReady,
isBeta = isBeta,
descEn = descEn,
descTh = descTh,
descZh = descZh
)
)
}
}
planets.add(
PlanetItem(
id = pId,
nameEn = pNameEn,
nameTh = pNameTh,
nameZh = pNameZh,
scenes = scenes
)
)
}
return planets
}
}
@@ -0,0 +1,93 @@
package com.catlas.escapepenacony.data
import android.content.Context
import android.net.Uri
import com.catlas.escapepenacony.model.PersistentHelper
import com.catlas.escapepenacony.model.SceneConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
object StorageManager {
private const val PREFS_NAME = "escape_penacony_prefs"
private const val KEY_PERSISTENT_URI = "persistent_uri"
private const val KEY_SELECTED_LANG = "selected_language"
fun savePersistentUri(context: Context, uri: Uri) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putString(KEY_PERSISTENT_URI, uri.toString())
.apply()
}
fun getPersistentUri(context: Context): Uri? {
val uriStr = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(KEY_PERSISTENT_URI, null) ?: return null
return try {
Uri.parse(uriStr)
} catch (e: Exception) {
null
}
}
fun saveLanguage(context: Context, langCode: String) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putString(KEY_SELECTED_LANG, langCode)
.apply()
}
fun getSavedLanguage(context: Context): String {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(KEY_SELECTED_LANG, "en") ?: "en"
}
suspend fun readCurrentConfig(context: Context, uri: Uri): SceneConfig? = withContext(Dispatchers.IO) {
try {
val content = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }
?: return@withContext null
val root = JSONObject(content)
val sceneObj = root.optJSONObject("scene") ?: return@withContext null
SceneConfig.fromJsonObject(sceneObj)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
suspend fun applySceneConfig(context: Context, uri: Uri, newConfig: SceneConfig): Result<Unit> = withContext(Dispatchers.IO) {
try {
// Read original content
val originalContent = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() } ?: "{}"
// Backup copy in app cache
try {
val backupFile = File(context.cacheDir, "persistent_backup.json")
backupFile.writeText(originalContent)
} catch (_: Exception) {}
// Generate updated JSON
val updatedJson = PersistentHelper.updateJsonString(originalContent, newConfig)
// Overwrite target file via SAF
context.contentResolver.openOutputStream(uri, "wt")?.use { output ->
output.write(updatedJson.toByteArray(Charsets.UTF_8))
output.flush()
} ?: return@withContext Result.failure(Exception("Failed to open output stream"))
Result.success(Unit)
} catch (e: Exception) {
e.printStackTrace()
Result.failure(e)
}
}
fun exportTemplateJson(context: Context, newConfig: SceneConfig): String {
val root = JSONObject()
root.put("scene", newConfig.toJsonObject())
return root.toString(2)
}
}
@@ -0,0 +1,183 @@
package com.catlas.escapepenacony.data
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import androidx.core.content.FileProvider
import com.catlas.escapepenacony.BuildConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
data class ReleaseInfo(
val tagName: String,
val releaseTitle: String,
val changelog: String,
val apkDownloadUrl: String,
val isNewer: Boolean
)
object UpdateManager {
private const val GITHUB_RELEASES_API =
"https://api.github.com/repos/CAtlas0784/Escape-penacony/releases/latest"
private const val GITHUB_RAW_SCENES_URL =
"https://raw.githubusercontent.com/CAtlas0784/Escape-penacony/main/scenes.json"
suspend fun checkForAppUpdate(currentVersion: String = BuildConfig.VERSION_NAME): Result<ReleaseInfo?> =
withContext(Dispatchers.IO) {
try {
val url = URL(GITHUB_RELEASES_API)
val conn = url.openConnection() as HttpURLConnection
conn.connectTimeout = 8000
conn.readTimeout = 8000
conn.setRequestProperty("Accept", "application/vnd.github.v3+json")
conn.setRequestProperty("User-Agent", "Escape-Penacony-Android-App")
if (conn.responseCode == 200) {
val body = conn.inputStream.bufferedReader().use { it.readText() }
val json = JSONObject(body)
val tagName = json.getString("tag_name")
val title = json.optString("name", tagName)
val changelog = json.optString("body", "No changelog provided.")
var downloadUrl = ""
val assets = json.optJSONArray("assets")
if (assets != null) {
for (i in 0 until assets.length()) {
val asset = assets.getJSONObject(i)
val name = asset.optString("name", "")
if (name.endsWith(".apk", ignoreCase = true)) {
downloadUrl = asset.getString("browser_download_url")
break
}
}
}
val isNewer = isNewerVersion(currentVersion, tagName)
Result.success(
ReleaseInfo(
tagName = tagName,
releaseTitle = title,
changelog = changelog,
apkDownloadUrl = downloadUrl,
isNewer = isNewer
)
)
} else {
Result.success(null)
}
} catch (e: Exception) {
e.printStackTrace()
Result.failure(e)
}
}
suspend fun downloadApk(
context: Context,
downloadUrl: String,
onProgress: (Int) -> Unit
): Result<File> = withContext(Dispatchers.IO) {
try {
val url = URL(downloadUrl)
val conn = url.openConnection() as HttpURLConnection
conn.connectTimeout = 15000
conn.readTimeout = 15000
conn.setRequestProperty("User-Agent", "Escape-Penacony-Android-App")
val fileLength = conn.contentLength
val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: context.filesDir
val apkFile = File(dir, "Escape-penacony-update.apk")
conn.inputStream.use { input ->
FileOutputStream(apkFile).use { output ->
val buffer = ByteArray(8 * 1024)
var total: Long = 0
var count: Int
while (input.read(buffer).also { count = it } != -1) {
total += count
if (fileLength > 0) {
val progress = ((total * 100) / fileLength).toInt()
withContext(Dispatchers.Main) {
onProgress(progress)
}
}
output.write(buffer, 0, count)
}
}
}
Result.success(apkFile)
} catch (e: Exception) {
e.printStackTrace()
Result.failure(e)
}
}
fun promptInstallApk(context: Context, apkFile: File) {
// Android 8.0+ Check Unknown App Sources permission
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (!context.packageManager.canRequestPackageInstalls()) {
val settingsIntent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(settingsIntent)
return
}
}
val apkUri: Uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
apkFile
)
val installIntent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(apkUri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(installIntent)
}
suspend fun fetchLatestScenesJson(): Result<String> = withContext(Dispatchers.IO) {
try {
val url = URL(GITHUB_RAW_SCENES_URL)
val conn = url.openConnection() as HttpURLConnection
conn.connectTimeout = 8000
conn.readTimeout = 8000
conn.setRequestProperty("User-Agent", "Escape-Penacony-Android-App")
if (conn.responseCode == 200) {
val content = conn.inputStream.bufferedReader().use { it.readText() }
Result.success(content)
} else {
Result.failure(Exception("HTTP error ${conn.responseCode}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
private fun isNewerVersion(current: String, latest: String): Boolean {
val c = current.removePrefix("v").split(".").map { it.toIntOrNull() ?: 0 }
val l = latest.removePrefix("v").split(".").map { it.toIntOrNull() ?: 0 }
val maxLen = maxOf(c.size, l.size)
for (i in 0 until maxLen) {
val cv = c.getOrElse(i) { 0 }
val lv = l.getOrElse(i) { 0 }
if (lv > cv) return true
if (lv < cv) return false
}
return false
}
}
@@ -0,0 +1,87 @@
package com.catlas.escapepenacony.model
import org.json.JSONObject
data class SceneConfig(
var planeId: Long = 20461,
var x: Long = 12080,
var y: Long = 393572,
var z: Long = 322230,
var calyxX: Long = 12080,
var calyxY: Long = 393572,
var calyxZ: Long = 324030,
var mapLayer: Int = 1,
var calyxGroupId: Long = 186,
var calyxInstId: Long = 300001,
var calyxEntityId: Long = 1337,
var calyxPropId: Long = 808
) {
fun toJsonObject(): JSONObject {
return JSONObject().apply {
put("plane_id", planeId)
put("x", x)
put("y", y)
put("z", z)
put("calyx_x", calyxX)
put("calyx_y", calyxY)
put("calyx_z", calyxZ)
put("map_layer", mapLayer)
put("calyx_group_id", calyxGroupId)
put("calyx_inst_id", calyxInstId)
put("calyx_entity_id", calyxEntityId)
put("calyx_prop_id", calyxPropId)
}
}
companion object {
fun fromScene(scene: SceneItem): SceneConfig {
val cx = scene.calyxX ?: scene.x
val cy = scene.calyxY ?: scene.y
val cz = scene.calyxZ ?: (scene.z + 1800)
return SceneConfig(
planeId = scene.planeId,
x = scene.x,
y = scene.y,
z = scene.z,
calyxX = cx,
calyxY = cy,
calyxZ = cz,
mapLayer = scene.mapLayer,
calyxGroupId = scene.calyxGroupId,
calyxInstId = scene.calyxInstId,
calyxEntityId = scene.calyxEntityId,
calyxPropId = scene.calyxPropId
)
}
fun fromJsonObject(obj: JSONObject): SceneConfig {
return SceneConfig(
planeId = obj.optLong("plane_id", 20461),
x = obj.optLong("x", 12080),
y = obj.optLong("y", 393572),
z = obj.optLong("z", 322230),
calyxX = obj.optLong("calyx_x", 12080),
calyxY = obj.optLong("calyx_y", 393572),
calyxZ = obj.optLong("calyx_z", 324030),
mapLayer = obj.optInt("map_layer", 1),
calyxGroupId = obj.optLong("calyx_group_id", 186),
calyxInstId = obj.optLong("calyx_inst_id", 300001),
calyxEntityId = obj.optLong("calyx_entity_id", 1337),
calyxPropId = obj.optLong("calyx_prop_id", 808)
)
}
}
}
object PersistentHelper {
fun updateJsonString(originalJson: String, sceneConfig: SceneConfig): String {
val root = try {
if (originalJson.trim().isNotEmpty()) JSONObject(originalJson) else JSONObject()
} catch (e: Exception) {
JSONObject()
}
root.put("scene", sceneConfig.toJsonObject())
return root.toString(2)
}
}
@@ -0,0 +1,64 @@
package com.catlas.escapepenacony.model
enum class AppLanguage(val code: String, val displayName: String) {
EN("en", "English"),
TH("th", "ภาษาไทย"),
ZH("zh", "简体中文")
}
enum class FilterCategory(val code: String) {
ALL("all"),
COMBAT("combat"),
EXPLORE("explore"),
BETA("beta")
}
data class SceneItem(
val id: String,
val nameEn: String,
val nameTh: String,
val nameZh: String,
val planeId: Long,
val x: Long,
val y: Long,
val z: Long,
val calyxX: Long?,
val calyxY: Long?,
val calyxZ: Long?,
val mapLayer: Int,
val calyxGroupId: Long,
val calyxInstId: Long,
val calyxEntityId: Long,
val calyxPropId: Long,
val combatReady: Boolean,
val isBeta: Boolean,
val descEn: String,
val descTh: String,
val descZh: String
) {
fun localizedName(lang: AppLanguage): String = when (lang) {
AppLanguage.TH -> nameTh.ifEmpty { nameEn }
AppLanguage.ZH -> nameZh.ifEmpty { nameEn }
AppLanguage.EN -> nameEn
}
fun localizedDesc(lang: AppLanguage): String = when (lang) {
AppLanguage.TH -> descTh.ifEmpty { descEn }
AppLanguage.ZH -> descZh.ifEmpty { descEn }
AppLanguage.EN -> descEn
}
}
data class PlanetItem(
val id: String,
val nameEn: String,
val nameTh: String,
val nameZh: String,
val scenes: List<SceneItem>
) {
fun localizedName(lang: AppLanguage): String = when (lang) {
AppLanguage.TH -> nameTh.ifEmpty { nameEn }
AppLanguage.ZH -> nameZh.ifEmpty { nameEn }
AppLanguage.EN -> nameEn
}
}
@@ -0,0 +1,299 @@
package com.catlas.escapepenacony.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.catlas.escapepenacony.model.AppLanguage
import com.catlas.escapepenacony.model.FilterCategory
import com.catlas.escapepenacony.model.SceneItem
import com.catlas.escapepenacony.ui.theme.*
@Composable
fun FilterChipRow(
selectedCategory: FilterCategory,
onCategorySelected: (FilterCategory) -> Unit,
counts: Map<FilterCategory, Int>
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilterItem(
text = "All (${counts[FilterCategory.ALL] ?: 0})",
selected = selectedCategory == FilterCategory.ALL,
color = PurplePrimary,
onClick = { onCategorySelected(FilterCategory.ALL) }
)
FilterItem(
text = "⚔️ Combat (${counts[FilterCategory.COMBAT] ?: 0})",
selected = selectedCategory == FilterCategory.COMBAT,
color = CombatGreen,
onClick = { onCategorySelected(FilterCategory.COMBAT) }
)
FilterItem(
text = "🗺️ Explore (${counts[FilterCategory.EXPLORE] ?: 0})",
selected = selectedCategory == FilterCategory.EXPLORE,
color = ExploreAmber,
onClick = { onCategorySelected(FilterCategory.EXPLORE) }
)
FilterItem(
text = "🧪 Beta (${counts[FilterCategory.BETA] ?: 0})",
selected = selectedCategory == FilterCategory.BETA,
color = BetaPurple,
onClick = { onCategorySelected(FilterCategory.BETA) }
)
}
}
@Composable
fun RowScope.FilterItem(
text: String,
selected: Boolean,
color: Color,
onClick: () -> Unit
) {
Surface(
modifier = Modifier
.weight(1f)
.height(36.dp)
.clip(RoundedCornerShape(18.dp))
.clickable(onClick = onClick),
color = if (selected) color.copy(alpha = 0.25f) else DarkSurfaceElevated,
border = if (selected) androidx.compose.foundation.BorderStroke(1.5.dp, color) else null
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = text,
fontSize = 11.sp,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal,
color = if (selected) color else TextLight
)
}
}
}
@Composable
fun SceneCard(
scene: SceneItem,
language: AppLanguage,
onTeleportClick: (SceneItem) -> Unit,
onCopyJsonClick: (SceneItem) -> Unit
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = DarkSurface),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp)
) {
// Title & Badges
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = scene.localizedName(language),
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
color = TextLight,
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(8.dp))
// Badge
if (scene.isBeta) {
Badge(text = "🧪 4.7 Beta", color = BetaPurple)
} else if (scene.combatReady) {
Badge(text = "⚔️ Combat Ready", color = CombatGreen)
} else {
Badge(text = "🗺️ Safe Scenery", color = ExploreAmber)
}
}
Spacer(modifier = Modifier.height(6.dp))
// Coords & Plane ID
Text(
text = "Plane: ${scene.planeId} | X: ${scene.x} Y: ${scene.y} Z: ${scene.z} (Layer ${scene.mapLayer})",
fontSize = 12.sp,
color = NeonCyan,
fontWeight = FontWeight.Medium
)
// Desc
val desc = scene.localizedDesc(language)
if (desc.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = desc,
fontSize = 12.sp,
color = TextMuted,
lineHeight = 16.sp
)
}
Spacer(modifier = Modifier.height(10.dp))
// Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
OutlinedButton(
onClick = { onCopyJsonClick(scene) },
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
modifier = Modifier.height(34.dp),
shape = RoundedCornerShape(8.dp)
) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = "Copy JSON",
modifier = Modifier.size(14.dp),
tint = TextLight
)
Spacer(modifier = Modifier.width(4.dp))
Text("Copy JSON", fontSize = 11.sp, color = TextLight)
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = { onTeleportClick(scene) },
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp),
modifier = Modifier.height(34.dp),
colors = ButtonDefaults.buttonColors(containerColor = PurplePrimary),
shape = RoundedCornerShape(8.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Teleport",
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text("Teleport", fontSize = 12.sp, fontWeight = FontWeight.Bold)
}
}
}
}
}
@Composable
fun Badge(text: String, color: Color) {
Surface(
color = color.copy(alpha = 0.15f),
shape = RoundedCornerShape(6.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, color.copy(alpha = 0.6f))
) {
Text(
text = text,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = color,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
@Composable
fun CustomCoordsDialog(
onDismiss: () -> Unit,
onConfirm: (Long, Long, Long, Long, Int) -> Unit
) {
var planeIdText by remember { mutableStateOf("20461") }
var xText by remember { mutableStateOf("12080") }
var yText by remember { mutableStateOf("393572") }
var zText by remember { mutableStateOf("322230") }
var layerText by remember { mutableStateOf("1") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Custom Coordinates", color = TextLight, fontWeight = FontWeight.Bold) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = planeIdText,
onValueChange = { planeIdText = it },
label = { Text("Plane ID (e.g. 20461)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = xText,
onValueChange = { xText = it },
label = { Text("X") },
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = yText,
onValueChange = { yText = it },
label = { Text("Y") },
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = zText,
onValueChange = { zText = it },
label = { Text("Z") },
singleLine = true,
modifier = Modifier.weight(1f)
)
}
OutlinedTextField(
value = layerText,
onValueChange = { layerText = it },
label = { Text("Map Layer (Default: 1)") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
},
confirmButton = {
Button(
onClick = {
val p = planeIdText.toLongOrNull() ?: 20461
val x = xText.toLongOrNull() ?: 0
val y = yText.toLongOrNull() ?: 0
val z = zText.toLongOrNull() ?: 0
val l = layerText.toIntOrNull() ?: 1
onConfirm(p, x, y, z, l)
},
colors = ButtonDefaults.buttonColors(containerColor = PurplePrimary)
) {
Text("Apply")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel", color = TextMuted)
}
},
containerColor = DarkSurfaceElevated
)
}
@@ -0,0 +1,494 @@
package com.catlas.escapepenacony.ui
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.net.Uri
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.catlas.escapepenacony.BuildConfig
import com.catlas.escapepenacony.data.ReleaseInfo
import com.catlas.escapepenacony.data.SceneRepository
import com.catlas.escapepenacony.data.StorageManager
import com.catlas.escapepenacony.data.UpdateManager
import com.catlas.escapepenacony.model.*
import com.catlas.escapepenacony.ui.theme.*
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
currentPersistentUri: Uri?,
onPickPersistentFile: () -> Unit
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
var planets by remember { mutableStateOf<List<PlanetItem>>(emptyList()) }
var selectedPlanetId by remember { mutableStateOf<String?>(null) }
var selectedCategory by remember { mutableStateOf(FilterCategory.ALL) }
var searchQuery by remember { mutableStateOf("") }
var language by remember { mutableStateOf(AppLanguage.EN) }
var currentActiveConfig by remember { mutableStateOf<SceneConfig?>(null) }
var showCustomDialog by remember { mutableStateOf(false) }
// Update States
var showUpdateDialog by remember { mutableStateOf(false) }
var releaseInfo by remember { mutableStateOf<ReleaseInfo?>(null) }
var isDownloadingUpdate by remember { mutableStateOf(false) }
var downloadProgress by remember { mutableStateOf(0) }
var isCheckingUpdate by remember { mutableStateOf(false) }
// Load initial data
LaunchedEffect(Unit) {
val savedLang = StorageManager.getSavedLanguage(context)
language = AppLanguage.values().firstOrNull { it.code == savedLang } ?: AppLanguage.EN
planets = SceneRepository.loadScenes(context)
if (planets.isNotEmpty()) {
selectedPlanetId = planets.first().id
}
}
// Refresh active config when persistent URI changes
LaunchedEffect(currentPersistentUri) {
if (currentPersistentUri != null) {
currentActiveConfig = StorageManager.readCurrentConfig(context, currentPersistentUri)
} else {
currentActiveConfig = null
}
}
// Filter scenes
val activePlanet = planets.firstOrNull { it.id == selectedPlanetId }
val displayedScenes = remember(activePlanet, selectedCategory, searchQuery, language) {
val baseScenes = activePlanet?.scenes ?: emptyList()
baseScenes.filter { scene ->
val matchCategory = when (selectedCategory) {
FilterCategory.ALL -> true
FilterCategory.COMBAT -> scene.combatReady
FilterCategory.EXPLORE -> !scene.combatReady && !scene.isBeta
FilterCategory.BETA -> scene.isBeta
}
val matchQuery = if (searchQuery.isBlank()) true else {
scene.nameEn.contains(searchQuery, ignoreCase = true) ||
scene.nameTh.contains(searchQuery, ignoreCase = true) ||
scene.nameZh.contains(searchQuery, ignoreCase = true) ||
scene.planeId.toString().contains(searchQuery)
}
matchCategory && matchQuery
}
}
// Category Counts
val counts = remember(activePlanet) {
val list = activePlanet?.scenes ?: emptyList()
mapOf(
FilterCategory.ALL to list.size,
FilterCategory.COMBAT to list.count { it.combatReady },
FilterCategory.EXPLORE to list.count { !it.combatReady && !it.isBeta },
FilterCategory.BETA to list.count { it.isBeta }
)
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(
text = "Escape penacony standalone",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = TextLight
)
Text(
text = "Escape penacony | ${BuildConfig.VERSION_NAME}",
fontSize = 11.sp,
color = NeonCyan
)
}
},
actions = {
// Language Switcher
IconButton(onClick = {
val nextLang = when (language) {
AppLanguage.EN -> AppLanguage.TH
AppLanguage.TH -> AppLanguage.ZH
AppLanguage.ZH -> AppLanguage.EN
}
language = nextLang
StorageManager.saveLanguage(context, nextLang.code)
}) {
Badge(text = language.name, color = StarGold)
}
// Hot OTA scenes.json update
IconButton(onClick = {
coroutineScope.launch {
Toast.makeText(context, "Checking map updates...", Toast.LENGTH_SHORT).show()
val res = UpdateManager.fetchLatestScenesJson()
res.onSuccess { jsonStr ->
val ok = SceneRepository.saveUpdatedScenesJson(context, jsonStr)
if (ok) {
planets = SceneRepository.loadScenes(context)
Toast.makeText(context, "Maps updated successfully!", Toast.LENGTH_LONG).show()
} else {
Toast.makeText(context, "Map data invalid", Toast.LENGTH_SHORT).show()
}
}.onFailure {
Toast.makeText(context, "Update map failed: ${it.message}", Toast.LENGTH_SHORT).show()
}
}
}) {
Icon(Icons.Default.CloudSync, contentDescription = "Sync Maps", tint = NeonCyan)
}
// Check APK Update
IconButton(onClick = {
if (isCheckingUpdate) return@IconButton
isCheckingUpdate = true
coroutineScope.launch {
Toast.makeText(context, "Checking latest release...", Toast.LENGTH_SHORT).show()
val result = UpdateManager.checkForAppUpdate()
isCheckingUpdate = false
result.onSuccess { info ->
if (info != null && info.isNewer) {
releaseInfo = info
showUpdateDialog = true
} else {
Toast.makeText(context, "You are on the latest version (${BuildConfig.VERSION_NAME})", Toast.LENGTH_LONG).show()
}
}.onFailure {
Toast.makeText(context, "Check update failed: ${it.message}", Toast.LENGTH_SHORT).show()
}
}
}) {
Icon(Icons.Default.SystemUpdate, contentDescription = "Check Update", tint = StarGold)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = DarkSurfaceElevated)
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { showCustomDialog = true },
containerColor = PurplePrimary,
contentColor = TextLight
) {
Icon(Icons.Default.AddLocationAlt, contentDescription = "Custom Teleport")
}
},
containerColor = DarkBackground
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
// Persistent Target File Section
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
shape = RoundedCornerShape(10.dp),
color = DarkSurface
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = if (currentPersistentUri != null) "Target: persistent.json" else "Target: Not Selected",
fontWeight = FontWeight.Bold,
fontSize = 13.sp,
color = if (currentPersistentUri != null) CombatGreen else TextMuted
)
if (currentActiveConfig != null) {
Text(
text = "Current Plane: ${currentActiveConfig?.planeId} (Layer ${currentActiveConfig?.mapLayer})",
fontSize = 11.sp,
color = NeonCyan
)
} else {
Text(
text = "Select file to enable one-tap teleport",
fontSize = 11.sp,
color = TextMuted
)
}
}
Button(
onClick = onPickPersistentFile,
shape = RoundedCornerShape(8.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (currentPersistentUri != null) DarkSurfaceElevated else PurplePrimary
),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
modifier = Modifier.height(32.dp)
) {
Text(
text = if (currentPersistentUri != null) "Change" else "Choose File",
fontSize = 11.sp
)
}
}
}
// Search Bar
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
placeholder = { Text("Search scenes (e.g. Penacony, 20461, Parlor Car)...", fontSize = 12.sp) },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = TextMuted) },
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { searchQuery = "" }) {
Icon(Icons.Default.Close, contentDescription = "Clear", tint = TextMuted)
}
}
},
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
shape = RoundedCornerShape(10.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = PurplePrimary,
unfocusedBorderColor = BorderSubtle,
focusedContainerColor = DarkSurface,
unfocusedContainerColor = DarkSurface
)
)
// Planet Horizontal Tabs
if (planets.isNotEmpty()) {
LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(planets) { planet ->
val isSelected = planet.id == selectedPlanetId
Surface(
shape = RoundedCornerShape(16.dp),
color = if (isSelected) PurplePrimary else DarkSurface,
modifier = Modifier
.height(32.dp)
.clip(RoundedCornerShape(16.dp))
.clickable { selectedPlanetId = planet.id }
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(horizontal = 12.dp)
) {
Text(
text = planet.localizedName(language),
fontSize = 12.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
color = if (isSelected) TextLight else TextMuted
)
}
}
}
}
}
// Filter Category Chips
FilterChipRow(
selectedCategory = selectedCategory,
onCategorySelected = { selectedCategory = it },
counts = counts
)
// Scene List
if (displayedScenes.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.weight(1f),
contentAlignment = Alignment.Center
) {
Text("No scenes match your filter", color = TextMuted, fontSize = 13.sp)
}
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.weight(1f),
contentPadding = PaddingValues(vertical = 6.dp)
) {
items(displayedScenes, key = { it.id }) { scene ->
SceneCard(
scene = scene,
language = language,
onTeleportClick = { targetScene ->
if (currentPersistentUri == null) {
Toast.makeText(context, "Please choose persistent.json first!", Toast.LENGTH_SHORT).show()
onPickPersistentFile()
return@SceneCard
}
val cfg = SceneConfig.fromScene(targetScene)
coroutineScope.launch {
val res = StorageManager.applySceneConfig(context, currentPersistentUri, cfg)
res.onSuccess {
currentActiveConfig = cfg
Toast.makeText(
context,
"Teleported to ${targetScene.localizedName(language)} (Plane ${targetScene.planeId})!",
Toast.LENGTH_SHORT
).show()
}.onFailure {
Toast.makeText(context, "Teleport failed: ${it.message}", Toast.LENGTH_LONG).show()
}
}
},
onCopyJsonClick = { targetScene ->
val cfg = SceneConfig.fromScene(targetScene)
val jsonStr = StorageManager.exportTemplateJson(context, cfg)
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("ppSR Teleport JSON", jsonStr))
Toast.makeText(context, "Teleport JSON copied to clipboard!", Toast.LENGTH_SHORT).show()
}
)
}
}
}
}
}
// Custom Coordinates Dialog
if (showCustomDialog) {
CustomCoordsDialog(
onDismiss = { showCustomDialog = false },
onConfirm = { planeId, x, y, z, layer ->
showCustomDialog = false
val cfg = SceneConfig(
planeId = planeId,
x = x,
y = y,
z = z,
calyxX = x,
calyxY = y,
calyxZ = z + 1800,
mapLayer = layer
)
if (currentPersistentUri == null) {
val jsonStr = StorageManager.exportTemplateJson(context, cfg)
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("ppSR Custom Teleport JSON", jsonStr))
Toast.makeText(context, "JSON copied! Please choose persistent.json to apply directly.", Toast.LENGTH_LONG).show()
} else {
coroutineScope.launch {
val res = StorageManager.applySceneConfig(context, currentPersistentUri, cfg)
res.onSuccess {
currentActiveConfig = cfg
Toast.makeText(context, "Custom coordinates applied!", Toast.LENGTH_SHORT).show()
}.onFailure {
Toast.makeText(context, "Failed: ${it.message}", Toast.LENGTH_LONG).show()
}
}
}
}
)
}
// Update Dialog
if (showUpdateDialog && releaseInfo != null) {
val info = releaseInfo!!
AlertDialog(
onDismissRequest = {
if (!isDownloadingUpdate) showUpdateDialog = false
},
title = {
Text(
text = "Update Available: ${info.tagName}",
color = TextLight,
fontWeight = FontWeight.Bold
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(info.releaseTitle, fontWeight = FontWeight.SemiBold, color = NeonCyan)
Text(info.changelog, fontSize = 12.sp, color = TextMuted)
if (isDownloadingUpdate) {
Spacer(modifier = Modifier.height(8.dp))
LinearProgressIndicator(
progress = { downloadProgress / 100f },
modifier = Modifier.fillMaxWidth()
)
Text(
text = "Downloading APK: $downloadProgress%",
fontSize = 11.sp,
color = NeonCyan
)
}
}
},
confirmButton = {
Button(
onClick = {
if (info.apkDownloadUrl.isEmpty()) {
Toast.makeText(context, "No APK asset in release", Toast.LENGTH_SHORT).show()
return@Button
}
isDownloadingUpdate = true
coroutineScope.launch {
val res = UpdateManager.downloadApk(context, info.apkDownloadUrl) { p ->
downloadProgress = p
}
isDownloadingUpdate = false
res.onSuccess { apkFile ->
showUpdateDialog = false
UpdateManager.promptInstallApk(context, apkFile)
}.onFailure {
Toast.makeText(context, "Download failed: ${it.message}", Toast.LENGTH_SHORT).show()
}
}
},
enabled = !isDownloadingUpdate,
colors = ButtonDefaults.buttonColors(containerColor = PurplePrimary)
) {
Text(if (isDownloadingUpdate) "Downloading..." else "Update Now")
}
},
dismissButton = {
if (!isDownloadingUpdate) {
TextButton(onClick = { showUpdateDialog = false }) {
Text("Later", color = TextMuted)
}
}
},
containerColor = DarkSurfaceElevated
)
}
}
@@ -0,0 +1,17 @@
package com.catlas.escapepenacony.ui.theme
import androidx.compose.ui.graphics.Color
val PurplePrimary = Color(0xFF7C4DFF)
val PurpleDark = Color(0xFF512DA8)
val NeonCyan = Color(0xFF00E5FF)
val StarGold = Color(0xFFFFD700)
val DarkBackground = Color(0xFF0E0F14)
val DarkSurface = Color(0xFF171922)
val DarkSurfaceElevated = Color(0xFF202330)
val TextLight = Color(0xFFF1F1F5)
val TextMuted = Color(0xFF9E9EA7)
val CombatGreen = Color(0xFF00E676)
val ExploreAmber = Color(0xFFFFAB00)
val BetaPurple = Color(0xFFE040FB)
val BorderSubtle = Color(0xFF2C2F40)
@@ -0,0 +1,44 @@
package com.catlas.escapepenacony.ui.theme
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme(
primary = PurplePrimary,
secondary = NeonCyan,
tertiary = StarGold,
background = DarkBackground,
surface = DarkSurface,
onPrimary = TextLight,
onSecondary = DarkBackground,
onBackground = TextLight,
onSurface = TextLight
)
@Composable
fun EscapePenaconyTheme(
content: @Composable () -> Unit
) {
val colorScheme = DarkColorScheme
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
window.statusBarColor = colorScheme.background.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = false
}
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@@ -0,0 +1,31 @@
package com.catlas.escapepenacony.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
),
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#7C4DFF"
android:pathData="M54,14 L86,32 L86,76 L54,94 L22,76 L22,32 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M54,28 L74,40 L74,68 L54,80 L34,68 L34,40 Z" />
<path
android:fillColor="#00E5FF"
android:pathData="M48,46 L62,54 L48,62 Z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -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="@color/background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+8
View File
@@ -0,0 +1,8 @@
<resources>
<color name="primary">#7C4DFF</color>
<color name="primary_dark">#512DA8</color>
<color name="accent">#00E5FF</color>
<color name="background">#12131A</color>
<color name="surface">#1B1C26</color>
<color name="text_primary">#F1F1F5</color>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<resources>
<string name="app_name">Escape penacony</string>
<string name="app_full_name">Escape penacony standalone</string>
</resources>
+6
View File
@@ -0,0 +1,6 @@
<resources>
<style name="Theme.EscapePenacony" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@color/background</item>
<item name="android:navigationBarColor">@color/background</item>
</style>
</resources>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-files-path name="downloads" path="Download/" />
<cache-path name="cache" path="." />
</paths>
+5
View File
@@ -0,0 +1,5 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
+27
View File
@@ -0,0 +1,27 @@
[versions]
agp = "8.7.0"
kotlin = "2.0.21"
coreKtx = "1.13.1"
lifecycleRuntimeKtx = "2.8.6"
activityCompose = "1.9.3"
composeBom = "2024.09.03"
material3 = "1.3.0"
coroutines = "1.9.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# 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.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/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/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+93
View File
@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
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
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
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
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
: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
+23
View File
@@ -0,0 +1,23 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Escape penacony standalone"
include(":app")