diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ea0fa78e..258d04db 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ android { applicationId = "dev.typetype.android" minSdk = 23 targetSdk = 37 - versionCode = 11001 - versionName = "1.10.0-beta.1" + versionCode = 11002 + versionName = "1.10.0-beta.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } diff --git a/app/src/main/java/dev/typetype/android/services/PlaybackNetworkRecoveryGate.kt b/app/src/main/java/dev/typetype/android/services/PlaybackNetworkRecoveryGate.kt index a683c784..de76d1a2 100644 --- a/app/src/main/java/dev/typetype/android/services/PlaybackNetworkRecoveryGate.kt +++ b/app/src/main/java/dev/typetype/android/services/PlaybackNetworkRecoveryGate.kt @@ -6,6 +6,11 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.HttpDataSource import dev.typetype.android.core.error.CodedFailure import dev.typetype.android.data.network.PlaybackNetworkState +import java.net.ConnectException +import java.net.NoRouteToHostException +import java.net.SocketException +import java.net.SocketTimeoutException +import java.net.UnknownHostException internal sealed interface PlaybackNetworkRecoveryAction { data object Wait : PlaybackNetworkRecoveryAction @@ -41,9 +46,9 @@ internal class PlaybackNetworkRecoveryGate { attempt = 0 } if (!network.isAvailable) return PlaybackNetworkRecoveryAction.Wait - val delayMs = RETRY_DELAYS_MS.getOrNull(attempt) - ?: return PlaybackNetworkRecoveryAction.Wait - attempt++ + val retryIndex = attempt.coerceAtMost(RETRY_DELAYS_MS.lastIndex) + val delayMs = RETRY_DELAYS_MS[retryIndex] + attempt = (retryIndex + 1).coerceAtMost(RETRY_DELAYS_MS.lastIndex) return PlaybackNetworkRecoveryAction.RetryAfter(delayMs) } @@ -115,6 +120,15 @@ private fun Throwable.hasHttpTransportFailure(): Boolean { ) { return true } + if ( + current is ConnectException || + current is NoRouteToHostException || + current is SocketException || + current is SocketTimeoutException || + current is UnknownHostException + ) { + return true + } current = current?.cause?.takeUnless { it === current } } return false diff --git a/app/src/test/java/dev/typetype/android/services/PlaybackNetworkRecoveryGateTest.kt b/app/src/test/java/dev/typetype/android/services/PlaybackNetworkRecoveryGateTest.kt index 95230895..2ec6e333 100644 --- a/app/src/test/java/dev/typetype/android/services/PlaybackNetworkRecoveryGateTest.kt +++ b/app/src/test/java/dev/typetype/android/services/PlaybackNetworkRecoveryGateTest.kt @@ -4,6 +4,7 @@ import androidx.media3.common.PlaybackException import dev.typetype.android.core.error.CodedFailure import dev.typetype.android.data.network.PlaybackNetworkState import java.io.IOException +import java.net.ConnectException import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -36,17 +37,20 @@ class PlaybackNetworkRecoveryGateTest { } @Test - fun `same route recovery uses a bounded progressive schedule`() { + fun `same route recovery keeps retrying at a capped interval`() { val gate = PlaybackNetworkRecoveryGate() val network = PlaybackNetworkState(true, 3L) val expectedDelays = listOf(1_000L, 3_000L, 10_000L, 30_000L) + - List(4) { 60_000L } + List(8) { 60_000L } - val actions = expectedDelays.map { gate.failed("video", network) } - val exhausted = gate.failed("video", network) + val actions = List(expectedDelays.size) { gate.failed("video", network) } + val cappedRetries = List(3) { gate.failed("video", network) } assertEquals(expectedDelays.map(PlaybackNetworkRecoveryAction::RetryAfter), actions) - assertEquals(PlaybackNetworkRecoveryAction.Wait, exhausted) + assertEquals( + List(3) { PlaybackNetworkRecoveryAction.RetryAfter(60_000L) }, + cappedRetries, + ) } @Test @@ -119,4 +123,14 @@ class PlaybackNetworkRecoveryGateTest { assertTrue(isNetworkFailure) } + + @Test + fun `raw connection refusal with unspecified code is classified as network interruption`() { + val isNetworkFailure = isNetworkPlaybackFailure( + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + ConnectException("Connection refused"), + ) + + assertTrue(isNetworkFailure) + } } diff --git a/release-notes/v1.10.0-beta.2.md b/release-notes/v1.10.0-beta.2.md new file mode 100644 index 00000000..e7f42e47 --- /dev/null +++ b/release-notes/v1.10.0-beta.2.md @@ -0,0 +1,7 @@ +TypeType Android 1.10.0-beta.2 + +- Keep playback retrying during temporary network or server outages, then resume + automatically when the instance becomes reachable again. +- Recognize raw socket connection failures as recoverable network interruptions. +- Continue retrying at a capped 60-second interval instead of stopping after a + fixed number of attempts.