diff --git a/android/app/build.gradle b/android/app/build.gradle index b0349cd3..b3725b88 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -60,9 +60,6 @@ android { proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } - testOptions { - unitTests.returnDefaultValues = true - } } repositories { @@ -84,7 +81,6 @@ configurations.configureEach { } dependencies { - testImplementation "junit:junit:$junit_version" implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" implementation fileTree(include: ['*.jar'], dir: 'libs') implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt index 06bc84c0..fc5a8c3a 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt @@ -3,12 +3,12 @@ package com.audiobookshelf.app.managers import android.content.Context import android.net.Uri import android.os.StatFs -import android.util.Log import androidx.documentfile.provider.DocumentFile import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.device.FolderScanner import com.audiobookshelf.app.models.DownloadItem import com.audiobookshelf.app.models.DownloadItemPart +import com.audiobookshelf.app.plugins.AbsLogger import com.fasterxml.jackson.core.json.JsonReadFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.getcapacitor.JSObject @@ -72,7 +72,7 @@ class DownloadItemManager( DeviceManager.dbManager.getDownloadItems().forEach { item -> item.downloadItemParts.filter { it.moved }.forEach { part -> if (!finalizedFileExists(part)) { - Log.w(tag, "Finalized file is missing; resetting ${part.filename}") + AbsLogger.error(tag, "Finalized file is missing; resetting ${part.filename}") part.moved = false part.completed = false part.completedDestinationUri = null @@ -161,7 +161,10 @@ class DownloadItemManager( try { DocumentFile.fromSingleUri(context, Uri.parse(uri))?.delete() } catch (e: Exception) { - Log.w(tag, "Could not delete cancelled SAF file ${part.filename}", e) + AbsLogger.error( + tag, + "Could not delete cancelled SAF file ${part.filename}: ${e.message}" + ) } } } @@ -177,11 +180,12 @@ class DownloadItemManager( @Synchronized fun hasWork(): Boolean = - finalizingItems.isNotEmpty() || downloadItemQueue.any { item -> - item.downloadItemParts.any { part -> - (!part.moved && !part.failed) || part.isMoving - } - } + finalizingItems.isNotEmpty() || + downloadItemQueue.any { item -> + item.downloadItemParts.any { part -> + (!part.moved && !part.failed) || part.isMoving + } + } @Synchronized private fun checkUpdateDownloadQueue() { @@ -190,8 +194,12 @@ class DownloadItemManager( if (slots <= 0) return@forEach item.downloadItemParts .filter { part -> - part.completed && !part.moved && !part.failed && !part.isMoving && - part !in currentDownloadItemParts && File(part.destinationPath).exists() && + part.completed && + !part.moved && + !part.failed && + !part.isMoving && + part !in currentDownloadItemParts && + File(part.destinationPath).exists() && !hasActiveDestinationConflict(part) } .take(slots) @@ -238,6 +246,7 @@ class DownloadItemManager( part.lastUpdateTime = System.currentTimeMillis() currentDownloadItemParts.add(part) persist(item, force = true) + AbsLogger.info(tag, "Starting download for ${part.filename}") val activeConfig = DeviceManager.serverConnectionConfig val token = if (activeConfig?.id == item.serverConnectionConfigId) activeConfig.token @@ -271,7 +280,8 @@ class DownloadItemManager( } }, { hasAvailableSpace(part) } - ).download(serverUrl(item, part), token) + ) + .download(serverUrl(item, part), token) if (part in currentDownloadItemParts && !part.completed && !part.failed) { activeCalls[part.id] = handle } @@ -310,7 +320,7 @@ class DownloadItemManager( if (!part.completed && !part.failed) { val lastUpdate = part.lastUpdateTime ?: return if (System.currentTimeMillis() - lastUpdate > STALL_TIMEOUT_MS) { - Log.w(tag, "Download stalled: ${part.filename}") + AbsLogger.error(tag, "Download stalled: ${part.filename}") activeCalls.remove(part.id)?.cancel() failOrRetry(item, part, "Download stalled") } @@ -329,7 +339,7 @@ class DownloadItemManager( part.retryCount += 1 reservations.remove(part.destinationPath) if (part.retryCount > MAX_RETRIES) { - Log.e(tag, "$reason after $MAX_RETRIES retries: ${part.filename}") + AbsLogger.error(tag, "$reason after $MAX_RETRIES retries: ${part.filename}") part.failed = true part.completed = false part.downloadId = null @@ -411,7 +421,7 @@ class DownloadItemManager( ?: throw IllegalStateException("Could not reopen finalized SAF file") if (destination.length() != staging.length()) throw IllegalStateException("SAF final size mismatch") - if (!staging.delete()) Log.w(tag, "Could not remove staging file ${staging.name}") + if (!staging.delete()) AbsLogger.error(tag, "Could not remove staging file ${staging.name}") part.completedDestinationUri = destination.uri.toString() completePart(item, part) } catch (e: Exception) { @@ -422,7 +432,7 @@ class DownloadItemManager( @Synchronized private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) { - Log.e(tag, message) + AbsLogger.error(tag, message) part.isMoving = false part.failed = true failOrRetry(item, part, message) @@ -562,6 +572,7 @@ class DownloadItemManager( part.bytesDownloaded = file.length() part.progress = 100L part.reusedExistingFile = true + AbsLogger.info(tag, "Reusing existing cover ${part.filename}") File(part.destinationPath).delete() completePart(item, part) clientEventEmitter.onDownloadItemPartUpdate(part) @@ -570,7 +581,8 @@ class DownloadItemManager( private fun hasActiveDestinationConflict(part: DownloadItemPart): Boolean = currentDownloadItemParts.any { activePart -> - activePart !== part && activePart.localFolderId == part.localFolderId && + activePart !== part && + activePart.localFolderId == part.localFolderId && activePart.finalDestinationPath == part.finalDestinationPath } @@ -594,10 +606,10 @@ class DownloadItemManager( part.completedDestinationUri?.let { uri -> try { val file = DocumentFile.fromSingleUri(context, Uri.parse(uri)) - if (file?.isFile == true && - (part.fileSize <= 0L || file.length() == part.fileSize)) return true + if (file?.isFile == true && (part.fileSize <= 0L || file.length() == part.fileSize)) + return true } catch (e: Exception) { - Log.w(tag, "Could not validate SAF file ${part.filename}", e) + AbsLogger.error(tag, "Could not validate SAF file ${part.filename}: ${e.message}") } } return findSharedStorageFile(part) != null @@ -607,7 +619,7 @@ class DownloadItemManager( private fun resetPartForFreshDownload(part: DownloadItemPart): Boolean { val stagingFile = File(part.destinationPath) if (stagingFile.exists() && !stagingFile.delete()) { - Log.e(tag, "Could not delete staging file ${part.filename}") + AbsLogger.error(tag, "Could not delete staging file ${part.filename}") part.failed = true return false } @@ -624,11 +636,14 @@ class DownloadItemManager( } private fun findDocumentByFilename(folder: DocumentFile, part: DownloadItemPart): DocumentFile? { - folder.findFile(part.filename)?.let { return it } + folder.findFile(part.filename)?.let { + return it + } val expectedBaseName = part.filename.substringBeforeLast('.') return folder.listFiles().firstOrNull { document -> document.name == part.filename || - (part.audioTrack != null && document.isFile && + (part.audioTrack != null && + document.isFile && (document.name ?: "").substringBeforeLast('.') == expectedBaseName) } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt index f51716ee..fdb90a4f 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt @@ -1,7 +1,6 @@ package com.audiobookshelf.app.managers import android.content.Context -import android.util.Log import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager @@ -9,6 +8,7 @@ import androidx.work.Worker import androidx.work.WorkerParameters import com.audiobookshelf.app.device.DeviceManager import com.audiobookshelf.app.models.DownloadItem +import com.audiobookshelf.app.plugins.AbsLogger import java.io.File import java.util.concurrent.TimeUnit @@ -64,7 +64,7 @@ object IncompleteDownloadCleanup { item.stagingCleanupAt = System.currentTimeMillis() DeviceManager.dbManager.saveDownloadItem(item) cancel(context, item.id) - Log.i(tag, "Deleted staging files for terminally failed download item ${item.id}") + AbsLogger.info(tag, "Deleted staging files for terminally failed download item ${item.id}") } private fun deleteAppOwnedFile(context: Context, file: File) { @@ -72,10 +72,10 @@ object IncompleteDownloadCleanup { val internal = context.filesDir.absolutePath val external = context.getExternalFilesDir(null)?.absolutePath if (path.startsWith(internal) || (external != null && path.startsWith(external))) { - if (file.exists() && !file.delete()) Log.w(tag, "Could not delete expired staging file $path") + if (file.exists() && !file.delete()) AbsLogger.error(tag, "Could not delete expired staging file $path") file.parentFile?.takeIf { it.isDirectory && it.list()?.isEmpty() == true }?.delete() } else { - Log.w(tag, "Refusing to delete non-app-owned path $path") + AbsLogger.error(tag, "Refusing to delete non-app-owned path $path") } } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt index aa42fe0e..5aec3965 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt @@ -1,6 +1,6 @@ package com.audiobookshelf.app.managers -import android.util.Log +import com.audiobookshelf.app.plugins.AbsLogger import java.io.File import java.io.FileOutputStream import java.io.IOException @@ -61,6 +61,9 @@ class InternalDownloadManager( allowRestart: Boolean ) { var existingBytes = destinationFile.takeIf { it.exists() }?.length() ?: 0L + AbsLogger.info( + tag, + "Starting ${if (existingBytes > 0L) "resumed" else "new"} download for ${destinationFile.name} at byte $existingBytes") if (expectedSize > 0L && existingBytes == expectedSize) { progressCallback.onProgress(existingBytes, 100L) progressCallback.onComplete(false) @@ -68,7 +71,7 @@ class InternalDownloadManager( } if (expectedSize > 0L && existingBytes > expectedSize) { if (!destinationFile.delete()) { - Log.e(tag, "Could not delete oversized staging file ${destinationFile.name}") + AbsLogger.error(tag, "Could not delete oversized staging file ${destinationFile.name}") progressCallback.onComplete(true) return } @@ -86,7 +89,7 @@ class InternalDownloadManager( call.enqueue( object : Callback { override fun onFailure(call: Call, e: IOException) { - Log.e(tag, "Download URL failed", e) + AbsLogger.error(tag, "Download request failed for ${destinationFile.name}: ${e.message}") progressCallback.onComplete(true) } @@ -102,10 +105,10 @@ class InternalDownloadManager( progressCallback.onProgress(existingBytes, 100L) progressCallback.onComplete(false) } else if (allowRestart && destinationFile.delete()) { - Log.w(tag, "Restarting stale range from byte zero") + AbsLogger.info(tag, "Restarting stale range from byte zero for ${destinationFile.name}") startRequest(url, token, handle, allowRestart = false) } else { - Log.e(tag, "Could not recover invalid range at offset $existingBytes") + AbsLogger.error(tag, "Could not recover invalid range for ${destinationFile.name} at byte $existingBytes") progressCallback.onComplete(true) } return @@ -115,15 +118,15 @@ class InternalDownloadManager( response.code == 206 && hasExpectedRange(response, existingBytes) if (existingBytes > 0L && !append && response.code != 200) { - Log.e( + AbsLogger.error( tag, - "Invalid resume response ${response.code} for offset $existingBytes" + "Invalid resume response ${response.code} for ${destinationFile.name} at byte $existingBytes" ) progressCallback.onComplete(true) return } if (!response.isSuccessful || response.body == null) { - Log.e(tag, "Download HTTP failure ${response.code}") + AbsLogger.error(tag, "Download HTTP failure ${response.code} for ${destinationFile.name}") progressCallback.onComplete(true) return } @@ -153,16 +156,16 @@ class InternalDownloadManager( } if (expectedSize > 0L && destinationFile.length() != expectedSize) { - Log.e( + AbsLogger.error( tag, - "Downloaded size ${destinationFile.length()} did not match $expectedSize" + "Downloaded size for ${destinationFile.name} was ${destinationFile.length()}, expected $expectedSize" ) progressCallback.onComplete(true) } else { progressCallback.onComplete(false) } } catch (e: IOException) { - Log.e(tag, "Could not write staging file", e) + AbsLogger.error(tag, "Could not write staging file ${destinationFile.name}: ${e.message}") progressCallback.onComplete(true) } } diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt index b2d6ebdf..cd678a18 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt @@ -1,7 +1,6 @@ package com.audiobookshelf.app.plugins import android.os.Environment -import android.util.Log import com.audiobookshelf.app.MainActivity import com.audiobookshelf.app.data.* import com.audiobookshelf.app.device.DeviceManager @@ -84,14 +83,14 @@ class AbsDownloader : Plugin() { var episodeId = call.data.getString("episodeId").toString() if (episodeId == "null") episodeId = "" var localFolderId = call.data.getString("localFolderId", "").toString() - Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId") + AbsLogger.info(tag, "Requested download for item $libraryItemId${if (episodeId.isEmpty()) "" else " / episode $episodeId"}") val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId" DownloadServiceHost.retryExisting(mainActivity, downloadId) { result -> when (result) { DownloadServiceHost.ExistingDownloadResult.RETRIED -> call.resolve() DownloadServiceHost.ExistingDownloadResult.ACTIVE -> { - Log.d(tag, "Download already started for this media entity $downloadId") + AbsLogger.info(tag, "Download already active for $downloadId") call.resolve(JSObject("{\"error\":\"Download already started for this media entity\"}")) } DownloadServiceHost.ExistingDownloadResult.SERVICE_START_FAILED -> @@ -101,12 +100,12 @@ class AbsDownloader : Plugin() { if (libraryItem == null) { call.resolve(JSObject("{\"error\":\"Server request failed\"}")) } else { - Log.d(tag, "Got library item from server ${libraryItem.id}") + AbsLogger.info(tag, "Preparing download for ${libraryItem.id}") if (localFolderId == "") localFolderId = "internal-${libraryItem.mediaType}" var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId) if (localFolder == null && localFolderId.startsWith("internal-")) { - Log.d(tag, "Creating new App Storage internal LocalFolder $localFolderId") + AbsLogger.info(tag, "Creating internal download folder $localFolderId") localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "internal", libraryItem.mediaType) DeviceManager.dbManager.saveLocalFolder(localFolder) } @@ -174,14 +173,13 @@ class AbsDownloader : Plugin() { "${mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: mainActivity.filesDir}/download-staging/${libraryItem.id}" } - Log.d(tag, "downloadCacheDirectory=$tempFolderPath") if (libraryItem.mediaType == "book") { val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title) val bookAuthor = cleanStringForFileSystem(libraryItem.media.metadata.getAuthorDisplayName()) val tracks = libraryItem.media.getAudioTracks() - Log.d(tag, "Starting library item download with ${tracks.size} tracks") + AbsLogger.info(tag, "Queueing library item download with ${tracks.size} files") val itemSubfolder = "$bookAuthor/$bookTitle" val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$itemSubfolder" val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf()) @@ -208,7 +206,6 @@ class AbsDownloader : Plugin() { val serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download" val destinationFilename = getFilenameFromRelPath(audioTrack.relPath) - Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename") val finalDestinationFile = File("$itemFolderPath/$destinationFilename") val destinationFile = File("$tempFolderPath/$destinationFilename.part") @@ -242,14 +239,13 @@ class AbsDownloader : Plugin() { val audioFileIno = episode?.audioFile?.ino val fileSize = audioTrack?.metadata?.size ?: 0 - Log.d(tag, "Starting podcast episode download") + AbsLogger.info(tag, "Queueing podcast episode download") val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$podcastTitle" val downloadItemId = "${libraryItem.id}-${episode?.id}" val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf()) var serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download" var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "") - Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename") var destinationFile = File("$tempFolderPath/$destinationFilename.part") var finalDestinationFile = File("$itemFolderPath/$destinationFilename") diff --git a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt index d385eb19..1bf1f8c3 100644 --- a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt +++ b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt @@ -1,13 +1,13 @@ package com.audiobookshelf.app.services import android.content.Context -import android.util.Log import androidx.core.content.ContextCompat import com.audiobookshelf.app.device.FolderScanner import com.audiobookshelf.app.managers.DbManager import com.audiobookshelf.app.managers.DownloadItemManager import com.audiobookshelf.app.managers.IncompleteDownloadCleanup import com.audiobookshelf.app.models.DownloadItem +import com.audiobookshelf.app.plugins.AbsLogger import com.getcapacitor.JSObject import java.util.Collections import kotlinx.coroutines.CoroutineScope @@ -171,7 +171,7 @@ object DownloadServiceHost { ContextCompat.startForegroundService(context, DownloadService.intent(context)) true } catch (e: RuntimeException) { - Log.e(TAG, "Could not start download foreground service", e) + AbsLogger.error(TAG, "Could not start download foreground service: ${e.message}") false } } diff --git a/android/app/src/test/java/com/audiobookshelf/app/managers/InternalDownloadManagerTest.kt b/android/app/src/test/java/com/audiobookshelf/app/managers/InternalDownloadManagerTest.kt deleted file mode 100644 index a3fcf82f..00000000 --- a/android/app/src/test/java/com/audiobookshelf/app/managers/InternalDownloadManagerTest.kt +++ /dev/null @@ -1,176 +0,0 @@ -package com.audiobookshelf.app.managers - -import java.io.Closeable -import java.net.ServerSocket -import java.nio.file.Files -import java.util.Collections -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicBoolean -import org.junit.After -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class InternalDownloadManagerTest { - private var server: TestHttpServer? = null - - @After - fun tearDown() { - server?.close() - } - - @Test - fun exactKnownStagingFileCompletesWithoutHttpRequest() { - val destination = Files.createTempFile("abs-complete", ".part").toFile() - destination.writeBytes(byteArrayOf(1, 2, 3, 4)) - val callback = RecordingCallback() - - InternalDownloadManager(destination, 4L, callback) { true } - .download("http://127.0.0.1:1/download", "token") - - assertTrue(callback.completed.await(1, TimeUnit.SECONDS)) - assertFalse(callback.failed.get()) - assertEquals(4L, destination.length()) - destination.delete() - } - - @Test - fun unknownSizeFullFileIsAcceptedFrom416ContentRange() { - server = TestHttpServer { _, _ -> - response(416, headers = listOf("Content-Range: bytes */4")) - } - val destination = Files.createTempFile("abs-unknown", ".part").toFile() - destination.writeBytes(byteArrayOf(1, 2, 3, 4)) - val callback = RecordingCallback() - - InternalDownloadManager(destination, 0L, callback) { true } - .download(server!!.url, "token") - - assertTrue(callback.completed.await(3, TimeUnit.SECONDS)) - assertFalse(callback.failed.get()) - assertEquals("bytes=4-", server!!.requests.single()["range"]) - destination.delete() - } - - @Test - fun partialFileResumesWithRangeDuringLiveRetry() { - server = TestHttpServer { _, _ -> - response(206, byteArrayOf(3, 4), listOf("Content-Range: bytes 2-3/4")) - } - val destination = Files.createTempFile("abs-partial", ".part").toFile() - destination.writeBytes(byteArrayOf(1, 2)) - val callback = RecordingCallback() - - InternalDownloadManager(destination, 4L, callback) { true } - .download(server!!.url, "token") - - assertTrue(callback.completed.await(3, TimeUnit.SECONDS)) - assertFalse(callback.failed.get()) - assertEquals("bytes=2-", server!!.requests.single()["range"]) - assertTrue(destination.readBytes().contentEquals(byteArrayOf(1, 2, 3, 4))) - destination.delete() - } - - @Test - fun stale416RestartsOnceFromByteZero() { - server = TestHttpServer { index, _ -> - if (index == 0) response(416, headers = listOf("Content-Range: bytes */2")) - else response(200, byteArrayOf(9, 8)) - } - val destination = Files.createTempFile("abs-stale", ".part").toFile() - destination.writeBytes(byteArrayOf(1, 2, 3, 4)) - val callback = RecordingCallback() - - InternalDownloadManager(destination, 0L, callback) { true } - .download(server!!.url, "token") - - assertTrue(callback.completed.await(3, TimeUnit.SECONDS)) - assertFalse(callback.failed.get()) - assertEquals(2, server!!.requests.size) - assertEquals("bytes=4-", server!!.requests[0]["range"]) - assertNull(server!!.requests[1]["range"]) - assertTrue(destination.readBytes().contentEquals(byteArrayOf(9, 8))) - destination.delete() - } - - private class RecordingCallback : DownloadItemManager.InternalProgressCallback { - val completed = CountDownLatch(1) - val failed = AtomicBoolean(true) - - override fun onProgress(totalBytesWritten: Long, progress: Long) = Unit - - override fun onComplete(failed: Boolean) { - this.failed.set(failed) - completed.countDown() - } - } - - private class TestHttpServer( - private val responder: (Int, Map) -> ByteArray - ) : Closeable { - private val socket = ServerSocket(0) - val requests = Collections.synchronizedList(mutableListOf>()) - val url = "http://127.0.0.1:${socket.localPort}/download" - private val thread = Thread { - while (!socket.isClosed) { - try { - socket.accept().use { connection -> - val reader = connection.getInputStream().bufferedReader() - reader.readLine() - val headers = mutableMapOf() - while (true) { - val line = reader.readLine() ?: break - if (line.isEmpty()) break - val separator = line.indexOf(':') - if (separator > 0) { - headers[line.substring(0, separator).lowercase()] = - line.substring(separator + 1).trim() - } - } - val index = requests.size - requests.add(headers) - connection.getOutputStream().use { output -> - output.write(responder(index, headers)) - output.flush() - } - } - } catch (_: Exception) { - if (!socket.isClosed) throw IllegalStateException("Test HTTP server failed") - } - } - }.apply { - isDaemon = true - start() - } - - override fun close() { - socket.close() - thread.join(1_000L) - } - } - - companion object { - private fun response( - status: Int, - body: ByteArray = byteArrayOf(), - headers: List = emptyList() - ): ByteArray { - val reason = - when (status) { - 200 -> "OK" - 206 -> "Partial Content" - else -> "Range Not Satisfiable" - } - val head = buildString { - append("HTTP/1.1 $status $reason\r\n") - headers.forEach { append("$it\r\n") } - append("Content-Length: ${body.size}\r\n") - append("Connection: close\r\n\r\n") - }.toByteArray() - return head + body - } - } -}