diff --git a/TODO b/TODO index 70eb2e3..f67a3ca 100644 --- a/TODO +++ b/TODO @@ -3,11 +3,11 @@ functionality: refactor: (done) extract file opener from settings activity (done) extract zip parser from settings activity - make use of Config.kt (maybe move consts here?) + (done) make use of Config.kt fix: app rotation with placed point ui: - proper file loading menu + (done) proper file loading menu translation usage test: khm: diff --git a/app/src/main/java/com/mirenkov/ktheightmap/Config.kt b/app/src/main/java/com/mirenkov/ktheightmap/Config.kt deleted file mode 100644 index 4d04d8b..0000000 --- a/app/src/main/java/com/mirenkov/ktheightmap/Config.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.mirenkov.ktheightmap - -class Config { - companion object { - const val MAP_START_OFFSET_X = -646.65625F - const val MAP_START_OFFSET_Y = -1157.2814F - } -} \ No newline at end of file diff --git a/app/src/main/java/com/mirenkov/ktheightmap/FileLoader.kt b/app/src/main/java/com/mirenkov/ktheightmap/FileLoader.kt index 73a4326..4566413 100644 --- a/app/src/main/java/com/mirenkov/ktheightmap/FileLoader.kt +++ b/app/src/main/java/com/mirenkov/ktheightmap/FileLoader.kt @@ -13,7 +13,9 @@ import com.nareshchocha.filepickerlibrary.models.FilePickerResult class FileLoader { companion object { var filePath: MutableState = mutableStateOf(null) + val launcherActive = mutableStateOf(false) fun filePickerResult(result: FilePickerResult): String? { + launcherActive.value = false if (result.errorMessage != null) { Log.e(TAG, result.errorMessage!!) return null diff --git a/app/src/main/java/com/mirenkov/ktheightmap/Global.kt b/app/src/main/java/com/mirenkov/ktheightmap/Global.kt new file mode 100644 index 0000000..42d8821 --- /dev/null +++ b/app/src/main/java/com/mirenkov/ktheightmap/Global.kt @@ -0,0 +1,32 @@ +package com.mirenkov.ktheightmap + +import android.util.Log +import java.io.FileInputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipException +import java.util.zip.ZipInputStream + +class Global { + companion object { + const val MAP_START_OFFSET_X = -646.65625F + const val MAP_START_OFFSET_Y = -1157.2814F + + fun fileIsValidZip(file: String): Boolean { + ZipInputStream(FileInputStream(file)).use { + return it.nextEntry != null + } + } + + fun zipContainsSQLite(file: String): Boolean { + ZipInputStream(FileInputStream(file)).use { + var entry: ZipEntry? = it.nextEntry + while (entry != null) { + if (entry.name.endsWith(".sqlitedb")) + return true + entry = it.nextEntry + } + } + return false + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mirenkov/ktheightmap/MapCanvas.kt b/app/src/main/java/com/mirenkov/ktheightmap/MapCanvas.kt index d691183..5134a03 100644 --- a/app/src/main/java/com/mirenkov/ktheightmap/MapCanvas.kt +++ b/app/src/main/java/com/mirenkov/ktheightmap/MapCanvas.kt @@ -29,12 +29,9 @@ import androidx.compose.ui.text.drawText import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle -import com.mirenkov.ktheightmap.Config.Companion.MAP_START_OFFSET_X -import com.mirenkov.ktheightmap.Config.Companion.MAP_START_OFFSET_Y import com.mirenkov.ktheightmap.parser.KhmParser import kotlin.math.absoluteValue import kotlin.math.ceil -import kotlin.math.floor import kotlin.math.min import kotlin.math.pow import kotlin.math.sqrt diff --git a/app/src/main/java/com/mirenkov/ktheightmap/SettingsActivity.kt b/app/src/main/java/com/mirenkov/ktheightmap/SettingsActivity.kt index dc7c39e..873c328 100644 --- a/app/src/main/java/com/mirenkov/ktheightmap/SettingsActivity.kt +++ b/app/src/main/java/com/mirenkov/ktheightmap/SettingsActivity.kt @@ -13,13 +13,16 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -32,6 +35,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.viewModel +import com.mirenkov.ktheightmap.Global.Companion.fileIsValidZip +import com.mirenkov.ktheightmap.Global.Companion.zipContainsSQLite import com.mirenkov.ktheightmap.parser.KhmParser import com.mirenkov.ktheightmap.parser.SasJPEGParser import com.mirenkov.ktheightmap.parser.SasSQLiteParser @@ -66,50 +71,78 @@ class SettingsActivity : ComponentActivity() { fun SettingsMain(vm: TileViewModel, launcher: ActivityResultLauncher) { val coroutineScope = rememberCoroutineScope() + val filePath by remember { FileLoader.filePath } + val pathIsNotNull = filePath != null + val pathIsValidKhm = pathIsNotNull && KhmParser.isFileValid(filePath!!) + val pathIsValidZip = pathIsNotNull && fileIsValidZip(filePath!!) + val pathContainsSQLite = pathIsValidZip && zipContainsSQLite(filePath!!) + + var launcherActive by remember { FileLoader.launcherActive } + var showLoading by remember { mutableStateOf(false) } + val ctx = LocalContext.current + + var enableClearTiles by remember { mutableStateOf(vm.repository.containsTiles()) } + var enableClearHeight by remember { mutableStateOf(KhmParser.fileExists(ctx)) } + val loadToast = Toast.makeText(ctx, "File successfully loaded.", Toast.LENGTH_LONG) val clearTilesToast = Toast.makeText(ctx, "Tiles removed from database.", Toast.LENGTH_LONG) val clearHeightToast = Toast.makeText(ctx, "Height data removed.", Toast.LENGTH_LONG) + + val selectedFileText = if (pathIsNotNull) filePath!!.split('/').last() else "none" + val fileSelectorRow = @Composable () { - Row(Modifier.fillMaxWidth()) { - Button({ launcher.launch(null) }) { Text("Select") } - Text("Selected file: none", Modifier.padding(8.dp, 0.dp).align(Alignment.CenterVertically)) - } + Row(Modifier.fillMaxWidth()) { + Button({ launcher.launch(null); launcherActive = true }) { Text("Select") } + Text("Selected file: $selectedFileText", Modifier.padding(8.dp, 0.dp).align(Alignment.CenterVertically)) + } } + KtHeightMapTheme { - Scaffold { paddingValues -> + Scaffold(floatingActionButton = { if (showLoading || launcherActive) CircularProgressIndicator() }) { paddingValues -> Column(Modifier.padding(paddingValues)) { LabeledSection("Select file:", Modifier.padding(24.dp, 8.dp)) { fileSelectorRow() } LabeledSection("Load file as...", Modifier.padding(24.dp, 8.dp)) { Button({ filePath?.let { + showLoading = true coroutineScope.launch(Dispatchers.IO) { SasSQLiteParser.processZip(it, vm.repository, ctx) - coroutineScope.launch(Dispatchers.Main) { loadToast.show() } + enableClearTiles = true + coroutineScope.launch(Dispatchers.Main) { loadToast.show(); showLoading = false } } - } }) { Text("SQLite (.zip)") } + } }, enabled = pathContainsSQLite) { Text("SQLite (.zip)") } Button({ filePath?.let { + showLoading = true coroutineScope.launch(Dispatchers.IO) { SasJPEGParser.processZip(it, vm.repository) - coroutineScope.launch(Dispatchers.Main) { loadToast.show() } + enableClearTiles = true + coroutineScope.launch(Dispatchers.Main) { loadToast.show(); showLoading = false } } - } }) { Text("JPEG (.zip)") } + } }, enabled = pathIsValidZip && !pathContainsSQLite) { Text("JPEG (.zip)") } Button({ filePath?.let { - KhmParser.load(it, ctx) - loadToast.show() - } }) { Text("Height data (.khm)") } + showLoading = true + coroutineScope.launch(Dispatchers.IO) { + KhmParser.load(it, ctx) + enableClearHeight = true + coroutineScope.launch(Dispatchers.Main) { loadToast.show(); showLoading = false } + } + } }, enabled = pathIsValidKhm) { Text("Height data (.khm)") } } LabeledSection("Clear...", Modifier.padding(24.dp, 8.dp)) { Button({ coroutineScope.launch { + showLoading = true vm.repository.clearTiles() - clearTilesToast.show() - } }) { Text("Tile data") } + enableClearTiles = false + coroutineScope.launch(Dispatchers.Main) { clearTilesToast.show(); showLoading = false } + } }, enabled = enableClearTiles) { Text("Tile data") } Button({ KhmParser.clear(ctx) + enableClearHeight = false clearHeightToast.show() - }) { Text("Height data") } + }, enabled = enableClearHeight) { Text("Height data") } } } } diff --git a/app/src/main/java/com/mirenkov/ktheightmap/TileRepository.kt b/app/src/main/java/com/mirenkov/ktheightmap/TileRepository.kt index 028d931..e4ca4e2 100644 --- a/app/src/main/java/com/mirenkov/ktheightmap/TileRepository.kt +++ b/app/src/main/java/com/mirenkov/ktheightmap/TileRepository.kt @@ -25,4 +25,8 @@ class TileRepository(private val tileDao: TileDao) { fun getMaxLevel(): Int { return coroutineScope.future(Dispatchers.IO){ tileDao.maxLevel() }.join() ?: 2 } + + fun containsTiles(): Boolean { + return coroutineScope.future(Dispatchers.IO){ tileDao.maxLevel() }.join() != null + } } \ No newline at end of file diff --git a/app/src/main/java/com/mirenkov/ktheightmap/TileViewModel.kt b/app/src/main/java/com/mirenkov/ktheightmap/TileViewModel.kt index 9d35050..2bb07e7 100644 --- a/app/src/main/java/com/mirenkov/ktheightmap/TileViewModel.kt +++ b/app/src/main/java/com/mirenkov/ktheightmap/TileViewModel.kt @@ -7,8 +7,8 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel -import com.mirenkov.ktheightmap.Config.Companion.MAP_START_OFFSET_X -import com.mirenkov.ktheightmap.Config.Companion.MAP_START_OFFSET_Y +import com.mirenkov.ktheightmap.Global.Companion.MAP_START_OFFSET_X +import com.mirenkov.ktheightmap.Global.Companion.MAP_START_OFFSET_Y class TileViewModel(application: Application): ViewModel() { val repository: TileRepository diff --git a/app/src/main/java/com/mirenkov/ktheightmap/parser/KhmParser.kt b/app/src/main/java/com/mirenkov/ktheightmap/parser/KhmParser.kt index 35801e8..e22cf5b 100644 --- a/app/src/main/java/com/mirenkov/ktheightmap/parser/KhmParser.kt +++ b/app/src/main/java/com/mirenkov/ktheightmap/parser/KhmParser.kt @@ -3,6 +3,7 @@ package com.mirenkov.ktheightmap.parser import android.content.Context import java.io.DataInputStream import java.io.FileInputStream +import kotlin.math.abs import kotlin.math.max import kotlin.math.min @@ -21,27 +22,48 @@ class KhmParser { const val HEIGHT_FILE: String = "height.khm" fun load(filePath: String, ctx: Context) { - if (ctx.getFileStreamPath(HEIGHT_FILE).exists()) { + if (fileExists(ctx)) { return } val inp = FileInputStream(filePath) - ctx.openFileOutput(HEIGHT_FILE, Context.MODE_PRIVATE).use { - it.write(inp.readBytes()) - } + + if (checkHeader(inp)) + ctx.openFileOutput(HEIGHT_FILE, Context.MODE_PRIVATE).use { + it.write(inp.readBytes()) + } inp.close() } + fun isFileValid(filePath: String): Boolean { + FileInputStream(filePath).use { + return checkHeader(it) + } + } + fun clear(ctx: Context) { - if (ctx.getFileStreamPath(HEIGHT_FILE).exists()) { + if (fileExists(ctx)) { assert(ctx.deleteFile(HEIGHT_FILE)) } } + fun fileExists(ctx: Context): Boolean { + return (ctx.getFileStreamPath(HEIGHT_FILE).exists()) + } + private fun getOffset(header: HeightInfo, x: Int, y: Int): Int { return (x * header.width + y) * 2 } + // NOTE: this function only checks if values are in accepted ranges + private fun checkHeader(fis: FileInputStream): Boolean { + header = null + readHeader(DataInputStream(fis)) + return abs(header!!.latPerValue) < abs(header!!.minLat) && + abs(header!!.lonPerValue) < abs(header!!.minLon) && + header!!.width > 0 && header!!.height > 0 + } + private fun readHeader(dis: DataInputStream): HeightInfo { if (header == null) { header = with(dis) { @@ -70,7 +92,7 @@ class KhmParser { } fun getHeight(lon: Float, lat: Float, ctx: Context): UShort { - if (!ctx.getFileStreamPath(HEIGHT_FILE).exists()) + if (!fileExists(ctx)) return 0u.toUShort() val dis = DataInputStream(ctx.openFileInput(HEIGHT_FILE)) dis.use {