@@ -43,12 +43,18 @@ import kotlinx.coroutines.flow.transformLatest
4343import kotlinx.coroutines.launch
4444import kotlinx.coroutines.sync.withLock
4545import kotlinx.coroutines.withContext
46+ import okhttp3.ResponseBody
47+ import okio.ByteString.Companion.decodeHex
48+ import okio.HashingSource.Companion.sha256
49+ import okio.buffer
50+ import okio.sink
4651import org.ccci.gto.android.common.kotlin.coroutines.MutexMap
4752import org.ccci.gto.android.common.kotlin.coroutines.ReadWriteMutex
4853import org.ccci.gto.android.common.kotlin.coroutines.flow.EmptyStateFlow
4954import org.ccci.gto.android.common.kotlin.coroutines.flow.combineTransformLatest
5055import org.ccci.gto.android.common.kotlin.coroutines.withLock
5156import org.cru.godtools.api.AttachmentsApi
57+ import org.cru.godtools.api.CdnApi
5258import org.cru.godtools.api.TranslationsApi
5359import org.cru.godtools.base.Settings
5460import org.cru.godtools.base.ToolFileSystem
@@ -66,6 +72,7 @@ import org.cru.godtools.model.Translation
6672import org.cru.godtools.model.TranslationKey
6773import org.cru.godtools.shared.tool.parser.ManifestParser
6874import org.cru.godtools.shared.tool.parser.ParserResult
75+ import retrofit2.Response
6976
7077@VisibleForTesting
7178internal const val CLEANUP_DELAY = 30_000L
@@ -74,6 +81,7 @@ internal const val CLEANUP_DELAY = 30_000L
7481class GodToolsDownloadManager @VisibleForTesting internal constructor(
7582 private val attachmentsApi : AttachmentsApi ,
7683 private val attachmentsRepository : AttachmentsRepository ,
84+ private val cdnApi : CdnApi ,
7785 private val downloadedFilesRepository : DownloadedFilesRepository ,
7886 private val fs : ToolFileSystem ,
7987 private val manifestParser : ManifestParser ,
@@ -87,6 +95,7 @@ class GodToolsDownloadManager @VisibleForTesting internal constructor(
8795 internal constructor (
8896 attachmentsApi: AttachmentsApi ,
8997 attachmentsRepository: AttachmentsRepository ,
98+ cdnApi: CdnApi ,
9099 downloadedFilesRepository: DownloadedFilesRepository ,
91100 fs: ToolFileSystem ,
92101 manifestParser: ManifestParser ,
@@ -96,6 +105,7 @@ class GodToolsDownloadManager @VisibleForTesting internal constructor(
96105 ) : this (
97106 attachmentsApi,
98107 attachmentsRepository,
108+ cdnApi,
99109 downloadedFilesRepository,
100110 fs,
101111 manifestParser,
@@ -280,7 +290,7 @@ class GodToolsDownloadManager @VisibleForTesting internal constructor(
280290 private suspend fun downloadTranslationFiles (translation : Translation ): Boolean = filesystemMutex.read.withLock {
281291 // download manifest if necessary
282292 val manifestFileName = translation.manifestFileName ? : return false
283- if (! downloadTranslationFileIfNecessary (manifestFileName)) return false
293+ if (! downloadPublishedFileIfNecessary (manifestFileName)) return false
284294
285295 // parse manifest
286296 val parserResult = manifestParser.parseManifest(
@@ -294,9 +304,14 @@ class GodToolsDownloadManager @VisibleForTesting internal constructor(
294304 val relatedFiles = manifest.relatedFiles
295305 val completedFiles = AtomicLong (0 )
296306 val successful = coroutineScope {
297- relatedFiles.map {
307+ relatedFiles.map { file ->
298308 async {
299- downloadTranslationFileIfNecessary(it).also {
309+ val src = file.src ? : return @async true
310+ downloadPublishedFileIfNecessary(
311+ fileName = src,
312+ sha256 = file.checksumSha256,
313+ size = file.size?.toLong()
314+ ).also {
300315 do {
301316 val completed = completedFiles.get()
302317 updateProgress(key, completed + 1 , relatedFiles.size.toLong())
@@ -309,23 +324,61 @@ class GodToolsDownloadManager @VisibleForTesting internal constructor(
309324
310325 // record the translation as downloaded
311326 downloadedFilesRepository.insertOrIgnore(DownloadedTranslationFile (translation, manifestFileName))
312- relatedFiles.forEach { downloadedFilesRepository.insertOrIgnore(DownloadedTranslationFile (translation, it)) }
327+ relatedFiles.mapNotNull { it.src }.forEach {
328+ downloadedFilesRepository.insertOrIgnore(DownloadedTranslationFile (translation, it))
329+ }
313330 translationsRepository.markTranslationDownloaded(translation.id, true )
314331
315332 return true
316333 }
317334
318- private suspend fun downloadTranslationFileIfNecessary (fileName : String ): Boolean = filesMutex.withLock(fileName) {
335+ private suspend fun downloadPublishedFileIfNecessary (
336+ fileName : String ,
337+ sha256 : String? = null,
338+ size : Long? = null,
339+ ): Boolean = filesMutex[fileName].withLock {
319340 if (downloadedFilesRepository.findDownloadedFile(fileName) != null ) return true
320- try {
321- val body = translationsApi.downloadFile(fileName).takeIf { it.isSuccessful }?.body() ? : return false
322- val downloadedFile = DownloadedFile (fileName)
323- withContext(Dispatchers .IO ) { body.byteStream().copyTo(downloadedFile) }
324- downloadedFilesRepository.insertOrIgnore(downloadedFile)
325- return true
326- } catch (e: IOException ) {
341+
342+ withContext(ioDispatcher) {
343+ downloadPublishedFileFromCdn(fileName, sha256, size) ||
344+ downloadPublishedFileFromApi(fileName, sha256, size)
345+ }
346+ }
347+
348+ private suspend fun downloadPublishedFileFromCdn (fileName : String , sha256 : String? , size : Long? ) = try {
349+ cdnApi.downloadPublishedFile(fileName).storeFile(fileName, sha256, size)
350+ } catch (_: IOException ) {
351+ false
352+ }
353+
354+ private suspend fun downloadPublishedFileFromApi (fileName : String , sha256 : String? , size : Long? ) = try {
355+ translationsApi.downloadFile(fileName).storeFile(fileName, sha256, size)
356+ } catch (_: IOException ) {
357+ false
358+ }
359+
360+ private suspend fun Response<ResponseBody>.storeFile (fileName : String , sha256 : String? , size : Long? ): Boolean {
361+ val body = body().takeIf { isSuccessful } ? : return false
362+
363+ val downloadedFile = DownloadedFile (fileName)
364+ val valid = downloadedFile.bufferedSink().use { sink ->
365+ body.source().use { source ->
366+ val digest = sha256(source)
367+ val bytesWritten = sink.writeAll(digest)
368+ when {
369+ size != null && size != bytesWritten -> false
370+ sha256 != null && sha256.decodeHex() != digest.hash -> false
371+ else -> true
372+ }
373+ }
374+ }
375+ if (! valid) {
376+ downloadedFile.getFile(fs).delete()
327377 return false
328378 }
379+
380+ downloadedFilesRepository.insertOrIgnore(downloadedFile)
381+ return true
329382 }
330383
331384 private suspend fun downloadTranslationZip (translation : Translation ) = try {
@@ -450,6 +503,8 @@ class GodToolsDownloadManager @VisibleForTesting internal constructor(
450503 }
451504 // endregion Cleanup
452505
506+ private suspend fun DownloadedFile.bufferedSink () = withContext(Dispatchers .IO ) { getFile(fs).sink().buffer() }
507+
453508 private suspend fun InputStream.copyTo (file : DownloadedFile ) = withContext(Dispatchers .IO ) {
454509 file.getFile(fs).outputStream().use { copyTo(it) }
455510 }
0 commit comments