From d0fe175bab2eecc2ac71bedc9052894290934d82 Mon Sep 17 00:00:00 2001 From: Jonathan Klee Date: Mon, 16 Mar 2026 14:25:26 +0100 Subject: [PATCH 1/5] fix: consider InterruptedIOException as Timeout also --- .../foundation/e/apps/data/NetworkHandler.kt | 22 ++++++++++++ .../e/apps/data/NetworkHandlerTest.kt | 34 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/app/src/main/java/foundation/e/apps/data/NetworkHandler.kt b/app/src/main/java/foundation/e/apps/data/NetworkHandler.kt index 10bde5c52..906058708 100644 --- a/app/src/main/java/foundation/e/apps/data/NetworkHandler.kt +++ b/app/src/main/java/foundation/e/apps/data/NetworkHandler.kt @@ -27,9 +27,11 @@ import foundation.e.apps.data.playstore.utils.GplayHttpRequestException import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import timber.log.Timber +import java.io.InterruptedIOException import java.net.SocketTimeoutException private const val TIMEOUT = "Timeout" +private val TIMEOUT_MESSAGE_PATTERNS = listOf("timeout", "timed out") private const val UNKNOWN = "Unknown" private const val STATUS = "Status:" private const val ERROR_GPLAY_API = "Gplay api has faced error!" @@ -49,6 +51,8 @@ suspend fun handleNetworkResult(call: suspend () -> T): ResultSupreme { throw e } catch (e: SocketTimeoutException) { handleSocketTimeoutException(e) + } catch (e: InterruptedIOException) { + handleInterruptedIOException(e) } catch (e: GplayHttpRequestException) { resultSupremeGplayHttpRequestException(e) } catch (e: Exception) { @@ -63,6 +67,16 @@ private fun handleSocketTimeoutException(e: SocketTimeoutException): ResultS return resultTimeout } +private fun handleInterruptedIOException(e: InterruptedIOException): ResultSupreme { + return if (isTimeoutInterruptedIOException(e)) { + val resultTimeout = ResultSupreme.Timeout(exception = e) + resultTimeout.message = extractErrorMessage(e) + resultTimeout + } else { + handleOthersException(e) + } +} + private fun resultSupremeGplayHttpRequestException(e: GplayHttpRequestException): ResultSupreme { val message = extractErrorMessage(e) val exception = GPlayException(e.status == GPlayHttpClient.STATUS_CODE_TIMEOUT, message) @@ -83,12 +97,20 @@ private fun extractErrorMessage(e: Exception): String { val status = when (e) { is GplayHttpRequestException -> e.status.toString() is SocketTimeoutException -> TIMEOUT + is InterruptedIOException -> if (isTimeoutInterruptedIOException(e)) TIMEOUT else UNKNOWN else -> UNKNOWN } return (e.localizedMessage?.ifBlank { ERROR_GPLAY_API } ?: ERROR_GPLAY_API) + " $STATUS $status" } +private fun isTimeoutInterruptedIOException(e: InterruptedIOException): Boolean { + val message = e.message ?: return true + return TIMEOUT_MESSAGE_PATTERNS.any { pattern -> + message.contains(pattern, ignoreCase = true) + } +} + suspend fun retryWithBackoff(retryDelayInSeconds: Int = -1, operation: suspend () -> T): T? { var result: T? = null try { diff --git a/app/src/test/java/foundation/e/apps/data/NetworkHandlerTest.kt b/app/src/test/java/foundation/e/apps/data/NetworkHandlerTest.kt index 8e0b9f0d0..931370a86 100644 --- a/app/src/test/java/foundation/e/apps/data/NetworkHandlerTest.kt +++ b/app/src/test/java/foundation/e/apps/data/NetworkHandlerTest.kt @@ -8,6 +8,7 @@ import foundation.e.apps.data.playstore.utils.GplayHttpRequestException import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Test +import java.io.InterruptedIOException import java.net.SocketTimeoutException @OptIn(ExperimentalCoroutinesApi::class) @@ -33,6 +34,39 @@ class NetworkHandlerTest { assertThat(result.exception).isSameInstanceAs(timeout) } + @Test + fun handleNetworkResult_convertsInterruptedTimeout() = runTest { + val timeout = InterruptedIOException("timeout") + + val result = handleNetworkResult { throw timeout } + + assertThat(result.isTimeout()).isTrue() + assertThat(result.message).isEqualTo("timeout Status: Timeout") + assertThat(result.exception).isSameInstanceAs(timeout) + } + + @Test + fun handleNetworkResult_convertsInterruptedTimedOutMessage() = runTest { + val timeout = InterruptedIOException("Read timed out") + + val result = handleNetworkResult { throw timeout } + + assertThat(result.isTimeout()).isTrue() + assertThat(result.message).isEqualTo("Read timed out Status: Timeout") + assertThat(result.exception).isSameInstanceAs(timeout) + } + + @Test + fun handleNetworkResult_mapsNonTimeoutInterruptedIoExceptionToError() = runTest { + val interrupted = InterruptedIOException("stream closed") + + val result = handleNetworkResult { throw interrupted } + + assertThat(result.isUnknownError()).isTrue() + assertThat(result.message).isEqualTo("stream closed Status: Unknown") + assertThat(result.exception).isSameInstanceAs(interrupted) + } + @Test fun handleNetworkResult_mapsHttp429ToError() = runTest { val exception = GplayHttpRequestException( -- GitLab From 41994fa2f60a4a3d1fd6bbdb881a5df4cc695542 Mon Sep 17 00:00:00 2001 From: Jonathan Klee Date: Mon, 16 Mar 2026 14:33:41 +0100 Subject: [PATCH 2/5] fix: prevent potential fdroid metadata request IOException crash --- .../e/apps/data/fdroid/FDroidRepository.kt | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/data/fdroid/FDroidRepository.kt b/app/src/main/java/foundation/e/apps/data/fdroid/FDroidRepository.kt index 8ddfc712f..bdbd05cd5 100644 --- a/app/src/main/java/foundation/e/apps/data/fdroid/FDroidRepository.kt +++ b/app/src/main/java/foundation/e/apps/data/fdroid/FDroidRepository.kt @@ -5,7 +5,12 @@ import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.cleanapk.ApkSignatureManager import foundation.e.apps.data.enums.Source import foundation.e.apps.data.fdroid.models.BuildInfo +import foundation.e.apps.data.fdroid.models.FdroidApiModel import foundation.e.apps.data.fdroid.models.FdroidEntity +import kotlinx.coroutines.CancellationException +import retrofit2.Response +import timber.log.Timber +import java.io.IOException import javax.inject.Inject import javax.inject.Singleton @@ -29,7 +34,7 @@ class FDroidRepository @Inject constructor( */ override suspend fun getFdroidInfo(packageName: String): FdroidEntity? { return fdroidDao.getFdroidEntityFromPackageName(packageName) - ?: fdroidApi.getFdroidInfoForPackage(packageName).body()?.let { + ?: getFdroidApiResponse(packageName)?.body()?.let { FdroidEntity(packageName, it.authorName).also { fdroidDao.saveFdroidEntity(it) } @@ -37,7 +42,7 @@ class FDroidRepository @Inject constructor( } suspend fun getBuildVersionInfo(packageName: String): List? { - return fdroidApi.getFdroidInfoForPackage(packageName).body()?.builds + return getFdroidApiResponse(packageName)?.body()?.builds } override suspend fun getAuthorName(application: Application): String { @@ -69,6 +74,17 @@ class FDroidRepository @Inject constructor( } override suspend fun isFdroidApplication(packageName: String): Boolean { - return fdroidApi.getFdroidInfoForPackage(packageName).isSuccessful + return getFdroidApiResponse(packageName)?.isSuccessful == true + } + + private suspend fun getFdroidApiResponse(packageName: String): Response? { + return try { + fdroidApi.getFdroidInfoForPackage(packageName) + } catch (exception: CancellationException) { + throw exception + } catch (exception: IOException) { + Timber.w(exception, "Failed to fetch F-Droid metadata for %s", packageName) + null + } } } -- GitLab From bbe45f2af01aae73f9b0285a642d1090d2d83df7 Mon Sep 17 00:00:00 2001 From: Jonathan Klee Date: Mon, 16 Mar 2026 20:08:23 +0100 Subject: [PATCH 3/5] tests: add FDroidRepository unit tests --- .../apps/data/fdroid/FDroidRepositoryTest.kt | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 app/src/test/java/foundation/e/apps/data/fdroid/FDroidRepositoryTest.kt diff --git a/app/src/test/java/foundation/e/apps/data/fdroid/FDroidRepositoryTest.kt b/app/src/test/java/foundation/e/apps/data/fdroid/FDroidRepositoryTest.kt new file mode 100644 index 000000000..0e1a2721d --- /dev/null +++ b/app/src/test/java/foundation/e/apps/data/fdroid/FDroidRepositoryTest.kt @@ -0,0 +1,168 @@ +package foundation.e.apps.data.fdroid + +import com.google.common.truth.Truth.assertThat +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.fdroid.models.BuildInfo +import foundation.e.apps.data.fdroid.models.FdroidApiModel +import foundation.e.apps.data.fdroid.models.FdroidEntity +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test +import retrofit2.Response +import java.io.InterruptedIOException +import kotlin.test.assertFailsWith + +@OptIn(ExperimentalCoroutinesApi::class) +class FDroidRepositoryTest { + + private val fdroidApi = mockk() + private val fdroidDao = mockk(relaxed = true) + private val repository = FDroidRepository(fdroidApi, fdroidDao) + + @Test + fun getFdroidInfo_returnsDaoEntityWithoutCallingApi() = runTest { + val entity = FdroidEntity("org.example.app", "DAO Author") + coEvery { fdroidDao.getFdroidEntityFromPackageName(entity.packageName) } returns entity + + val result = repository.getFdroidInfo(entity.packageName) + + assertThat(result).isSameInstanceAs(entity) + coVerify(exactly = 1) { fdroidDao.getFdroidEntityFromPackageName(entity.packageName) } + coVerify(exactly = 0) { fdroidApi.getFdroidInfoForPackage(any()) } + coVerify(exactly = 0) { fdroidDao.saveFdroidEntity(any()) } + } + + @Test + fun getFdroidInfo_fetchesFromApiAndPersistsEntity() = runTest { + val packageName = "org.example.app" + coEvery { fdroidDao.getFdroidEntityFromPackageName(packageName) } returns null + coEvery { fdroidApi.getFdroidInfoForPackage(packageName) } returns + Response.success(FdroidApiModel("API Author", emptyList())) + + val result = repository.getFdroidInfo(packageName) + + assertThat(result?.packageName).isEqualTo(packageName) + assertThat(result?.authorName).isEqualTo("API Author") + coVerify(exactly = 1) { fdroidApi.getFdroidInfoForPackage(packageName) } + coVerify(exactly = 1) { + fdroidDao.saveFdroidEntity(match { + it.packageName == packageName && it.authorName == "API Author" + }) + } + } + + @Test + fun getAuthorName_returnsDefaultWhenFdroidRequestFails() = runTest { + val application = Application( + author = FDroidRepository.UNKNOWN, + package_name = "org.example.app", + source = Source.OPEN_SOURCE, + ) + coEvery { fdroidDao.getFdroidEntityFromPackageName(application.package_name) } returns null + coEvery { fdroidApi.getFdroidInfoForPackage(application.package_name) } throws + InterruptedIOException("timeout") + + val authorName = repository.getAuthorName(application) + + assertThat(authorName).isEqualTo(FdroidEntity.DEFAULT_FDROID_AUTHOR_NAME) + } + + @Test + fun getAuthorName_returnsUnknownForPlayStoreAppsWithoutLookup() = runTest { + val application = Application( + author = "", + package_name = "org.example.play", + source = Source.PLAY_STORE, + ) + + val authorName = repository.getAuthorName(application) + + assertThat(authorName).isEqualTo(FDroidRepository.UNKNOWN) + coVerify(exactly = 0) { fdroidDao.getFdroidEntityFromPackageName(any()) } + coVerify(exactly = 0) { fdroidApi.getFdroidInfoForPackage(any()) } + } + + @Test + fun getAuthorName_usesInMemoryCacheAfterFirstLookup() = runTest { + val application = Application( + author = FDroidRepository.UNKNOWN, + package_name = "org.example.cached", + source = Source.OPEN_SOURCE, + ) + coEvery { fdroidDao.getFdroidEntityFromPackageName(application.package_name) } returns null + coEvery { fdroidApi.getFdroidInfoForPackage(application.package_name) } returns + Response.success(FdroidApiModel("Cached Author", emptyList())) + + val firstAuthorName = repository.getAuthorName(application) + val secondAuthorName = repository.getAuthorName(application) + + assertThat(firstAuthorName).isEqualTo("Cached Author") + assertThat(secondAuthorName).isEqualTo("Cached Author") + coVerify(exactly = 1) { fdroidDao.getFdroidEntityFromPackageName(application.package_name) } + coVerify(exactly = 1) { fdroidApi.getFdroidInfoForPackage(application.package_name) } + coVerify(exactly = 1) { fdroidDao.saveFdroidEntity(any()) } + } + + @Test + fun isFdroidApplication_returnsFalseWhenRequestFails() = runTest { + coEvery { fdroidApi.getFdroidInfoForPackage("org.example.app") } throws + InterruptedIOException("timeout") + + val isFdroidApplication = repository.isFdroidApplication("org.example.app") + + assertThat(isFdroidApplication).isFalse() + } + + @Test + fun isFdroidApplication_returnsTrueForSuccessfulResponse() = runTest { + coEvery { fdroidApi.getFdroidInfoForPackage("org.example.app") } returns + Response.success(FdroidApiModel("Author", emptyList())) + + val isFdroidApplication = repository.isFdroidApplication("org.example.app") + + assertThat(isFdroidApplication).isTrue() + } + + @Test + fun getBuildVersionInfo_returnsNullWhenRequestFails() = runTest { + coEvery { fdroidApi.getFdroidInfoForPackage("org.example.app") } throws + InterruptedIOException("timeout") + + val buildVersionInfo = repository.getBuildVersionInfo("org.example.app") + + assertThat(buildVersionInfo).isNull() + } + + @Test + fun getBuildVersionInfo_returnsBuildsFromApi() = runTest { + val builds = listOf( + BuildInfo("123", "1.2.3"), + BuildInfo("124", "1.2.4"), + ) + coEvery { fdroidApi.getFdroidInfoForPackage("org.example.app") } returns + Response.success(FdroidApiModel("Author", builds)) + + val buildVersionInfo = repository.getBuildVersionInfo("org.example.app") + + assertThat(buildVersionInfo).hasSize(2) + assertThat(buildVersionInfo?.map { it.versionCode }).containsExactly("123", "124").inOrder() + assertThat(buildVersionInfo?.map { it.versionName }).containsExactly("1.2.3", "1.2.4").inOrder() + } + + @Test + fun getBuildVersionInfo_rethrowsCancellationException() = runTest { + coEvery { fdroidApi.getFdroidInfoForPackage("org.example.app") } throws + CancellationException("cancelled") + + val exception = assertFailsWith { + repository.getBuildVersionInfo("org.example.app") + } + + assertThat(exception).hasMessageThat().contains("cancelled") + } +} -- GitLab From bb05b15505d02a0b6fb948947a47831521d27c0b Mon Sep 17 00:00:00 2001 From: Jonathan Klee Date: Tue, 17 Mar 2026 10:59:04 +0100 Subject: [PATCH 4/5] refactor: simplify fdroid retrofit API contract --- .../main/java/foundation/e/apps/data/fdroid/FDroidRepository.kt | 2 +- .../java/foundation/e/apps/data/fdroid/FdroidApiInterface.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/data/fdroid/FDroidRepository.kt b/app/src/main/java/foundation/e/apps/data/fdroid/FDroidRepository.kt index bdbd05cd5..b21f39a26 100644 --- a/app/src/main/java/foundation/e/apps/data/fdroid/FDroidRepository.kt +++ b/app/src/main/java/foundation/e/apps/data/fdroid/FDroidRepository.kt @@ -77,7 +77,7 @@ class FDroidRepository @Inject constructor( return getFdroidApiResponse(packageName)?.isSuccessful == true } - private suspend fun getFdroidApiResponse(packageName: String): Response? { + private suspend fun getFdroidApiResponse(packageName: String): Response? { return try { fdroidApi.getFdroidInfoForPackage(packageName) } catch (exception: CancellationException) { diff --git a/app/src/main/java/foundation/e/apps/data/fdroid/FdroidApiInterface.kt b/app/src/main/java/foundation/e/apps/data/fdroid/FdroidApiInterface.kt index 32c695369..fbaebbb82 100644 --- a/app/src/main/java/foundation/e/apps/data/fdroid/FdroidApiInterface.kt +++ b/app/src/main/java/foundation/e/apps/data/fdroid/FdroidApiInterface.kt @@ -34,5 +34,5 @@ interface FdroidApiInterface { } @GET("{packageName}.yml") - suspend fun getFdroidInfoForPackage(@Path("packageName") packageName: String): Response + suspend fun getFdroidInfoForPackage(@Path("packageName") packageName: String): Response } -- GitLab From 01f507901abe0bb269cfc145ae07fcb6d2fd170e Mon Sep 17 00:00:00 2001 From: Jonathan Klee Date: Tue, 17 Mar 2026 11:31:23 +0100 Subject: [PATCH 5/5] refactor: sanitize getBuildVersionInfo() prototype returning non-nullable --- .../foundation/e/apps/data/fdroid/FDroidRepository.kt | 4 ++-- .../foundation/e/apps/data/updates/UpdatesManagerImpl.kt | 2 +- .../foundation/e/apps/data/fdroid/FDroidRepositoryTest.kt | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/data/fdroid/FDroidRepository.kt b/app/src/main/java/foundation/e/apps/data/fdroid/FDroidRepository.kt index b21f39a26..a9821c1a8 100644 --- a/app/src/main/java/foundation/e/apps/data/fdroid/FDroidRepository.kt +++ b/app/src/main/java/foundation/e/apps/data/fdroid/FDroidRepository.kt @@ -41,8 +41,8 @@ class FDroidRepository @Inject constructor( } } - suspend fun getBuildVersionInfo(packageName: String): List? { - return getFdroidApiResponse(packageName)?.body()?.builds + suspend fun getBuildVersionInfo(packageName: String): List { + return getFdroidApiResponse(packageName)?.body()?.builds ?: emptyList() } override suspend fun getAuthorName(application: Application): String { diff --git a/app/src/main/java/foundation/e/apps/data/updates/UpdatesManagerImpl.kt b/app/src/main/java/foundation/e/apps/data/updates/UpdatesManagerImpl.kt index fbd86faba..caf08f89c 100644 --- a/app/src/main/java/foundation/e/apps/data/updates/UpdatesManagerImpl.kt +++ b/app/src/main/java/foundation/e/apps/data/updates/UpdatesManagerImpl.kt @@ -389,7 +389,7 @@ class UpdatesManagerImpl @Inject constructor( // Received list has build info of the latest version at the bottom. // We want it at the top. val builds = handleNetworkResult { - fDroidRepository.getBuildVersionInfo(packageName)?.asReversed() ?: listOf() + fDroidRepository.getBuildVersionInfo(packageName).asReversed() }.data val matchingIndex = builds?.find { diff --git a/app/src/test/java/foundation/e/apps/data/fdroid/FDroidRepositoryTest.kt b/app/src/test/java/foundation/e/apps/data/fdroid/FDroidRepositoryTest.kt index 0e1a2721d..e95441f90 100644 --- a/app/src/test/java/foundation/e/apps/data/fdroid/FDroidRepositoryTest.kt +++ b/app/src/test/java/foundation/e/apps/data/fdroid/FDroidRepositoryTest.kt @@ -129,13 +129,13 @@ class FDroidRepositoryTest { } @Test - fun getBuildVersionInfo_returnsNullWhenRequestFails() = runTest { + fun getBuildVersionInfo_returnsEmptyListWhenRequestFails() = runTest { coEvery { fdroidApi.getFdroidInfoForPackage("org.example.app") } throws InterruptedIOException("timeout") val buildVersionInfo = repository.getBuildVersionInfo("org.example.app") - assertThat(buildVersionInfo).isNull() + assertThat(buildVersionInfo).isEmpty() } @Test @@ -150,8 +150,8 @@ class FDroidRepositoryTest { val buildVersionInfo = repository.getBuildVersionInfo("org.example.app") assertThat(buildVersionInfo).hasSize(2) - assertThat(buildVersionInfo?.map { it.versionCode }).containsExactly("123", "124").inOrder() - assertThat(buildVersionInfo?.map { it.versionName }).containsExactly("1.2.3", "1.2.4").inOrder() + assertThat(buildVersionInfo.map { it.versionCode }).containsExactly("123", "124").inOrder() + assertThat(buildVersionInfo.map { it.versionName }).containsExactly("1.2.3", "1.2.4").inOrder() } @Test -- GitLab