From dbb7c8d8f69ebee27928e6498c7e3a54c37748c1 Mon Sep 17 00:00:00 2001
From: chinosk <74499927+chinosk6@users.noreply.github.com>
Date: Thu, 27 Jun 2024 10:54:17 -0500
Subject: [PATCH] Feature: Hot Update Translation (#32)

* update translation data from repo
---
 app/build.gradle                              |   6 +-
 app/src/main/AndroidManifest.xml              |  11 +
 app/src/main/assets/gakumas-local             |   2 +-
 app/src/main/cpp/GakumasLocalify/Hook.cpp     |  11 +-
 .../gakumas/localify/ConfigUpdateListener.kt  |  50 +++++
 .../gakumas/localify/GakumasHookMain.kt       |  56 ++++-
 .../chinosk/gakumas/localify/MainActivity.kt  | 149 +++++++++++--
 .../localify/hookUtils/FileHotUpdater.kt      | 181 +++++++++++++++
 .../localify/hookUtils/FilesChecker.kt        |  49 ++++-
 .../localify/mainUtils/FileDownloader.kt      | 139 ++++++++++++
 .../gakumas/localify/models/ProgramConfig.kt  |  12 +
 .../gakumas/localify/models/ViewModels.kt     |  63 +++++-
 .../localify/ui/components/GakuButton.kt      |  12 +-
 .../localify/ui/components/GakuProgressBar.kt |  55 +++++
 .../localify/ui/components/GakuSwitch.kt      |   2 +-
 .../ui/components/base/CollapsibleBox.kt      |   5 +
 .../ui/pages/subPages/AdvancedSettingsPage.kt |   8 +-
 .../localify/ui/pages/subPages/HomePage.kt    | 208 +++++++++++++++++-
 app/src/main/res/values-zh-rCN/strings.xml    |  11 +
 app/src/main/res/values/strings.xml           |  11 +
 app/src/main/res/xml/file_paths.xml           |   4 +
 21 files changed, 999 insertions(+), 46 deletions(-)
 create mode 100644 app/src/main/java/io/github/chinosk/gakumas/localify/hookUtils/FileHotUpdater.kt
 create mode 100644 app/src/main/java/io/github/chinosk/gakumas/localify/mainUtils/FileDownloader.kt
 create mode 100644 app/src/main/java/io/github/chinosk/gakumas/localify/models/ProgramConfig.kt
 create mode 100644 app/src/main/java/io/github/chinosk/gakumas/localify/ui/components/GakuProgressBar.kt
 create mode 100644 app/src/main/res/xml/file_paths.xml

diff --git a/app/build.gradle b/app/build.gradle
index 0838e55..5d34550 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -14,7 +14,7 @@ android {
         minSdk 29
         targetSdk 34
         versionCode 2
-        versionName "v1.1"
+        versionName "v1.2"
 
         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
         vectorDrawables {
@@ -112,6 +112,10 @@ dependencies {
     implementation "io.coil-kt:coil-compose:2.6.0"
     implementation "io.coil-kt:coil-svg:2.6.0"
 
+    implementation(platform("com.squareup.okhttp3:okhttp-bom:4.12.0"))
+    implementation "com.squareup.okhttp3:okhttp"
+    implementation "com.squareup.okhttp3:logging-interceptor"
+
     implementation 'io.github.hexhacking:xdl:2.1.1'
     implementation 'com.bytedance.android:shadowhook:1.0.9'
     compileOnly 'de.robv.android.xposed:api:82'
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4481f0b..dac5051 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -7,6 +7,7 @@
     <application
         android:allowBackup="true"
         android:dataExtractionRules="@xml/data_extraction_rules"
+        android:usesCleartextTraffic="true"
         android:fullBackupContent="@xml/backup_rules"
         android:icon="@mipmap/ic_launcher"
         android:label="@string/app_name"
@@ -42,6 +43,16 @@
                 <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>
\ No newline at end of file
diff --git a/app/src/main/assets/gakumas-local b/app/src/main/assets/gakumas-local
index cdd0ad0..a60a171 160000
--- a/app/src/main/assets/gakumas-local
+++ b/app/src/main/assets/gakumas-local
@@ -1 +1 @@
-Subproject commit cdd0ad064cf6d3f13107e19b5d08c582d8d0664e
+Subproject commit a60a171b40b22b04d567ab39a8fd7f571c7921f5
diff --git a/app/src/main/cpp/GakumasLocalify/Hook.cpp b/app/src/main/cpp/GakumasLocalify/Hook.cpp
index 7ad488a..5694fb6 100644
--- a/app/src/main/cpp/GakumasLocalify/Hook.cpp
+++ b/app/src/main/cpp/GakumasLocalify/Hook.cpp
@@ -299,6 +299,11 @@ namespace GakumasLocal::HookMain {
 
     void* fontCache = nullptr;
     void* GetReplaceFont() {
+        static std::string fontName = Local::GetBasePath() / "local-files" / "gkamsZHFontMIX.otf";
+        if (!std::filesystem::exists(fontName)) {
+            return nullptr;
+        }
+
         static auto CreateFontFromPath = reinterpret_cast<void (*)(void* self, Il2cppString* path)>(
                 Il2cppUtils::il2cpp_resolve_icall("UnityEngine.Font::Internal_CreateFontFromPath(UnityEngine.Font,System.String)")
         );
@@ -315,7 +320,6 @@ namespace GakumasLocal::HookMain {
         const auto newFont = Font_klass->New<void*>();
         Font_ctor->Invoke<void>(newFont);
 
-        static std::string fontName = Local::GetBasePath() / "local-files" / "gkamsZHFontMIX.otf";
         CreateFontFromPath(newFont, Il2cppString::New(fontName));
         fontCache = newFont;
         return newFont;
@@ -334,9 +338,10 @@ namespace GakumasLocal::HookMain {
         static auto UpdateFontAssetData = Il2cppUtils::GetMethod("Unity.TextMeshPro.dll", "TMPro",
                                                                  "TMP_FontAsset", "UpdateFontAssetData");
 
-        auto fontAsset = get_font->Invoke<void*>(TMP_Textself);
         auto newFont = GetReplaceFont();
-        if (fontAsset && newFont) {
+        if (!newFont) return;
+        auto fontAsset = get_font->Invoke<void*>(TMP_Textself);
+        if (fontAsset) {
             set_sourceFontFile->Invoke<void>(fontAsset, newFont);
             if (!updatedFontPtrs.contains(fontAsset)) {
                 updatedFontPtrs.emplace(fontAsset);
diff --git a/app/src/main/java/io/github/chinosk/gakumas/localify/ConfigUpdateListener.kt b/app/src/main/java/io/github/chinosk/gakumas/localify/ConfigUpdateListener.kt
index 9606238..a123491 100644
--- a/app/src/main/java/io/github/chinosk/gakumas/localify/ConfigUpdateListener.kt
+++ b/app/src/main/java/io/github/chinosk/gakumas/localify/ConfigUpdateListener.kt
@@ -8,6 +8,9 @@ import androidx.lifecycle.ViewModel
 import androidx.lifecycle.ViewModelProvider
 import io.github.chinosk.gakumas.localify.databinding.ActivityMainBinding
 import io.github.chinosk.gakumas.localify.models.GakumasConfig
+import io.github.chinosk.gakumas.localify.models.ProgramConfig
+import io.github.chinosk.gakumas.localify.models.ProgramConfigViewModel
+import io.github.chinosk.gakumas.localify.models.ProgramConfigViewModelFactory
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.asStateFlow
@@ -55,6 +58,15 @@ interface ConfigListener {
     fun onBUseArmCorrectionChanged(value: Boolean)
     fun onBUseScaleChanged(value: Boolean)
     fun onBClickPresetChanged(index: Int)
+    fun onPCheckBuiltInAssetsChanged(value: Boolean)
+    fun onPUseRemoteAssetsChanged(value: Boolean)
+    fun onPCleanLocalAssetsChanged(value: Boolean)
+    fun onPDelRemoteAfterUpdateChanged(value: Boolean)
+    fun onPTransRemoteZipUrlChanged(s: CharSequence, start: Int, before: Int, count: Int)
+    fun mainPageAssetsViewDataUpdate(downloadAbleState: Boolean? = null,
+                                     downloadProgressState: Float? = null,
+                                     localResourceVersionState: String? = null,
+                                     errorString: String? = null)
 }
 
 class UserConfigViewModelFactory(private val initialValue: GakumasConfig) : ViewModelProvider.Factory {
@@ -78,10 +90,15 @@ interface ConfigUpdateListener: ConfigListener {
     var factory: UserConfigViewModelFactory
     var viewModel: UserConfigViewModel
 
+    var programConfig: ProgramConfig
+    var programConfigFactory: ProgramConfigViewModelFactory
+    var programConfigViewModel: ProgramConfigViewModel
+
     fun pushKeyEvent(event: KeyEvent): Boolean
     fun getConfigContent(): String
     fun checkConfigAndUpdateView()
     fun saveConfig()
+    fun saveProgramConfig()
 
 
     override fun onEnabledChanged(value: Boolean) {
@@ -493,4 +510,37 @@ interface ConfigUpdateListener: ConfigListener {
         saveConfig()
     }
 
+    override fun onPCheckBuiltInAssetsChanged(value: Boolean) {
+        programConfig.checkBuiltInAssets = value
+        saveProgramConfig()
+    }
+
+    override fun onPUseRemoteAssetsChanged(value: Boolean) {
+        programConfig.useRemoteAssets = value
+        saveProgramConfig()
+    }
+
+    override fun onPCleanLocalAssetsChanged(value: Boolean) {
+        programConfig.cleanLocalAssets = value
+        saveProgramConfig()
+    }
+
+    override fun onPDelRemoteAfterUpdateChanged(value: Boolean) {
+        programConfig.delRemoteAfterUpdate = value
+        saveProgramConfig()
+    }
+
+    override fun onPTransRemoteZipUrlChanged(s: CharSequence, start: Int, before: Int, count: Int) {
+        programConfig.transRemoteZipUrl = s.toString()
+        saveProgramConfig()
+    }
+
+    override fun mainPageAssetsViewDataUpdate(downloadAbleState: Boolean?, downloadProgressState: Float?,
+                                              localResourceVersionState: String?, errorString: String?) {
+        downloadAbleState?.let { programConfigViewModel.downloadAbleState.value = downloadAbleState }
+        downloadProgressState?.let{ programConfigViewModel.downloadProgressState.value = downloadProgressState }
+        localResourceVersionState?.let{ programConfigViewModel.localResourceVersionState.value = localResourceVersionState }
+        errorString?.let{ programConfigViewModel.errorStringState.value = errorString }
+    }
+
 }
\ No newline at end of file
diff --git a/app/src/main/java/io/github/chinosk/gakumas/localify/GakumasHookMain.kt b/app/src/main/java/io/github/chinosk/gakumas/localify/GakumasHookMain.kt
index 31fcbd1..0a84172 100644
--- a/app/src/main/java/io/github/chinosk/gakumas/localify/GakumasHookMain.kt
+++ b/app/src/main/java/io/github/chinosk/gakumas/localify/GakumasHookMain.kt
@@ -11,19 +11,19 @@ import android.net.Uri
 import android.os.Handler
 import android.os.Looper
 import android.util.Log
-import com.bytedance.shadowhook.ShadowHook
-import com.bytedance.shadowhook.ShadowHook.ConfigBuilder
-import de.robv.android.xposed.IXposedHookLoadPackage
-import de.robv.android.xposed.IXposedHookZygoteInit
-import de.robv.android.xposed.XC_MethodHook
-import de.robv.android.xposed.XposedHelpers
-import de.robv.android.xposed.callbacks.XC_LoadPackage
-import io.github.chinosk.gakumas.localify.hookUtils.FilesChecker
 import android.view.KeyEvent
 import android.view.MotionEvent
 import android.widget.Toast
+import com.bytedance.shadowhook.ShadowHook
+import com.bytedance.shadowhook.ShadowHook.ConfigBuilder
 import com.google.gson.Gson
+import de.robv.android.xposed.IXposedHookLoadPackage
+import de.robv.android.xposed.IXposedHookZygoteInit
+import de.robv.android.xposed.XC_MethodHook
 import de.robv.android.xposed.XposedBridge
+import de.robv.android.xposed.XposedHelpers
+import de.robv.android.xposed.callbacks.XC_LoadPackage
+import io.github.chinosk.gakumas.localify.hookUtils.FilesChecker
 import io.github.chinosk.gakumas.localify.models.GakumasConfig
 import kotlinx.coroutines.DelicateCoroutinesApi
 import kotlinx.coroutines.GlobalScope
@@ -33,6 +33,11 @@ import kotlinx.coroutines.launch
 import java.io.File
 import java.util.Locale
 import kotlin.system.measureTimeMillis
+import android.content.ContentResolver
+import io.github.chinosk.gakumas.localify.hookUtils.FileHotUpdater
+import io.github.chinosk.gakumas.localify.models.ProgramConfig
+import java.io.BufferedReader
+import java.io.InputStreamReader
 
 val TAG = "GakumasLocalify"
 
@@ -46,6 +51,7 @@ class GakumasHookMain : IXposedHookLoadPackage, IXposedHookZygoteInit {
     private var gkmsDataInited = false
 
     private var getConfigError: Exception? = null
+    private var externalFilesChecked: Boolean = false
 
     override fun handleLoadPackage(lpparam: XC_LoadPackage.LoadPackageParam) {
 //        if (lpparam.packageName == "io.github.chinosk.gakumas.localify") {
@@ -183,7 +189,7 @@ class GakumasHookMain : IXposedHookLoadPackage, IXposedHookZygoteInit {
                         requestConfig(app.applicationContext)
                     }
 
-                    FilesChecker.initAndCheck(app.filesDir, modulePath)
+                    FilesChecker.initDir(app.filesDir, modulePath)
                     initHook(
                         "${app.applicationInfo.nativeLibraryDir}/libil2cpp.so",
                         File(
@@ -215,6 +221,7 @@ class GakumasHookMain : IXposedHookLoadPackage, IXposedHookZygoteInit {
     fun initGkmsConfig(activity: Activity) {
         val intent = activity.intent
         val gkmsData = intent.getStringExtra("gkmsData")
+        val programData = intent.getStringExtra("localData")
         if (gkmsData != null) {
             gkmsDataInited = true
             val initConfig = try {
@@ -223,10 +230,41 @@ class GakumasHookMain : IXposedHookLoadPackage, IXposedHookZygoteInit {
             catch (e: Exception) {
                 null
             }
+            val programConfig = try {
+                Gson().fromJson(programData, ProgramConfig::class.java)
+            }
+            catch (e: Exception) {
+                null
+            }
+
+            // 清理本地文件
+            if (programConfig?.cleanLocalAssets == true) {
+                FilesChecker.cleanAssets()
+            }
+
+            // 检查 files 版本和 assets 版本并更新
+            if (programConfig?.checkBuiltInAssets == true) {
+                FilesChecker.initAndCheck(activity.filesDir, modulePath)
+            }
+
+            // 强制导出 assets 文件
             if (initConfig?.forceExportResource == true) {
                 FilesChecker.updateFiles()
             }
 
+            // 使用热更新文件
+            if (programConfig?.useRemoteAssets == true) {
+                val dataUri = intent.data
+                if (dataUri != null) {
+                    if (!externalFilesChecked) {
+                        externalFilesChecked = true
+                        // Log.d(TAG, "dataUri: $dataUri")
+                        FileHotUpdater.updateFilesFromZip(activity, dataUri, activity.filesDir,
+                            programConfig.delRemoteAfterUpdate)
+                    }
+                }
+            }
+
             loadConfig(gkmsData)
             Log.d(TAG, "gkmsData: $gkmsData")
         }
diff --git a/app/src/main/java/io/github/chinosk/gakumas/localify/MainActivity.kt b/app/src/main/java/io/github/chinosk/gakumas/localify/MainActivity.kt
index cedd43a..ce9286e 100644
--- a/app/src/main/java/io/github/chinosk/gakumas/localify/MainActivity.kt
+++ b/app/src/main/java/io/github/chinosk/gakumas/localify/MainActivity.kt
@@ -1,6 +1,5 @@
 package io.github.chinosk.gakumas.localify
 
-import SplashScreen
 import android.annotation.SuppressLint
 import android.content.Intent
 import android.net.Uri
@@ -8,41 +7,59 @@ import android.os.Bundle
 import android.util.Log
 import android.view.KeyEvent
 import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.collectAsState
+import androidx.core.content.FileProvider
 import androidx.databinding.DataBindingUtil
+import androidx.lifecycle.ViewModelProvider
+import com.google.gson.ExclusionStrategy
+import com.google.gson.FieldAttributes
 import com.google.gson.Gson
+import com.google.gson.GsonBuilder
 import com.google.gson.JsonSyntaxException
 import io.github.chinosk.gakumas.localify.databinding.ActivityMainBinding
+import io.github.chinosk.gakumas.localify.hookUtils.FileHotUpdater
 import io.github.chinosk.gakumas.localify.hookUtils.FilesChecker
 import io.github.chinosk.gakumas.localify.hookUtils.MainKeyEventDispatcher
 import io.github.chinosk.gakumas.localify.models.GakumasConfig
-import io.github.chinosk.gakumas.localify.ui.theme.GakumasLocalifyTheme
-import java.io.File
-import androidx.activity.ComponentActivity
-import androidx.activity.compose.setContent
-import kotlinx.coroutines.flow.MutableStateFlow
-import androidx.compose.runtime.State
-import androidx.compose.runtime.collectAsState
-import androidx.lifecycle.ViewModelProvider
-import androidx.navigation.compose.NavHost
-import androidx.navigation.compose.composable
-import androidx.navigation.compose.rememberNavController
+import io.github.chinosk.gakumas.localify.models.ProgramConfig
+import io.github.chinosk.gakumas.localify.models.ProgramConfigViewModel
+import io.github.chinosk.gakumas.localify.models.ProgramConfigViewModelFactory
 import io.github.chinosk.gakumas.localify.ui.pages.MainUI
+import io.github.chinosk.gakumas.localify.ui.theme.GakumasLocalifyTheme
+import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asStateFlow
+import java.io.File
 
 
 class MainActivity : ComponentActivity(), ConfigUpdateListener {
     override lateinit var binding: ActivityMainBinding
+    override lateinit var programConfig: ProgramConfig
 
     override lateinit var factory: UserConfigViewModelFactory
     override lateinit var viewModel: UserConfigViewModel
 
+    override lateinit var programConfigFactory: ProgramConfigViewModelFactory
+    override lateinit var programConfigViewModel: ProgramConfigViewModel
+
     override fun onClickStartGame() {
         val intent = Intent().apply {
             setClassName("com.bandainamcoent.idolmaster_gakuen", "com.google.firebase.MessagingUnityPlayerActivity")
             putExtra("gkmsData", getConfigContent())
+            putExtra("localData", getProgramConfigContent(listOf("transRemoteZipUrl", "p")))
             flags = Intent.FLAG_ACTIVITY_NEW_TASK
         }
+
+        val updateFile = File(filesDir, "update_trans.zip")
+        if (updateFile.exists()) {
+            val dirUri = FileProvider.getUriForFile(this, "io.github.chinosk.gakumas.localify.fileprovider", File(updateFile.absolutePath))
+            intent.setDataAndType(dirUri, "resource/file")
+            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
+        }
+
         startActivity(intent)
     }
 
@@ -61,6 +78,32 @@ class MainActivity : ComponentActivity(), ConfigUpdateListener {
         }
     }
 
+    private fun getProgramConfigContent(excludes: List<String>? = null): String {
+        if (excludes == null) {
+            val configFile = File(filesDir, "localify-config.json")
+            return if (configFile.exists()) {
+                configFile.readText()
+            }
+            else {
+                "{}"
+            }
+        }
+        else {
+            val gson = GsonBuilder()
+                .setExclusionStrategies(object : ExclusionStrategy {
+                    override fun shouldSkipField(f: FieldAttributes): Boolean {
+                        return excludes.contains(f.name)
+                    }
+
+                    override fun shouldSkipClass(clazz: Class<*>): Boolean {
+                        return false
+                    }
+                })
+                .create()
+            return gson.toJson(programConfig)
+        }
+    }
+
     override fun saveConfig() {
         try {
             binding.config!!.pf = false
@@ -73,6 +116,18 @@ class MainActivity : ComponentActivity(), ConfigUpdateListener {
         configFile.writeText(Gson().toJson(binding.config!!))
     }
 
+    override fun saveProgramConfig() {
+        try {
+            programConfig.p = false
+            programConfigViewModel.configState.value = programConfig.copy( p = true )  // 更新 UI
+        }
+        catch (e: RuntimeException) {
+            Log.d(TAG, e.toString())
+        }
+        val configFile = File(filesDir, "localify-config.json")
+        configFile.writeText(Gson().toJson(programConfig))
+    }
+
     fun getVersion(): List<String> {
         var versionText = ""
         var resVersionText = "unknown"
@@ -107,6 +162,14 @@ class MainActivity : ComponentActivity(), ConfigUpdateListener {
             Gson().fromJson("{}", GakumasConfig::class.java)
         }
         saveConfig()
+
+        val programConfigStr = getProgramConfigContent()
+        programConfig = try {
+            Gson().fromJson(programConfigStr, ProgramConfig::class.java)
+        }
+        catch (e: JsonSyntaxException) {
+            Gson().fromJson("{}", ProgramConfig::class.java)
+        }
     }
 
     override fun checkConfigAndUpdateView() {
@@ -151,8 +214,13 @@ class MainActivity : ComponentActivity(), ConfigUpdateListener {
         factory = UserConfigViewModelFactory(binding.config!!)
         viewModel = ViewModelProvider(this, factory)[UserConfigViewModel::class.java]
 
+        programConfigFactory = ProgramConfigViewModelFactory(programConfig,
+            FileHotUpdater.getZipResourceVersion(File(filesDir, "update_trans.zip").absolutePath).toString()
+        )
+        programConfigViewModel = ViewModelProvider(this, programConfigFactory)[ProgramConfigViewModel::class.java]
+
         setContent {
-            GakumasLocalifyTheme(dynamicColor = false) {
+            GakumasLocalifyTheme(dynamicColor = false, darkTheme = false) {
                 MainUI(context = this)
                 /*
                 val navController = rememberNavController()
@@ -182,6 +250,61 @@ fun getConfigState(context: MainActivity?, previewData: GakumasConfig?): State<G
     }
 }
 
+@Composable
+fun getProgramConfigState(context: MainActivity?, previewData: ProgramConfig? = null): State<ProgramConfig> {
+    return if (context != null) {
+        context.programConfigViewModel.config.collectAsState()
+    }
+    else {
+        val configMSF = MutableStateFlow(previewData ?: ProgramConfig())
+        configMSF.asStateFlow().collectAsState()
+    }
+}
+
+@Composable
+fun getProgramDownloadState(context: MainActivity?): State<Float> {
+    return if (context != null) {
+        context.programConfigViewModel.downloadProgress.collectAsState()
+    }
+    else {
+        val configMSF = MutableStateFlow(0f)
+        configMSF.asStateFlow().collectAsState()
+    }
+}
+
+@Composable
+fun getProgramDownloadAbleState(context: MainActivity?): State<Boolean> {
+    return if (context != null) {
+        context.programConfigViewModel.downloadAble.collectAsState()
+    }
+    else {
+        val configMSF = MutableStateFlow(true)
+        configMSF.asStateFlow().collectAsState()
+    }
+}
+
+@Composable
+fun getProgramLocalResourceVersionState(context: MainActivity?): State<String> {
+    return if (context != null) {
+        context.programConfigViewModel.localResourceVersion.collectAsState()
+    }
+    else {
+        val configMSF = MutableStateFlow("null")
+        configMSF.asStateFlow().collectAsState()
+    }
+}
+
+@Composable
+fun getProgramDownloadErrorStringState(context: MainActivity?): State<String> {
+    return if (context != null) {
+        context.programConfigViewModel.errorString.collectAsState()
+    }
+    else {
+        val configMSF = MutableStateFlow("")
+        configMSF.asStateFlow().collectAsState()
+    }
+}
+
 /*
 class OldActivity : AppCompatActivity(), ConfigUpdateListener {
     override lateinit var binding: ActivityMainBinding
diff --git a/app/src/main/java/io/github/chinosk/gakumas/localify/hookUtils/FileHotUpdater.kt b/app/src/main/java/io/github/chinosk/gakumas/localify/hookUtils/FileHotUpdater.kt
new file mode 100644
index 0000000..b96dc99
--- /dev/null
+++ b/app/src/main/java/io/github/chinosk/gakumas/localify/hookUtils/FileHotUpdater.kt
@@ -0,0 +1,181 @@
+package io.github.chinosk.gakumas.localify.hookUtils
+
+import android.app.Activity
+import android.net.Uri
+import android.util.Log
+import io.github.chinosk.gakumas.localify.GakumasHookMain
+import io.github.chinosk.gakumas.localify.TAG
+import java.io.BufferedReader
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.io.InputStream
+import java.io.InputStreamReader
+import java.util.zip.ZipInputStream
+
+object FileHotUpdater {
+    private fun unzip(zipFile: InputStream, destDir: String, matchNamePrefix: String = "",
+                      replaceMatchNamePrefix: String? = null) {
+        val buffer = ByteArray(1024)
+        try {
+            val folder = File(destDir)
+            if (!folder.exists()) {
+                folder.mkdir()
+            }
+
+            val zipIn = ZipInputStream(zipFile)
+
+            var entry = zipIn.nextEntry
+            while (entry != null) {
+                var writeEntryName = entry.name
+                if (matchNamePrefix.isNotEmpty()) {
+                    if (!entry.name.startsWith(matchNamePrefix)) {
+                        zipIn.closeEntry()
+                        entry = zipIn.nextEntry
+                        continue
+                    }
+                    replaceMatchNamePrefix?.let {
+                        writeEntryName = replaceMatchNamePrefix + writeEntryName.substring(
+                            matchNamePrefix.length, writeEntryName.length
+                        )
+                    }
+                }
+                val filePath = destDir + File.separator + writeEntryName
+                if (!entry.isDirectory) {
+                    extractFile(zipIn, filePath, buffer)
+                } else {
+                    val dir = File(filePath)
+                    dir.mkdirs()
+                }
+                zipIn.closeEntry()
+                entry = zipIn.nextEntry
+            }
+            zipIn.close()
+        } catch (e: Exception) {
+            Log.e(TAG, "unzip error: $e")
+        }
+    }
+
+    private fun unzip(zipFile: String, destDir: String, matchNamePrefix: String = "") {
+        return unzip(FileInputStream(zipFile), destDir, matchNamePrefix)
+    }
+
+    private fun extractFile(zipIn: ZipInputStream, filePath: String, buffer: ByteArray) {
+        val fout = FileOutputStream(filePath)
+        var length: Int
+        while (zipIn.read(buffer).also { length = it } > 0) {
+            fout.write(buffer, 0, length)
+        }
+        fout.close()
+    }
+
+    private fun getZipResourcePath(zipFile: InputStream): String? {
+        try {
+            val zipIn = ZipInputStream(zipFile)
+
+            var entry = zipIn.nextEntry
+            while (entry != null) {
+                if (entry.isDirectory) {
+                    if (entry.name.endsWith("local-files/")) {
+                        zipIn.close()
+                        var retPath = File(entry.name, "..").canonicalPath
+                        if (retPath.startsWith("/")) retPath = retPath.substring(1)
+                        return retPath
+                    }
+                }
+                zipIn.closeEntry()
+                entry = zipIn.nextEntry
+            }
+            zipIn.close()
+        }
+        catch (e: Exception) {
+            Log.e(TAG, "getZipResourcePath error: $e")
+        }
+        return null
+    }
+
+    private fun getZipResourceVersion(zipFile: InputStream, basePath: String): String? {
+        try {
+            val targetVersionFilePath = File(basePath, "version.txt").canonicalPath
+
+            val zipIn = ZipInputStream(zipFile)
+            var entry = zipIn.nextEntry
+            while (entry != null) {
+                if (!entry.isDirectory) {
+                    if ("/${entry.name}" == targetVersionFilePath) {
+                        Log.d(TAG, "targetVersionFilePath: $targetVersionFilePath")
+                        val reader = BufferedReader(InputStreamReader(zipIn))
+                        val versionContent = reader.use { it.readText() }
+                        Log.d(TAG, "versionContent: $versionContent")
+                        zipIn.close()
+                        return versionContent
+                    }
+                }
+                zipIn.closeEntry()
+                entry = zipIn.nextEntry
+            }
+            zipIn.close()
+        }
+        catch (e: Exception) {
+            Log.e(TAG, "getZipResourceVersion error: $e")
+        }
+        return null
+    }
+
+    private fun getZipResourceVersion(zipFile: String, basePath: String): String? {
+        return getZipResourceVersion(FileInputStream(zipFile), basePath)
+    }
+
+    fun getZipResourceVersion(zipFile: String): String? {
+        return try {
+            val basePath = getZipResourcePath(FileInputStream(zipFile))
+            basePath?.let { getZipResourceVersion(zipFile, it) }
+        }
+        catch (_: Exception) {
+            null
+        }
+    }
+
+    fun updateFilesFromZip(activity: Activity, zipFileUri: Uri, filesDir: File, deleteAfterUpdate: Boolean) {
+        try {
+            GakumasHookMain.showToast("Updating files from zip...")
+
+            var basePath: String?
+            activity.contentResolver.openInputStream(zipFileUri).use {
+                basePath = it?.let { getZipResourcePath(it) }
+                if (basePath == null) {
+                    Log.e(TAG, "getZipResourcePath failed.")
+                    return@updateFilesFromZip
+                }
+            }
+
+            /*
+            var resourceVersion: String?
+            activity.contentResolver.openInputStream(zipFileUri).use {
+                resourceVersion = it?.let { getZipResourceVersion(it, basePath!!) }
+                Log.d(TAG, "resourceVersion: $resourceVersion ($basePath)")
+            }*/
+
+            activity.contentResolver.openInputStream(zipFileUri).use {
+                it?.let {
+                    unzip(it, File(filesDir, FilesChecker.localizationFilesDir).absolutePath,
+                        basePath!!, "../gakumas-local/")
+                    if (deleteAfterUpdate) {
+                        activity.contentResolver.delete(zipFileUri, null, null)
+                    }
+                    GakumasHookMain.showToast("Update success.")
+                }
+            }
+
+        }
+        catch (e: java.io.FileNotFoundException) {
+            Log.i(TAG, "updateFilesFromZip - file not found: $e")
+            GakumasHookMain.showToast("Update file not found.")
+        }
+        catch (e: Exception) {
+            Log.e(TAG, "updateFilesFromZip failed: $e")
+            GakumasHookMain.showToast("Updating files failed: $e")
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/github/chinosk/gakumas/localify/hookUtils/FilesChecker.kt b/app/src/main/java/io/github/chinosk/gakumas/localify/hookUtils/FilesChecker.kt
index b62e567..61d72e7 100644
--- a/app/src/main/java/io/github/chinosk/gakumas/localify/hookUtils/FilesChecker.kt
+++ b/app/src/main/java/io/github/chinosk/gakumas/localify/hookUtils/FilesChecker.kt
@@ -16,12 +16,16 @@ object FilesChecker {
     var filesUpdated = false
 
     fun initAndCheck(fileDir: File, modulePath: String) {
-        this.filesDir = fileDir
-        this.modulePath = modulePath
+        initDir(fileDir, modulePath)
 
         checkFiles()
     }
 
+    fun initDir(fileDir: File, modulePath: String) {
+        this.filesDir = fileDir
+        this.modulePath = modulePath
+    }
+
     fun checkFiles() {
         val installedVersion = getInstalledVersion()
         val pluginVersion = getPluginVersion()
@@ -118,4 +122,45 @@ object FilesChecker {
         return stringBuilder.toString()
     }
 
+    private fun deleteRecursively(file: File): Boolean {
+        if (file.isDirectory) {
+            val children = file.listFiles()
+            if (children != null) {
+                for (child in children) {
+                    val success = deleteRecursively(child)
+                    if (!success) {
+                        return false
+                    }
+                }
+            }
+        }
+        return file.delete()
+    }
+
+    fun cleanAssets() {
+        val pluginBasePath = File(filesDir, localizationFilesDir)
+        val localFilesDir = File(pluginBasePath, "local-files")
+
+        val fontFile = File(localFilesDir, "gkamsZHFontMIX.otf")
+        val resourceDir = File(localFilesDir, "resource")
+        val genericTransDir = File(localFilesDir, "genericTrans")
+        val genericTransFile = File(localFilesDir, "generic.json")
+        val i18nFile = File(localFilesDir, "localization.json")
+
+        if (fontFile.exists()) {
+            fontFile.delete()
+        }
+        if (deleteRecursively(resourceDir)) {
+            resourceDir.mkdirs()
+        }
+        if (deleteRecursively(genericTransDir)) {
+            genericTransDir.mkdirs()
+        }
+        if (genericTransFile.exists()) {
+            genericTransFile.writeText("{}")
+        }
+        if (i18nFile.exists()) {
+            i18nFile.writeText("{}")
+        }
+    }
 }
\ No newline at end of file
diff --git a/app/src/main/java/io/github/chinosk/gakumas/localify/mainUtils/FileDownloader.kt b/app/src/main/java/io/github/chinosk/gakumas/localify/mainUtils/FileDownloader.kt
new file mode 100644
index 0000000..0e908b6
--- /dev/null
+++ b/app/src/main/java/io/github/chinosk/gakumas/localify/mainUtils/FileDownloader.kt
@@ -0,0 +1,139 @@
+package io.github.chinosk.gakumas.localify.mainUtils
+
+import android.util.Log
+import io.github.chinosk.gakumas.localify.TAG
+import okhttp3.*
+import java.io.IOException
+import java.io.ByteArrayOutputStream
+import java.util.concurrent.TimeUnit
+
+object FileDownloader {
+    private val client = OkHttpClient.Builder()
+        .connectTimeout(30, TimeUnit.SECONDS)
+        .writeTimeout(0, TimeUnit.SECONDS)
+        .readTimeout(0, TimeUnit.SECONDS)
+        .build()
+
+    private var call: Call? = null
+
+    fun downloadFile(
+        url: String,
+        onDownload: (Float, downloaded: Long, size: Long) -> Unit,
+        onSuccess: (ByteArray) -> Unit,
+        onFailed: (Int, String) -> Unit,
+        checkContentTypes: List<String>? = null
+    ) {
+        try {
+            if (call != null) {
+                onFailed(-1, "Another file is downloading.")
+                return
+            }
+            val request = Request.Builder()
+                .url(url)
+                .build()
+
+            call = client.newCall(request)
+            call?.enqueue(object : Callback {
+                override fun onFailure(call: Call, e: IOException) {
+                    this@FileDownloader.call = null
+                    if (call.isCanceled()) {
+                        onFailed(-1, "Download canceled")
+                    } else {
+                        onFailed(-1, e.message ?: "Unknown error")
+                    }
+                }
+
+                override fun onResponse(call: Call, response: Response) {
+                    if (!response.isSuccessful) {
+                        this@FileDownloader.call = null
+                        onFailed(response.code, response.message)
+                        return
+                    }
+
+                    if (checkContentTypes != null) {
+                        val contentType = response.header("Content-Type")
+                        if (!checkContentTypes.contains(contentType)) {
+                            onFailed(-1, "Unexpected content type: $contentType")
+                            this@FileDownloader.call = null
+                            return
+                        }
+                    }
+
+                    response.body?.let { responseBody ->
+                        val contentLength = responseBody.contentLength()
+                        val inputStream = responseBody.byteStream()
+                        val buffer = ByteArray(8 * 1024)
+                        var downloadedBytes = 0L
+                        var read: Int
+                        val outputStream = ByteArrayOutputStream()
+
+                        try {
+                            while (inputStream.read(buffer).also { read = it } != -1) {
+                                outputStream.write(buffer, 0, read)
+                                downloadedBytes += read
+                                val progress = if (contentLength < 0) {
+                                    0f
+                                }
+                                else {
+                                    downloadedBytes.toFloat() / contentLength
+                                }
+                                onDownload(progress, downloadedBytes, contentLength)
+                            }
+                            onSuccess(outputStream.toByteArray())
+                        } catch (e: IOException) {
+                            if (call.isCanceled()) {
+                                onFailed(-1, "Download canceled")
+                            } else {
+                                onFailed(-1, e.message ?: "Error reading stream")
+                            }
+                        } finally {
+                            this@FileDownloader.call = null
+                            inputStream.close()
+                            outputStream.close()
+                        }
+                    } ?: run {
+                        this@FileDownloader.call = null
+                        onFailed(-1, "Response body is null")
+                    }
+                }
+            })
+        }
+        catch (e: Exception) {
+            onFailed(-1, e.toString())
+            call = null
+        }
+
+    }
+
+    fun cancel() {
+        call?.cancel()
+        this@FileDownloader.call = null
+    }
+
+    /**
+    * return: Status, newString
+    * Status: 0 - not change, 1 - need check, 2 - modified, 3 - checked
+    **/
+    fun checkAndChangeDownloadURL(url: String, forceEdit: Boolean = false): Pair<Int, String> {
+
+        if (!url.startsWith("https://github.com/")) {  // check github only
+            return Pair(0, url)
+        }
+        if (url.endsWith(".zip")) {
+            return Pair(0, url)
+        }
+
+        // https://github.com/chinosk6/GakumasTranslationData
+        // https://github.com/chinosk6/GakumasTranslationData.git
+        // https://github.com/chinosk6/GakumasTranslationData/archive/refs/heads/main.zip
+        if (url.endsWith(".git")) {
+            return Pair(2, "${url.substring(0, url.length - 4)}/archive/refs/heads/main.zip")
+        }
+
+        if (forceEdit) {
+            return Pair(3, "$url/archive/refs/heads/main.zip")
+        }
+
+        return Pair(1, url)
+    }
+}
diff --git a/app/src/main/java/io/github/chinosk/gakumas/localify/models/ProgramConfig.kt b/app/src/main/java/io/github/chinosk/gakumas/localify/models/ProgramConfig.kt
new file mode 100644
index 0000000..04ddb24
--- /dev/null
+++ b/app/src/main/java/io/github/chinosk/gakumas/localify/models/ProgramConfig.kt
@@ -0,0 +1,12 @@
+package io.github.chinosk.gakumas.localify.models
+
+
+data class ProgramConfig (
+    var checkBuiltInAssets: Boolean = true,
+    var transRemoteZipUrl: String = "",
+    var useRemoteAssets: Boolean = false,
+    var delRemoteAfterUpdate: Boolean = true,
+    var cleanLocalAssets: Boolean = false,
+
+    var p: Boolean = false
+)
diff --git a/app/src/main/java/io/github/chinosk/gakumas/localify/models/ViewModels.kt b/app/src/main/java/io/github/chinosk/gakumas/localify/models/ViewModels.kt
index a6c190f..4a66d9a 100644
--- a/app/src/main/java/io/github/chinosk/gakumas/localify/models/ViewModels.kt
+++ b/app/src/main/java/io/github/chinosk/gakumas/localify/models/ViewModels.kt
@@ -5,18 +5,67 @@ import androidx.compose.runtime.getValue
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.lifecycle.ViewModelProvider
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
 
-
-class CollapsibleBoxViewModel(initiallyExpanded: Boolean = false) : ViewModel() {
-    var expanded by mutableStateOf(initiallyExpanded)
+open class CollapsibleBoxViewModel(initiallyBreastExpanded: Boolean = false) : ViewModel() {
+    open var expanded by mutableStateOf(initiallyBreastExpanded)
 }
 
-class CollapsibleBoxViewModelFactory(private val initiallyExpanded: Boolean) : ViewModelProvider.Factory {
+class BreastCollapsibleBoxViewModel(initiallyBreastExpanded: Boolean = false) : CollapsibleBoxViewModel(initiallyBreastExpanded) {
+    override var expanded by mutableStateOf(initiallyBreastExpanded)
+}
+
+class ResourceCollapsibleBoxViewModel(initiallyBreastExpanded: Boolean = false) : CollapsibleBoxViewModel(initiallyBreastExpanded) {
+    override var expanded by mutableStateOf(initiallyBreastExpanded)
+}
+
+class BreastCollapsibleBoxViewModelFactory(private val initiallyExpanded: Boolean) : ViewModelProvider.Factory {
     override fun <T : ViewModel> create(modelClass: Class<T>): T {
-        if (modelClass.isAssignableFrom(CollapsibleBoxViewModel::class.java)) {
+        if (modelClass.isAssignableFrom(BreastCollapsibleBoxViewModel::class.java)) {
             @Suppress("UNCHECKED_CAST")
-            return CollapsibleBoxViewModel(initiallyExpanded) as T
+            return BreastCollapsibleBoxViewModel(initiallyExpanded) as T
         }
         throw IllegalArgumentException("Unknown ViewModel class")
     }
-}
\ No newline at end of file
+}
+
+class ResourceCollapsibleBoxViewModelFactory(private val initiallyExpanded: Boolean) : ViewModelProvider.Factory {
+    override fun <T : ViewModel> create(modelClass: Class<T>): T {
+        if (modelClass.isAssignableFrom(ResourceCollapsibleBoxViewModel::class.java)) {
+            @Suppress("UNCHECKED_CAST")
+            return ResourceCollapsibleBoxViewModel(initiallyExpanded) as T
+        }
+        throw IllegalArgumentException("Unknown ViewModel class")
+    }
+}
+
+
+class ProgramConfigViewModelFactory(private val initialValue: ProgramConfig,
+                                    private val localResourceVersion: String) : ViewModelProvider.Factory {
+    override fun <T : ViewModel> create(modelClass: Class<T>): T {
+        if (modelClass.isAssignableFrom(ProgramConfigViewModel::class.java)) {
+            @Suppress("UNCHECKED_CAST")
+            return ProgramConfigViewModel(initialValue, localResourceVersion) as T
+        }
+        throw IllegalArgumentException("Unknown ViewModel class")
+    }
+}
+
+class ProgramConfigViewModel(initValue: ProgramConfig, initLocalResourceVersion: String) : ViewModel() {
+    val configState = MutableStateFlow(initValue)
+    val config: StateFlow<ProgramConfig> = configState.asStateFlow()
+
+    val downloadProgressState = MutableStateFlow(-1f)
+    val downloadProgress: StateFlow<Float> = downloadProgressState.asStateFlow()
+
+    val downloadAbleState = MutableStateFlow(true)
+    val downloadAble: StateFlow<Boolean> = downloadAbleState.asStateFlow()
+
+    val localResourceVersionState = MutableStateFlow(initLocalResourceVersion)
+    val localResourceVersion: StateFlow<String> = localResourceVersionState.asStateFlow()
+
+    val errorStringState = MutableStateFlow("")
+    val errorString: StateFlow<String> = errorStringState.asStateFlow()
+}
diff --git a/app/src/main/java/io/github/chinosk/gakumas/localify/ui/components/GakuButton.kt b/app/src/main/java/io/github/chinosk/gakumas/localify/ui/components/GakuButton.kt
index 5f9b446..8dc5279 100644
--- a/app/src/main/java/io/github/chinosk/gakumas/localify/ui/components/GakuButton.kt
+++ b/app/src/main/java/io/github/chinosk/gakumas/localify/ui/components/GakuButton.kt
@@ -34,13 +34,15 @@ fun GakuButton(
     shape: Shape = RoundedCornerShape(50.dp), // 用于实现左右两边的半圆角
     shadowElevation: Dp = 8.dp, // 阴影的高度
     borderWidth: Dp = 1.dp, // 描边的宽度
-    borderColor: Color = Color.Transparent // 描边的颜色
+    borderColor: Color = Color.Transparent, // 描边的颜色
+    enabled: Boolean = true
 ) {
     var buttonSize by remember { mutableStateOf(IntSize.Zero) }
 
     val gradient = remember(buttonSize) {
         Brush.linearGradient(
-            colors = listOf(Color(0xFFFF5F19), Color(0xFFFFA028)),
+            colors = if (enabled) listOf(Color(0xFFFF5F19), Color(0xFFFFA028)) else
+                listOf(Color(0xFFF9F9F9), Color(0xFFF0F0F0)),
             start = Offset(0f, 0f),
             end = Offset(buttonSize.width.toFloat(), buttonSize.height.toFloat()) // 动态终点
         )
@@ -48,6 +50,7 @@ fun GakuButton(
 
     Button(
         onClick = onClick,
+        enabled = enabled,
         colors = ButtonDefaults.buttonColors(
             containerColor = Color.Transparent
         ),
@@ -61,7 +64,7 @@ fun GakuButton(
             .border(borderWidth, borderColor, shape),
         contentPadding = PaddingValues(0.dp)
     ) {
-        Text(text = text)
+        Text(text = text, color = if (enabled) Color.White else Color(0xFF111111))
     }
 }
 
@@ -69,5 +72,6 @@ fun GakuButton(
 @Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
 @Composable
 fun GakuButtonPreview() {
-    GakuButton(modifier = Modifier.width(80.dp).height(40.dp), text = "Button", onClick = {})
+    GakuButton(modifier = Modifier.width(80.dp).height(40.dp), text = "Button", onClick = {},
+        enabled = true)
 }
diff --git a/app/src/main/java/io/github/chinosk/gakumas/localify/ui/components/GakuProgressBar.kt b/app/src/main/java/io/github/chinosk/gakumas/localify/ui/components/GakuProgressBar.kt
new file mode 100644
index 0000000..3d6ce9b
--- /dev/null
+++ b/app/src/main/java/io/github/chinosk/gakumas/localify/ui/components/GakuProgressBar.kt
@@ -0,0 +1,55 @@
+package io.github.chinosk.gakumas.localify.ui.components
+
+
+import android.content.res.Configuration.UI_MODE_NIGHT_NO
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+
+
+@Composable
+fun GakuProgressBar(modifier: Modifier = Modifier, progress: Float, isError: Boolean = false) {
+    val animatedProgress by animateFloatAsState(targetValue = progress, label = "progressAnime")
+
+    Row(
+        verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
+        modifier = modifier
+    ) {
+        if (progress <= 0f) {
+            LinearProgressIndicator(
+                modifier = Modifier
+                    .weight(1f)
+                    .clip(RoundedCornerShape(4.dp))
+                    .height(8.dp),
+                color = if (isError) Color(0xFFE2041B) else Color(0xFFF9C114),
+            )
+        }
+        else {
+            LinearProgressIndicator(
+                progress = { animatedProgress },
+                modifier = Modifier
+                    .weight(1f)
+                    .clip(RoundedCornerShape(4.dp))
+                    .height(8.dp),
+                color = if (isError) Color(0xFFE2041B) else Color(0xFFF9C114),
+            )
+        }
+
+        Spacer(modifier = Modifier.width(8.dp))
+
+        Text(if (progress > 0f) "${(progress * 100).toInt()}%" else if (isError) "Failed" else "Downloading")
+    }
+}
+
+@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
+@Composable
+fun GakuProgressBarPreview() {
+    GakuProgressBar(progress = 0.25f)
+}
diff --git a/app/src/main/java/io/github/chinosk/gakumas/localify/ui/components/GakuSwitch.kt b/app/src/main/java/io/github/chinosk/gakumas/localify/ui/components/GakuSwitch.kt
index aa1b0ef..1355e1a 100644
--- a/app/src/main/java/io/github/chinosk/gakumas/localify/ui/components/GakuSwitch.kt
+++ b/app/src/main/java/io/github/chinosk/gakumas/localify/ui/components/GakuSwitch.kt
@@ -34,7 +34,7 @@ fun GakuSwitch(modifier: Modifier = Modifier,
             modifier = Modifier,
             colors = SwitchDefaults.colors(
                 checkedThumbColor = Color(0xFFFFFFFF),
-                checkedTrackColor = Color(0xFFF89400),
+                checkedTrackColor = Color(0xFFF9C114),
 
                 uncheckedThumbColor = Color(0xFFFFFFFF),
                 uncheckedTrackColor = Color(0xFFCFD8DC),
diff --git a/app/src/main/java/io/github/chinosk/gakumas/localify/ui/components/base/CollapsibleBox.kt b/app/src/main/java/io/github/chinosk/gakumas/localify/ui/components/base/CollapsibleBox.kt
index ffcd4a7..bbc45aa 100644
--- a/app/src/main/java/io/github/chinosk/gakumas/localify/ui/components/base/CollapsibleBox.kt
+++ b/app/src/main/java/io/github/chinosk/gakumas/localify/ui/components/base/CollapsibleBox.kt
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
 import androidx.compose.material3.Text
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.*
@@ -29,6 +30,8 @@ fun CollapsibleBox(
     viewModel: CollapsibleBoxViewModel = viewModel(),
     showExpand: Boolean = true,
     expandState: Boolean? = null,
+    innerPaddingTopBottom: Dp = 0.dp,
+    innerPaddingLeftRight: Dp = 0.dp,
     content: @Composable () -> Unit
 ) {
     val expanded by viewModel::expanded
@@ -65,6 +68,8 @@ fun CollapsibleBox(
             modifier = Modifier
                 .height(animatedHeight)
                 .fillMaxWidth()
+                .padding(start = innerPaddingLeftRight, end = innerPaddingLeftRight,
+                    top = innerPaddingTopBottom, bottom = innerPaddingTopBottom)
                 // .fillMaxSize()
                 .clickable {
                     if (!expanded && showExpand) {
diff --git a/app/src/main/java/io/github/chinosk/gakumas/localify/ui/pages/subPages/AdvancedSettingsPage.kt b/app/src/main/java/io/github/chinosk/gakumas/localify/ui/pages/subPages/AdvancedSettingsPage.kt
index 127769a..69972b0 100644
--- a/app/src/main/java/io/github/chinosk/gakumas/localify/ui/pages/subPages/AdvancedSettingsPage.kt
+++ b/app/src/main/java/io/github/chinosk/gakumas/localify/ui/pages/subPages/AdvancedSettingsPage.kt
@@ -29,8 +29,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel
 import io.github.chinosk.gakumas.localify.MainActivity
 import io.github.chinosk.gakumas.localify.R
 import io.github.chinosk.gakumas.localify.getConfigState
-import io.github.chinosk.gakumas.localify.models.CollapsibleBoxViewModel
-import io.github.chinosk.gakumas.localify.models.CollapsibleBoxViewModelFactory
+import io.github.chinosk.gakumas.localify.models.BreastCollapsibleBoxViewModel
+import io.github.chinosk.gakumas.localify.models.BreastCollapsibleBoxViewModelFactory
 import io.github.chinosk.gakumas.localify.models.GakumasConfig
 import io.github.chinosk.gakumas.localify.ui.components.base.CollapsibleBox
 import io.github.chinosk.gakumas.localify.ui.components.GakuButton
@@ -47,8 +47,8 @@ fun AdvanceSettingsPage(modifier: Modifier = Modifier,
     val config = getConfigState(context, previewData)
     // val scrollState = rememberScrollState()
 
-    val breastParamViewModel: CollapsibleBoxViewModel =
-        viewModel(factory = CollapsibleBoxViewModelFactory(initiallyExpanded = false))
+    val breastParamViewModel: BreastCollapsibleBoxViewModel =
+        viewModel(factory = BreastCollapsibleBoxViewModelFactory(initiallyExpanded = false))
     val keyBoardOptionsDecimal = remember {
         KeyboardOptions(keyboardType = KeyboardType.Decimal)
     }
diff --git a/app/src/main/java/io/github/chinosk/gakumas/localify/ui/pages/subPages/HomePage.kt b/app/src/main/java/io/github/chinosk/gakumas/localify/ui/pages/subPages/HomePage.kt
index f818ea6..be25fef 100644
--- a/app/src/main/java/io/github/chinosk/gakumas/localify/ui/pages/subPages/HomePage.kt
+++ b/app/src/main/java/io/github/chinosk/gakumas/localify/ui/pages/subPages/HomePage.kt
@@ -2,6 +2,7 @@ package io.github.chinosk.gakumas.localify.ui.pages.subPages
 
 import GakuGroupBox
 import android.content.res.Configuration.UI_MODE_NIGHT_NO
+import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
@@ -16,24 +17,39 @@ import androidx.compose.material3.HorizontalDivider
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.input.KeyboardType
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
+import androidx.lifecycle.viewmodel.compose.viewModel
 import io.github.chinosk.gakumas.localify.MainActivity
 import io.github.chinosk.gakumas.localify.R
 import io.github.chinosk.gakumas.localify.getConfigState
+import io.github.chinosk.gakumas.localify.getProgramConfigState
+import io.github.chinosk.gakumas.localify.getProgramDownloadAbleState
+import io.github.chinosk.gakumas.localify.getProgramDownloadErrorStringState
+import io.github.chinosk.gakumas.localify.getProgramDownloadState
+import io.github.chinosk.gakumas.localify.getProgramLocalResourceVersionState
+import io.github.chinosk.gakumas.localify.hookUtils.FileHotUpdater
+import io.github.chinosk.gakumas.localify.mainUtils.FileDownloader
 import io.github.chinosk.gakumas.localify.models.GakumasConfig
+import io.github.chinosk.gakumas.localify.models.ResourceCollapsibleBoxViewModel
+import io.github.chinosk.gakumas.localify.models.ResourceCollapsibleBoxViewModelFactory
 import io.github.chinosk.gakumas.localify.ui.components.base.CollapsibleBox
 import io.github.chinosk.gakumas.localify.ui.components.GakuButton
+import io.github.chinosk.gakumas.localify.ui.components.GakuProgressBar
 import io.github.chinosk.gakumas.localify.ui.components.GakuRadio
 import io.github.chinosk.gakumas.localify.ui.components.GakuSwitch
 import io.github.chinosk.gakumas.localify.ui.components.GakuTextInput
+import java.io.File
 
 
 @Composable
@@ -43,6 +59,13 @@ fun HomePage(modifier: Modifier = Modifier,
              bottomSpacerHeight: Dp = 120.dp,
              screenH: Dp = 1080.dp) {
     val config = getConfigState(context, previewData)
+    val programConfig = getProgramConfigState(context)
+
+    val downloadProgress by getProgramDownloadState(context)
+    val downloadAble by getProgramDownloadAbleState(context)
+    val localResourceVersion by getProgramLocalResourceVersionState(context)
+    val downloadErrorString by getProgramDownloadErrorStringState(context)
+
     // val scrollState = rememberScrollState()
     val keyboardOptionsNumber = remember {
         KeyboardOptions(keyboardType = KeyboardType.Number)
@@ -51,6 +74,57 @@ fun HomePage(modifier: Modifier = Modifier,
         KeyboardOptions(keyboardType = KeyboardType.Decimal)
     }
 
+    val resourceSettingsViewModel: ResourceCollapsibleBoxViewModel =
+        viewModel(factory = ResourceCollapsibleBoxViewModelFactory(initiallyExpanded = false))
+
+    fun onClickDownload() {
+        context?.mainPageAssetsViewDataUpdate(
+            downloadAbleState = false,
+            errorString = "",
+            downloadProgressState = -1f
+        )
+        val (_, newUrl) = FileDownloader.checkAndChangeDownloadURL(programConfig.value.transRemoteZipUrl)
+        context?.onPTransRemoteZipUrlChanged(newUrl, 0, 0, 0)
+        FileDownloader.downloadFile(
+            newUrl,
+            checkContentTypes = listOf("application/zip", "application/octet-stream"),
+            onDownload = { progress, _, _ ->
+                context?.mainPageAssetsViewDataUpdate(downloadProgressState = progress)
+            },
+
+            onSuccess = { byteArray ->
+                context?.mainPageAssetsViewDataUpdate(
+                    downloadAbleState = true,
+                    errorString = "",
+                    downloadProgressState = -1f
+                )
+                val file = File(context?.filesDir, "update_trans.zip")
+                file.writeBytes(byteArray)
+                val newFileVersion = FileHotUpdater.getZipResourceVersion(file.absolutePath)
+                if (newFileVersion != null) {
+                    context?.mainPageAssetsViewDataUpdate(
+                        localResourceVersionState = newFileVersion
+                    )
+                }
+                else {
+                    context?.mainPageAssetsViewDataUpdate(
+                        localResourceVersionState = context.getString(
+                            R.string.invalid_zip_file
+                        ),
+                        errorString = context.getString(R.string.invalid_zip_file_warn)
+                    )
+                }
+            },
+
+            onFailed = { code, reason ->
+                context?.mainPageAssetsViewDataUpdate(
+                    downloadAbleState = true,
+                    errorString = reason,
+                )
+            })
+
+    }
+
 
     LazyColumn(modifier = modifier
         .sizeIn(maxHeight = screenH)
@@ -75,6 +149,138 @@ fun HomePage(modifier: Modifier = Modifier,
             Spacer(Modifier.height(6.dp))
         }
 
+        item {
+            GakuGroupBox(modifier, stringResource(R.string.resource_settings),
+                contentPadding = 0.dp,
+                onHeadClick = {
+                    resourceSettingsViewModel.expanded = !resourceSettingsViewModel.expanded
+                }) {
+                CollapsibleBox(modifier = modifier,
+                    viewModel = resourceSettingsViewModel
+                ) {
+                    LazyColumn(modifier = modifier
+                        // .padding(8.dp)
+                        .sizeIn(maxHeight = screenH),
+                        // verticalArrangement = Arrangement.spacedBy(12.dp)
+                    ) {
+                        item {
+                            GakuSwitch(modifier = modifier.padding(start = 8.dp, end = 8.dp, top = 8.dp),
+                                checked = programConfig.value.checkBuiltInAssets,
+                                text = stringResource(id = R.string.check_built_in_resource)
+                            ) { v -> context?.onPCheckBuiltInAssetsChanged(v) }
+                        }
+                        item {
+                            GakuSwitch(modifier = modifier.padding(start = 8.dp, end = 8.dp),
+                                checked = programConfig.value.cleanLocalAssets,
+                                text = stringResource(id = R.string.delete_plugin_resource)
+                            ) { v -> context?.onPCleanLocalAssetsChanged(v) }
+                        }
+
+                        item {
+                            HorizontalDivider(
+                                thickness = 1.dp,
+                                color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)
+                            )
+                        }
+
+                        item {
+                            GakuSwitch(modifier = modifier.padding(start = 8.dp, end = 8.dp),
+                                checked = programConfig.value.useRemoteAssets,
+                                text = stringResource(id = R.string.use_remote_zip_resource)
+                            ) { v -> context?.onPUseRemoteAssetsChanged(v) }
+
+                            CollapsibleBox(modifier = modifier.graphicsLayer(clip = false),
+                                expandState = programConfig.value.useRemoteAssets,
+                                collapsedHeight = 0.dp,
+                                innerPaddingLeftRight = 8.dp,
+                                showExpand = false
+                            ) {
+                                GakuSwitch(modifier = modifier,
+                                    checked = programConfig.value.delRemoteAfterUpdate,
+                                    text = stringResource(id = R.string.del_remote_after_update)
+                                ) { v -> context?.onPDelRemoteAfterUpdateChanged(v) }
+
+                                LazyColumn(modifier = modifier
+                                    // .padding(8.dp)
+                                    .sizeIn(maxHeight = screenH),
+                                    verticalArrangement = Arrangement.spacedBy(12.dp)
+                                ) {
+                                    item {
+                                        Row(modifier = modifier.fillMaxWidth(),
+                                            horizontalArrangement = Arrangement.spacedBy(2.dp),
+                                            verticalAlignment = Alignment.CenterVertically) {
+
+                                            GakuTextInput(modifier = modifier
+                                                .height(45.dp)
+                                                .padding(end = 8.dp)
+                                                .fillMaxWidth()
+                                                .weight(1f),
+                                                fontSize = 14f,
+                                                value = programConfig.value.transRemoteZipUrl,
+                                                onValueChange = { c -> context?.onPTransRemoteZipUrlChanged(c, 0, 0, 0)},
+                                                label = { Text(stringResource(id = R.string.resource_url)) },
+                                                keyboardOptions = keyboardOptionsNumber)
+
+                                            if (downloadAble) {
+                                                GakuButton(modifier = modifier
+                                                    .height(40.dp)
+                                                    .sizeIn(minWidth = 80.dp),
+                                                    text = stringResource(id = R.string.download),
+                                                    onClick = { onClickDownload() })
+                                            }
+                                            else {
+                                                GakuButton(modifier = modifier
+                                                    .height(40.dp)
+                                                    .sizeIn(minWidth = 80.dp),
+                                                    text = stringResource(id = R.string.cancel), onClick = {
+                                                        FileDownloader.cancel()
+                                                    })
+                                            }
+
+                                        }
+                                    }
+
+                                    if (downloadProgress >= 0) {
+                                        item {
+                                            GakuProgressBar(progress = downloadProgress, isError = downloadErrorString.isNotEmpty())
+                                        }
+                                    }
+
+                                    if (downloadErrorString.isNotEmpty()) {
+                                        item {
+                                            Text(text = downloadErrorString, color = Color(0xFFE2041B))
+                                        }
+                                    }
+
+                                    item {
+                                        Text(modifier = Modifier
+                                            .fillMaxWidth()
+                                            .clickable {
+                                                val file =
+                                                    File(context?.filesDir, "update_trans.zip")
+                                                context?.mainPageAssetsViewDataUpdate(
+                                                    localResourceVersionState = FileHotUpdater
+                                                        .getZipResourceVersion(file.absolutePath)
+                                                        .toString()
+                                                )
+                                            }, text = "${stringResource(R.string.downloaded_resource_version)}: $localResourceVersion")
+                                    }
+
+                                    item {
+                                        Spacer(Modifier.height(0.dp))
+                                    }
+
+                                }
+
+                            }
+                        }
+                    }
+                }
+            }
+
+            Spacer(Modifier.height(6.dp))
+        }
+
         item {
             GakuGroupBox(modifier = modifier, contentPadding = 0.dp, title = stringResource(R.string.graphic_settings)) {
                 LazyColumn(modifier = Modifier
@@ -263,7 +469,7 @@ fun HomePage(modifier: Modifier = Modifier,
 }
 
 
-@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO, widthDp = 880)
+@Preview(showBackground = true, uiMode = UI_MODE_NIGHT_NO)
 @Composable
 fun HomePagePreview(modifier: Modifier = Modifier, data: GakumasConfig = GakumasConfig()) {
     HomePage(modifier, previewData = data)
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index 2b4dfdf..f53086c 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -63,6 +63,17 @@
     <string name="plugin_code">插件本体</string>
     <string name="contributors">贡献者列表</string>
     <string name="translation_repository">译文仓库</string>
+    <string name="resource_settings">资源设置</string>
+    <string name="check_built_in_resource">检查内置数据更新</string>
+    <string name="delete_plugin_resource">清除游戏目录内的插件资源</string>
+    <string name="use_remote_zip_resource">使用远程 ZIP 数据</string>
+    <string name="resource_url">资源地址</string>
+    <string name="download">下载</string>
+    <string name="invalid_zip_file">文件解析失败</string>
+    <string name="invalid_zip_file_warn">此文件不是一个有效的 ZIP 翻译资源包</string>
+    <string name="cancel">取消</string>
+    <string name="downloaded_resource_version">已下载资源版本</string>
+    <string name="del_remote_after_update">替换文件后删除下载缓存</string>
 
     <string name="about_contributors_asset_file">about_contributors_zh_cn.json</string>
 </resources>
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index be1df2f..49e079e 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -63,6 +63,17 @@
     <string name="plugin_code">Plugin Code</string>
     <string name="contributors">Contributors</string>
     <string name="translation_repository">Translation Repository</string>
+    <string name="resource_settings">Resource Settings</string>
+    <string name="check_built_in_resource">Check Built-in Assets Update</string>
+    <string name="delete_plugin_resource">Delete Plugin Resource</string>
+    <string name="use_remote_zip_resource">Use Remote ZIP Resource</string>
+    <string name="resource_url">Resource URL</string>
+    <string name="download">Download</string>
+    <string name="invalid_zip_file">Invalid file</string>
+    <string name="invalid_zip_file_warn">This file is not a valid ZIP translation resource pack.</string>
+    <string name="cancel">Cancel</string>
+    <string name="downloaded_resource_version">Downloaded Version</string>
+    <string name="del_remote_after_update">Delete Cache File After Update</string>
 
     <string name="about_contributors_asset_file">about_contributors_en.json</string>
 </resources>
\ No newline at end of file
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..4f7310f
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<paths>
+    <files-path name="files" path="." />
+</paths>