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 0000000000000000000000000000000000000000..dce05d46e53e4e7e48dd4878f13f5ca90269247a
--- /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 0000000000000000000000000000000000000000..96ed5f75dd75a77d957a4849b4bd29dc49944cbf
--- /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 284711771420c2356e3539d57e503c84248c700b..8f6d0602c8ca0a0f384e98e061f763dab7ddc5f9 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 ff07d434e6bc2407989729e25fb7746faabee0db..fc8db6e8657eec4a8657dc6236eb810ce6c21818 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
@@ -100,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")
@@ -125,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
@@ -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 {
@@ -176,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)
@@ -211,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(
@@ -232,27 +212,11 @@ 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
*/
- 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)
@@ -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,
@@ -663,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
@@ -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,
@@ -1043,7 +1007,7 @@ class TileDownloadManager(
/**
* Process a batch of tiles
*/
- private suspend fun processBatch(
+ suspend fun processBatch(
chunk: List>,
areaId: String,
areaName: String,
@@ -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,
@@ -1206,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 {
@@ -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 aee91b67e1a620236a91b1228879034eeba33dfc..c2840c47f38540ac6c9200139f59af44efc47d7d 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
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 0000000000000000000000000000000000000000..b2965748ef0d09a82c4f38dd0f9d44c23cc27df1
--- /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
+ }
+}