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.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCacheFactory 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.CacheClientConnector
import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
@@ -485,6 +487,8 @@ class AppModules(
fun terminate(appContext: Context) { fun terminate(appContext: Context) {
pokeyReceiver.unregister(appContext) pokeyReceiver.unregister(appContext)
BackgroundMedia.removeBackgroundControllerAndReleaseIt()
PlaybackServiceClient.shutdown()
applicationIOScope.cancel("Application onTerminate $appContext") applicationIOScope.cancel("Application onTerminate $appContext")
accountsCache.clear() accountsCache.clear()
} }
@@ -227,7 +227,6 @@ import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.cache.LargeCache import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -1735,8 +1734,7 @@ object LocalCache : ILocalCache, ICacheProvider {
requestNote?.let { request -> zappedNote?.addZapPayment(request, note) } requestNote?.let { request -> zappedNote?.addZapPayment(request, note) }
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class) Amethyst.instance.applicationIOScope.launch {
GlobalScope.launch(Dispatchers.IO) {
responseCallback(event) responseCallback(event)
} }
@@ -25,28 +25,39 @@ import com.vitorpamplona.quartz.utils.Log
object ChoreographerHelper { object ChoreographerHelper {
var lastFrameTimeNanos: Long = 0 var lastFrameTimeNanos: Long = 0
private var running = false
fun start() { private val frameCallback =
Choreographer.getInstance().postFrameCallback( object : Choreographer.FrameCallback {
object : Choreographer.FrameCallback { override fun doFrame(frameTimeNanos: Long) {
override fun doFrame(frameTimeNanos: Long) { if (!running) return
// Last callback time
if (lastFrameTimeNanos == 0L) { // Last callback time
lastFrameTimeNanos = frameTimeNanos if (lastFrameTimeNanos == 0L) {
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 lastFrameTimeNanos = frameTimeNanos
Choreographer.getInstance().postFrameCallback(this) 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
class NotificationReplyReceiver : BroadcastReceiver() { class NotificationReplyReceiver : BroadcastReceiver() {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
override fun onReceive( override fun onReceive(
context: Context, context: Context,
intent: Intent, intent: Intent,
@@ -71,6 +70,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
if (members.isEmpty()) return if (members.isEmpty()) return
val pendingResult = goAsync() val pendingResult = goAsync()
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
scope.launch { scope.launch {
// activates the relay to send the message. // activates the relay to send the message.
@@ -91,6 +91,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
// closes the relay connection. // closes the relay connection.
collectionJob.cancel() collectionJob.cancel()
scope.cancel()
} }
} }
} }
@@ -33,7 +33,7 @@ import androidx.core.app.Person
import androidx.core.app.RemoteInput import androidx.core.app.RemoteInput
import androidx.core.graphics.drawable.IconCompat import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import coil3.ImageLoader import coil3.SingletonImageLoader
import coil3.asDrawable import coil3.asDrawable
import coil3.request.ImageRequest import coil3.request.ImageRequest
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
@@ -270,7 +270,7 @@ object NotificationUtils {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { try {
val request = ImageRequest.Builder(applicationContext).data(pictureUrl).build() val request = ImageRequest.Builder(applicationContext).data(pictureUrl).build()
val imageLoader = ImageLoader(applicationContext) val imageLoader = SingletonImageLoader.get(applicationContext)
val result = imageLoader.execute(request) val result = imageLoader.execute(request)
(result.image?.asDrawable(applicationContext.resources) as? BitmapDrawable)?.bitmap (result.image?.asDrawable(applicationContext.resources) as? BitmapDrawable)?.bitmap
} catch (_: Exception) { } catch (_: Exception) {
@@ -29,6 +29,7 @@ import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@@ -93,11 +94,14 @@ class ExoPlayerPool(
} }
fun destroy() { fun destroy() {
scope.launch { scope
mutex.withLock { .launch {
playerPool.forEach { it.release() } mutex.withLock {
playerPool.clear() 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.google.common.util.concurrent.ListenableFuture
import com.vitorpamplona.amethyst.ui.MainActivity import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.quartz.utils.TimeUtils 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.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class SessionListener( class SessionListener(
@@ -60,6 +62,14 @@ class MediaSessionPool(
val appContext: Context, val appContext: Context,
val reset: (MediaSession, Boolean) -> Unit, 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) val globalCallback = MediaSessionCallback(this, appContext)
var lastCleanup = TimeUtils.now() var lastCleanup = TimeUtils.now()
@@ -133,17 +143,11 @@ class MediaSessionPool(
fun cleanupUnused() { fun cleanupUnused() {
if (lastCleanup < TimeUtils.oneMinuteAgo()) { if (lastCleanup < TimeUtils.oneMinuteAgo()) {
lastCleanup = TimeUtils.now() lastCleanup = TimeUtils.now()
@kotlin.OptIn(DelicateCoroutinesApi::class) scope.launch {
GlobalScope.launch(Dispatchers.Main) {
var counter = 0
val snap = cache.snapshot() 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 { snap.values.forEach {
if (it.session.connectedControllers.isEmpty()) { if (it.session.connectedControllers.isEmpty()) {
releaseSession(it.session) releaseSession(it.session)
counter++
} }
} }
lastCleanup = TimeUtils.now() lastCleanup = TimeUtils.now()
@@ -152,8 +156,7 @@ class MediaSessionPool(
} }
fun destroy() { fun destroy() {
@kotlin.OptIn(DelicateCoroutinesApi::class) scope.launch {
GlobalScope.launch(Dispatchers.Main) {
cache.evictAll() cache.evictAll()
playingMap.forEach { playingMap.forEach {
it.value.removeListeners() it.value.removeListeners()
@@ -164,6 +167,7 @@ class MediaSessionPool(
} }
exoPlayerPool.destroy() exoPlayerPool.destroy()
scope.cancel()
} }
fun getSession( fun getSession(
@@ -38,6 +38,10 @@ import kotlin.uuid.Uuid
object PlaybackServiceClient { object PlaybackServiceClient {
val executorService: ExecutorService = Executors.newCachedThreadPool() val executorService: ExecutorService = Executors.newCachedThreadPool()
fun shutdown() {
executorService.shutdown()
}
@OptIn(ExperimentalUuidApi::class) @OptIn(ExperimentalUuidApi::class)
fun controllerAsFlow( fun controllerAsFlow(
videoUri: String, videoUri: String,
@@ -160,6 +160,11 @@ class TextToSpeechEngine private constructor() {
tts?.stop() tts?.stop()
tts?.shutdown() tts?.shutdown()
tts = null tts = null
onStartListener = null
onDoneListener = null
onErrorListener = null
onHighlightListener = null
message = null
instance = null instance = null
} }
} }
@@ -90,7 +90,7 @@ class MainActivity : AppCompatActivity() {
@OptIn(DelicateCoroutinesApi::class) @OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(Dispatchers.IO) { GlobalScope.launch(Dispatchers.IO) {
debugState(this@MainActivity) debugState(applicationContext)
Amethyst.instance.relayReqStats?.printStats() Amethyst.instance.relayReqStats?.printStats()
} }