@@ -78,9 +78,43 @@ class ShareUrlResolver internal constructor(
7878 " Mozilla/5.0 (Linux; Android 13; SM-G991B) AppleWebKit/537.36 " +
7979 " (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
8080
81- private val RESOLVE_TIMEOUT = 6 .seconds
82- private const val MAX_ATTEMPTS = 2
83- private const val RETRY_DELAY_MS = 300L
81+ private val RESOLVE_TIMEOUT = 16 .seconds
82+ private const val MAX_ATTEMPTS = 4
83+
84+ /* *
85+ * Backoff delays BEFORE each attempt, in milliseconds. Index 0 is
86+ * before attempt 1 (initial fire), index N is before attempt N+1.
87+ *
88+ * attempt 1: 0ms baseline (immediate, but with jitter)
89+ * attempt 2: 2000ms after failure of 1
90+ * attempt 3: 4000ms after failure of 2
91+ * attempt 4: 6000ms after failure of 3
92+ *
93+ * Each value gets ±[JITTER_MS] of symmetric noise added. The reason
94+ * we don't fire at exactly 0/2/4/6s: Google's anti-bot heuristics
95+ * appear to fingerprint request timing patterns, so perfectly
96+ * regular intervals are themselves a signal. Jitter adds 200ms of
97+ * naturalness — small enough to not hurt UX, large enough to
98+ * scramble the period.
99+ *
100+ * Even attempt 1 gets jittered (0..200ms) to avoid the "fire
101+ * immediately on share intent" pattern that's distinctive in
102+ * server logs.
103+ */
104+ private val BACKOFF_MS = longArrayOf(0L , 2_000L , 4_000L , 6_000L )
105+ private const val JITTER_MS = 200L
106+ }
107+
108+ /* *
109+ * Symmetric jitter around a base delay. Returns base + uniform[-J, +J].
110+ * For BACKOFF_MS[0] = 0L this returns 0..JITTER_MS (clamped non-negative).
111+ */
112+ private fun jittered (baseMs : Long ): Long {
113+ // kotlin.random.Random is fine here — we don't need crypto-grade
114+ // randomness, just unpredictable-enough timing.
115+ val noise = kotlin.random.Random .nextLong(- JITTER_MS , JITTER_MS + 1 )
116+ val v = baseMs + noise
117+ return if (v < 0L ) 0L else v
84118 }
85119
86120 /* *
@@ -91,16 +125,26 @@ class ShareUrlResolver internal constructor(
91125 * Never throws — wraps failures into a fall-open return.
92126 *
93127 * Retry strategy: Google's Firebase Dynamic Links anti-bot heuristic is
94- * stochastic — the same headers can yield 200 once and 403 the next
95- * second. A single retry roughly doubles our success rate at minimal
96- * cost. Total worst-case latency is bounded by RESOLVE_TIMEOUT and
97- * runs concurrently with PKJS spinup so usually invisible.
128+ * stochastic — the same headers can yield 200 once and 404/403 the next
129+ * second. We use four attempts with exponentially-increasing backoff
130+ * (0, 2s, 4s, 6s) plus per-attempt jitter (±200ms) to spread requests
131+ * across the suspect rate-limit window without looking like a robotic
132+ * polling loop. Total worst-case latency is bounded by RESOLVE_TIMEOUT
133+ * and runs concurrently with PKJS spinup so partial latency is hidden.
98134 */
99135 suspend fun resolveIfShortened (url : String ): String {
100136 if (! isShortenedMapsUrl(url)) return url
101137
102138 val resolved = withTimeoutOrNull(RESOLVE_TIMEOUT ) {
103139 for (attempt in 1 .. MAX_ATTEMPTS ) {
140+ // Pre-attempt wait with jitter. Even the first attempt gets
141+ // 0..JITTER_MS of jitter so back-to-back shares don't all
142+ // fire at exactly the same offset from the share intent.
143+ val waitMs = jittered(BACKOFF_MS [attempt - 1 ])
144+ if (waitMs > 0L ) {
145+ logger.v { " resolve attempt $attempt waiting ${waitMs} ms before fire" }
146+ kotlinx.coroutines.delay(waitMs)
147+ }
104148 val r = try {
105149 doResolve(url)
106150 } catch (e: Exception ) {
@@ -111,13 +155,7 @@ class ShareUrlResolver internal constructor(
111155 if (attempt > 1 ) logger.i { " resolve succeeded on attempt $attempt " }
112156 return @withTimeoutOrNull r
113157 }
114- if (attempt < MAX_ATTEMPTS ) {
115- // Brief backoff before retry. Doesn't need to be long —
116- // Google's anti-bot decision seems request-local rather
117- // than IP-rate-based, so even ~300ms is enough to land
118- // a different decision tree.
119- kotlinx.coroutines.delay(RETRY_DELAY_MS )
120- }
158+ // Loop continues; next iteration's pre-attempt wait kicks in.
121159 }
122160 null
123161 }
0 commit comments