Skip to content

Commit de7d51f

Browse files
authored
Merge pull request #139 from kalzEOS/bug/investigate-app-issue
Add playback stale-state recovery
2 parents a43aec2 + 2e9626e commit de7d51f

4 files changed

Lines changed: 480 additions & 14 deletions

File tree

app/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ import java.util.Properties
33
import org.gradle.api.provider.Property
44
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
55

6-
val appVersionCode = 134
7-
val appVersionName = "3.3.8"
6+
val appVersionCode = 135
7+
val appVersionName = "3.3.9"
88

99
plugins {
1010
alias(libs.plugins.android.application)
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package com.example.xtreamplayer
2+
3+
import android.app.Activity
4+
import android.app.AlarmManager
5+
import android.app.PendingIntent
6+
import android.content.Context
7+
import android.content.ContextWrapper
8+
import android.content.Intent
9+
import android.os.SystemClock
10+
import com.example.xtreamplayer.content.ContentRepository
11+
import com.example.xtreamplayer.observability.AppDiagnostics
12+
import java.util.ArrayDeque
13+
import kotlin.system.exitProcess
14+
import kotlinx.coroutines.Dispatchers
15+
import kotlinx.coroutines.withContext
16+
import okhttp3.OkHttpClient
17+
18+
internal enum class PlaybackRecoveryAction {
19+
NONE,
20+
SOFT_RECOVERY,
21+
PROCESS_RESTART
22+
}
23+
24+
internal class PlaybackRecoveryTracker(
25+
private val failureWindowMs: Long = 90_000L,
26+
private val softRecoveryCooldownMs: Long = 5 * 60_000L,
27+
private val processRestartWindowMs: Long = 60_000L,
28+
private val failureThreshold: Int = 4,
29+
private val distinctMediaThreshold: Int = 2
30+
) {
31+
private data class FailureEvent(
32+
val mediaId: String,
33+
val occurredAtMs: Long
34+
)
35+
36+
private val recentFailures = ArrayDeque<FailureEvent>()
37+
private var lastSoftRecoveryAtMs: Long? = null
38+
private var restartMonitorUntilMs: Long? = null
39+
40+
fun recordFailure(mediaId: String?, nowMs: Long): PlaybackRecoveryAction {
41+
val normalizedMediaId = mediaId?.takeIf { it.isNotBlank() } ?: UNKNOWN_MEDIA_ID
42+
pruneOldFailures(nowMs)
43+
44+
val restartMonitorUntil = restartMonitorUntilMs
45+
if (restartMonitorUntil != null && nowMs <= restartMonitorUntil) {
46+
recentFailures.clear()
47+
return PlaybackRecoveryAction.PROCESS_RESTART
48+
}
49+
50+
recentFailures.addLast(FailureEvent(mediaId = normalizedMediaId, occurredAtMs = nowMs))
51+
pruneOldFailures(nowMs)
52+
53+
val distinctMediaCount = recentFailures.asSequence().map { it.mediaId }.distinct().count()
54+
if (recentFailures.size < failureThreshold || distinctMediaCount < distinctMediaThreshold) {
55+
return PlaybackRecoveryAction.NONE
56+
}
57+
58+
val lastSoftRecovery = lastSoftRecoveryAtMs
59+
if (lastSoftRecovery != null && nowMs - lastSoftRecovery < softRecoveryCooldownMs) {
60+
return PlaybackRecoveryAction.NONE
61+
}
62+
63+
return PlaybackRecoveryAction.SOFT_RECOVERY
64+
}
65+
66+
fun markSoftRecoveryPerformed(nowMs: Long) {
67+
lastSoftRecoveryAtMs = nowMs
68+
restartMonitorUntilMs = nowMs + processRestartWindowMs
69+
recentFailures.clear()
70+
}
71+
72+
fun markPlaybackHealthy() {
73+
recentFailures.clear()
74+
restartMonitorUntilMs = null
75+
}
76+
77+
private fun pruneOldFailures(nowMs: Long) {
78+
while (recentFailures.isNotEmpty() && nowMs - recentFailures.first().occurredAtMs > failureWindowMs) {
79+
recentFailures.removeFirst()
80+
}
81+
}
82+
83+
private companion object {
84+
const val UNKNOWN_MEDIA_ID = "unknown"
85+
}
86+
}
87+
88+
internal class AppRecoveryManager(
89+
private val appContext: Context,
90+
private val contentRepository: ContentRepository,
91+
private val okHttpClient: OkHttpClient
92+
) {
93+
suspend fun performSoftRecovery(reason: String) {
94+
withContext(Dispatchers.IO) {
95+
AppDiagnostics.recordWarning(
96+
event = "app_soft_recovery",
97+
fields = mapOf("reason" to reason)
98+
)
99+
okHttpClient.dispatcher.cancelAll()
100+
okHttpClient.connectionPool.evictAll()
101+
contentRepository.clearCache()
102+
}
103+
}
104+
105+
fun restartApp(reason: String) {
106+
val launchIntent =
107+
appContext.packageManager.getLaunchIntentForPackage(appContext.packageName)
108+
?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
109+
?: return
110+
val pendingIntent =
111+
PendingIntent.getActivity(
112+
appContext,
113+
RESTART_REQUEST_CODE,
114+
launchIntent,
115+
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE
116+
)
117+
val alarmManager = appContext.getSystemService(AlarmManager::class.java)
118+
if (alarmManager != null) {
119+
alarmManager.setExact(
120+
AlarmManager.ELAPSED_REALTIME,
121+
SystemClock.elapsedRealtime() + RESTART_DELAY_MS,
122+
pendingIntent
123+
)
124+
} else {
125+
appContext.startActivity(launchIntent)
126+
}
127+
AppDiagnostics.recordWarning(
128+
event = "app_process_restart",
129+
fields = mapOf("reason" to reason)
130+
)
131+
appContext.findActivity()?.finishAffinity()
132+
exitProcess(0)
133+
}
134+
135+
private fun Context.findActivity(): Activity? {
136+
return when (this) {
137+
is Activity -> this
138+
is ContextWrapper -> baseContext.findActivity()
139+
else -> null
140+
}
141+
}
142+
143+
private companion object {
144+
const val RESTART_REQUEST_CODE = 41_407
145+
const val RESTART_DELAY_MS = 300L
146+
}
147+
}

0 commit comments

Comments
 (0)