From 04b497add4cdabe998b00b8df76060137fae96de Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 03:40:31 +0000 Subject: [PATCH] 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 --- .../com/vitorpamplona/amethyst/AppModules.kt | 4 ++ .../amethyst/model/LocalCache.kt | 4 +- .../service/logging/ChoreographerHelper.kt | 49 ++++++++++++------- .../NotificationReplyReceiver.kt | 5 +- .../notifications/NotificationUtils.kt | 4 +- .../playback/playerPool/ExoPlayerPool.kt | 14 ++++-- .../playback/playerPool/MediaSessionPool.kt | 26 +++++----- .../playback/service/PlaybackServiceClient.kt | 4 ++ .../service/tts/TextToSpeechEngine.kt | 5 ++ .../vitorpamplona/amethyst/ui/MainActivity.kt | 2 +- 10 files changed, 74 insertions(+), 43 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 78a13e3f4..7fcd4b3e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 31fd76865..fc0105e8b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/ChoreographerHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/ChoreographerHelper.kt index 591079375..31ef98f4c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/ChoreographerHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/logging/ChoreographerHelper.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt index 0289c3406..c51877b63 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt @@ -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() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index ed5e76ac9..7064acc0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt index c8d4f3082..4f1398cf2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt @@ -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() } - } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt index 785efc5ec..4515554e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt index 534e2d025..3bd87f509 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt index c517d75f9..423d108b7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/tts/TextToSpeechEngine.kt @@ -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 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index ebf820d76..79ff84a3b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -90,7 +90,7 @@ class MainActivity : AppCompatActivity() { @OptIn(DelicateCoroutinesApi::class) GlobalScope.launch(Dispatchers.IO) { - debugState(this@MainActivity) + debugState(applicationContext) Amethyst.instance.relayReqStats?.printStats() }