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\" install golang.org/x/mobile/cmd/gobind@c31d5b91ecc32c0d598b8fe8457d244ca0b4e815)",
"Bash(\"/c/Users/dong/go/bin/go1.22.10.exe\" mod tidy)", "Bash(\"/c/Users/dong/go/bin/go1.22.10.exe\" mod tidy)",
"Bash(adb devices:*)", "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": [], "deny": [],
"ask": [] "ask": []

View File

@ -63,6 +63,10 @@ class TssRepository @Inject constructor(
// Called when 5-minute polling timeout is reached without session starting // Called when 5-minute polling timeout is reached without session starting
private var keygenTimeoutCallback: ((String) -> Unit)? = null 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 { companion object {
// Polling interval for session status check (matching Electron's 2-second interval) // Polling interval for session status check (matching Electron's 2-second interval)
private const val POLL_INTERVAL_MS = 2000L private const val POLL_INTERVAL_MS = 2000L
@ -142,6 +146,19 @@ class TssRepository @Inject constructor(
fun disconnect() { fun disconnect() {
messageCollectionJob?.cancel() messageCollectionJob?.cancel()
sessionEventJob?.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() grpcClient.disconnect()
} }
@ -199,7 +216,7 @@ class TssRepository @Inject constructor(
private fun startSessionEventSubscription() { private fun startSessionEventSubscription() {
sessionEventJob?.cancel() sessionEventJob?.cancel()
android.util.Log.d("TssRepository", "Starting session event subscription for partyId: $partyId") 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 -> grpcClient.subscribeSessionEvents(partyId).collect { event ->
android.util.Log.d("TssRepository", "=== Session event received ===") android.util.Log.d("TssRepository", "=== Session event received ===")
android.util.Log.d("TssRepository", " eventType: ${event.eventType}") 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") 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() val startTime = System.currentTimeMillis()
var pollCount = 0 var pollCount = 0
@ -1501,7 +1518,7 @@ class TssRepository @Inject constructor(
currentMessageRoutingPartyIndex = partyIndex currentMessageRoutingPartyIndex = partyIndex
messageCollectionJob?.cancel() messageCollectionJob?.cancel()
messageCollectionJob = CoroutineScope(Dispatchers.IO).launch { messageCollectionJob = repositoryScope.launch {
// Collect outgoing messages from TSS and route via gRPC // Collect outgoing messages from TSS and route via gRPC
launch { launch {
tssNativeBridge.outgoingMessages.collect { message -> tssNativeBridge.outgoingMessages.collect { message ->

View File

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