From 447fde5ded470f4faf5aed199bccc8e0323882b7 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Fri, 8 May 2026 17:14:31 -0600 Subject: [PATCH 1/4] Introduce ApiConfig to consolidate API configuration Co-Authored-By: Claude Sonnet 4.6 --- .../kotlin/org/cru/godtools/dagger/ConfigModule.kt | 8 ++++---- .../src/main/kotlin/org/cru/godtools/api/ApiConfig.kt | 5 +++++ .../src/main/kotlin/org/cru/godtools/api/ApiModule.kt | 11 ++++++----- 3 files changed, 15 insertions(+), 9 deletions(-) create mode 100644 library/api/src/main/kotlin/org/cru/godtools/api/ApiConfig.kt diff --git a/app/src/main/kotlin/org/cru/godtools/dagger/ConfigModule.kt b/app/src/main/kotlin/org/cru/godtools/dagger/ConfigModule.kt index c274f4dbb9..a5073f7795 100644 --- a/app/src/main/kotlin/org/cru/godtools/dagger/ConfigModule.kt +++ b/app/src/main/kotlin/org/cru/godtools/dagger/ConfigModule.kt @@ -4,14 +4,14 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import javax.inject.Named import org.cru.godtools.BuildConfig -import org.cru.godtools.api.ApiModule +import org.cru.godtools.api.ApiConfig @Module @InstallIn(SingletonComponent::class) object ConfigModule { @get:Provides - @get:Named(ApiModule.MOBILE_CONTENT_API_URL) - val mobileContentApiBaseUrl = BuildConfig.MOBILE_CONTENT_API + val apiConfig = ApiConfig( + mobileContentApiUrl = BuildConfig.MOBILE_CONTENT_API + ) } diff --git a/library/api/src/main/kotlin/org/cru/godtools/api/ApiConfig.kt b/library/api/src/main/kotlin/org/cru/godtools/api/ApiConfig.kt new file mode 100644 index 0000000000..0d26d1d525 --- /dev/null +++ b/library/api/src/main/kotlin/org/cru/godtools/api/ApiConfig.kt @@ -0,0 +1,5 @@ +package org.cru.godtools.api + +data class ApiConfig( + val mobileContentApiUrl: String +) diff --git a/library/api/src/main/kotlin/org/cru/godtools/api/ApiModule.kt b/library/api/src/main/kotlin/org/cru/godtools/api/ApiModule.kt index e6860c4658..ee0222b874 100644 --- a/library/api/src/main/kotlin/org/cru/godtools/api/ApiModule.kt +++ b/library/api/src/main/kotlin/org/cru/godtools/api/ApiModule.kt @@ -79,7 +79,6 @@ object ApiModule { .build() // region mobile-content-api APIs - const val MOBILE_CONTENT_API_URL = "MOBILE_CONTENT_API_BASE_URL" private const val MOBILE_CONTENT_API = "MOBILE_CONTENT_API" private const val MOBILE_CONTENT_API_AUTHENTICATED = "MOBILE_CONTENT_API_AUTHENTICATED" @@ -87,11 +86,11 @@ object ApiModule { @Reusable @Named(MOBILE_CONTENT_API) fun mobileContentApiRetrofit( - @Named(MOBILE_CONTENT_API_URL) baseUrl: String, + apiConfig: ApiConfig, jsonApiConverter: JsonApiConverter, okhttp: OkHttpClient, ): Retrofit = Retrofit.Builder() - .baseUrl(baseUrl) + .baseUrl(apiConfig.mobileContentApiUrl) .addConverterFactory(LocaleConverterFactory) .addConverterFactory(JsonApiConverterFactory(jsonApiConverter)) .callFactory(okhttp) @@ -165,14 +164,16 @@ object ApiModule { @Provides @Reusable fun actionCableScarlet( - @Named(MOBILE_CONTENT_API_URL) baseUrl: String, + apiConfig: ApiConfig, app: Application, jsonApi: JsonApiConverter, okhttp: OkHttpClient, referenceLifecycle: ReferenceLifecycle, ) = Scarlet.Builder() .forceDefaultPlatform() - .webSocketFactory(okhttp.newWebSocketFactory(ActionCableRequestFactory("${baseUrl}cable"))) + .webSocketFactory( + okhttp.newWebSocketFactory(ActionCableRequestFactory("${apiConfig.mobileContentApiUrl}cable")) + ) .addMessageAdapterFactory( ActionCableMessageAdapterFactory.Builder() .addMessageAdapterFactory(JsonApiMessageAdapterFactory(jsonApi)) From 034914e03fbdc708b604bb79ceef95853b35ac3f Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Mon, 11 May 2026 10:39:22 -0600 Subject: [PATCH 2/4] Verify SHA-256 checksum and size when downloading translation files Co-Authored-By: Claude Sonnet 4.6 --- gradle/libs.versions.toml | 2 +- .../GodToolsDownloadManager.kt | 51 ++++++++++++++----- .../GodToolsDownloadManagerTest.kt | 7 ++- 3 files changed, 45 insertions(+), 15 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5a3cd7b556..8d546af9dc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ facebook = "18.2.3" facebook-flipper = "0.273.0" firebase-crashlytics = "20.0.6" firebase-perf = "22.0.5" -godtoolsShared = "1.3.3-SNAPSHOT" +godtoolsShared = "1.4.0-SNAPSHOT" gtoSupport = "4.5.1-SNAPSHOT" kotlin = "2.3.21" kotlinCoroutines = "1.11.0" diff --git a/library/download-manager/src/main/kotlin/org/cru/godtools/downloadmanager/GodToolsDownloadManager.kt b/library/download-manager/src/main/kotlin/org/cru/godtools/downloadmanager/GodToolsDownloadManager.kt index 719614a3a0..8df390bb7f 100644 --- a/library/download-manager/src/main/kotlin/org/cru/godtools/downloadmanager/GodToolsDownloadManager.kt +++ b/library/download-manager/src/main/kotlin/org/cru/godtools/downloadmanager/GodToolsDownloadManager.kt @@ -43,6 +43,10 @@ import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import okio.ByteString.Companion.decodeHex +import okio.HashingSource.Companion.sha256 +import okio.buffer +import okio.sink import org.ccci.gto.android.common.kotlin.coroutines.MutexMap import org.ccci.gto.android.common.kotlin.coroutines.ReadWriteMutex import org.ccci.gto.android.common.kotlin.coroutines.flow.EmptyStateFlow @@ -294,9 +298,10 @@ class GodToolsDownloadManager @VisibleForTesting internal constructor( val relatedFiles = manifest.relatedFiles val completedFiles = AtomicLong(0) val successful = coroutineScope { - relatedFiles.map { + relatedFiles.map { file -> async { - downloadTranslationFileIfNecessary(it).also { + val src = file.src ?: return@async true + downloadTranslationFileIfNecessary(src, sha256 = file.checksumSha256, size = file.size).also { do { val completed = completedFiles.get() updateProgress(key, completed + 1, relatedFiles.size.toLong()) @@ -309,22 +314,42 @@ class GodToolsDownloadManager @VisibleForTesting internal constructor( // record the translation as downloaded downloadedFilesRepository.insertOrIgnore(DownloadedTranslationFile(translation, manifestFileName)) - relatedFiles.forEach { downloadedFilesRepository.insertOrIgnore(DownloadedTranslationFile(translation, it)) } + relatedFiles.mapNotNull { it.src }.forEach { + downloadedFilesRepository.insertOrIgnore(DownloadedTranslationFile(translation, it)) + } translationsRepository.markTranslationDownloaded(translation.id, true) return true } - private suspend fun downloadTranslationFileIfNecessary(fileName: String): Boolean = filesMutex.withLock(fileName) { + private suspend fun downloadTranslationFileIfNecessary( + fileName: String, + sha256: String? = null, + size: Long? = null, + ): Boolean = filesMutex.withLock(fileName) { if (downloadedFilesRepository.findDownloadedFile(fileName) != null) return true - try { - val body = translationsApi.downloadFile(fileName).takeIf { it.isSuccessful }?.body() ?: return false - val downloadedFile = DownloadedFile(fileName) - withContext(Dispatchers.IO) { body.byteStream().copyTo(downloadedFile) } - downloadedFilesRepository.insertOrIgnore(downloadedFile) - return true - } catch (e: IOException) { - return false + + return withContext(Dispatchers.IO) { + try { + val body = translationsApi.downloadFile(fileName).takeIf { it.isSuccessful }?.body() + ?: return@withContext false + + val downloadedFile = DownloadedFile(fileName) + downloadedFile.bufferedSink().use { sink -> + body.source().use { source -> + val digest = sha256(source) + val bytesWritten = sink.writeAll(digest) + + if (size != null && size != bytesWritten) return@withContext false + if (sha256 != null && sha256.decodeHex() != digest.hash) return@withContext false + } + } + + downloadedFilesRepository.insertOrIgnore(downloadedFile) + true + } catch (_: IOException) { + false + } } } @@ -450,6 +475,8 @@ class GodToolsDownloadManager @VisibleForTesting internal constructor( } // endregion Cleanup + private suspend fun DownloadedFile.bufferedSink() = withContext(Dispatchers.IO) { getFile(fs).sink().buffer() } + private suspend fun InputStream.copyTo(file: DownloadedFile) = withContext(Dispatchers.IO) { file.getFile(fs).outputStream().use { copyTo(it) } } diff --git a/library/download-manager/src/test/kotlin/org/cru/godtools/downloadmanager/GodToolsDownloadManagerTest.kt b/library/download-manager/src/test/kotlin/org/cru/godtools/downloadmanager/GodToolsDownloadManagerTest.kt index b82982b017..5aaadeca60 100644 --- a/library/download-manager/src/test/kotlin/org/cru/godtools/downloadmanager/GodToolsDownloadManagerTest.kt +++ b/library/download-manager/src/test/kotlin/org/cru/godtools/downloadmanager/GodToolsDownloadManagerTest.kt @@ -60,6 +60,8 @@ import org.cru.godtools.model.randomTranslation import org.cru.godtools.shared.tool.parser.ManifestParser import org.cru.godtools.shared.tool.parser.ParserConfig import org.cru.godtools.shared.tool.parser.ParserResult +import org.cru.godtools.shared.tool.parser.model.Manifest +import org.cru.godtools.shared.tool.parser.model.Manifest.XmlFile import retrofit2.Response private const val TOOL = "tool" @@ -318,8 +320,9 @@ class GodToolsDownloadManagerTest { } returns translation coEvery { translationsRepository.markTranslationDownloaded(any(), any()) } just Runs val config = slot() - coEvery { manifestParser.parseManifest(translation.manifestFileName!!, capture(config)) } returns - ParserResult.Data(mockk { every { relatedFiles } returns setOf("a.txt", "b.txt") }) + val manifest = Manifest(pageXmlFiles = listOf(XmlFile("a.txt", "a.txt"), XmlFile("b.txt", "b.txt"))) + coEvery { manifestParser.parseManifest(translation.manifestFileName!!, capture(config)) } + .returns(ParserResult.Data(manifest)) coEvery { translationsApi.downloadFile(translation.manifestFileName!!) } returns Response.success(RealResponseBody(null, 0, Buffer().writeUtf8("manifest"))) coEvery { translationsApi.downloadFile("a.txt") } returns From f3ae27284f4d7e3f857719b0c59855a4843b72dd Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Mon, 11 May 2026 12:05:22 -0600 Subject: [PATCH 3/4] Introduce CdnApi and configure CDN base URL per flavor Co-Authored-By: Claude Sonnet 4.6 --- app/build.gradle.kts | 2 ++ .../kotlin/org/cru/godtools/dagger/ConfigModule.kt | 3 ++- .../main/kotlin/org/cru/godtools/api/ApiConfig.kt | 4 +--- .../main/kotlin/org/cru/godtools/api/ApiModule.kt | 9 +++++++++ .../src/main/kotlin/org/cru/godtools/api/CdnApi.kt | 13 +++++++++++++ 5 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 library/api/src/main/kotlin/org/cru/godtools/api/CdnApi.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ca5fa30b51..1112aab893 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -46,6 +46,7 @@ android { applicationIdSuffix = ".stage" buildConfigField("String", "MOBILE_CONTENT_API", "\"$URI_MOBILE_CONTENT_API_STAGE\"") + buildConfigField("String", "MOBILE_CONTENT_CDN", "\"https://mobilecontent-stage.cru.org\"") // Facebook resValue("string", "facebook_app_id", "448969905944197") @@ -61,6 +62,7 @@ android { } named("production") { buildConfigField("String", "MOBILE_CONTENT_API", "\"$URI_MOBILE_CONTENT_API_PRODUCTION\"") + buildConfigField("String", "MOBILE_CONTENT_CDN", "\"https://mobilecontent.cru.org\"") // Facebook resValue("string", "facebook_app_id", "2236701616451487") diff --git a/app/src/main/kotlin/org/cru/godtools/dagger/ConfigModule.kt b/app/src/main/kotlin/org/cru/godtools/dagger/ConfigModule.kt index a5073f7795..35f22f1484 100644 --- a/app/src/main/kotlin/org/cru/godtools/dagger/ConfigModule.kt +++ b/app/src/main/kotlin/org/cru/godtools/dagger/ConfigModule.kt @@ -12,6 +12,7 @@ import org.cru.godtools.api.ApiConfig object ConfigModule { @get:Provides val apiConfig = ApiConfig( - mobileContentApiUrl = BuildConfig.MOBILE_CONTENT_API + mobileContentApiUrl = BuildConfig.MOBILE_CONTENT_API, + cdnUrl = BuildConfig.MOBILE_CONTENT_CDN ) } diff --git a/library/api/src/main/kotlin/org/cru/godtools/api/ApiConfig.kt b/library/api/src/main/kotlin/org/cru/godtools/api/ApiConfig.kt index 0d26d1d525..283a7604bb 100644 --- a/library/api/src/main/kotlin/org/cru/godtools/api/ApiConfig.kt +++ b/library/api/src/main/kotlin/org/cru/godtools/api/ApiConfig.kt @@ -1,5 +1,3 @@ package org.cru.godtools.api -data class ApiConfig( - val mobileContentApiUrl: String -) +data class ApiConfig(val mobileContentApiUrl: String, val cdnUrl: String) diff --git a/library/api/src/main/kotlin/org/cru/godtools/api/ApiModule.kt b/library/api/src/main/kotlin/org/cru/godtools/api/ApiModule.kt index ee0222b874..854abd5074 100644 --- a/library/api/src/main/kotlin/org/cru/godtools/api/ApiModule.kt +++ b/library/api/src/main/kotlin/org/cru/godtools/api/ApiModule.kt @@ -196,4 +196,13 @@ object ApiModule { .callFactory(okhttp) .build().create() // endregion Adobe APIs + + // region CDN APIs + @Provides + @Reusable + fun cdnApi(okhttp: OkHttpClient, apiConfig: ApiConfig): CdnApi = Retrofit.Builder().baseUrl(apiConfig.cdnUrl) + .callFactory(okhttp) + .build() + .create() + // endregion CDN APIs } diff --git a/library/api/src/main/kotlin/org/cru/godtools/api/CdnApi.kt b/library/api/src/main/kotlin/org/cru/godtools/api/CdnApi.kt new file mode 100644 index 0000000000..5ddc218ce2 --- /dev/null +++ b/library/api/src/main/kotlin/org/cru/godtools/api/CdnApi.kt @@ -0,0 +1,13 @@ +package org.cru.godtools.api + +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Streaming + +interface CdnApi { + @Streaming + @GET("translations/files/{filename}") + suspend fun downloadPublishedFile(@Path("filename") name: String): Response +} From 3af7a56cab24401dbb94f79a43d801082aff42f9 Mon Sep 17 00:00:00 2001 From: Daniel Frett Date: Tue, 12 May 2026 14:04:01 -0600 Subject: [PATCH 4/4] Prefer CDN when downloading published translation files, falling back to API Co-Authored-By: Claude Sonnet 4.6 --- .../GodToolsDownloadManager.kt | 64 ++++++--- .../GodToolsDownloadManagerTest.kt | 127 +++++++++++++++++- 2 files changed, 169 insertions(+), 22 deletions(-) diff --git a/library/download-manager/src/main/kotlin/org/cru/godtools/downloadmanager/GodToolsDownloadManager.kt b/library/download-manager/src/main/kotlin/org/cru/godtools/downloadmanager/GodToolsDownloadManager.kt index 8df390bb7f..77f6138762 100644 --- a/library/download-manager/src/main/kotlin/org/cru/godtools/downloadmanager/GodToolsDownloadManager.kt +++ b/library/download-manager/src/main/kotlin/org/cru/godtools/downloadmanager/GodToolsDownloadManager.kt @@ -43,6 +43,7 @@ import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import okhttp3.ResponseBody import okio.ByteString.Companion.decodeHex import okio.HashingSource.Companion.sha256 import okio.buffer @@ -53,6 +54,7 @@ import org.ccci.gto.android.common.kotlin.coroutines.flow.EmptyStateFlow import org.ccci.gto.android.common.kotlin.coroutines.flow.combineTransformLatest import org.ccci.gto.android.common.kotlin.coroutines.withLock import org.cru.godtools.api.AttachmentsApi +import org.cru.godtools.api.CdnApi import org.cru.godtools.api.TranslationsApi import org.cru.godtools.base.Settings import org.cru.godtools.base.ToolFileSystem @@ -70,6 +72,7 @@ import org.cru.godtools.model.Translation import org.cru.godtools.model.TranslationKey import org.cru.godtools.shared.tool.parser.ManifestParser import org.cru.godtools.shared.tool.parser.ParserResult +import retrofit2.Response @VisibleForTesting internal const val CLEANUP_DELAY = 30_000L @@ -78,6 +81,7 @@ internal const val CLEANUP_DELAY = 30_000L class GodToolsDownloadManager @VisibleForTesting internal constructor( private val attachmentsApi: AttachmentsApi, private val attachmentsRepository: AttachmentsRepository, + private val cdnApi: CdnApi, private val downloadedFilesRepository: DownloadedFilesRepository, private val fs: ToolFileSystem, private val manifestParser: ManifestParser, @@ -91,6 +95,7 @@ class GodToolsDownloadManager @VisibleForTesting internal constructor( internal constructor( attachmentsApi: AttachmentsApi, attachmentsRepository: AttachmentsRepository, + cdnApi: CdnApi, downloadedFilesRepository: DownloadedFilesRepository, fs: ToolFileSystem, manifestParser: ManifestParser, @@ -100,6 +105,7 @@ class GodToolsDownloadManager @VisibleForTesting internal constructor( ) : this( attachmentsApi, attachmentsRepository, + cdnApi, downloadedFilesRepository, fs, manifestParser, @@ -284,7 +290,7 @@ class GodToolsDownloadManager @VisibleForTesting internal constructor( private suspend fun downloadTranslationFiles(translation: Translation): Boolean = filesystemMutex.read.withLock { // download manifest if necessary val manifestFileName = translation.manifestFileName ?: return false - if (!downloadTranslationFileIfNecessary(manifestFileName)) return false + if (!downloadPublishedFileIfNecessary(manifestFileName)) return false // parse manifest val parserResult = manifestParser.parseManifest( @@ -301,7 +307,11 @@ class GodToolsDownloadManager @VisibleForTesting internal constructor( relatedFiles.map { file -> async { val src = file.src ?: return@async true - downloadTranslationFileIfNecessary(src, sha256 = file.checksumSha256, size = file.size).also { + downloadPublishedFileIfNecessary( + fileName = src, + sha256 = file.checksumSha256, + size = file.size?.toLong() + ).also { do { val completed = completedFiles.get() updateProgress(key, completed + 1, relatedFiles.size.toLong()) @@ -322,35 +332,47 @@ class GodToolsDownloadManager @VisibleForTesting internal constructor( return true } - private suspend fun downloadTranslationFileIfNecessary( + private suspend fun downloadPublishedFileIfNecessary( fileName: String, sha256: String? = null, size: Long? = null, - ): Boolean = filesMutex.withLock(fileName) { + ): Boolean = filesMutex[fileName].withLock { if (downloadedFilesRepository.findDownloadedFile(fileName) != null) return true - return withContext(Dispatchers.IO) { - try { - val body = translationsApi.downloadFile(fileName).takeIf { it.isSuccessful }?.body() - ?: return@withContext false + withContext(ioDispatcher) { + downloadPublishedFileFromCdn(fileName, sha256, size) || + downloadPublishedFileFromApi(fileName, sha256, size) + } + } - val downloadedFile = DownloadedFile(fileName) - downloadedFile.bufferedSink().use { sink -> - body.source().use { source -> - val digest = sha256(source) - val bytesWritten = sink.writeAll(digest) + private suspend fun downloadPublishedFileFromCdn(fileName: String, sha256: String?, size: Long?) = try { + cdnApi.downloadPublishedFile(fileName).storeFile(fileName, sha256, size) + } catch (_: IOException) { + false + } - if (size != null && size != bytesWritten) return@withContext false - if (sha256 != null && sha256.decodeHex() != digest.hash) return@withContext false - } - } + private suspend fun downloadPublishedFileFromApi(fileName: String, sha256: String?, size: Long?) = try { + translationsApi.downloadFile(fileName).storeFile(fileName, sha256, size) + } catch (_: IOException) { + false + } + + private suspend fun Response.storeFile(fileName: String, sha256: String?, size: Long?): Boolean { + val body = body().takeIf { isSuccessful } ?: return false - downloadedFilesRepository.insertOrIgnore(downloadedFile) - true - } catch (_: IOException) { - false + val downloadedFile = DownloadedFile(fileName) + downloadedFile.bufferedSink().use { sink -> + body.source().use { source -> + val digest = sha256(source) + val bytesWritten = sink.writeAll(digest) + + if (size != null && size != bytesWritten) return false + if (sha256 != null && sha256.decodeHex() != digest.hash) return false } } + + downloadedFilesRepository.insertOrIgnore(downloadedFile) + return true } private suspend fun downloadTranslationZip(translation: Translation) = try { diff --git a/library/download-manager/src/test/kotlin/org/cru/godtools/downloadmanager/GodToolsDownloadManagerTest.kt b/library/download-manager/src/test/kotlin/org/cru/godtools/downloadmanager/GodToolsDownloadManagerTest.kt index 5aaadeca60..e7b5fa9120 100644 --- a/library/download-manager/src/test/kotlin/org/cru/godtools/downloadmanager/GodToolsDownloadManagerTest.kt +++ b/library/download-manager/src/test/kotlin/org/cru/godtools/downloadmanager/GodToolsDownloadManagerTest.kt @@ -41,11 +41,14 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest +import okhttp3.ResponseBody.Companion.toResponseBody import okhttp3.internal.http.RealResponseBody import okio.Buffer +import okio.ByteString.Companion.toByteString import okio.buffer import okio.source import org.cru.godtools.api.AttachmentsApi +import org.cru.godtools.api.CdnApi import org.cru.godtools.api.TranslationsApi import org.cru.godtools.base.ToolFileSystem import org.cru.godtools.db.repository.AttachmentsRepository @@ -55,6 +58,7 @@ import org.cru.godtools.downloadmanager.work.WORK_NAME_DOWNLOAD_ATTACHMENT import org.cru.godtools.model.Attachment import org.cru.godtools.model.DownloadedFile import org.cru.godtools.model.DownloadedTranslationFile +import org.cru.godtools.model.Translation import org.cru.godtools.model.TranslationKey import org.cru.godtools.model.randomTranslation import org.cru.godtools.shared.tool.parser.ManifestParser @@ -75,6 +79,9 @@ class GodToolsDownloadManagerTest { private val attachmentsApi = mockk() private val attachmentsRepository: AttachmentsRepository = mockk(relaxUnitFun = true) + private val cdnApi: CdnApi = mockk { + coEvery { downloadPublishedFile(any()) } returns Response.error(404, "".toResponseBody()) + } private val downloadedFilesRepository: DownloadedFilesRepository = mockk(relaxUnitFun = true) { coEvery { findDownloadedFile(any()) } returns null coEvery { getDownloadedFiles() } returns emptyList() @@ -90,9 +97,13 @@ class GodToolsDownloadManagerTest { every { defaultConfig } returns ParserConfig() excludeRecords { defaultConfig } } - private val translationsApi = mockk() + private val translationsApi: TranslationsApi = mockk { + coEvery { download(any()) } returns Response.error(404, "".toResponseBody()) + coEvery { downloadFile(any()) } returns Response.error(404, "".toResponseBody()) + } private val translationsRepository: TranslationsRepository = mockk { coEvery { markStaleTranslationsAsNotDownloaded() } returns false + coEvery { markTranslationDownloaded(any(), any()) } just Runs } private val workManager: WorkManager = mockk { every { enqueueUniqueWork(any(), any(), any()) } returns mockk() @@ -102,6 +113,7 @@ class GodToolsDownloadManagerTest { private val downloadManager = GodToolsDownloadManager( attachmentsApi, attachmentsRepository, + cdnApi = cdnApi, downloadedFilesRepository, fs, manifestParser, @@ -434,6 +446,119 @@ class GodToolsDownloadManagerTest { } // endregion downloadLatestPublishedTranslation() + // region downloadPublishedFileIfNecessary + private fun setupTranslationFilesDownload(translation: Translation, manifest: Manifest) { + coEvery { translationsRepository.findLatestTranslation(translation.toolCode, translation.languageCode) } + .returns(translation) + coEvery { manifestParser.parseManifest(translation.manifestFileName!!, any()) } + .returns(ParserResult.Data(manifest)) + coEvery { translationsApi.downloadFile(translation.manifestFileName!!) } + .returns(Response.success(RealResponseBody(null, 0, Buffer().writeUtf8("manifest")))) + } + + @Test + fun `downloadPublishedFileIfNecessary - prefers CDN over API`() = testScope.runTest { + downloadManager.cleanupActor.close() + val fileContent = "a".repeat(1024) + val translation = randomTranslation(manifestFileName = "manifest.xml", isDownloaded = false) + val manifest = Manifest(pageXmlFiles = listOf(XmlFile("file.xml", "file.xml"))) + setupTranslationFilesDownload(translation, manifest) + coEvery { cdnApi.downloadPublishedFile("file.xml") } returns Response.success(fileContent.toResponseBody()) + + assertTrue(downloadManager.downloadLatestPublishedTranslation(TranslationKey(translation))) + assertEquals(fileContent, files["file.xml"]!!.readText()) + coVerify { cdnApi.downloadPublishedFile("file.xml") } + coVerify(exactly = 0) { translationsApi.downloadFile("file.xml") } + } + + @Test + fun `downloadPublishedFileIfNecessary - falls back to API on CDN 404`() = testScope.runTest { + downloadManager.cleanupActor.close() + val fileContent = "a".repeat(1024) + val translation = randomTranslation(manifestFileName = "manifest.xml", isDownloaded = false) + val manifest = Manifest(pageXmlFiles = listOf(XmlFile("file.xml", "file.xml"))) + setupTranslationFilesDownload(translation, manifest) + coEvery { translationsApi.downloadFile("file.xml") } returns Response.success(fileContent.toResponseBody()) + + assertTrue(downloadManager.downloadLatestPublishedTranslation(TranslationKey(translation))) + assertEquals(fileContent, files["file.xml"]!!.readText()) + coVerify { cdnApi.downloadPublishedFile("file.xml") } + coVerify { translationsApi.downloadFile("file.xml") } + } + + @Test + fun `downloadPublishedFileIfNecessary - falls back to API on CDN IOException`() = testScope.runTest { + downloadManager.cleanupActor.close() + val fileContent = "a".repeat(1024) + val translation = randomTranslation(manifestFileName = "manifest.xml", isDownloaded = false) + val manifest = Manifest(pageXmlFiles = listOf(XmlFile("file.xml", "file.xml"))) + setupTranslationFilesDownload(translation, manifest) + coEvery { cdnApi.downloadPublishedFile("file.xml") } throws IOException() + coEvery { translationsApi.downloadFile("file.xml") } returns Response.success(fileContent.toResponseBody()) + + assertTrue(downloadManager.downloadLatestPublishedTranslation(TranslationKey(translation))) + assertEquals(fileContent, files["file.xml"]!!.readText()) + coVerify { cdnApi.downloadPublishedFile("file.xml") } + coVerify { translationsApi.downloadFile("file.xml") } + } + + @Test + fun `downloadPublishedFileIfNecessary - returns false when both CDN and API fail`() = testScope.runTest { + downloadManager.cleanupActor.close() + val translation = randomTranslation(manifestFileName = "manifest.xml", isDownloaded = false) + val manifest = Manifest(pageXmlFiles = listOf(XmlFile("file.xml", "file.xml"))) + setupTranslationFilesDownload(translation, manifest) + + assertFalse(downloadManager.downloadLatestPublishedTranslation(TranslationKey(translation))) + coVerify { cdnApi.downloadPublishedFile("file.xml") } + coVerify { translationsApi.downloadFile("file.xml") } + } + + @Test + fun `downloadPublishedFileIfNecessary - accepts file with matching sha256`() = testScope.runTest { + downloadManager.cleanupActor.close() + val fileContent = "a".repeat(1024) + val sha256Hex = fileContent.toByteArray().toByteString().sha256().hex() + val translation = randomTranslation(manifestFileName = "manifest.xml", isDownloaded = false) + val manifest = Manifest(pageXmlFiles = listOf(XmlFile("file.xml", "file.xml", checksumSha256 = sha256Hex))) + setupTranslationFilesDownload(translation, manifest) + coEvery { cdnApi.downloadPublishedFile("file.xml") } returns Response.success(fileContent.toResponseBody()) + + assertTrue(downloadManager.downloadLatestPublishedTranslation(TranslationKey(translation))) + coVerify { downloadedFilesRepository.insertOrIgnore(DownloadedFile("file.xml")) } + } + + @Test + fun `downloadPublishedFileIfNecessary - rejects file with wrong sha256`() = testScope.runTest { + downloadManager.cleanupActor.close() + val fileContent = "a".repeat(1024) + val translation = randomTranslation(manifestFileName = "manifest.xml", isDownloaded = false) + val manifest = Manifest( + pageXmlFiles = listOf(XmlFile("file.xml", "file.xml", checksumSha256 = "00".repeat(32))), + ) + setupTranslationFilesDownload(translation, manifest) + coEvery { cdnApi.downloadPublishedFile("file.xml") } returns Response.success(fileContent.toResponseBody()) + coEvery { translationsApi.downloadFile("file.xml") } returns Response.success(fileContent.toResponseBody()) + + assertFalse(downloadManager.downloadLatestPublishedTranslation(TranslationKey(translation))) + coVerify(exactly = 0) { downloadedFilesRepository.insertOrIgnore(DownloadedFile("file.xml")) } + } + + @Test + fun `downloadPublishedFileIfNecessary - rejects file with wrong size`() = testScope.runTest { + downloadManager.cleanupActor.close() + val fileContent = "a".repeat(1024) + val translation = randomTranslation(manifestFileName = "manifest.xml", isDownloaded = false) + val manifest = Manifest(pageXmlFiles = listOf(XmlFile("file.xml", "file.xml", size = 9999))) + setupTranslationFilesDownload(translation, manifest) + coEvery { cdnApi.downloadPublishedFile("file.xml") } returns Response.success(fileContent.toResponseBody()) + coEvery { translationsApi.downloadFile("file.xml") } returns Response.success(fileContent.toResponseBody()) + + assertFalse(downloadManager.downloadLatestPublishedTranslation(TranslationKey(translation))) + coVerify(exactly = 0) { downloadedFilesRepository.insertOrIgnore(DownloadedFile("file.xml")) } + } + // endregion downloadPublishedFileIfNecessary + @Test fun verifyImportTranslation() = testScope.runTest { coEvery { translationsRepository.findLatestTranslation(any(), any(), any()) } returns null