fix(android): prevent memory leaks from detached coroutine scopes

Critical fixes to prevent app crashes when screens are kept open for extended periods:

- Add repositoryScope with SupervisorJob for structured concurrency in TssRepository
- Replace detached CoroutineScope(Dispatchers.IO).launch with repositoryScope.launch:
  - Session event subscription (line 206)
  - Session status polling (line 291)
  - Message routing (line 1508)
- Add cleanup() method to properly cancel all jobs and repositoryScope
- Update disconnect() to also cancel sessionStatusPollingJob
- Update MainViewModel.onCleared() to call repository.cleanup()

This ensures all background coroutines are properly cancelled when the ViewModel
is cleared, preventing memory accumulation over time.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-01 19:05:13 -08:00
parent fc86af918f
commit b30017f3a7
3 changed files with 23 additions and 5 deletions

View File

@ -553,7 +553,8 @@
"Bash(\"/c/Users/dong/go/bin/go1.22.10.exe\" install golang.org/x/mobile/cmd/gobind@c31d5b91ecc32c0d598b8fe8457d244ca0b4e815)",
"Bash(\"/c/Users/dong/go/bin/go1.22.10.exe\" mod tidy)",
"Bash(adb devices:*)",
"Bash(adb logcat:*)"
"Bash(adb logcat:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(android\\): add 5-minute polling timeout mechanism for keygen/sign\n\nImplements Electron''s checkAndTriggerKeygen\\(\\) polling fallback:\n- Adds polling every 2 seconds with 5-minute timeout\n- Triggers keygen/sign via synthetic session_started event on in_progress status\n- Handles gRPC stream disconnection when app goes to background\n- Shows timeout error in UI via existing error mechanism\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n\\)\")"
],
"deny": [],
"ask": []

View File

@ -63,6 +63,10 @@ class TssRepository @Inject constructor(
// Called when 5-minute polling timeout is reached without session starting
private var keygenTimeoutCallback: ((String) -> Unit)? = null
// Repository-level CoroutineScope for background tasks
// Uses SupervisorJob so individual task failures don't cancel other tasks
private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
companion object {
// Polling interval for session status check (matching Electron's 2-second interval)
private const val POLL_INTERVAL_MS = 2000L
@ -142,6 +146,19 @@ class TssRepository @Inject constructor(
fun disconnect() {
messageCollectionJob?.cancel()
sessionEventJob?.cancel()
sessionStatusPollingJob?.cancel()
grpcClient.disconnect()
}
/**
* Cleanup all resources when the repository is destroyed.
* Call this when the app is being destroyed to prevent memory leaks.
*/
fun cleanup() {
messageCollectionJob?.cancel()
sessionEventJob?.cancel()
sessionStatusPollingJob?.cancel()
repositoryScope.cancel()
grpcClient.disconnect()
}
@ -199,7 +216,7 @@ class TssRepository @Inject constructor(
private fun startSessionEventSubscription() {
sessionEventJob?.cancel()
android.util.Log.d("TssRepository", "Starting session event subscription for partyId: $partyId")
sessionEventJob = CoroutineScope(Dispatchers.IO).launch {
sessionEventJob = repositoryScope.launch {
grpcClient.subscribeSessionEvents(partyId).collect { event ->
android.util.Log.d("TssRepository", "=== Session event received ===")
android.util.Log.d("TssRepository", " eventType: ${event.eventType}")
@ -284,7 +301,7 @@ class TssRepository @Inject constructor(
android.util.Log.d("TssRepository", "[POLLING] Starting session status polling for $sessionType session: $sessionId")
sessionStatusPollingJob = CoroutineScope(Dispatchers.IO).launch {
sessionStatusPollingJob = repositoryScope.launch {
val startTime = System.currentTimeMillis()
var pollCount = 0
@ -1501,7 +1518,7 @@ class TssRepository @Inject constructor(
currentMessageRoutingPartyIndex = partyIndex
messageCollectionJob?.cancel()
messageCollectionJob = CoroutineScope(Dispatchers.IO).launch {
messageCollectionJob = repositoryScope.launch {
// Collect outgoing messages from TSS and route via gRPC
launch {
tssNativeBridge.outgoingMessages.collect { message ->

View File

@ -1204,7 +1204,7 @@ class MainViewModel @Inject constructor(
override fun onCleared() {
super.onCleared()
repository.disconnect()
repository.cleanup()
}
}