Merge remote-tracking branch 'upstream/master'

This commit is contained in:
kmmiio99o 2026-01-31 20:23:09 +01:00
commit a85f399b29
3 changed files with 85 additions and 86 deletions

View file

@ -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")
}

View file

@ -37,11 +37,6 @@ class Utils {
reloadApp()
}
2 -> {
UpdaterModule.resetLoaderConfig(context)
reloadApp()
}
}
}.show()
}

View file

@ -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<String>, 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<LoaderConfig>(configFile.readText())
} else LoaderConfig()
if (configFile.exists()) JSON.decodeFromString<LoaderConfig>(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<EndpointInfo>(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
}
}