diff --git a/app/src/main/java/org/matrix/TEESimulator/App.kt b/app/src/main/java/org/matrix/TEESimulator/App.kt
index d8230e0..478242b 100644
--- a/app/src/main/java/org/matrix/TEESimulator/App.kt
+++ b/app/src/main/java/org/matrix/TEESimulator/App.kt
@@ -9,6 +9,7 @@ import android.os.Looper
import java.security.Security
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.matrix.TEESimulator.config.BootStateManager
+import org.matrix.TEESimulator.config.BulletinPoller
import org.matrix.TEESimulator.config.ConfigurationManager
import org.matrix.TEESimulator.config.PatchLevelManager
import org.matrix.TEESimulator.interception.keystore.AbstractKeystoreInterceptor
@@ -59,6 +60,8 @@ object App {
NativeCertGen.initialize("/data/adb/modules/tricky_store/libcertgen.so")
+ BulletinPoller.start()
+
// This starts the message queue processing. It blocks here indefinitely
// processing messages until Looper.myLooper().quit() is called.
Looper.loop()
diff --git a/app/src/main/java/org/matrix/TEESimulator/config/BulletinPoller.kt b/app/src/main/java/org/matrix/TEESimulator/config/BulletinPoller.kt
new file mode 100644
index 0000000..49888ec
--- /dev/null
+++ b/app/src/main/java/org/matrix/TEESimulator/config/BulletinPoller.kt
@@ -0,0 +1,171 @@
+package org.matrix.TEESimulator.config
+
+import android.os.Handler
+import android.os.HandlerThread
+import java.io.File
+import java.net.URL
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+import javax.net.ssl.HttpsURLConnection
+import org.json.JSONArray
+import org.json.JSONObject
+import org.matrix.TEESimulator.BuildConfig
+import org.matrix.TEESimulator.logging.SystemLogger
+
+object BulletinPoller {
+ private const val BULLETIN_URL = "https://source.android.com/docs/security/bulletin/pixel"
+ private const val PATCH_FILE = "/data/adb/tricky_store/security_patch.txt"
+ private const val HISTORY_FILE = "/data/adb/tricky_store/last_bulletin_fetch.json"
+ private const val HISTORY_STAGING = "/data/adb/tricky_store/last_bulletin_fetch.json.next"
+ private const val HISTORY_CAP = 10
+ private const val CONNECT_TIMEOUT_MS = 10_000
+ private const val READ_TIMEOUT_MS = 15_000
+ private const val STEADY_INTERVAL_MS = 24L * 60 * 60 * 1000
+
+ private val BOOTSTRAP_INTERVALS = longArrayOf(5_000, 30_000, 120_000, 600_000, 1_800_000)
+ private val DATE_REGEX = Regex("
(\\d{4}-\\d{2}-\\d{2}) | ")
+
+ private lateinit var handler: Handler
+ @Volatile private var bootstrapStep = 0
+ @Volatile private var steadyArmed = false
+
+ fun start() {
+ val thread = HandlerThread("BulletinPoller").apply { start() }
+ handler = Handler(thread.looper)
+ handler.postDelayed(::pollOnce, BOOTSTRAP_INTERVALS[0])
+ }
+
+ private fun pollOnce() {
+ val result = fetchAndParse()
+ appendHistory(result)
+ scheduleNext(result.status == "success")
+ }
+
+ private fun scheduleNext(success: Boolean) {
+ if (success || steadyArmed) {
+ steadyArmed = true
+ handler.postDelayed(::pollOnce, STEADY_INTERVAL_MS)
+ return
+ }
+ bootstrapStep++
+ if (bootstrapStep >= BOOTSTRAP_INTERVALS.size) {
+ steadyArmed = true
+ handler.postDelayed(::pollOnce, STEADY_INTERVAL_MS)
+ } else {
+ handler.postDelayed(::pollOnce, BOOTSTRAP_INTERVALS[bootstrapStep])
+ }
+ }
+
+ private data class FetchResult(
+ val ts: Long,
+ val status: String,
+ val httpCode: Int?,
+ val parsedDate: String?,
+ val applied: Boolean,
+ val error: String?,
+ )
+
+ private fun fetchAndParse(): FetchResult {
+ val ts = System.currentTimeMillis()
+ var conn: HttpsURLConnection? = null
+ return try {
+ conn =
+ (URL(BULLETIN_URL).openConnection() as HttpsURLConnection).apply {
+ connectTimeout = CONNECT_TIMEOUT_MS
+ readTimeout = READ_TIMEOUT_MS
+ setRequestProperty(
+ "User-Agent",
+ "TEESimulator/${BuildConfig.VERSION_NAME}",
+ )
+ requestMethod = "GET"
+ }
+ val code = conn.responseCode
+ if (code != 200) {
+ return FetchResult(ts, "network_error", code, null, false, "HTTP $code")
+ }
+ val html = conn.inputStream.bufferedReader().use { it.readText() }
+ val date = DATE_REGEX.find(html)?.groupValues?.get(1)
+ if (date == null) {
+ return FetchResult(
+ ts,
+ "parse_error",
+ code,
+ null,
+ false,
+ "no YYYY-MM-DD | match",
+ )
+ }
+ val current = currentPatch()
+ val isNewer = current == null || date > current
+ if (isNewer) {
+ PatchLevelManager.updateTo(date)
+ }
+ FetchResult(ts, "success", code, date, isNewer, null)
+ } catch (e: Exception) {
+ FetchResult(ts, "network_error", null, null, false, e.toString())
+ } finally {
+ conn?.disconnect()
+ }
+ }
+
+ private fun currentPatch(): String? {
+ val f = File(PATCH_FILE)
+ if (!f.exists()) return null
+ return try {
+ f.readLines()
+ .firstOrNull { it.startsWith("system=") }
+ ?.substringAfter("system=")
+ ?.trim()
+ ?.takeIf { it != "prop" && it.isNotEmpty() }
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ private fun appendHistory(result: FetchResult) {
+ try {
+ val target = File(HISTORY_FILE)
+ val staging = File(HISTORY_STAGING)
+ val existing = if (target.exists()) runCatching { target.readText() }.getOrNull() else null
+ val history =
+ existing
+ ?.let { runCatching { JSONObject(it).optJSONArray("history") }.getOrNull() }
+ ?: JSONArray()
+ val entry =
+ JSONObject().apply {
+ put("ts", result.ts)
+ put("status", result.status)
+ put("http_code", result.httpCode ?: JSONObject.NULL)
+ put("parsed_date", result.parsedDate ?: JSONObject.NULL)
+ put("applied", result.applied)
+ put("error", result.error ?: JSONObject.NULL)
+ }
+ history.put(entry)
+ while (history.length() > HISTORY_CAP) history.remove(0)
+
+ val latestKnown =
+ (0 until history.length())
+ .mapNotNull {
+ history.optJSONObject(it)?.optString("parsed_date", "")?.takeIf { d ->
+ d.isNotBlank()
+ }
+ }
+ .lastOrNull()
+
+ val root =
+ JSONObject().apply {
+ put("latest_known_date", latestKnown ?: JSONObject.NULL)
+ put("history", history)
+ }
+ staging.writeText(root.toString(2))
+ Files.move(
+ staging.toPath(),
+ target.toPath(),
+ StandardCopyOption.ATOMIC_MOVE,
+ StandardCopyOption.REPLACE_EXISTING,
+ )
+ } catch (e: Exception) {
+ SystemLogger.error("BulletinPoller: failed to persist history", e)
+ }
+ }
+}
diff --git a/module/sepolicy.rule b/module/sepolicy.rule
index 21b522a..8e0124e 100644
--- a/module/sepolicy.rule
+++ b/module/sepolicy.rule
@@ -1,2 +1,9 @@
allow keystore {adb_data_file shell_data_file} file *
allow crash_dump keystore process *
+
+allow ksu self:tcp_socket { create connect read write getopt setopt }
+allow ksu node:tcp_socket node_bind
+allow ksu port:tcp_socket name_connect
+allow magisk self:tcp_socket { create connect read write getopt setopt }
+allow magisk node:tcp_socket node_bind
+allow magisk port:tcp_socket name_connect
diff --git a/module/uninstall.sh b/module/uninstall.sh
index d9cceab..ec896c5 100644
--- a/module/uninstall.sh
+++ b/module/uninstall.sh
@@ -10,3 +10,4 @@ done
rm -rf "$CONFIG_DIR/persistent_keys"
rm -f "$CONFIG_DIR/tee_status.txt"
rm -f "$CONFIG_DIR/boot_hash.bin" "$CONFIG_DIR/boot_key.bin"
+rm -f "$CONFIG_DIR/security_patch.txt" "$CONFIG_DIR/security_patch.txt.next" "$CONFIG_DIR/last_bulletin_fetch.json"