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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+30
-19
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+9
-5
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-11
@@ -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(
|
||||
|
||||
+4
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user