Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
24f3122
auto-claude: subtask-1-2 - Implement automatic network reconnection
jeremykit Jan 4, 2026
1c27073
auto-claude: subtask-1-2 - Implement automatic network reconnection
jeremykit Jan 4, 2026
ca25edd
auto-claude: subtask-1-3 - Add auto-reconnect preference to SettingsRepo
jeremykit Jan 4, 2026
d2eb6d9
auto-claude: subtask-1-5 - Add connection-timeout preference to Setti…
jeremykit Jan 4, 2026
d3037c9
auto-claude: subtask-1-5 - Add connection-timeout preference to Setti…
jeremykit Jan 4, 2026
35a5553
auto-claude: subtask-1-5 - Add connection-timeout preference to Setti…
jeremykit Jan 4, 2026
1367519
auto-claude: subtask-1-6 - Add reconnection-config persistence to Set…
jeremykit Jan 4, 2026
8ac9c1f
auto-claude: subtask-1-3 - Add string resources for reconnection sett…
jeremykit Jan 4, 2026
fa348ea
auto-claude: subtask-1-3 - Add string resources for reconnection sett…
jeremykit Jan 4, 2026
4c01529
auto-claude: subtask-1-3 - Add string resources for reconnection sett…
jeremykit Jan 4, 2026
8db2877
auto-claude: subtask-1-4 - Implement reconnection UI and state manage…
jeremykit Jan 4, 2026
d11ce48
auto-claude: subtask-1-5 - Implement reconnection logic in SettingsVi…
jeremykit Jan 4, 2026
4968dd4
auto-claude: subtask-1-5 - Implement reconnection logic in SettingsRe…
jeremykit Jan 4, 2026
542f3de
auto-claude: subtask-2-2 - Implement reconnection config persistence …
jeremykit Jan 4, 2026
87aeb61
auto-claude: subtask-3-1 - Add BackHandler and navigation confirmatio…
jeremykit Jan 4, 2026
df23c36
auto-claude: subtask-3-1 - Create NetworkMonitor utility class with S…
jeremykit Jan 4, 2026
b60fb6b
auto-claude: subtask-4-1 - Add reconnection job and logic to RtmpStre…
jeremykit Jan 4, 2026
90334c9
auto-claude: subtask-5-2 - Add reconnection banner UI to StreamScreen
jeremykit Jan 4, 2026
6984d5a
auto-claude: subtask-6-1 - Add StreamManager observation and notifica…
jeremykit Jan 4, 2026
e5c093c
auto-claude: subtask-5-1 - Fix StreamScreen integration with confirma…
jeremykit Jan 4, 2026
5b5bc82
auto-claude: subtask-5-1 - Fix StreamScreen integration with confirma…
jeremykit Jan 4, 2026
24f79f1
auto-claude: subtask-5-1 - Add cancelReconnection function to StreamV…
jeremykit Jan 4, 2026
7824f52
auto-claude: subtask-7-1 - Manual E2E testing of reconnection flow
jeremykit Jan 4, 2026
b4084d7
auto-claude: subtask-5-1 - Fix StreamScreen integration with confirma…
jeremykit Jan 4, 2026
c5be9b5
fix: implement cancelReconnection, use configurable retries (qa-reque…
jeremykit Jan 4, 2026
2463a70
Merge branch 'main' into auto-claude/002-automatic-network-reconnection
jeremykit Jan 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .auto-claude-status
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
{
"active": true,
"spec": "001-complete-settings-dialogs",
"spec": "002-automatic-network-reconnection",
"state": "complete",
"subtasks": {
"completed": 10,
"total": 10,
"completed": 11,
"total": 11,
"in_progress": 0,
"failed": 0
},
"phase": {
"current": "Integration & Verification",
"current": "Integration Testing",
"id": null,
"total": 1
},
Expand All @@ -18,8 +18,8 @@
"max": 1
},
"session": {
"number": 12,
"started_at": "2026-01-04T10:24:27.911589"
"number": 2,
"started_at": "2026-01-04T14:10:53.659340"
},
"last_update": "2026-01-04T11:31:06.900459"
"last_update": "2026-01-04T14:23:49.536188"
}
14 changes: 14 additions & 0 deletions .claude_settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@
"Edit(./**)",
"Glob(./**)",
"Grep(./**)",
"Read(E:\\237\\live\\livepush\\.worktrees\\002-automatic-network-reconnection/**)",
"Write(E:\\237\\live\\livepush\\.worktrees\\002-automatic-network-reconnection/**)",
"Edit(E:\\237\\live\\livepush\\.worktrees\\002-automatic-network-reconnection/**)",
"Glob(E:\\237\\live\\livepush\\.worktrees\\002-automatic-network-reconnection/**)",
"Grep(E:\\237\\live\\livepush\\.worktrees\\002-automatic-network-reconnection/**)",
"Read(E:\\237\\live\\livepush\\.worktrees\\002-automatic-network-reconnection\\.auto-claude\\specs\\002-automatic-network-reconnection/**)",
"Write(E:\\237\\live\\livepush\\.worktrees\\002-automatic-network-reconnection\\.auto-claude\\specs\\002-automatic-network-reconnection/**)",
"Edit(E:\\237\\live\\livepush\\.worktrees\\002-automatic-network-reconnection\\.auto-claude\\specs\\002-automatic-network-reconnection/**)",
"Read(E:\\237\\live\\livepush\\.worktrees\\001-complete-settings-dialogs/**)",
"Write(E:\\237\\live\\livepush\\.worktrees\\001-complete-settings-dialogs/**)",
"Edit(E:\\237\\live\\livepush\\.worktrees\\001-complete-settings-dialogs/**)",
Expand All @@ -23,6 +31,12 @@
"WebFetch(*)",
"WebSearch(*)",
"mcp__context7__resolve-library-id(*)",
"mcp__context7__get-library-docs(*)",
"mcp__graphiti-memory__search_nodes(*)",
"mcp__graphiti-memory__search_facts(*)",
"mcp__graphiti-memory__add_episode(*)",
"mcp__graphiti-memory__get_episodes(*)",
"mcp__graphiti-memory__get_entity_edge(*)"
"mcp__context7__get-library-docs(*)"
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import com.livepush.domain.model.AudioCodec
import com.livepush.domain.model.AudioConfig
import com.livepush.domain.model.ReconnectionConfig
import com.livepush.domain.model.StreamConfig
import com.livepush.domain.model.StreamProtocol
import com.livepush.domain.model.VideoCodec
Expand Down Expand Up @@ -41,6 +42,9 @@ class SettingsRepositoryImpl @Inject constructor(
// Protocol
val STREAM_PROTOCOL = stringPreferencesKey("stream_protocol")

// Reconnection Settings
val RECONNECTION_MAX_RETRIES = intPreferencesKey("reconnection_max_retries")
val RECONNECTION_INITIAL_DELAY_MS = intPreferencesKey("reconnection_initial_delay_ms")
// Network Settings
val MAX_RECONNECT_ATTEMPTS = intPreferencesKey("max_reconnect_attempts")
val CONNECTION_TIMEOUT = intPreferencesKey("connection_timeout")
Expand All @@ -66,7 +70,11 @@ class SettingsRepositoryImpl @Inject constructor(
bitrate = prefs[AUDIO_BITRATE] ?: 128_000,
codec = prefs[AUDIO_CODEC]?.let { AudioCodec.valueOf(it) } ?: AudioCodec.AAC
),
protocol = prefs[STREAM_PROTOCOL]?.let { StreamProtocol.valueOf(it) } ?: StreamProtocol.RTMP
protocol = prefs[STREAM_PROTOCOL]?.let { StreamProtocol.valueOf(it) } ?: StreamProtocol.RTMP,
reconnectionConfig = ReconnectionConfig(
maxRetries = prefs[RECONNECTION_MAX_RETRIES] ?: 5,
initialDelayMs = prefs[RECONNECTION_INITIAL_DELAY_MS] ?: 2000
)
)
}
}
Expand All @@ -89,6 +97,10 @@ class SettingsRepositoryImpl @Inject constructor(

// Protocol
prefs[STREAM_PROTOCOL] = config.protocol.name

// Reconnection
prefs[RECONNECTION_MAX_RETRIES] = config.reconnectionConfig.maxRetries
prefs[RECONNECTION_INITIAL_DELAY_MS] = config.reconnectionConfig.initialDelayMs
}
}

Expand All @@ -102,6 +114,19 @@ class SettingsRepositoryImpl @Inject constructor(
}
}

override fun getReconnectionConfig(): Flow<ReconnectionConfig> {
return dataStore.data.map { prefs ->
ReconnectionConfig(
maxRetries = prefs[RECONNECTION_MAX_RETRIES] ?: 5,
initialDelayMs = prefs[RECONNECTION_INITIAL_DELAY_MS] ?: 2000
)
}
}

override suspend fun updateReconnectionConfig(config: ReconnectionConfig) {
dataStore.edit { prefs ->
prefs[RECONNECTION_MAX_RETRIES] = config.maxRetries
prefs[RECONNECTION_INITIAL_DELAY_MS] = config.initialDelayMs
override suspend fun getMaxReconnectAttempts(): Int {
return dataStore.data.first()[MAX_RECONNECT_ATTEMPTS] ?: 5
}
Expand Down
8 changes: 7 additions & 1 deletion app/src/main/java/com/livepush/domain/model/StreamConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ package com.livepush.domain.model
data class StreamConfig(
val videoConfig: VideoConfig = VideoConfig(),
val audioConfig: AudioConfig = AudioConfig(),
val protocol: StreamProtocol = StreamProtocol.RTMP
val protocol: StreamProtocol = StreamProtocol.RTMP,
val reconnectionConfig: ReconnectionConfig = ReconnectionConfig()
)

data class VideoConfig(
Expand All @@ -22,6 +23,11 @@ data class AudioConfig(
val codec: AudioCodec = AudioCodec.AAC
)

data class ReconnectionConfig(
val maxRetries: Int = 5,
val initialDelayMs: Int = 2000
)

enum class StreamProtocol {
RTMP,
WEBRTC
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/com/livepush/domain/model/StreamState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ sealed class StreamState {
data object Previewing : StreamState()
data object Connecting : StreamState()
data class Streaming(val startTime: Long = System.currentTimeMillis()) : StreamState()
data object Reconnecting : StreamState()
data class Reconnecting(val attempt: Int, val maxAttempts: Int) : StreamState()
data class Error(val error: StreamError) : StreamState()
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.livepush.domain.repository

import com.livepush.domain.model.ReconnectionConfig
import com.livepush.domain.model.StreamConfig
import kotlinx.coroutines.flow.Flow

Expand All @@ -8,6 +9,8 @@ interface SettingsRepository {
suspend fun updateStreamConfig(config: StreamConfig)
suspend fun getLastStreamUrl(): String?
suspend fun setLastStreamUrl(url: String)
fun getReconnectionConfig(): Flow<ReconnectionConfig>
suspend fun updateReconnectionConfig(config: ReconnectionConfig)

// Network settings
suspend fun getMaxReconnectAttempts(): Int
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ interface StreamManager {

fun stopStream()

fun cancelReconnection()

fun switchCamera()

fun enableTorch(enabled: Boolean)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,53 @@ fun StreamScreen(
}
}

// 重连状态显示
if (streamState is StreamState.Reconnecting) {
Surface(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 16.dp)
.padding(horizontal = 16.dp),
color = Color(0xFFFF9800),
shape = MaterialTheme.shapes.medium
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = stringResource(R.string.reconnecting),
color = Color.White,
style = MaterialTheme.typography.bodyMedium
)
Text(
text = stringResource(
R.string.reconnect_attempt,
(streamState as StreamState.Reconnecting).attempt,
streamState.maxAttempts
),
color = Color.White.copy(alpha = 0.9f),
style = MaterialTheme.typography.bodySmall
)
}
TextButton(
onClick = { viewModel.cancelReconnection() }
) {
Text(
text = stringResource(R.string.cancel),
color = Color.White
)
}
}
}
}

// 错误状态显示
if (streamState is StreamState.Error) {
Surface(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,10 @@ class StreamViewModel @Inject constructor(
streamManager.stopStream()
}

fun cancelReconnection() {
streamManager.cancelReconnection()
}

fun toggleMute() {
val newMuted = !_uiState.value.isMuted
_uiState.update { it.copy(isMuted = newMuted) }
Expand Down
71 changes: 71 additions & 0 deletions app/src/main/java/com/livepush/streaming/RtmpStreamManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ class RtmpStreamManager @Inject constructor(

private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var statsJob: Job? = null
private var reconnectionJob: Job? = null
private var streamStartTime: Long = 0L
private var streamUrl: String? = null
private var reconnectionAttempt: Int = 0

override val isFrontCamera: Boolean
get() = rtmpCamera?.cameraFacing == CameraHelper.Facing.FRONT
Expand Down Expand Up @@ -99,6 +102,7 @@ class RtmpStreamManager @Inject constructor(
return
}

streamUrl = url
_streamState.value = StreamState.Connecting

try {
Expand All @@ -113,10 +117,13 @@ class RtmpStreamManager @Inject constructor(
}

override fun stopStream() {
reconnectionJob?.cancel()
statsJob?.cancel()
rtmpCamera?.stopStream()
_streamState.value = StreamState.Previewing
_streamStats.value = StreamStats()
streamUrl = null
reconnectionAttempt = 0
}

override fun switchCamera() {
Expand Down Expand Up @@ -144,11 +151,21 @@ class RtmpStreamManager @Inject constructor(
}

override fun release() {
reconnectionJob?.cancel()
statsJob?.cancel()
rtmpCamera?.stopStream()
rtmpCamera?.stopPreview()
rtmpCamera = null
surfaceView = null
streamUrl = null
reconnectionAttempt = 0
}

override fun cancelReconnection() {
reconnectionJob?.cancel()
reconnectionAttempt = 0
_streamState.value = StreamState.Previewing
Timber.d("Reconnection cancelled by user")
}

// ConnectChecker callbacks
Expand All @@ -158,6 +175,8 @@ class RtmpStreamManager @Inject constructor(

override fun onConnectionSuccess() {
Timber.d("Connection success")
reconnectionJob?.cancel()
reconnectionAttempt = 0
streamStartTime = System.currentTimeMillis()
_streamState.value = StreamState.Streaming(streamStartTime)
startStatsCollection()
Expand All @@ -181,6 +200,7 @@ class RtmpStreamManager @Inject constructor(
_streamState.value = StreamState.Error(
StreamError.ConnectionLost("Connection lost")
)
startReconnection()
}
}

Expand Down Expand Up @@ -211,4 +231,55 @@ class RtmpStreamManager @Inject constructor(
}
}
}

private fun startReconnection() {
val url = streamUrl
if (url == null) {
Timber.e("Cannot reconnect: stream URL is null")
return
}

reconnectionJob?.cancel()
reconnectionJob = scope.launch {
while (isActive && reconnectionAttempt < currentConfig.reconnectionConfig.maxRetries) {
reconnectionAttempt++
val delaySeconds = calculateBackoffDelay(reconnectionAttempt)

Timber.d("Reconnection attempt $reconnectionAttempt/${currentConfig.reconnectionConfig.maxRetries} in ${delaySeconds}s")
_streamState.value = StreamState.Reconnecting(reconnectionAttempt, currentConfig.reconnectionConfig.maxRetries)

delay(delaySeconds * 1000L)

if (!isActive) break

Timber.d("Attempting to reconnect to: $url")
_streamState.value = StreamState.Connecting

try {
rtmpCamera?.stopStream()
delay(500) // Brief pause before reconnecting
rtmpCamera?.startStream(url)
} catch (e: Exception) {
Timber.e(e, "Reconnection attempt failed")
if (reconnectionAttempt >= currentConfig.reconnectionConfig.maxRetries) {
_streamState.value = StreamState.Error(
StreamError.ConnectionFailed("Max reconnection attempts reached")
)
}
}
}
}
}

private fun calculateBackoffDelay(attempt: Int): Int {
// Exponential backoff: 2s, 4s, 8s, 16s, 30s (max)
val delay = minOf(BASE_RECONNECTION_DELAY * (1 shl (attempt - 1)), MAX_RECONNECTION_DELAY)
return delay
}

companion object {
private const val BASE_RECONNECTION_DELAY = 2 // seconds
private const val MAX_RECONNECTION_DELAY = 30 // seconds
private const val MAX_RECONNECTION_ATTEMPTS = 10
}
}
Loading
Loading