From 8f6e47ba9362b2b433156f2a66eb73f2b33102ff Mon Sep 17 00:00:00 2001 From: Ellen Poe Date: Sat, 4 Oct 2025 20:56:40 -0700 Subject: [PATCH 1/2] refactor: TileDownloadManager separation of concerns --- .../tileserver/DownloadProgressReporter.kt | 53 ++++++++ .../tileserver/ServiceProgressReporter.kt | 47 ++++++++ .../TileDownloadForegroundService.kt | 49 ++------ .../tileserver/TileDownloadManager.kt | 113 +++++------------- .../cardinal/ui/home/OfflineAreasViewModel.kt | 4 +- 5 files changed, 143 insertions(+), 123 deletions(-) create mode 100644 cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/DownloadProgressReporter.kt create mode 100644 cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/ServiceProgressReporter.kt diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/DownloadProgressReporter.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/DownloadProgressReporter.kt new file mode 100644 index 0000000..dce05d4 --- /dev/null +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/DownloadProgressReporter.kt @@ -0,0 +1,53 @@ +/* + * Cardinal Maps + * Copyright (C) 2025 Cardinal Maps Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package earth.maps.cardinal.tileserver + +/** + * Interface for reporting download progress to external components + */ +interface DownloadProgressReporter { + /** + * Update download progress + * @param areaId The ID of the area being downloaded + * @param areaName The name of the area being downloaded + * @param currentStage The current stage of the download + * @param stageProgress Current progress within the current stage + * @param stageTotal Total expected progress for the current stage + * @param isCompleted Whether the download is fully completed + * @param hasError Whether an error occurred during download + */ + fun updateProgress( + areaId: String, + areaName: String, + currentStage: DownloadStage?, + stageProgress: Int, + stageTotal: Int, + isCompleted: Boolean, + hasError: Boolean + ) +} + +/** + * Represents the different stages of the download process + */ +enum class DownloadStage { + BASEMAP, + VALHALLA, + PROCESSING +} diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/ServiceProgressReporter.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/ServiceProgressReporter.kt new file mode 100644 index 0000000..96ed5f7 --- /dev/null +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/ServiceProgressReporter.kt @@ -0,0 +1,47 @@ +/* + * Cardinal Maps + * Copyright (C) 2025 Cardinal Maps Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package earth.maps.cardinal.tileserver + +/** + * Implementation of DownloadProgressReporter that delegates to the TileDownloadForegroundService + */ +class ServiceProgressReporter( + private val service: TileDownloadForegroundService +) : DownloadProgressReporter { + + override fun updateProgress( + areaId: String, + areaName: String, + currentStage: DownloadStage?, + stageProgress: Int, + stageTotal: Int, + isCompleted: Boolean, + hasError: Boolean + ) { + service.updateProgress( + areaId = areaId, + areaName = areaName, + currentStage = currentStage, + stageProgress = stageProgress, + stageTotal = stageTotal, + isCompleted = isCompleted, + hasError = hasError + ) + } +} diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/TileDownloadForegroundService.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/TileDownloadForegroundService.kt index 2847117..8f6d060 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/TileDownloadForegroundService.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/TileDownloadForegroundService.kt @@ -58,9 +58,6 @@ import javax.inject.Inject @AndroidEntryPoint class TileDownloadForegroundService : Service() { - @Inject - lateinit var tileDownloadManager: TileDownloadManager - @Inject lateinit var offlineAreaDao: OfflineAreaDao @@ -73,6 +70,8 @@ class TileDownloadForegroundService : Service() { @Inject lateinit var permissionRequestManager: PermissionRequestManager + private lateinit var tileDownloadManager: TileDownloadManager + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private var downloadJob: Job? = null @@ -140,18 +139,17 @@ class TileDownloadForegroundService : Service() { } } - enum class DownloadStage { - BASEMAP, - VALHALLA, - PROCESSING - } - inner class TileDownloadBinder : Binder() { fun getService(): TileDownloadForegroundService = this@TileDownloadForegroundService } override fun onCreate() { super.onCreate() + tileDownloadManager = TileDownloadManager( + this, downloadedTileDao, offlineAreaDao, tileProcessor, + ServiceProgressReporter(this) + ) + Log.d(TAG, "TileDownloadForegroundService created") createNotificationChannel() @@ -368,10 +366,9 @@ class TileDownloadForegroundService : Service() { // Start the actual download tileDownloadManager.downloadTilesInternal( - boundingBox, minZoom, maxZoom, areaId, areaName, + boundingBox, minZoom, maxZoom, areaId, areaName ) - } catch (e: Exception) { Log.e(TAG, "Error during download", e) // Handle download failure @@ -566,36 +563,6 @@ class TileDownloadForegroundService : Service() { } } - private suspend fun resumeIncompleteDownloads() { - Log.d(TAG, "Checking for incomplete downloads to resume") - - try { - val areas = offlineAreaDao.getAllOfflineAreas().first() - val incompleteAreas = areas.filter { - it.shouldAutomaticallyResume() - } - - Log.d(TAG, "Found ${incompleteAreas.size} incomplete downloads") - - if (incompleteAreas.isNotEmpty()) { - // For now, resume the first incomplete download - // In a more advanced implementation, you could queue multiple downloads - val areaToResume = incompleteAreas.first() - Log.d(TAG, "Resuming download for area: ${areaToResume.name}") - - startDownloadJob( - areaToResume.id, - areaToResume.name, - areaToResume.boundingBox(), - areaToResume.minZoom, - areaToResume.maxZoom - ) - } - } catch (e: Exception) { - Log.e(TAG, "Error resuming incomplete downloads", e) - } - } - fun pauseDownload() { Log.d(TAG, "Pausing download") diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/TileDownloadManager.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/TileDownloadManager.kt index ff07d43..4c83bdd 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/TileDownloadManager.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/TileDownloadManager.kt @@ -18,13 +18,10 @@ package earth.maps.cardinal.tileserver -import android.content.ComponentName import android.content.Context import android.content.Intent -import android.content.ServiceConnection import android.database.Cursor import android.database.sqlite.SQLiteDatabase -import android.os.IBinder import android.util.Log import earth.maps.cardinal.R import earth.maps.cardinal.data.BoundingBox @@ -63,7 +60,8 @@ class TileDownloadManager( private val context: Context, private val downloadedTileDao: DownloadedTileDao, private val offlineAreaDao: OfflineAreaDao, - private val tileProcessor: TileProcessor? = null + private val tileProcessor: TileProcessor? = null, + private val progressReporter: DownloadProgressReporter? = null ) { private val TAG = "TileDownloadManager" private val coroutineScope = CoroutineScope(Dispatchers.IO + Job()) @@ -72,23 +70,6 @@ class TileDownloadManager( install(ContentNegotiation) } - // Service binding infrastructure - private var serviceBinder: TileDownloadForegroundService.TileDownloadBinder? = null - private var isBound = false - - private val serviceConnection = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - Log.d(TAG, "Connected to TileDownloadForegroundService") - serviceBinder = service as TileDownloadForegroundService.TileDownloadBinder - isBound = true - } - - override fun onServiceDisconnected(name: ComponentName?) { - Log.d(TAG, "Disconnected from TileDownloadForegroundService") - serviceBinder = null - isBound = false - } - } companion object { private const val MAX_BASEMAP_ZOOM = 14 @@ -157,7 +138,6 @@ class TileDownloadManager( Log.d(TAG, "Starting download for area: $name (ID: $areaId)") handleExistingArea(areaId, name, boundingBox, minZoom, maxZoom) - bindToServiceWithTimeout() // Start the foreground service val intent = Intent(context, TileDownloadForegroundService::class.java).apply { @@ -232,22 +212,6 @@ class TileDownloadManager( Log.d(TAG, "Created offline area: $areaId with status ${offlineArea.status}") } - /** - * Bind to the service with timeout - */ - private suspend fun bindToServiceWithTimeout() { - bindToService() - - val bindTimeout = 3000L // 3 seconds - val bindStartTime = System.currentTimeMillis() - while (!isBound && (System.currentTimeMillis() - bindStartTime) < bindTimeout) { - delay(100) - } - - if (!isBound) { - Log.w(TAG, "Service binding timeout, starting service anyway") - } - } /** * Update area status @@ -390,7 +354,7 @@ class TileDownloadManager( getStageProgress(currentStage, downloadedBasemapTiles, downloadedValhallaTiles) val stageTotal = getStageTotal(currentStage, totalBasemapTiles, totalValhallaTiles) - serviceBinder?.getService()?.updateProgress( + progressReporter?.updateProgress( areaId = areaId, areaName = name, currentStage = currentStage, @@ -417,13 +381,13 @@ class TileDownloadManager( private fun determineCurrentStage( totalBasemapTiles: Int, totalValhallaTiles: Int, downloadedBasemapTiles: Int, downloadedValhallaTiles: Int - ): TileDownloadForegroundService.DownloadStage { + ): DownloadStage { return if (downloadedBasemapTiles != totalBasemapTiles) { - TileDownloadForegroundService.DownloadStage.BASEMAP + DownloadStage.BASEMAP } else if (downloadedValhallaTiles != totalValhallaTiles) { - TileDownloadForegroundService.DownloadStage.VALHALLA + DownloadStage.VALHALLA } else { - TileDownloadForegroundService.DownloadStage.PROCESSING + DownloadStage.PROCESSING } } @@ -431,13 +395,13 @@ class TileDownloadManager( * Get progress for current stage */ private fun getStageProgress( - currentStage: TileDownloadForegroundService.DownloadStage, + currentStage: DownloadStage, downloadedBasemapTiles: Int, downloadedValhallaTiles: Int ): Int { return when (currentStage) { - TileDownloadForegroundService.DownloadStage.BASEMAP -> downloadedBasemapTiles - TileDownloadForegroundService.DownloadStage.VALHALLA -> downloadedValhallaTiles - TileDownloadForegroundService.DownloadStage.PROCESSING -> 0 + DownloadStage.BASEMAP -> downloadedBasemapTiles + DownloadStage.VALHALLA -> downloadedValhallaTiles + DownloadStage.PROCESSING -> 0 } } @@ -445,13 +409,13 @@ class TileDownloadManager( * Get total for current stage */ private fun getStageTotal( - currentStage: TileDownloadForegroundService.DownloadStage, + currentStage: DownloadStage, totalBasemapTiles: Int, totalValhallaTiles: Int ): Int { return when (currentStage) { - TileDownloadForegroundService.DownloadStage.BASEMAP -> totalBasemapTiles - TileDownloadForegroundService.DownloadStage.VALHALLA -> totalValhallaTiles - TileDownloadForegroundService.DownloadStage.PROCESSING -> 1 + DownloadStage.BASEMAP -> totalBasemapTiles + DownloadStage.VALHALLA -> totalValhallaTiles + DownloadStage.PROCESSING -> 1 } } @@ -479,10 +443,10 @@ class TileDownloadManager( updateAreaStatus(areaId, DownloadStatus.DOWNLOADING_VALHALLA) // Update progress to show basemap completion - serviceBinder?.getService()?.updateProgress( + progressReporter?.updateProgress( areaId = areaId, areaName = name, - currentStage = TileDownloadForegroundService.DownloadStage.VALHALLA, + currentStage = DownloadStage.VALHALLA, stageProgress = 0, stageTotal = totalValhallaTiles, isCompleted = false, @@ -592,10 +556,10 @@ class TileDownloadManager( } // Update service progress - downloads completed, now processing - serviceBinder?.getService()?.updateProgress( + progressReporter?.updateProgress( areaId = areaId, areaName = name, - currentStage = TileDownloadForegroundService.DownloadStage.PROCESSING, + currentStage = DownloadStage.PROCESSING, stageProgress = 0, stageTotal = 1, isCompleted = false, @@ -615,7 +579,7 @@ class TileDownloadManager( } // Update service progress - processing completed - serviceBinder?.getService()?.updateProgress( + progressReporter?.updateProgress( areaId = areaId, areaName = name, currentStage = null, @@ -631,7 +595,7 @@ class TileDownloadManager( */ private suspend fun handleDownloadError(areaId: String, name: String) { // Update service progress - download failed - serviceBinder?.getService()?.updateProgress( + progressReporter?.updateProgress( areaId = areaId, areaName = name, currentStage = null, @@ -826,10 +790,10 @@ class TileDownloadManager( "Skipping already downloaded Valhalla tile $hierarchyLevel/$tileIndex for area $areaId" ) // Update service progress without incrementing counters - serviceBinder?.getService()?.updateProgress( + progressReporter?.updateProgress( areaId = areaId, areaName = areaName, - currentStage = TileDownloadForegroundService.DownloadStage.VALHALLA, + currentStage = DownloadStage.VALHALLA, stageProgress = downloadedCount, stageTotal = totalValhallaTiles, isCompleted = false, @@ -844,10 +808,10 @@ class TileDownloadManager( storeValhallaTileReference(db, hierarchyLevel, tileIndex, filePath, areaId) // Update service progress - serviceBinder?.getService()?.updateProgress( + progressReporter?.updateProgress( areaId = areaId, areaName = areaName, - currentStage = TileDownloadForegroundService.DownloadStage.VALHALLA, + currentStage = DownloadStage.VALHALLA, stageProgress = downloadedCount + 1, // Add 1 since we return before increment stageTotal = totalValhallaTiles, isCompleted = false, @@ -1067,10 +1031,10 @@ class TileDownloadManager( // Don't increment downloadedCount here - only increment for actual new downloads // Update service progress with current count (not incremented) - serviceBinder?.getService()?.updateProgress( + progressReporter?.updateProgress( areaId = areaId, areaName = areaName, - currentStage = TileDownloadForegroundService.DownloadStage.BASEMAP, + currentStage = DownloadStage.BASEMAP, stageProgress = downloadedCount.get(), stageTotal = totalTiles, isCompleted = false, @@ -1102,10 +1066,10 @@ class TileDownloadManager( val currentProgress = downloadedCount.incrementAndGet() // Update service progress - serviceBinder?.getService()?.updateProgress( + progressReporter?.updateProgress( areaId = areaId, areaName = areaName, - currentStage = TileDownloadForegroundService.DownloadStage.BASEMAP, + currentStage = DownloadStage.BASEMAP, stageProgress = currentProgress, stageTotal = totalTiles, isCompleted = false, @@ -1527,17 +1491,6 @@ class TileDownloadManager( ) } - /** - * Bind to the TileDownloadForegroundService - */ - private fun bindToService() { - if (!isBound) { - Log.d(TAG, "Binding to TileDownloadForegroundService") - val intent = Intent(context, TileDownloadForegroundService::class.java) - context.bindService(intent, serviceConnection, Context.BIND_AUTO_CREATE) - } - } - /** * Get the total number of tiles in the database */ @@ -1671,10 +1624,10 @@ class TileDownloadManager( tileBatch.clear() // Update progress during processing phase - serviceBinder?.getService()?.updateProgress( + progressReporter?.updateProgress( areaId = areaId, areaName = "", - currentStage = TileDownloadForegroundService.DownloadStage.PROCESSING, + currentStage = DownloadStage.PROCESSING, stageProgress = processedCount, stageTotal = totalTilesToProcess, isCompleted = false, @@ -1693,10 +1646,10 @@ class TileDownloadManager( failedCount += batchFailed // Final progress update - serviceBinder?.getService()?.updateProgress( + progressReporter?.updateProgress( areaId = areaId, areaName = "", - currentStage = TileDownloadForegroundService.DownloadStage.PROCESSING, + currentStage = DownloadStage.PROCESSING, stageProgress = processedCount, stageTotal = totalTilesToProcess, isCompleted = false, diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/OfflineAreasViewModel.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/OfflineAreasViewModel.kt index aee91b6..c2840c4 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/OfflineAreasViewModel.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/ui/home/OfflineAreasViewModel.kt @@ -34,6 +34,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import earth.maps.cardinal.data.BoundingBox import earth.maps.cardinal.data.room.OfflineArea import earth.maps.cardinal.data.room.OfflineAreaRepository +import earth.maps.cardinal.tileserver.DownloadStage import earth.maps.cardinal.tileserver.TileDownloadForegroundService import earth.maps.cardinal.tileserver.calculateTileRange import kotlinx.coroutines.Job @@ -57,8 +58,7 @@ class OfflineAreasViewModel @Inject constructor( // New unified progress properties val unifiedProgress = mutableFloatStateOf(0f) // 0.0 to 1.0 - val currentStage = - mutableStateOf(TileDownloadForegroundService.DownloadStage.BASEMAP) + val currentStage = mutableStateOf(DownloadStage.BASEMAP) // Service binding infrastructure private var serviceBinder: TileDownloadForegroundService.TileDownloadBinder? = null -- GitLab From b50f56947508d93fd67f9c62fc7d9e9cfb5d9fd1 Mon Sep 17 00:00:00 2001 From: Ellen Poe Date: Sat, 4 Oct 2025 21:52:27 -0700 Subject: [PATCH 2/2] test: Add some tests for TileDownloadManager --- .../tileserver/TileDownloadManager.kt | 16 +- .../tileserver/TileDownloadManagerTest.kt | 573 ++++++++++++++++++ 2 files changed, 581 insertions(+), 8 deletions(-) create mode 100644 cardinal-android/app/src/test/java/earth/maps/cardinal/tileserver/TileDownloadManagerTest.kt diff --git a/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/TileDownloadManager.kt b/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/TileDownloadManager.kt index 4c83bdd..fc8db6e 100644 --- a/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/TileDownloadManager.kt +++ b/cardinal-android/app/src/main/java/earth/maps/cardinal/tileserver/TileDownloadManager.kt @@ -81,7 +81,7 @@ class TileDownloadManager( /** * Determines if the basemap download phase is complete for the given area */ - private suspend fun isBasemapPhaseComplete(areaId: String): Boolean { + suspend fun isBasemapPhaseComplete(areaId: String): Boolean { val expectedTileCount = downloadedTileDao.getDownloadedTileCountForAreaAndType(areaId, TileType.BASEMAP) Log.d(TAG, "Found $expectedTileCount basemap tiles for area $areaId") @@ -106,7 +106,7 @@ class TileDownloadManager( /** * Determines which phase the download should resume from based on current progress */ - private suspend fun determineResumePhase(areaId: String): DownloadStatus { + suspend fun determineResumePhase(areaId: String): DownloadStatus { val existingArea = offlineAreaDao.getOfflineAreaById(areaId) ?: return DownloadStatus.DOWNLOADING_BASEMAP @@ -156,7 +156,7 @@ class TileDownloadManager( /** * Handle logic for existing areas: check if exists, determine resume phase, skip if completed, or create new area */ - private suspend fun handleExistingArea( + suspend fun handleExistingArea( areaId: String, name: String, boundingBox: BoundingBox, minZoom: Int, maxZoom: Int ) { val existingArea = offlineAreaDao.getOfflineAreaById(areaId) @@ -191,7 +191,7 @@ class TileDownloadManager( /** * Create a new offline area */ - private suspend fun createNewOfflineArea( + suspend fun createNewOfflineArea( areaId: String, name: String, boundingBox: BoundingBox, minZoom: Int, maxZoom: Int ) { val offlineArea = OfflineArea( @@ -216,7 +216,7 @@ class TileDownloadManager( /** * Update area status */ - private suspend fun updateAreaStatus(areaId: String, status: DownloadStatus) { + suspend fun updateAreaStatus(areaId: String, status: DownloadStatus) { val area = offlineAreaDao.getOfflineAreaById(areaId) if (area != null) { val updatedArea = area.copy(status = status) @@ -627,7 +627,7 @@ class TileDownloadManager( /** * Download basemap tiles for the given bounds */ - private suspend fun downloadBasemapTiles( + suspend fun downloadBasemapTiles( boundingBox: BoundingBox, minZoom: Int, maxZoom: Int, areaId: String, areaName: String ): Pair { var db: SQLiteDatabase? = null @@ -1007,7 +1007,7 @@ class TileDownloadManager( /** * Process a batch of tiles */ - private suspend fun processBatch( + suspend fun processBatch( chunk: List>, areaId: String, areaName: String, @@ -1170,7 +1170,7 @@ class TileDownloadManager( /** * Download a single tile and return its data */ - private suspend fun downloadTile( + suspend fun downloadTile( zoom: Int, x: Int, y: Int, layer: String ): Pair = withContext(Dispatchers.IO) { try { diff --git a/cardinal-android/app/src/test/java/earth/maps/cardinal/tileserver/TileDownloadManagerTest.kt b/cardinal-android/app/src/test/java/earth/maps/cardinal/tileserver/TileDownloadManagerTest.kt new file mode 100644 index 0000000..b296574 --- /dev/null +++ b/cardinal-android/app/src/test/java/earth/maps/cardinal/tileserver/TileDownloadManagerTest.kt @@ -0,0 +1,573 @@ +package earth.maps.cardinal.tileserver + +import android.content.Context +import earth.maps.cardinal.MainCoroutineRule +import earth.maps.cardinal.data.BoundingBox +import earth.maps.cardinal.data.room.DownloadStatus +import earth.maps.cardinal.data.room.DownloadedTile +import earth.maps.cardinal.data.room.DownloadedTileDao +import earth.maps.cardinal.data.room.OfflineArea +import earth.maps.cardinal.data.room.OfflineAreaDao +import earth.maps.cardinal.data.room.TileType +import earth.maps.cardinal.geocoding.TileProcessor +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.util.concurrent.atomic.AtomicInteger + +@ExperimentalCoroutinesApi +@RunWith(RobolectricTestRunner::class) +class TileDownloadManagerTest { + + @get:Rule + val coroutineRule = MainCoroutineRule() + + private lateinit var context: Context + private lateinit var tileDownloadManager: TileDownloadManager + private val mockDownloadedTileDao = mockk() + private val mockOfflineAreaDao = mockk() + private val mockTileProcessor = mockk() + private val mockProgressReporter = mockk() + + @Before + fun setup() { + context = RuntimeEnvironment.application + tileDownloadManager = TileDownloadManager( + context = context, + downloadedTileDao = mockDownloadedTileDao, + offlineAreaDao = mockOfflineAreaDao, + tileProcessor = mockTileProcessor, + progressReporter = mockProgressReporter + ) + } + + @Test + fun `isBasemapPhaseComplete should return true when all expected tiles are downloaded`() = + runTest { + // Arrange + val areaId = "test_area" + val expectedTileCount = 3824 + + coEvery { + mockDownloadedTileDao.getDownloadedTileCountForAreaAndType( + areaId, + TileType.BASEMAP + ) + } returns expectedTileCount + coEvery { mockOfflineAreaDao.getOfflineAreaById(areaId) } returns OfflineArea( + id = areaId, + name = "Test Area", + north = 40.0, + south = 39.0, + east = -74.0, + west = -75.0, + minZoom = 10, + maxZoom = 14, + downloadDate = System.currentTimeMillis(), + fileSize = 0L, + status = DownloadStatus.DOWNLOADING_BASEMAP + ) + + // Act + val result = tileDownloadManager.isBasemapPhaseComplete(areaId) + + // Assert + assertTrue(result) + coVerify { + mockDownloadedTileDao.getDownloadedTileCountForAreaAndType( + areaId, + TileType.BASEMAP + ) + } + coVerify { mockOfflineAreaDao.getOfflineAreaById(areaId) } + } + + @Test + fun `isBasemapPhaseComplete should return false when not all tiles are downloaded`() = runTest { + // Arrange + val areaId = "test_area" + val expectedTileCount = 50 + + coEvery { + mockDownloadedTileDao.getDownloadedTileCountForAreaAndType( + areaId, + TileType.BASEMAP + ) + } returns expectedTileCount + coEvery { mockOfflineAreaDao.getOfflineAreaById(areaId) } returns OfflineArea( + id = areaId, + name = "Test Area", + north = 40.0, + south = 39.0, + east = -74.0, + west = -75.0, + minZoom = 10, + maxZoom = 14, + downloadDate = System.currentTimeMillis(), + fileSize = 0L, + status = DownloadStatus.DOWNLOADING_BASEMAP + ) + + // Act + val result = tileDownloadManager.isBasemapPhaseComplete(areaId) + + // Assert + assertFalse(result) + } + + @Test + fun `isBasemapPhaseComplete should return false when area does not exist`() = runTest { + // Arrange + val areaId = "test_area" + + coEvery { + mockDownloadedTileDao.getDownloadedTileCountForAreaAndType( + areaId, + TileType.BASEMAP + ) + } returns 0 + coEvery { mockOfflineAreaDao.getOfflineAreaById(areaId) } returns null + + // Act + val result = tileDownloadManager.isBasemapPhaseComplete(areaId) + + // Assert + assertFalse(result) + } + + @Test + fun `determineResumePhase should return DOWNLOADING_BASEMAP for PENDING area`() = runTest { + // Arrange + val areaId = "test_area" + val area = OfflineArea( + id = areaId, + name = "Test Area", + north = 40.0, + south = 39.0, + east = -74.0, + west = -75.0, + minZoom = 10, + maxZoom = 14, + downloadDate = System.currentTimeMillis(), + fileSize = 0L, + status = DownloadStatus.PENDING + ) + + coEvery { mockOfflineAreaDao.getOfflineAreaById(areaId) } returns area + + // Act + val result = tileDownloadManager.determineResumePhase(areaId) + + // Assert + assertEquals(DownloadStatus.DOWNLOADING_BASEMAP, result) + } + + @Test + fun `determineResumePhase should return DOWNLOADING_VALHALLA when basemap is complete`() = + runTest { + // Arrange + val areaId = "test_area" + val area = OfflineArea( + id = areaId, + name = "Test Area", + north = 40.0, + south = 39.0, + east = -74.0, + west = -75.0, + minZoom = 10, + maxZoom = 14, + downloadDate = System.currentTimeMillis(), + fileSize = 0L, + status = DownloadStatus.DOWNLOADING_BASEMAP + ) + + coEvery { mockOfflineAreaDao.getOfflineAreaById(areaId) } returns area + coEvery { + mockDownloadedTileDao.getDownloadedTileCountForAreaAndType( + areaId, + TileType.BASEMAP + ) + } returns 3824 + + // Act + val result = tileDownloadManager.determineResumePhase(areaId) + + // Assert + assertEquals(DownloadStatus.DOWNLOADING_VALHALLA, result) + } + + @Test + fun `determineResumePhase should return DOWNLOADING_BASEMAP when basemap is not complete`() = + runTest { + // Arrange + val areaId = "test_area" + val area = OfflineArea( + id = areaId, + name = "Test Area", + north = 40.0, + south = 39.0, + east = -74.0, + west = -75.0, + minZoom = 10, + maxZoom = 14, + downloadDate = System.currentTimeMillis(), + fileSize = 0L, + status = DownloadStatus.DOWNLOADING_BASEMAP + ) + + coEvery { mockOfflineAreaDao.getOfflineAreaById(areaId) } returns area + coEvery { + mockDownloadedTileDao.getDownloadedTileCountForAreaAndType( + areaId, + TileType.BASEMAP + ) + } returns 50 + + // Act + val result = tileDownloadManager.determineResumePhase(areaId) + + // Assert + assertEquals(DownloadStatus.DOWNLOADING_BASEMAP, result) + } + + @Test + fun `determineResumePhase should return DOWNLOADING_VALHALLA for DOWNLOADING_VALHALLA area`() = + runTest { + // Arrange + val areaId = "test_area" + val area = OfflineArea( + id = areaId, + name = "Test Area", + north = 40.0, + south = 39.0, + east = -74.0, + west = -75.0, + minZoom = 10, + maxZoom = 14, + downloadDate = System.currentTimeMillis(), + fileSize = 0L, + status = DownloadStatus.DOWNLOADING_VALHALLA + ) + + coEvery { mockOfflineAreaDao.getOfflineAreaById(areaId) } returns area + + // Act + val result = tileDownloadManager.determineResumePhase(areaId) + + // Assert + assertEquals(DownloadStatus.DOWNLOADING_VALHALLA, result) + } + + @Test + fun `determineResumePhase should return PROCESSING_GEOCODER for PROCESSING_GEOCODER area`() = + runTest { + // Arrange + val areaId = "test_area" + val area = OfflineArea( + id = areaId, + name = "Test Area", + north = 40.0, + south = 39.0, + east = -74.0, + west = -75.0, + minZoom = 10, + maxZoom = 14, + downloadDate = System.currentTimeMillis(), + fileSize = 0L, + status = DownloadStatus.PROCESSING_GEOCODER + ) + + coEvery { mockOfflineAreaDao.getOfflineAreaById(areaId) } returns area + + // Act + val result = tileDownloadManager.determineResumePhase(areaId) + + // Assert + assertEquals(DownloadStatus.PROCESSING_GEOCODER, result) + } + + @Test + fun `determineResumePhase should return COMPLETED for COMPLETED area`() = runTest { + // Arrange + val areaId = "test_area" + val area = OfflineArea( + id = areaId, + name = "Test Area", + north = 40.0, + south = 39.0, + east = -74.0, + west = -75.0, + minZoom = 10, + maxZoom = 14, + downloadDate = System.currentTimeMillis(), + fileSize = 0L, + status = DownloadStatus.COMPLETED + ) + + coEvery { mockOfflineAreaDao.getOfflineAreaById(areaId) } returns area + + // Act + val result = tileDownloadManager.determineResumePhase(areaId) + + // Assert + assertEquals(DownloadStatus.COMPLETED, result) + } + + @Test + fun `determineResumePhase should return DOWNLOADING_BASEMAP for FAILED area`() = runTest { + // Arrange + val areaId = "test_area" + val area = OfflineArea( + id = areaId, + name = "Test Area", + north = 40.0, + south = 39.0, + east = -74.0, + west = -75.0, + minZoom = 10, + maxZoom = 14, + downloadDate = System.currentTimeMillis(), + fileSize = 0L, + status = DownloadStatus.FAILED + ) + + coEvery { mockOfflineAreaDao.getOfflineAreaById(areaId) } returns area + + // Act + val result = tileDownloadManager.determineResumePhase(areaId) + + // Assert + assertEquals(DownloadStatus.DOWNLOADING_BASEMAP, result) + } + + @Test + fun `determineResumePhase should return DOWNLOADING_BASEMAP when area does not exist`() = + runTest { + // Arrange + val areaId = "test_area" + + coEvery { mockOfflineAreaDao.getOfflineAreaById(areaId) } returns null + + // Act + val result = tileDownloadManager.determineResumePhase(areaId) + + // Assert + assertEquals(DownloadStatus.DOWNLOADING_BASEMAP, result) + } + + @Test + fun `calculateTotalTiles should return correct tile count for zoom range`() { + // Arrange + val boundingBox = BoundingBox(40.0, 39.0, -74.0, -75.0) + val minZoom = 10 + val maxZoom = 12 + + // Act + val result = tileDownloadManager.calculateTotalTiles(boundingBox, minZoom, maxZoom) + + // Assert + assertEquals(284, result) + } + + @Test + fun `handleExistingArea should create new area when it doesn't exist`() = runTest { + // Arrange + val areaId = "test_area" + val name = "Test Area" + val boundingBox = BoundingBox(40.0, 39.0, -74.0, -75.0) + val minZoom = 10 + val maxZoom = 14 + + coEvery { mockOfflineAreaDao.getOfflineAreaById(areaId) } returns null + coEvery { mockOfflineAreaDao.insertOfflineArea(any()) } returns Unit + + // Act + tileDownloadManager.handleExistingArea(areaId, name, boundingBox, minZoom, maxZoom) + + // Assert + coVerify { + mockOfflineAreaDao.insertOfflineArea(match { + it.id == areaId && it.name == name && it.status == DownloadStatus.DOWNLOADING_BASEMAP + }) + } + } + + @Test + fun `handleExistingArea should handle resume logic when area exists`() = runTest { + // Arrange + val areaId = "test_area" + val name = "Test Area" + val boundingBox = BoundingBox(40.0, 39.0, -74.0, -75.0) + val minZoom = 10 + val maxZoom = 14 + val existingArea = OfflineArea( + id = areaId, + name = name, + north = 40.0, + south = 39.0, + east = -74.0, + west = -75.0, + minZoom = minZoom, + maxZoom = maxZoom, + downloadDate = System.currentTimeMillis(), + fileSize = 0L, + status = DownloadStatus.DOWNLOADING_BASEMAP + ) + + coEvery { mockOfflineAreaDao.getOfflineAreaById(areaId) } returns existingArea + coEvery { + mockDownloadedTileDao.getDownloadedTileCountForAreaAndType( + areaId, + TileType.BASEMAP + ) + } returns 0 + coEvery { mockOfflineAreaDao.updateOfflineArea(existingArea) } returns Unit + + // Act + tileDownloadManager.handleExistingArea(areaId, name, boundingBox, minZoom, maxZoom) + + // Assert + coVerify { mockOfflineAreaDao.getOfflineAreaById(areaId) } + } + + @Test + fun `updateAreaStatus should update area status`() = runTest { + // Arrange + val areaId = "test_area" + val status = DownloadStatus.DOWNLOADING_VALHALLA + val existingArea = OfflineArea( + id = areaId, + name = "Test Area", + north = 40.0, + south = 39.0, + east = -74.0, + west = -75.0, + minZoom = 10, + maxZoom = 14, + downloadDate = System.currentTimeMillis(), + fileSize = 0L, + status = DownloadStatus.DOWNLOADING_BASEMAP + ) + + coEvery { mockOfflineAreaDao.getOfflineAreaById(areaId) } returns existingArea + coEvery { mockOfflineAreaDao.updateOfflineArea(existingArea.copy(status = status)) } returns Unit + + // Act + tileDownloadManager.updateAreaStatus(areaId, status) + + // Assert + coVerify { + mockOfflineAreaDao.updateOfflineArea(match { + it.id == areaId && it.status == status + }) + } + } + + @Test + fun `updateAreaStatus should not update when area does not exist`() = runTest { + // Arrange + val areaId = "test_area" + val status = DownloadStatus.DOWNLOADING_VALHALLA + + coEvery { mockOfflineAreaDao.getOfflineAreaById(areaId) } returns null + + // Act + tileDownloadManager.updateAreaStatus(areaId, status) + + // Assert + coVerify(exactly = 0) { mockOfflineAreaDao.updateOfflineArea(any()) } + } + + @Test + fun `createNewOfflineArea should create area with correct parameters`() = runTest { + // Arrange + val areaId = "test_area" + val name = "Test Area" + val boundingBox = BoundingBox(40.0, 39.0, -74.0, -75.0) + val minZoom = 10 + val maxZoom = 14 + + coEvery { mockOfflineAreaDao.insertOfflineArea(any()) } returns Unit + + // Act + tileDownloadManager.createNewOfflineArea(areaId, name, boundingBox, minZoom, maxZoom) + + // Assert + coVerify { + mockOfflineAreaDao.insertOfflineArea(match { + it.id == areaId && + it.name == name && + it.north == boundingBox.north && + it.south == boundingBox.south && + it.east == boundingBox.east && + it.west == boundingBox.west && + it.minZoom == minZoom && + it.maxZoom == maxZoom && + it.status == DownloadStatus.DOWNLOADING_BASEMAP + }) + } + } + + @Test + fun `processBatch should skip already downloaded tiles`() = runTest { + // Arrange + val chunk = listOf(Triple(10, 100, 200)) + val areaId = "test_area" + val areaName = "Test Area" + val totalTiles = 1 + val downloadedCount = mockk() + val failedCount = mockk() + + every { downloadedCount.get() } returns 0 + every { downloadedCount.incrementAndGet() } returns 1 + every { + mockProgressReporter.updateProgress( + areaId, + areaName, + any(), + any(), + any(), + any(), + any() + ) + } returns Unit + + val existingTile = DownloadedTile( + id = "basemap_${areaId}_10_100_200", + areaId = areaId, + tileType = TileType.BASEMAP, + downloadTimestamp = System.currentTimeMillis(), + retryCount = 0, + zoom = 10, + tileX = 100, + tileY = 200 + ) + + coEvery { mockDownloadedTileDao.getTileById("basemap_${areaId}_10_100_200") } returns existingTile + + // Act + val result = tileDownloadManager.processBatch( + chunk, + areaId, + areaName, + totalTiles, + downloadedCount, + failedCount + ) + + // Assert + assertTrue(result.isEmpty()) // Should skip already downloaded tiles + } +} -- GitLab