fix: resolve Context and Activity memory leaks across the app

- MainActivity: use applicationContext instead of Activity ref in GlobalScope
- ChoreographerHelper: add stop() method and running flag to allow unregistering the perpetual FrameCallback
- MediaSessionPool: replace GlobalScope with class-scoped CoroutineScope, cancel on destroy
- ExoPlayerPool: cancel scope after destroy completes
- PlaybackServiceClient: add shutdown() for the ExecutorService thread pool
- LocalCache: replace GlobalScope with app-scoped coroutine for payment callbacks
- TextToSpeechEngine: clear all listener lambdas in destroy() to prevent capturing UI scope
- NotificationReplyReceiver: scope CoroutineScope to each onReceive call and cancel after use
- NotificationUtils: use SingletonImageLoader instead of creating new ImageLoader per notification
- AppModules: clean up BackgroundMedia and PlaybackServiceClient on terminate

https://claude.ai/code/session_01RR2kqsV23JKKdNGQVj7YaQ
This commit is contained in:
Claude
2026-04-01 03:40:31 +00:00
parent cd11503b1e
commit 04b497add4
10 changed files with 74 additions and 43 deletions
@@ -54,6 +54,8 @@ import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCacheFactory
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient
import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector
import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
@@ -485,6 +487,8 @@ class AppModules(
fun terminate(appContext: Context) {
pokeyReceiver.unregister(appContext)
BackgroundMedia.removeBackgroundControllerAndReleaseIt()
PlaybackServiceClient.shutdown()
applicationIOScope.cancel("Application onTerminate $appContext")
accountsCache.clear()
}
@@ -227,7 +227,6 @@ import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
@@ -1735,8 +1734,7 @@ object LocalCache : ILocalCache, ICacheProvider {
requestNote?.let { request -> zappedNote?.addZapPayment(request, note) }
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
Amethyst.instance.applicationIOScope.launch {
responseCallback(event)
}
@@ -25,28 +25,39 @@ import com.vitorpamplona.quartz.utils.Log
object ChoreographerHelper {
var lastFrameTimeNanos: Long = 0
private var running = false
fun start() {
Choreographer.getInstance().postFrameCallback(
object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
// Last callback time
if (lastFrameTimeNanos == 0L) {
lastFrameTimeNanos = frameTimeNanos
Choreographer.getInstance().postFrameCallback(this)
return
}
val diff = (frameTimeNanos - lastFrameTimeNanos) / 1000000
// only report after 30ms because videos play at 30fps
if (diff > 35) {
// Follow the frame number
val droppedCount = (diff / 16.6).toInt()
Log.w("block-canary") { "Dropped $droppedCount frames. Skipped $diff ms" }
}
private val frameCallback =
object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
if (!running) return
// Last callback time
if (lastFrameTimeNanos == 0L) {
lastFrameTimeNanos = frameTimeNanos
Choreographer.getInstance().postFrameCallback(this)
return
}
},
)
val diff = (frameTimeNanos - lastFrameTimeNanos) / 1000000
// only report after 30ms because videos play at 30fps
if (diff > 35) {
// Follow the frame number
val droppedCount = (diff / 16.6).toInt()
Log.w("block-canary") { "Dropped $droppedCount frames. Skipped $diff ms" }
}
lastFrameTimeNanos = frameTimeNanos
Choreographer.getInstance().postFrameCallback(this)
}
}
fun start() {
running = true
lastFrameTimeNanos = 0
Choreographer.getInstance().postFrameCallback(frameCallback)
}
fun stop() {
running = false
Choreographer.getInstance().removeFrameCallback(frameCallback)
}
}
@@ -34,13 +34,12 @@ import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
class NotificationReplyReceiver : BroadcastReceiver() {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun onReceive(
context: Context,
intent: Intent,
@@ -71,6 +70,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
if (members.isEmpty()) return
val pendingResult = goAsync()
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
scope.launch {
// activates the relay to send the message.
@@ -91,6 +91,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
// closes the relay connection.
collectionJob.cancel()
scope.cancel()
}
}
}
@@ -33,7 +33,7 @@ import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.asDrawable
import coil3.request.ImageRequest
import com.vitorpamplona.amethyst.R
@@ -270,7 +270,7 @@ object NotificationUtils {
withContext(Dispatchers.IO) {
try {
val request = ImageRequest.Builder(applicationContext).data(pictureUrl).build()
val imageLoader = ImageLoader(applicationContext)
val imageLoader = SingletonImageLoader.get(applicationContext)
val result = imageLoader.execute(request)
(result.image?.asDrawable(applicationContext.resources) as? BitmapDrawable)?.bitmap
} catch (_: Exception) {
@@ -29,6 +29,7 @@ import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -93,11 +94,14 @@ class ExoPlayerPool(
}
fun destroy() {
scope.launch {
mutex.withLock {
playerPool.forEach { it.release() }
playerPool.clear()
scope
.launch {
mutex.withLock {
playerPool.forEach { it.release() }
playerPool.clear()
}
}.invokeOnCompletion {
scope.cancel()
}
}
}
}
@@ -37,9 +37,11 @@ import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
class SessionListener(
@@ -60,6 +62,14 @@ class MediaSessionPool(
val appContext: Context,
val reset: (MediaSession, Boolean) -> Unit,
) {
private val exceptionHandler =
CoroutineExceptionHandler { _, throwable ->
com.vitorpamplona.quartz.utils.Log
.e("MediaSessionPool", "Caught exception: ${throwable.message}", throwable)
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + exceptionHandler)
val globalCallback = MediaSessionCallback(this, appContext)
var lastCleanup = TimeUtils.now()
@@ -133,17 +143,11 @@ class MediaSessionPool(
fun cleanupUnused() {
if (lastCleanup < TimeUtils.oneMinuteAgo()) {
lastCleanup = TimeUtils.now()
@kotlin.OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.Main) {
var counter = 0
scope.launch {
val snap = cache.snapshot()
// makes a copy and awaits 10 seconds in case a new token was just created
// but not connected yet.
// delay(10000)
snap.values.forEach {
if (it.session.connectedControllers.isEmpty()) {
releaseSession(it.session)
counter++
}
}
lastCleanup = TimeUtils.now()
@@ -152,8 +156,7 @@ class MediaSessionPool(
}
fun destroy() {
@kotlin.OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.Main) {
scope.launch {
cache.evictAll()
playingMap.forEach {
it.value.removeListeners()
@@ -164,6 +167,7 @@ class MediaSessionPool(
}
exoPlayerPool.destroy()
scope.cancel()
}
fun getSession(
@@ -38,6 +38,10 @@ import kotlin.uuid.Uuid
object PlaybackServiceClient {
val executorService: ExecutorService = Executors.newCachedThreadPool()
fun shutdown() {
executorService.shutdown()
}
@OptIn(ExperimentalUuidApi::class)
fun controllerAsFlow(
videoUri: String,
@@ -160,6 +160,11 @@ class TextToSpeechEngine private constructor() {
tts?.stop()
tts?.shutdown()
tts = null
onStartListener = null
onDoneListener = null
onErrorListener = null
onHighlightListener = null
message = null
instance = null
}
}
@@ -90,7 +90,7 @@ class MainActivity : AppCompatActivity() {
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) {
debugState(this@MainActivity)
debugState(applicationContext)
Amethyst.instance.relayReqStats?.printStats()
}