// Kotlin side of the differential test, run on the JVM once per IANA zone (TZ=). // // Functions marked VERBATIM are copied character-for-character from // android/app/src/main/kotlin/com/adelanteapp/adelante/AdelanteWidgetProvider.kt // and WidgetConfigurations.kt (Adelante af1aea2). Android's SharedPreferences is // replaced by a minimal interface with the same members the code touches; the // catalog-backed override mirrors WidgetConfigurations.resolve exactly. import org.json.JSONArray import org.json.JSONObject import java.io.File interface SharedPreferences { fun getString(key: String?, defValue: String?): String? fun getBoolean(key: String?, defValue: Boolean): Boolean val all: MutableMap } // Mirrors the anonymous object in WidgetConfigurations.resolve (lines 27-31). fun catalogPrefs(data: JSONObject): SharedPreferences = object : SharedPreferences { override fun getString(key: String?, defValue: String?): String? = if (key != null && data.has(key) && !data.isNull(key)) data.get(key).toString() else defValue override fun getBoolean(key: String?, defValue: Boolean): Boolean = data.optBoolean(key, defValue) override val all: MutableMap get() = data.keys().asSequence().associateWith { data.get(it) }.toMutableMap() } data class WidgetQuote(val id: String, val tier: Int) object Provider { // VERBATIM (AdelanteWidgetProvider.kt lines 284-338) private fun scheduleDates(data: SharedPreferences): List = try { val raw = JSONArray(data.getString("adelante_schedule_dates", "[]") ?: "[]") (0 until raw.length()).map { raw.getLong(it) * 1000L } } catch (_: Exception) { emptyList() } fun scheduledIndex(data: SharedPreferences, pool: List, now: Long): Int { if (pool.size <= 1) return 0 val dates = scheduleDates(data) if (dates.isNotEmpty()) { var steps = dates.count { it <= now } if (now > dates.last()) { val minute = doublePref(data, "adelante_daily_minute", -1.0).toInt() if (minute >= 0) { fun civilDay(time: Long): Long { val local = java.util.Calendar.getInstance().apply { timeInMillis = time } return java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC")).apply { clear(); set(local.get(java.util.Calendar.YEAR), local.get(java.util.Calendar.MONTH), local.get(java.util.Calendar.DAY_OF_MONTH)) }.timeInMillis / 86400000L } val today = dailyBoundary(now, minute) steps += (civilDay(now) - civilDay(dates.last()) - if (now < today) 1 else 0).coerceAtLeast(0).toInt() } } return if (data.getBoolean("adelante_initial_quote_only", false) && steps > 0) 1 + ((steps - 1) % (pool.size - 1)) else steps % pool.size } return currentPoolIndex((doublePref(data, "adelante_schedule_anchor_epoch", doublePref(data, "adelante_updated_epoch", 0.0)) * 1000).toLong(), doublePref(data, "adelante_refresh_interval_seconds", 0.0).toLong(), pool.size, now, data.getBoolean("adelante_initial_quote_only", false)) } private fun dailyBoundary(now: Long, minute: Int): Long = java.util.Calendar.getInstance().apply { timeInMillis = now set(java.util.Calendar.HOUR_OF_DAY, minute / 60) set(java.util.Calendar.MINUTE, minute % 60) set(java.util.Calendar.SECOND, 0) set(java.util.Calendar.MILLISECOND, 0) }.timeInMillis fun nextChange(data: SharedPreferences, now: Long): Long? { val dates = scheduleDates(data) if (dates.isNotEmpty()) { dates.firstOrNull { it > now }?.let { return it } val minute = doublePref(data, "adelante_daily_minute", -1.0).toInt() if (minute >= 0) { val calendar = java.util.Calendar.getInstance().apply { timeInMillis = dailyBoundary(now, minute) } if (calendar.timeInMillis <= now) calendar.add(java.util.Calendar.DATE, 1) return calendar.timeInMillis } } val interval = (doublePref(data, "adelante_refresh_interval_seconds", 0.0) * 1000).toLong() if (interval <= 0) return null val anchor = (doublePref(data, "adelante_schedule_anchor_epoch", doublePref(data, "adelante_updated_epoch", 0.0)) * 1000).toLong() return anchor + (((now - anchor).coerceAtLeast(0) / interval) + 1) * interval } // VERBATIM (lines 568-581) private fun doublePref(prefs: SharedPreferences, key: String, fallback: Double): Double { return try { when (val value = prefs.all[key]) { is Double -> value is Float -> value.toDouble() is Int -> value.toDouble() is Long -> value.toDouble() is String -> value.toDoubleOrNull() ?: fallback else -> fallback } } catch (_: Exception) { fallback } } // VERBATIM (lines 640-647) private fun currentPoolIndex(anchorMillis: Long, intervalSeconds: Long, poolSize: Int, nowMillis: Long, initialQuoteOnly: Boolean = false): Int { if (poolSize <= 1 || intervalSeconds <= 0 || anchorMillis <= 0) return 0 val elapsedSeconds = (nowMillis - anchorMillis) / 1000L if (elapsedSeconds <= 0) return 0 val steps = elapsedSeconds / intervalSeconds if (initialQuoteOnly && steps > 0) return 1 + ((steps - 1) % (poolSize - 1)).toInt() return (steps % poolSize).toInt() } // Pool preparation from onUpdate (lines 57-61, 88-93) for a widget of [maxTier]. fun eligible(widgetData: SharedPreferences, originalPool: List, maxTier: Int): List { val authoritativePool = widgetData.getBoolean("adelante_authoritative_pool", false) val pool = if (!authoritativePool && widgetData.getBoolean("adelante_sync_lock_and_home", true)) { originalPool.filter { it.tier == 0 }.ifEmpty { originalPool } } else originalPool return if (authoritativePool) pool else pool.filter { it.tier <= maxTier }.ifEmpty { pool } } } fun main(args: Array) { val zone = System.getenv("TZ") ?: "unset" val root = JSONObject(File(args[0]).readText()) val results = root.getJSONArray("results") val maxTier = if (args.size > 2) args[2].toInt() else 2 val ids = JSONArray(); val instant = JSONArray(); val shown = JSONArray() var queries = 0 val start = System.nanoTime() for (i in 0 until results.length()) { val r = results.getJSONObject(i) val prefs = catalogPrefs(r.getJSONObject("payload")) val poolJson = JSONArray(prefs.getString("adelante_quote_pool", "[]")) val original = (0 until poolJson.length()).map { val o = poolJson.getJSONObject(it); WidgetQuote(o.optString("id"), o.optInt("tier", 0)) } val pool = Provider.eligible(prefs, original, maxTier) val qs = r.getJSONArray("queries") val inst = JSONArray(); val disp = JSONArray() // Displayed quote: the provider renders at t0, then again at each // nextChange() alarm (idealised: the inexact 10-minute window fires at its start). var renderAt = r.getLong("t0") + 1 var renderedIndex = Provider.scheduledIndex(prefs, pool, renderAt) var next = Provider.nextChange(prefs, renderAt) for (k in 0 until qs.length()) { val q = qs.getLong(k) val idx = Provider.scheduledIndex(prefs, pool, q) inst.put(original.indexOf(pool[idx])) if (q < renderAt) { disp.put(-2); continue } var guard = 0 while (next != null && q >= next && guard < 2_000_000) { renderAt = next renderedIndex = Provider.scheduledIndex(prefs, pool, renderAt) next = Provider.nextChange(prefs, renderAt) guard++ } disp.put(original.indexOf(pool[renderedIndex])) } ids.put(r.getString("id")); instant.put(inst); shown.put(disp) queries += qs.length() } val micros = (System.nanoTime() - start) / 1000 val out = JSONObject() .put("runtime", "kotlin ${KotlinVersion.CURRENT} on JVM ${System.getProperty("java.version")}") .put("zone", zone).put("tzDefault", java.util.TimeZone.getDefault().id) .put("tzDataVersion", java.time.zone.ZoneRulesProvider.getVersions("UTC").lastKey()) .put("queries", queries).put("elapsedMicros", micros) .put("ids", ids).put("instant", instant).put("shown", shown) File(args[1]).writeText(out.toString()) println("$zone [${java.util.TimeZone.getDefault().id}] ${results.length()} scenarios $queries queries") }