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