---
name: network-reconnect
description: モバイル / デスクトップクライアントのネットワーク切断・復旧を堅牢に処理するための設計ガイドライン。ConnectivityManager、指数バックオフ、heartbeat、ライフサイクル連動の実装パターンを提供。
---

このスキルは以下の実装を支援:

- 再接続戦略の選定（指数バックオフ / circuit breaker / hybrid）
- プラットフォーム別の監視 API
- WebSocket 永続接続の heartbeat
- テスト可能な設計

## 呼び出し方

ユーザが「アプリの通信が切れた時に自動復旧したい」「WebSocket reconnect」「ネットワーク復帰時に同期」「切断検出」等を尋ねた時に起動。

詳細な設計方針は [`docs/NETWORK_RESILIENCE.md`](../../../docs/NETWORK_RESILIENCE.md) を参照。

## 推奨実装パス

1. `NetworkStateMonitor` — ネット状態を Flow で公開
2. `ExponentialBackoff` — jitter 付き再試行ポリシー
3. `ReconnectingWebSocket` — コルーチン + OkHttp の永続 WebSocket
4. `LifecycleAwareReconnect` — LifecycleObserver でフォアグラウンド連動
5. `AirplaneModeTest` — 自動テスト

---

## Android Kotlin スニペット

### NetworkStateMonitor.kt

```kotlin
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged

enum class NetworkState {
    Available,
    Unavailable,
    Losing,
}

class NetworkStateMonitor(context: Context) {

    private val connectivityManager =
        context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager

    val networkState: Flow<NetworkState> = callbackFlow {
        val callback = object : ConnectivityManager.NetworkCallback() {
            override fun onAvailable(network: Network) {
                trySend(NetworkState.Available)
            }

            override fun onCapabilitiesChanged(
                network: Network,
                networkCapabilities: NetworkCapabilities,
            ) {
                val validated = networkCapabilities.hasCapability(
                    NetworkCapabilities.NET_CAPABILITY_INTERNET,
                ) && networkCapabilities.hasCapability(
                    NetworkCapabilities.NET_CAPABILITY_VALIDATED,
                )
                trySend(if (validated) NetworkState.Available else NetworkState.Unavailable)
            }

            override fun onLosing(network: Network, maxMsToLive: Int) {
                trySend(NetworkState.Losing)
            }

            override fun onLost(network: Network) {
                trySend(NetworkState.Unavailable)
            }

            override fun onUnavailable() {
                trySend(NetworkState.Unavailable)
            }
        }

        val request = NetworkRequest.Builder()
            .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
            .build()

        connectivityManager.registerNetworkCallback(request, callback)

        // 初期状態を emit
        val currentState = getCurrentNetworkState()
        trySend(currentState)

        awaitClose {
            connectivityManager.unregisterNetworkCallback(callback)
        }
    }.distinctUntilChanged().conflate()

    private fun getCurrentNetworkState(): NetworkState {
        val activeNetwork = connectivityManager.activeNetwork ?: return NetworkState.Unavailable
        val caps = connectivityManager.getNetworkCapabilities(activeNetwork)
            ?: return NetworkState.Unavailable
        val validated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
            caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
        return if (validated) NetworkState.Available else NetworkState.Unavailable
    }
}
```

---

### ExponentialBackoff.kt

```kotlin
import kotlin.math.min
import kotlin.math.pow
import kotlin.random.Random

data class BackoffPolicy(
    val baseDelayMs: Long = 1_000L,
    val multiplier: Double = 2.0,
    val maxDelayMs: Long = 60_000L,
    val jitterFactor: Double = 0.3,
    val maxAttempts: Int = Int.MAX_VALUE,
)

class ExponentialBackoff(private val policy: BackoffPolicy = BackoffPolicy()) {

    private var attemptCount = 0

    val hasReachedMax: Boolean
        get() = attemptCount >= policy.maxAttempts

    fun nextDelayMs(): Long {
        val exponential = policy.baseDelayMs * policy.multiplier.pow(attemptCount.toDouble())
        val capped = min(exponential, policy.maxDelayMs.toDouble())
        val jitter = Random.nextDouble() * capped * policy.jitterFactor
        return (capped + jitter).toLong().coerceAtMost(policy.maxDelayMs).also {
            attemptCount++
        }
    }

    fun reset() {
        attemptCount = 0
    }
}
```

---

### CircuitBreaker.kt

```kotlin
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

enum class CircuitState { Closed, Open, HalfOpen }

class CircuitBreaker(
    private val failureThreshold: Int = 5,
    private val halfOpenTimeoutMs: Long = 30_000L,
) {
    private val mutex = Mutex()
    private var state: CircuitState = CircuitState.Closed
    private var consecutiveFailures = 0
    private var openedAt: Long = 0L

    suspend fun isCallAllowed(): Boolean = mutex.withLock {
        when (state) {
            CircuitState.Closed -> true
            CircuitState.Open -> {
                val elapsed = System.currentTimeMillis() - openedAt
                if (elapsed >= halfOpenTimeoutMs) {
                    state = CircuitState.HalfOpen
                    true
                } else {
                    false
                }
            }
            CircuitState.HalfOpen -> true
        }
    }

    suspend fun recordSuccess() = mutex.withLock {
        consecutiveFailures = 0
        state = CircuitState.Closed
    }

    suspend fun recordFailure() = mutex.withLock {
        consecutiveFailures++
        if (state == CircuitState.HalfOpen || consecutiveFailures >= failureThreshold) {
            state = CircuitState.Open
            openedAt = System.currentTimeMillis()
        }
    }

    suspend fun forceCloseOnNetworkAvailable() = mutex.withLock {
        if (state == CircuitState.Open) {
            state = CircuitState.HalfOpen
        }
    }
}
```

---

### ReconnectingWebSocket.kt

```kotlin
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.*
import okhttp3.*
import okio.ByteString
import java.util.concurrent.TimeUnit

sealed interface WsEvent {
    data class Message(val text: String) : WsEvent
    data class BinaryMessage(val bytes: ByteString) : WsEvent
    data object Connected : WsEvent
    data object Disconnected : WsEvent
    data class Error(val throwable: Throwable) : WsEvent
}

class ReconnectingWebSocket(
    private val url: String,
    private val pingIntervalMs: Long = 30_000L,
    private val pongTimeoutMs: Long = 10_000L,
    private val backoffPolicy: BackoffPolicy = BackoffPolicy(),
    private val scope: CoroutineScope,
) {
    private val client = OkHttpClient.Builder()
        .pingInterval(pingIntervalMs, TimeUnit.MILLISECONDS)
        .readTimeout(pongTimeoutMs + 5_000L, TimeUnit.MILLISECONDS)
        .build()

    private val _events = MutableSharedFlow<WsEvent>(extraBufferCapacity = 64)
    val events: SharedFlow<WsEvent> = _events.asSharedFlow()

    private val outgoing = Channel<String>(capacity = Channel.BUFFERED)
    private val backoff = ExponentialBackoff(backoffPolicy)
    private val circuitBreaker = CircuitBreaker()
    private var currentSocket: WebSocket? = null
    private var connectionJob: Job? = null

    fun connect() {
        connectionJob?.cancel()
        connectionJob = scope.launch { connectLoop() }
    }

    fun disconnect() {
        connectionJob?.cancel()
        currentSocket?.close(1000, "Normal closure")
        currentSocket = null
    }

    fun send(message: String) {
        outgoing.trySend(message)
    }

    private suspend fun connectLoop() {
        while (isActive) {
            if (!circuitBreaker.isCallAllowed()) {
                delay(5_000L)
                continue
            }

            try {
                connectOnce()
                backoff.reset()
                circuitBreaker.recordSuccess()
            } catch (e: CancellationException) {
                throw e
            } catch (e: Exception) {
                circuitBreaker.recordFailure()
                if (!backoff.hasReachedMax) {
                    delay(backoff.nextDelayMs())
                }
            }
        }
    }

    private suspend fun connectOnce() = suspendCancellableCoroutine<Unit> { cont ->
        val request = Request.Builder().url(url).build()
        val socket = client.newWebSocket(request, object : WebSocketListener() {
            override fun onOpen(webSocket: WebSocket, response: Response) {
                _events.tryEmit(WsEvent.Connected)
                scope.launch {
                    for (msg in outgoing) {
                        webSocket.send(msg)
                    }
                }
            }

            override fun onMessage(webSocket: WebSocket, text: String) {
                _events.tryEmit(WsEvent.Message(text))
            }

            override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
                _events.tryEmit(WsEvent.BinaryMessage(bytes))
            }

            override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
                webSocket.close(1000, null)
            }

            override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
                _events.tryEmit(WsEvent.Disconnected)
                if (cont.isActive) cont.resumeWith(Result.success(Unit))
            }

            override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
                _events.tryEmit(WsEvent.Error(t))
                if (cont.isActive) cont.resumeWith(Result.failure(t))
            }
        })

        currentSocket = socket
        cont.invokeOnCancellation {
            socket.cancel()
            currentSocket = null
        }
    }
}
```

---

### LifecycleAwareReconnect.kt

```kotlin
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch

class LifecycleAwareReconnect(
    private val networkMonitor: NetworkStateMonitor,
    private val webSocket: ReconnectingWebSocket,
) : DefaultLifecycleObserver {

    private val lifecycleScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)

    override fun onStart(owner: LifecycleOwner) {
        webSocket.connect()
        observeNetwork()
    }

    override fun onStop(owner: LifecycleOwner) {
        webSocket.disconnect()
        lifecycleScope.coroutineContext[SupervisorJob()]?.children?.forEach { it.cancel() }
    }

    override fun onDestroy(owner: LifecycleOwner) {
        lifecycleScope.cancel()
    }

    private fun observeNetwork() {
        lifecycleScope.launch {
            networkMonitor.networkState.collect { state ->
                when (state) {
                    NetworkState.Available -> webSocket.connect()
                    NetworkState.Unavailable -> webSocket.disconnect()
                    NetworkState.Losing -> Unit
                }
            }
        }
    }
}

// Activity / Fragment での登録:
//
// private val reconnect by lazy {
//     LifecycleAwareReconnect(networkMonitor, webSocket)
// }
//
// override fun onCreate(savedInstanceState: Bundle?) {
//     super.onCreate(savedInstanceState)
//     lifecycle.addObserver(reconnect)
// }
```

---

### ReconnectViewModel.kt（ViewModel 統合例）

```kotlin
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

data class ConnectionUiState(
    val isConnected: Boolean = false,
    val isReconnecting: Boolean = false,
    val errorMessage: String? = null,
)

class ReconnectViewModel(
    private val networkMonitor: NetworkStateMonitor,
    private val webSocket: ReconnectingWebSocket,
) : ViewModel() {

    private val _uiState = MutableStateFlow(ConnectionUiState())
    val uiState: StateFlow<ConnectionUiState> = _uiState.asStateFlow()

    init {
        observeNetworkAndSocket()
    }

    private fun observeNetworkAndSocket() {
        viewModelScope.launch {
            combine(
                networkMonitor.networkState,
                webSocket.events,
            ) { net, event -> net to event }
                .collect { (net, event) ->
                    _uiState.update { current ->
                        when {
                            event is WsEvent.Connected ->
                                current.copy(isConnected = true, isReconnecting = false, errorMessage = null)
                            event is WsEvent.Disconnected && net == NetworkState.Available ->
                                current.copy(isConnected = false, isReconnecting = true)
                            event is WsEvent.Disconnected ->
                                current.copy(isConnected = false, isReconnecting = false)
                            event is WsEvent.Error ->
                                current.copy(isConnected = false, isReconnecting = true, errorMessage = event.throwable.message)
                            else -> current
                        }
                    }
                }
        }
    }
}
```

---

### NetworkResilienceTest.kt（Instrumented テスト）

```kotlin
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.SocketPolicy
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class NetworkResilienceTest {

    private lateinit var server: MockWebServer
    private val context = InstrumentationRegistry.getInstrumentation().targetContext

    @Before
    fun setUp() {
        server = MockWebServer()
        server.start()
    }

    @After
    fun tearDown() {
        server.shutdown()
    }

    @Test
    fun reconnectsAfterServerDisconnect() = runTest {
        // 1回目: 接続後即切断
        server.enqueue(MockResponse().withWebSocketUpgrade(object : okhttp3.mockwebserver.WebSocketListener() {
            override fun onOpen(webSocket: okhttp3.WebSocket, response: okhttp3.Response) {
                webSocket.close(1001, "Server going away")
            }
        }))
        // 2回目: 正常接続
        server.enqueue(MockResponse().withWebSocketUpgrade(object : okhttp3.mockwebserver.WebSocketListener() {
            override fun onOpen(webSocket: okhttp3.WebSocket, response: okhttp3.Response) {
                // 接続維持
            }
        }))

        val scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO)
        val ws = ReconnectingWebSocket(
            url = server.url("/ws").toString(),
            pingIntervalMs = 5_000L,
            backoffPolicy = BackoffPolicy(baseDelayMs = 100L, maxDelayMs = 500L),
            scope = scope,
        )
        ws.connect()

        // 再接続イベントの確認
        val connected = ws.events
            .filterIsInstance<WsEvent.Connected>()
            .take(2)  // 2回接続されることを確認
            .toList()

        assert(connected.size == 2)
        scope.cancel()
    }
}
```

---

## iOS Swift スニペット

### NetworkMonitor.swift

```swift
import Network
import Combine

enum NetworkStatus: Equatable {
    case available(isExpensive: Bool, isConstrained: Bool)
    case unavailable
}

final class NetworkMonitor {

    private let monitor: NWPathMonitor
    private let queue = DispatchQueue(label: "com.app.network-monitor", qos: .utility)

    private let statusSubject = CurrentValueSubject<NetworkStatus, Never>(.unavailable)
    var statusPublisher: AnyPublisher<NetworkStatus, Never> {
        statusSubject
            .removeDuplicates()
            .eraseToAnyPublisher()
    }

    var currentStatus: NetworkStatus { statusSubject.value }

    init(requiredInterface: NWInterface.InterfaceType? = nil) {
        monitor = requiredInterface.map { NWPathMonitor(requiredInterfaceType: $0) }
            ?? NWPathMonitor()
    }

    func start() {
        monitor.pathUpdateHandler = { [weak self] path in
            guard let self else { return }
            let status: NetworkStatus
            if path.status == .satisfied {
                status = .available(
                    isExpensive: path.isExpensive,
                    isConstrained: path.isConstrained
                )
            } else {
                status = .unavailable
            }
            self.statusSubject.send(status)
        }
        monitor.start(queue: queue)
    }

    func stop() {
        monitor.cancel()
    }

    deinit { stop() }
}
```

---

### ExponentialBackoff.swift

```swift
import Foundation

struct BackoffPolicy {
    let baseDelaySeconds: TimeInterval
    let multiplier: Double
    let maxDelaySeconds: TimeInterval
    let jitterFactor: Double
    let maxAttempts: Int

    init(
        baseDelaySeconds: TimeInterval = 1.0,
        multiplier: Double = 2.0,
        maxDelaySeconds: TimeInterval = 60.0,
        jitterFactor: Double = 0.3,
        maxAttempts: Int = .max
    ) {
        self.baseDelaySeconds = baseDelaySeconds
        self.multiplier = multiplier
        self.maxDelaySeconds = maxDelaySeconds
        self.jitterFactor = jitterFactor
        self.maxAttempts = maxAttempts
    }
}

final class ExponentialBackoff {
    private let policy: BackoffPolicy
    private var attemptCount: Int = 0

    var hasReachedMax: Bool { attemptCount >= policy.maxAttempts }

    init(policy: BackoffPolicy = BackoffPolicy()) {
        self.policy = policy
    }

    func nextDelaySeconds() -> TimeInterval {
        let exponential = policy.baseDelaySeconds * pow(policy.multiplier, Double(attemptCount))
        let capped = min(exponential, policy.maxDelaySeconds)
        let jitter = Double.random(in: 0.0...1.0) * capped * policy.jitterFactor
        attemptCount += 1
        return min(capped + jitter, policy.maxDelaySeconds)
    }

    func reset() {
        attemptCount = 0
    }
}
```

---

### ReconnectingWebSocketClient.swift

```swift
import Foundation
import Combine

enum WebSocketEvent {
    case connected
    case message(String)
    case disconnected
    case error(Error)
}

final class ReconnectingWebSocketClient {
    private let url: URL
    private let pingInterval: TimeInterval
    private let backoff: ExponentialBackoff
    private let networkMonitor: NetworkMonitor

    private var webSocketTask: URLSessionWebSocketTask?
    private var pingTimer: Timer?
    private var reconnectTask: Task<Void, Never>?
    private var cancellables = Set<AnyCancellable>()

    private let eventsSubject = PassthroughSubject<WebSocketEvent, Never>()
    var events: AnyPublisher<WebSocketEvent, Never> { eventsSubject.eraseToAnyPublisher() }

    init(
        url: URL,
        pingInterval: TimeInterval = 30,
        backoffPolicy: BackoffPolicy = BackoffPolicy(),
        networkMonitor: NetworkMonitor
    ) {
        self.url = url
        self.pingInterval = pingInterval
        self.backoff = ExponentialBackoff(policy: backoffPolicy)
        self.networkMonitor = networkMonitor
    }

    func connect() {
        observeNetwork()
        startConnection()
    }

    func disconnect() {
        reconnectTask?.cancel()
        stopPing()
        webSocketTask?.cancel(with: .normalClosure, reason: nil)
        webSocketTask = nil
    }

    func send(_ text: String) {
        webSocketTask?.send(.string(text)) { _ in }
    }

    private func startConnection() {
        reconnectTask = Task {
            while !Task.isCancelled {
                do {
                    try await connectOnce()
                    backoff.reset()
                } catch is CancellationError {
                    break
                } catch {
                    eventsSubject.send(.error(error))
                    let delay = backoff.nextDelaySeconds()
                    try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
                }
            }
        }
    }

    private func connectOnce() async throws {
        let session = URLSession(configuration: .default)
        let task = session.webSocketTask(with: url)
        webSocketTask = task
        task.resume()

        eventsSubject.send(.connected)
        startPing()

        try await receiveLoop(task: task)
    }

    private func receiveLoop(task: URLSessionWebSocketTask) async throws {
        while !Task.isCancelled {
            let message = try await task.receive()
            switch message {
            case .string(let text):
                eventsSubject.send(.message(text))
            case .data:
                break
            @unknown default:
                break
            }
        }
    }

    private func startPing() {
        stopPing()
        pingTimer = Timer.scheduledTimer(withTimeInterval: pingInterval, repeats: true) { [weak self] _ in
            self?.webSocketTask?.sendPing { _ in }
        }
    }

    private func stopPing() {
        pingTimer?.invalidate()
        pingTimer = nil
    }

    private func observeNetwork() {
        networkMonitor.statusPublisher
            .receive(on: DispatchQueue.main)
            .sink { [weak self] status in
                guard let self else { return }
                if case .available = status {
                    self.disconnect()
                    self.startConnection()
                }
            }
            .store(in: &cancellables)
        networkMonitor.start()
    }
}
```

---

## 使い方（Claude / Cowork）

```
/skill network-reconnect
```

または Cowork Dispatch に対して:

```
network-reconnect スキルを使って WebSocket の自動再接続を実装してください
```

---

## 参照

- [`docs/NETWORK_RESILIENCE.md`](../../../docs/NETWORK_RESILIENCE.md) — 設計方針・テスト戦略の詳細
- [Android ConnectivityManager docs](https://developer.android.com/reference/android/net/ConnectivityManager.NetworkCallback)
- [Apple NWPathMonitor docs](https://developer.apple.com/documentation/network/nwpathmonitor)
- RFC 6455 — The WebSocket Protocol
