package com.parkplants.map import android.content.ContentValues import android.content.Context import android.database.sqlite.SQLiteDatabase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request import java.io.File import kotlin.math.PI import kotlin.math.cos import kotlin.math.ln import kotlin.math.tan /** * Prefetches OSM map tiles for the park area into osmdroid's persistent tile cache. * * Tile math: standard Slippy Map / Mercator tiling scheme. * Cache format: osmdroid SqlTileWriter SQLite schema. * Cache location: context.filesDir/osmdroid/tiles/cache.db (persistent, not cleared by OS). */ object TilePrefetcher { // Park bounding box (Краснодар, Парк облаков) private const val PARK_NORTH = 45.055 private const val PARK_SOUTH = 45.035 private const val PARK_WEST = 39.022 private const val PARK_EAST = 39.048 private val ZOOM_RANGE = 13..19 // Must match TileSourceFactory.MAPNIK.name() private const val PROVIDER = "Mapnik" private const val TILE_URL = "https://tile.openstreetmap.org/%d/%d/%d.png" // 30 days in ms private const val TILE_TTL_MS = 30L * 24 * 3600 * 1000 // osmdroid 6.x tile index: (zoom << 52) | (x << 26) | y private fun tileKey(zoom: Int, x: Int, y: Int): Long = (zoom.toLong() shl 52) or (x.toLong() shl 26) or y.toLong() private fun lonToX(lon: Double, zoom: Int): Int { val n = 1 shl zoom return ((lon + 180.0) / 360.0 * n).toInt().coerceIn(0, n - 1) } private fun latToY(lat: Double, zoom: Int): Int { val latRad = Math.toRadians(lat) val n = 1 shl zoom return (n * (1.0 - ln(tan(latRad) + 1.0 / cos(latRad)) / PI) / 2.0) .toInt().coerceIn(0, n - 1) } /** Path where osmdroid reads/writes tiles (must match MapPickerDialog config). */ fun tileCacheDir(context: Context): File = File(context.filesDir, "osmdroid/tiles") private fun openDb(context: Context): SQLiteDatabase { val dir = tileCacheDir(context) dir.mkdirs() val db = SQLiteDatabase.openOrCreateDatabase(File(dir, "cache.db"), null) db.execSQL( "CREATE TABLE IF NOT EXISTS tiles " + "(key INTEGER, provider TEXT, tile BLOB, expires BIGINT, " + "PRIMARY KEY (key, provider))" ) return db } /** * Downloads all missing / expired tiles for the park area. * Safe to call repeatedly — skips tiles already in cache. * * @param onProgress callback with (downloaded, total) counts */ suspend fun prefetch( context: Context, onProgress: (done: Int, total: Int) -> Unit = { _, _ -> }, ) = withContext(Dispatchers.IO) { data class Tile(val zoom: Int, val x: Int, val y: Int) val tiles = ZOOM_RANGE.flatMap { zoom -> val xMin = lonToX(PARK_WEST, zoom) val xMax = lonToX(PARK_EAST, zoom) val yMin = latToY(PARK_NORTH, zoom) // north = smaller y in tile coords val yMax = latToY(PARK_SOUTH, zoom) // south = larger y (xMin..xMax).flatMap { x -> (yMin..yMax).map { y -> Tile(zoom, x, y) } } } val http = OkHttpClient.Builder() .addNetworkInterceptor { chain -> chain.proceed( chain.request().newBuilder() .header("User-Agent", context.packageName) .build() ) } .build() val expires = System.currentTimeMillis() + TILE_TTL_MS val now = System.currentTimeMillis() var done = 0 openDb(context).use { db -> for (tile in tiles) { val key = tileKey(tile.zoom, tile.x, tile.y) // Skip if cached and not expired val cur = db.rawQuery( "SELECT 1 FROM tiles WHERE key=? AND provider=? AND expires>?", arrayOf(key.toString(), PROVIDER, now.toString()) ) val cached = cur.moveToFirst() cur.close() if (!cached) { try { val url = TILE_URL.format(tile.zoom, tile.x, tile.y) val resp = http.newCall(Request.Builder().url(url).build()).execute() val bytes = resp.body?.bytes() resp.close() if (bytes != null && bytes.isNotEmpty()) { db.insertWithOnConflict( "tiles", null, ContentValues().apply { put("key", key) put("provider", PROVIDER) put("tile", bytes) put("expires", expires) }, SQLiteDatabase.CONFLICT_REPLACE ) } } catch (_: Exception) { // Skip failed tiles — they'll retry on next run } } done++ onProgress(done, tiles.size) } } } /** Returns count of valid (non-expired) cached tiles for the park. */ fun cachedTileCount(context: Context): Int { val dbFile = File(tileCacheDir(context), "cache.db") if (!dbFile.exists()) return 0 return try { SQLiteDatabase.openDatabase( dbFile.path, null, SQLiteDatabase.OPEN_READONLY ).use { db -> db.rawQuery( "SELECT COUNT(*) FROM tiles WHERE provider=? AND expires>?", arrayOf(PROVIDER, System.currentTimeMillis().toString()) ).use { cur -> if (cur.moveToFirst()) cur.getInt(0) else 0 } } } catch (_: Exception) { 0 } } }