diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 020a31e..b07cc7d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -52,4 +52,5 @@ dependencies { implementation("io.ktor:ktor-client-core:3.3.1") implementation("io.ktor:ktor-client-cio:3.3.1") implementation("com.google.android.material:material:1.12.0") + implementation("dev.rushii:libunbound:1.0.0") } diff --git a/app/src/main/kotlin/ShiggyXposed/xposed/xposed/Utils.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/Utils.kt index 8957912..c38a063 100644 --- a/app/src/main/kotlin/ShiggyXposed/xposed/xposed/Utils.kt +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/Utils.kt @@ -37,11 +37,6 @@ class Utils { reloadApp() } - - 2 -> { - UpdaterModule.resetLoaderConfig(context) - reloadApp() - } } }.show() } diff --git a/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/UpdaterModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/UpdaterModule.kt index 9eea8f2..a7e7794 100644 --- a/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/UpdaterModule.kt +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/UpdaterModule.kt @@ -5,8 +5,8 @@ import android.app.AlertDialog import android.content.Context import android.util.AtomicFile import android.widget.Toast -import androidx.core.util.writeBytes import de.robv.android.xposed.callbacks.XC_LoadPackage +import dev.rushii.libunbound.LibUnbound import ShiggyXposed.xposed.Constants import ShiggyXposed.xposed.Module import ShiggyXposed.xposed.Utils @@ -22,24 +22,20 @@ import io.ktor.client.statement.* import io.ktor.http.* import kotlinx.coroutines.* import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.encodeToString import java.io.File import java.lang.ref.WeakReference @Serializable -data class CustomLoadUrl( - val enabled: Boolean = false, val url: String = "" -) +data class CustomLoadUrl(val enabled: Boolean = false, val url: String = "") @Serializable -data class LoaderConfig( - val customLoadUrl: CustomLoadUrl = CustomLoadUrl() -) +data class LoaderConfig(val customLoadUrl: CustomLoadUrl = CustomLoadUrl(), val disableInjection: Boolean = false) + +@Serializable +data class EndpointInfo(val paths: ArrayList, val hash: String? = null, val version: String) -/** - * Module that updates the JS bundle by downloading it from a remote URL. - * - * Shows dialogs when failed allowing retry. - */ object UpdaterModule : Module() { private lateinit var config: LoaderConfig private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -48,20 +44,16 @@ object UpdaterModule : Module() { private lateinit var cacheDir: File private lateinit var bundle: File private lateinit var etag: File - // Path to the application's data directory so we can read LogBox settings - private lateinit var appDataDir: File - private const val TIMEOUT_CACHED = 5000L - private const val TIMEOUT = 10000L + private const val TIMEOUT_STRICT = 15000L + private const val MIN_BYTECODE_SIZE = 512 private const val ETAG_FILE = "etag.txt" private const val CONFIG_FILE = "loader.json" - private const val DEFAULT_BUNDLE_URL = - "https://github.com/kmmiio99o/ShiggyCord/releases/latest/download/shiggycord.js" + private const val DEFAULT_BASE_URL = "https://github.com/kmmiio99o/ShiggyCord/releases/latest/download/" + private const val DEFAULT_BUNDLE_NAME = "shiggycord.min.js" override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { - // store app data dir for later checks (LogBox settings live under files/logbox) - appDataDir = File(appInfo.dataDir) cacheDir = File(appInfo.dataDir, Constants.CACHE_DIR).apply { mkdirs() } val filesDir = File(appInfo.dataDir, Constants.FILES_DIR).apply { mkdirs() } @@ -69,91 +61,102 @@ object UpdaterModule : Module() { etag = File(cacheDir, ETAG_FILE) val configFile = File(filesDir, CONFIG_FILE) - config = runCatching { - if (configFile.exists()) { - JSON.decodeFromString(configFile.readText()) - } else LoaderConfig() + if (configFile.exists()) JSON.decodeFromString(configFile.readText()) else LoaderConfig() }.getOrDefault(LoaderConfig()) } fun downloadScript(activity: Activity? = null): Job = scope.launch { try { - // Respect LogBox setting to disable bundle injections. - // If the file "files/logbox/LOGBOX_SETTINGS" contains the flag bundleInjectionDisabled=true, - // skip attempting to download/update the bundle. - try { - val settingsFile = File(appDataDir, "files/logbox/LOGBOX_SETTINGS") - if (settingsFile.exists()) { - val txt = settingsFile.readText() - // look for a simple true marker; this avoids introducing a new JSON dependency here - if (txt.contains("\"bundleInjectionDisabled\"") && txt.contains("true")) { - Log.i("UpdaterModule: bundle injection disabled by LogBox settings — skipping download") - return@launch - } - } - } catch (ignore: Exception) { - // If anything goes wrong reading the setting, proceed with normal behaviour. - } - HttpClient(CIO) { expectSuccess = false + install(HttpTimeout) { + requestTimeoutMillis = TIMEOUT_STRICT + connectTimeoutMillis = 5000L + socketTimeoutMillis = TIMEOUT_STRICT + } install(UserAgent) { agent = Constants.USER_AGENT } - install(HttpRedirect) {} + install(HttpRedirect) { checkHttpMethod = false } }.use { client -> - val url = config.customLoadUrl.takeIf { it.enabled }?.url ?: DEFAULT_BUNDLE_URL - Log.i("Fetching JS bundle from: $url") + val targetUrl = resolveTargetUrl(client) + Log.i("Fetching bundle: $targetUrl") - val response: HttpResponse = client.get(url) { + val response: HttpResponse = client.get(targetUrl) { headers { if (etag.exists() && bundle.exists()) { append(HttpHeaders.IfNoneMatch, etag.readText()) } } - - // Retries don't need timeout - if (activity == null) { - timeout { - requestTimeoutMillis = if (!bundle.exists()) TIMEOUT else TIMEOUT_CACHED - } - } } when (response.status) { HttpStatusCode.OK -> { val bytes: ByteArray = response.body() - AtomicFile(bundle).writeBytes(bytes) - val newTag = response.headers[HttpHeaders.ETag] - if (!newTag.isNullOrEmpty()) etag.writeText(newTag) else etag.delete() + if (bytes.size < MIN_BYTECODE_SIZE) { + throw Exception("Payload too small (${bytes.size} bytes). Possible corrupt build.") + } - Log.i("Bundle updated (${bytes.size} bytes)") + AtomicFile(bundle).apply { + val stream = startWrite() + try { + stream.write(bytes) + finishWrite(stream) + } catch (e: Exception) { + failWrite(stream) + throw e + } + } + + response.headers[HttpHeaders.ETag]?.let { etag.writeText(it) } ?: etag.delete() + Log.i("Bundle updated: ${bytes.size} bytes") - // This is a retry, so we show a dialog if (activity != null) { withContext(Dispatchers.Main) { - AlertDialog.Builder(activity).setTitle("ShiggyCord Update Successful") - .setMessage("A reload is required for changes to take effect.") - .setPositiveButton("Reload") { dialog, _ -> - reloadApp() - dialog.dismiss() - }.setCancelable(false).show() + AlertDialog.Builder(activity) + .setTitle("Update Successful") + .setMessage("Reload required.") + .setPositiveButton("Reload") { _, _ -> reloadApp() } + .setCancelable(false) + .show() } } } - HttpStatusCode.NotModified -> { - Log.i("Server responded with 304, no changes") - } - - else -> { - throw ResponseException(response, "Received status: ${response.status}") - } + HttpStatusCode.NotModified -> Log.i("Bundle is up to date (304)") + else -> throw ResponseException(response, "HTTP ${response.status}") } } } catch (e: Throwable) { - Log.e("Failed to download script", e) - showErrorDialog(e) + Log.e("Updater Error", e) + if (activity != null) withContext(Dispatchers.Main) { + Toast.makeText(activity, "Update failed: ${e.message}", Toast.LENGTH_LONG).show() + } + } + } + + private suspend fun resolveTargetUrl(client: HttpClient): String { + if (config.customLoadUrl.enabled && config.customLoadUrl.url.isNotEmpty()) { + return config.customLoadUrl.url + } + + return try { + val infoResponse = client.get("${DEFAULT_BASE_URL}info.json") + if (infoResponse.status == HttpStatusCode.OK) { + val info = JSON.decodeFromString(infoResponse.bodyAsText()) + val hermesVersion = withTimeoutOrNull(2000L) { + runCatching { LibUnbound.getHermesRuntimeBytecodeVersion() }.getOrNull() + } ?: 96 + + val hbcName = "kettu.$hermesVersion.hbc" + when { + info.paths.contains(hbcName) -> DEFAULT_BASE_URL + hbcName + info.paths.contains("kettu.min.js") -> DEFAULT_BASE_URL + "kettu.min.js" + else -> DEFAULT_BASE_URL + DEFAULT_BUNDLE_NAME + } + } else DEFAULT_BASE_URL + DEFAULT_BUNDLE_NAME + } catch (e: Exception) { + DEFAULT_BASE_URL + DEFAULT_BUNDLE_NAME } } @@ -161,16 +164,16 @@ object UpdaterModule : Module() { lastActivity = WeakReference(activity) } - fun showErrorDialog(e: Throwable) { - val activity = lastActivity?.get() ?: return - - activity.runOnUiThread { - } + fun setDisableInjection(context: Context, disabled: Boolean) { + val filesDir = File(context.dataDir, Constants.FILES_DIR).apply { mkdirs() } + val configFile = File(filesDir, CONFIG_FILE) + val newCfg = config.copy(disableInjection = disabled) + configFile.writeText(JSON.encodeToString(newCfg)) + config = newCfg + Toast.makeText(context, "Injection ${if (disabled) "disabled" else "enabled"}", Toast.LENGTH_SHORT).show() } - fun resetLoaderConfig(context: Context) { - val filesDir = File(context.dataDir, Constants.FILES_DIR) - val config = File(filesDir, CONFIG_FILE) - if (config.exists()) config.delete() + fun isInjectionDisabled(context: Context? = null): Boolean { + return if (::config.isInitialized) config.disableInjection else false } }