diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 00000000..7b1edfd4
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,3 @@
+-keep class org.osmdroid.** { *; }
+-keep class com.rtbishop.look4sat.feature.map.*TiandituTileSource* { *; }
+-dontwarn org.osmdroid.**
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index cd9649c9..31b92409 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -15,14 +15,15 @@
@@ -41,7 +42,7 @@
+ android:value="cn.bh2vsq.look4sat" />
diff --git a/app/src/main/java/com/rtbishop/look4sat/MainActivity.kt b/app/src/main/java/cn/bh2vsq/look4sat/MainActivity.kt
similarity index 99%
rename from app/src/main/java/com/rtbishop/look4sat/MainActivity.kt
rename to app/src/main/java/cn/bh2vsq/look4sat/MainActivity.kt
index 3a8ded66..751718cf 100644
--- a/app/src/main/java/com/rtbishop/look4sat/MainActivity.kt
+++ b/app/src/main/java/cn/bh2vsq/look4sat/MainActivity.kt
@@ -15,7 +15,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.rtbishop.look4sat
+package cn.bh2vsq.look4sat
import android.content.Context
import android.content.res.Configuration
diff --git a/app/src/main/java/com/rtbishop/look4sat/MainApplication.kt b/app/src/main/java/cn/bh2vsq/look4sat/MainApplication.kt
similarity index 98%
rename from app/src/main/java/com/rtbishop/look4sat/MainApplication.kt
rename to app/src/main/java/cn/bh2vsq/look4sat/MainApplication.kt
index 58fc429c..1af6ed06 100644
--- a/app/src/main/java/com/rtbishop/look4sat/MainApplication.kt
+++ b/app/src/main/java/cn/bh2vsq/look4sat/MainApplication.kt
@@ -15,7 +15,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.rtbishop.look4sat
+package cn.bh2vsq.look4sat
import android.app.Application
import com.rtbishop.look4sat.core.data.injection.MainContainer
diff --git a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt b/app/src/main/java/cn/bh2vsq/look4sat/MainScreen.kt
similarity index 99%
rename from app/src/main/java/com/rtbishop/look4sat/MainScreen.kt
rename to app/src/main/java/cn/bh2vsq/look4sat/MainScreen.kt
index cab6e3c9..cb745375 100644
--- a/app/src/main/java/com/rtbishop/look4sat/MainScreen.kt
+++ b/app/src/main/java/cn/bh2vsq/look4sat/MainScreen.kt
@@ -15,7 +15,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-package com.rtbishop.look4sat
+package cn.bh2vsq.look4sat
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 00000000..bcbd5c57
--- /dev/null
+++ b/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,6 @@
+
+
+
+ tianditu.gov.cn
+
+
diff --git a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/PluginSetupUtils.kt b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/PluginSetupUtils.kt
index 334a9308..e7eb98c0 100644
--- a/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/PluginSetupUtils.kt
+++ b/build-logic/convention/src/main/java/com/rtbishop/look4sat/convention/PluginSetupUtils.kt
@@ -69,7 +69,7 @@ internal fun Project.setupAndroidApp() {
release {
isMinifyEnabled = true
isShrinkResources = true
- proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
+ proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
androidResources {
diff --git a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt
index 42acc318..d5dd63c5 100644
--- a/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt
+++ b/core/data/src/main/java/com/rtbishop/look4sat/core/data/repository/SettingsRepo.kt
@@ -25,6 +25,7 @@ import androidx.core.location.LocationListenerCompat
import androidx.core.location.LocationManagerCompat
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.DatabaseState
+import com.rtbishop.look4sat.core.domain.model.MapSource
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings
@@ -82,6 +83,8 @@ class SettingsRepo(
private val keyShouldSeeWarning = "shouldSeeWarning"
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
private val keySstvMode = "sstvMode"
+ private val keyMapSource = "mapSource"
+ private val keyTiandituKey = "tiandituKey"
private val keyUseCustomTle = "useCustomTle"
private val keyUseCustomTransceivers = "useCustomTransceivers"
private val keyTleUrl = "tleUrl"
@@ -335,6 +338,8 @@ class SettingsRepo(
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
putString(keySstvMode, new.sstvMode)
+ putString(keyMapSource, MapSource.normalize(new.mapSource))
+ putString(keyTiandituKey, new.tiandituKey)
}
new
}
@@ -349,7 +354,9 @@ class SettingsRepo(
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
- sstvMode = preferences.getString(keySstvMode, null) ?: "Auto"
+ sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
+ mapSource = MapSource.normalize(preferences.getString(keyMapSource, null) ?: MapSource.TIANDITU_VECTOR),
+ tiandituKey = preferences.getString(keyTiandituKey, null) ?: ""
)
//endregion
diff --git a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt
index fcdc2667..784a3911 100644
--- a/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt
+++ b/core/domain/src/main/java/com/rtbishop/look4sat/core/domain/model/Settings.kt
@@ -57,9 +57,24 @@ data class OtherSettings(
val stateOfNightMode: Boolean = false,
val shouldSeeWarning: Boolean,
val shouldSeeWhatsNew: Boolean,
- val sstvMode: String = "Auto"
+ val sstvMode: String = "Auto",
+ val mapSource: String = MapSource.TIANDITU_VECTOR,
+ val tiandituKey: String = ""
)
+object MapSource {
+ const val OSM = "osm"
+ const val TIANDITU = "tianditu"
+ const val TIANDITU_VECTOR = "tianditu_vector"
+ const val TIANDITU_IMAGE = "tianditu_image"
+
+ fun normalize(source: String): String = when (source) {
+ TIANDITU -> TIANDITU_VECTOR
+ OSM, TIANDITU_VECTOR, TIANDITU_IMAGE -> source
+ else -> TIANDITU_VECTOR
+ }
+}
+
data class DataSourcesSettings(
val useCustomTLE: Boolean,
val useCustomTransceivers: Boolean,
diff --git a/core/presentation/src/main/res/values-es/strings.xml b/core/presentation/src/main/res/values-es/strings.xml
index b2bb7db9..1bbf9d27 100644
--- a/core/presentation/src/main/res/values-es/strings.xml
+++ b/core/presentation/src/main/res/values-es/strings.xml
@@ -38,7 +38,7 @@
Asegúrate de que tus filtros sean correctos y hayas seleccionado satélites
No hay pases de satélite próximos para mostrar
- Bienvenido a Look4Sat\!
+ Bienvenido a Look4Sat-CNMap\!
Asegúrate de configurar tu posición mediante GPS, Latitud/Longitud o QTH en Configuración.
\n\nActualiza la base de datos al menos una vez por semana para obtener predicciones precisas.
diff --git a/core/presentation/src/main/res/values-ru/strings.xml b/core/presentation/src/main/res/values-ru/strings.xml
index bb107c73..795e1ca4 100644
--- a/core/presentation/src/main/res/values-ru/strings.xml
+++ b/core/presentation/src/main/res/values-ru/strings.xml
@@ -37,7 +37,7 @@
Убедитесь, что ваши фильтры настроены правильно и вы выбрали спутники
Нет предстоящих пролётов спутников
- Добро пожаловать в Look4Sat\!
+ Добро пожаловать в Look4Sat-CNMap\!
Обязательно укажите свое местоположение по GPS, широте/долготе или QTH в настройках.
\n\nПожалуйста, обновляйте базу данных как минимум раз в неделю, чтобы получать точные прогнозы.
diff --git a/core/presentation/src/main/res/values-si/strings.xml b/core/presentation/src/main/res/values-si/strings.xml
index ad77e225..37263f98 100644
--- a/core/presentation/src/main/res/values-si/strings.xml
+++ b/core/presentation/src/main/res/values-si/strings.xml
@@ -38,7 +38,7 @@
ඔබගේ පෙරහන් නිවැරදි බවත් ඔබ චන්ද්රිකා තෝරාගෙන ඇති බවත් සහතික කර ගන්න
ප්රදර්ශනය කිරීමට ඉදිරියට එන චන්ද්රිකා පසුකර යෑම් නැත.
- Look4Sat වෙත සාදරයෙන් පිළිගනිමු\!
+ Look4Sat-CNMap වෙත සාදරයෙන් පිළිගනිමු\!
සැකසුම් තුළ GPS, Lat/Lon හෝ QTH හරහා ඔබේ ස්ථානය සැකසීමට වග බලා ගන්න.
\n\nනිවැරදි අනාවැකි ලබා ගැනීම සඳහා අවම වශයෙන් සතිපතා දත්ත සමුදාය යාවත්කාලීන කරන්න.
diff --git a/core/presentation/src/main/res/values-tr/strings.xml b/core/presentation/src/main/res/values-tr/strings.xml
index 071009d6..6c4f639e 100644
--- a/core/presentation/src/main/res/values-tr/strings.xml
+++ b/core/presentation/src/main/res/values-tr/strings.xml
@@ -1,7 +1,7 @@
- Look4Sat
+ Look4Sat-CNMap
Kabul et
İptal
Gösterilecek veri bulunamadı
@@ -42,11 +42,11 @@
Filtrelerinizin doğru olduğundan ve uydu seçtiğinizden emin olun
Gösterilecek yaklaşan uydu geçişi bulunmuyor
- Look4Sat\'a hoş geldiniz\!
+ Look4Sat-CNMap\'a hoş geldiniz\!
Ayarlar\'dan GPS, Enlem/Boylam veya QTH kullanarak konumunuzu belirlediğinizden emin olun.
\n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.
- What\'s new in Look4Sat
+ What\'s new in Look4Sat-CNMap
* Switched to short-form commands in NetworkReporter, by theojalba
\n* Added CAT radio control for full-duplex SAT operation, by jcmerg
@@ -84,7 +84,8 @@
Önceki
Sonraki
- © OpenStreetMap contributors
+ © OpenStreetMap contributors
+ © Tianditu
Azimut: %.1f°
Yükseklik açısı: %.1f°
İrtifa: %.0f km
@@ -100,12 +101,12 @@
Görünür
- Look4Sat v%s
- https://play.google.com/store/apps/details?id=com.rtbishop.look4sat
+ Look4Sat-CNMap v%s
+ https://play.google.com/store/apps/details?id=cn.bh2vsq.look4sat
Bağış yap
https://ko-fi.com/rt_bishop
F-Droid
- https://f-droid.org/en/packages/com.rtbishop.look4sat/
+ https://f-droid.org/en/packages/cn.bh2vsq.look4sat/
GitHub
https://github.com/rt-bishop/Look4Sat/
GPLv3
diff --git a/core/presentation/src/main/res/values-uk/strings.xml b/core/presentation/src/main/res/values-uk/strings.xml
index 8604a1b6..ace67a46 100644
--- a/core/presentation/src/main/res/values-uk/strings.xml
+++ b/core/presentation/src/main/res/values-uk/strings.xml
@@ -38,7 +38,7 @@
Переконайтеся, що ваші фільтри правильні, і ви вибрали супутники
Немає майбутніх прольотів супутників
- Ласкаво просимо до Look4Sat\!
+ Ласкаво просимо до Look4Sat-CNMap\!
Обов’язково встановіть своє місцезнаходження за допомогою GPS, широти/довготи або QTH у налаштуваннях.
\n\nБудь ласка, оновлюйте базу даних принаймні раз на тиждень, щоб отримувати точні прогнози.
diff --git a/core/presentation/src/main/res/values-zh/strings.xml b/core/presentation/src/main/res/values-zh/strings.xml
index 8f69176a..7a6c5cb4 100644
--- a/core/presentation/src/main/res/values-zh/strings.xml
+++ b/core/presentation/src/main/res/values-zh/strings.xml
@@ -32,7 +32,7 @@
高度:%d km
请确保您的过滤器设置正确,并且已选择卫星
没有即将过境的卫星
- 欢迎来到 Look4Sat\!
+ 欢迎来到 Look4Sat-CNMap\!
请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性
diff --git a/core/presentation/src/main/res/values/strings.xml b/core/presentation/src/main/res/values/strings.xml
index de886431..3c4bd264 100644
--- a/core/presentation/src/main/res/values/strings.xml
+++ b/core/presentation/src/main/res/values/strings.xml
@@ -1,7 +1,7 @@
- Look4Sat
+ Look4Sat-CNMap
Accept
Cancel
No data to show
@@ -43,11 +43,11 @@
Make sure your filters are correct and you have selected satellites
There are no upcoming satellite passes to display
- Welcome to Look4Sat\!
+ Welcome to Look4Sat-CNMap\!
Make sure to set your position via GPS, Lat/Lon or QTH in Settings.
\n\nPlease update the database at least weekly to get accurate predictions.
- What\'s new in Look4Sat
+ What\'s new in Look4Sat-CNMap
* Added required tweaks to support Android 17 (API 37)
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output
@@ -82,7 +82,8 @@
Prev
Next
- © OpenStreetMap contributors
+ © OpenStreetMap contributors
+ © Tianditu
Azimuth: %.1f°
Elevation: %.1f°
Altitude: %.0f km
@@ -98,12 +99,12 @@
Visible
- Look4Sat v%s
- https://play.google.com/store/apps/details?id=com.rtbishop.look4sat
+ Look4Sat-CNMap v%s
+ https://play.google.com/store/apps/details?id=cn.bh2vsq.look4sat
Donate
https://ko-fi.com/rt_bishop
F-Droid
- https://f-droid.org/en/packages/com.rtbishop.look4sat/
+ https://f-droid.org/en/packages/cn.bh2vsq.look4sat/
GitHub
https://github.com/rt-bishop/Look4Sat/
GPLv3
@@ -147,6 +148,13 @@
Bluetooth
CAT
+ Map settings
+ OSM
+ 天地图-矢量
+ 天地图-影像
+ 天地图密钥
+ 保存密钥
+
Radio Control
CAT Radio Control
diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapNightOverlay.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapNightOverlay.kt
deleted file mode 100644
index db31dfc1..00000000
--- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapNightOverlay.kt
+++ /dev/null
@@ -1,152 +0,0 @@
-/*
- * Look4Sat. Amateur radio satellite tracker and pass predictor.
- * Copyright (C) 2019-2026 Arty Bishop and contributors.
- *
- * 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 com.rtbishop.look4sat.feature.map
-
-import android.graphics.Canvas
-import android.graphics.Color
-import android.graphics.Paint
-import android.graphics.RectF
-import org.osmdroid.views.MapView
-import org.osmdroid.views.overlay.Overlay
-import kotlin.math.cos
-import kotlin.math.sin
-
-/**
- * Custom osmdroid overlay that shades the night side of the globe.
- *
- * Works entirely in screen-pixel space: for each vertical strip on screen it
- * asks osmdroid for the geographic coordinate, then tests whether that point is
- * in the night half-sphere relative to the sub-solar point. Because the
- * computation happens during draw() the result is always correct regardless
- * of zoom level or map scroll position — no polygon winding issues possible.
- *
- * A point (latRad, lonRad) is in night when the angle to the sub-solar point
- * exceeds 90°, i.e. the dot product of the two unit vectors is negative:
- * dot = sin(lat)*sin(sunLat) + cos(lat)*cos(sunLat)*cos(lon - sunLon) < 0
- *
- * Performance: we sample one column per [stepPx] pixels (default 4) and draw
- * filled vertical rectangles. On a 1080-wide screen this means ~270 trig
- * evaluations per row, which is imperceptible.
- */
-class MapNightOverlay : Overlay() {
-
- /** Sub-solar latitude in degrees. */
- var sunLatDeg: Double = 0.0
-
- /** Sub-solar longitude in degrees. */
- var sunLonDeg: Double = 0.0
-
- private val nightPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
- style = Paint.Style.FILL
- color = Color.argb(75, 0, 0, 0)
- }
-
- private val rect = RectF()
-
- override fun draw(canvas: Canvas, mapView: MapView, shadow: Boolean) {
- if (shadow) return
-
- val proj = mapView.projection
- val sunLatRad = Math.toRadians(sunLatDeg)
- val sunLonRad = Math.toRadians(sunLonDeg)
- val sinSunLat = sin(sunLatRad)
- val cosSunLat = cos(sunLatRad)
-
- val w = mapView.width
- val h = mapView.height
- val stepPx = 4 // sample every N pixels — balance quality vs CPU
-
- // We scan column by column. For each column we determine the longitude,
- // then find the latitude range that is in night and shade it.
- // Since longitude is constant along a vertical strip and the day/night
- // boundary at a given longitude is at most two latitudes, we can do a
- // scan-line fill efficiently.
-
- var x = 0
- while (x < w) {
- // Get the geographic coordinate at the top and bottom of this column.
- val geoTop = proj.fromPixels(x, 0) ?: run { x += stepPx; continue }
- val geoBot = proj.fromPixels(x, h - 1) ?: run { x += stepPx; continue }
-
- val lonRad = Math.toRadians(geoTop.longitude)
- val cosLonDiff = cos(lonRad - sunLonRad)
-
- // Top pixel geographic lat
- val latTopRad = Math.toRadians(geoTop.latitude)
- // Bottom pixel geographic lat (osmdroid: y=0 is top of screen, higher y = lower lat)
- val latBotRad = Math.toRadians(geoBot.latitude)
-
- // dot(sunVec, pointVec) < 0 → night
- // dot = sin(lat)*sinSunLat + cos(lat)*cosSunLat*cosLonDiff
- val dotTop = sin(latTopRad) * sinSunLat + cos(latTopRad) * cosSunLat * cosLonDiff
- val dotBot = sin(latBotRad) * sinSunLat + cos(latBotRad) * cosSunLat * cosLonDiff
-
- when {
- dotTop < 0 && dotBot < 0 -> {
- // Entire column is night — shade from top to bottom
- rect.set(x.toFloat(), 0f, (x + stepPx).toFloat(), h.toFloat())
- canvas.drawRect(rect, nightPaint)
- }
-
- dotTop >= 0 && dotBot >= 0 -> {
- // Entire column is day — nothing to draw
- }
-
- else -> {
- // Terminator crosses this column — find the crossing pixel by binary search
- val crossY = findCrossingY(proj, x, 0, h - 1, sinSunLat, cosSunLat, cosLonDiff)
- if (dotTop < 0) {
- // Night at top, day at bottom
- rect.set(x.toFloat(), 0f, (x + stepPx).toFloat(), crossY.toFloat())
- canvas.drawRect(rect, nightPaint)
- } else {
- // Day at top, night at bottom
- rect.set(x.toFloat(), crossY.toFloat(), (x + stepPx).toFloat(), h.toFloat())
- canvas.drawRect(rect, nightPaint)
- }
- }
- }
- x += stepPx
- }
- }
-
- /**
- * Binary-search for the pixel row where the day/night boundary crosses column [x].
- * [yTop] is in day, [yBot] is in night (or vice versa).
- */
- private fun findCrossingY(
- proj: org.osmdroid.views.Projection,
- x: Int,
- yTop: Int,
- yBot: Int,
- sinSunLat: Double,
- cosSunLat: Double,
- cosLonDiff: Double
- ): Int {
- var lo = yTop
- var hi = yBot
- while (hi - lo > 1) {
- val mid = (lo + hi) / 2
- val geo = proj.fromPixels(x, mid) ?: return mid
- val latRad = Math.toRadians(geo.latitude)
- val dot = sin(latRad) * sinSunLat + cos(latRad) * cosSunLat * cosLonDiff
- if (dot < 0) hi = mid else lo = mid
- }
- return (lo + hi) / 2
- }
-}
diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt
index 3ed234d4..10ca6770 100644
--- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt
+++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapScreen.kt
@@ -60,6 +60,7 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
+import com.rtbishop.look4sat.core.domain.model.MapSource
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
@@ -71,37 +72,56 @@ import com.rtbishop.look4sat.core.presentation.TimerRow
import com.rtbishop.look4sat.core.presentation.TopBar
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding
+import org.osmdroid.config.Configuration
+import org.osmdroid.tileprovider.MapTileProviderBasic
+import org.osmdroid.tileprovider.tilesource.OnlineTileSourceBase
+import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.tileprovider.tilesource.XYTileSource
import org.osmdroid.util.GeoPoint
+import org.osmdroid.util.MapTileIndex
import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.FolderOverlay
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polyline
+import org.osmdroid.views.overlay.TilesOverlay
+import java.io.File
+import java.net.URLEncoder
// Overlay indices
-private const val OVERLAY_STATION = 0
-private const val OVERLAY_TRACK = 1
-private const val OVERLAY_FOOTPRINT = 2
-private const val OVERLAY_POSITIONS = 3
-private const val OVERLAY_TERMINATOR = 4
+private const val OVERLAY_TDT_LABELS = 0
+private const val OVERLAY_STATION = 1
+private const val OVERLAY_TRACK = 2
+private const val OVERLAY_FOOTPRINT = 3
+private const val OVERLAY_POSITIONS = 4
private const val OVERLAY_SUN = 5
private const val OVERLAY_MOON = 6
private const val OVERLAY_COUNT = 7
private val minLat = MapView.getTileSystem().minLatitude
private val maxLat = MapView.getTileSystem().maxLatitude
+private val offlineTileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray())
+private const val OFFLINE_MAX_ZOOM = 7.0
+private const val OSM_MAX_ZOOM = 19.0
+private const val TIANDITU_MAX_ZOOM = 18.0
+private const val TILE_CACHE_MAX_BYTES = 1024L * 1024L * 1024L
+private const val TILE_CACHE_TRIM_BYTES = 900L * 1024L * 1024L
+private const val TILE_REFRESH_DELAY_MS = 250L
+private const val TILE_SOURCE_OSM = "osm"
+private const val TILE_USER_AGENT = "Look4Sat-CNMap"
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
- strokeWidth = 3f
+ strokeWidth = 4f
style = Paint.Style.STROKE
- color = Color.RED
+ color = "#1565C0".toColorInt()
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
}
-private val footprintPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
- strokeWidth = 3f
- style = Paint.Style.FILL_AND_STROKE
- color = "#FFE082".toColorInt()
+private val footprintOutlinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ strokeWidth = 4f
+ style = Paint.Style.STROKE
+ color = "#00897B".toColorInt()
+ strokeCap = Paint.Cap.ROUND
+ strokeJoin = Paint.Join.ROUND
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 36f
@@ -134,11 +154,17 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
val rotateMod = Modifier.rotate(180f)
val timeString = uiState.mapData?.aosTime ?: "00:00:00"
val isTimeAos = uiState.mapData?.isTimeAos ?: true
+ val usesTianditu = tiandituLayers(uiState.mapSource) != null && uiState.tiandituKey.isNotBlank()
+ val copyrightResId = if (usesTianditu) R.string.map_copyright_tianditu
+ else R.string.map_copyright_osm
LaunchedEffect(uiState.track) {
val firstPos = uiState.track?.firstOrNull()?.firstOrNull() ?: return@LaunchedEffect
mapView.controller.animateTo(GeoPoint(firstPos.latitude, firstPos.longitude))
}
+ LaunchedEffect(uiState.mapSource, uiState.tiandituKey) {
+ configureTileSources(mapView, uiState.mapSource, uiState.tiandituKey)
+ }
Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
val isVertical = isVerticalLayout()
if (isVertical) {
@@ -163,13 +189,12 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
uiState.track?.let { setSatelliteTrack(it, view) }
uiState.footprint?.let { setFootprint(it, view) }
uiState.positions?.let { setPositions(it, view) { item -> onAction(MapAction.SelectItem(item)) } }
- setTerminator(uiState.sunLatDeg, uiState.sunLonDeg, view)
setSubSolarPoint(uiState.sunLatDeg, uiState.sunLonDeg, view)
setMoonPosition(uiState.moonLatDeg, uiState.moonLonDeg, view)
view.invalidate()
}
uiState.mapData?.let { mapData ->
- if (isVertical) MapDataCard(mapData) else MapDataCards(mapData)
+ if (isVertical) MapDataCard(mapData, copyrightResId) else MapDataCards(mapData, copyrightResId)
}
}
}
@@ -178,11 +203,11 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
// region Map data composables
@Composable
-private fun MapDataCard(data: MapData) {
+private fun MapDataCard(data: MapData, copyrightResId: Int) {
val textColor = MaterialTheme.colorScheme.primary
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
- Text(text = stringResource(R.string.map_copyright), fontSize = 14.sp)
+ Text(text = stringResource(copyrightResId), fontSize = 14.sp)
Card(colors = cardColors) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
MapDataRow(
@@ -220,7 +245,7 @@ private fun MapDataRow(
}
@Composable
-private fun MapDataCards(data: MapData) {
+private fun MapDataCards(data: MapData, copyrightResId: Int) {
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
val paddingMod = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
@@ -246,7 +271,7 @@ private fun MapDataCards(data: MapData) {
}
}
Text(
- text = stringResource(R.string.map_copyright),
+ text = stringResource(copyrightResId),
fontSize = 14.sp,
modifier = Modifier.align(Alignment.BottomCenter)
)
@@ -260,6 +285,114 @@ private fun MapDataCards(data: MapData) {
}
// endregion
+// region Tile sources
+private var configuredTileMapView: MapView? = null
+private var configuredTileSource: String? = null
+
+private fun configureTileSources(mapView: MapView, mapSource: String, tiandituKey: String) {
+ val key = tiandituKey.trim()
+ val layers = tiandituLayers(mapSource)
+ val tileSourceKey = if (layers == null || key.isBlank()) {
+ TILE_SOURCE_OSM
+ } else {
+ "tianditu:${layers.base}:${layers.label}:$key"
+ }
+ if (configuredTileMapView === mapView && configuredTileSource == tileSourceKey) {
+ requestTileRefresh(mapView)
+ return
+ }
+ configuredTileMapView = mapView
+ configuredTileSource = tileSourceKey
+
+ if (layers == null || key.isBlank()) {
+ mapView.setUseDataConnection(true)
+ mapView.setTileProvider(MapTileProviderBasic(mapView.context, TileSourceFactory.MAPNIK))
+ mapView.maxZoomLevel = OSM_MAX_ZOOM
+ mapView.overlayManager.tilesOverlay.applyTileOverlayDefaults()
+ mapView.overlays[OVERLAY_TDT_LABELS] = FolderOverlay()
+ requestTileRefresh(mapView)
+ return
+ }
+ mapView.setUseDataConnection(true)
+ mapView.setTileProvider(MapTileProviderBasic(mapView.context, TiandituTileSource(layer = layers.base, key = key)))
+ mapView.maxZoomLevel = TIANDITU_MAX_ZOOM
+ mapView.overlayManager.tilesOverlay.applyTileOverlayDefaults()
+ mapView.overlays[OVERLAY_TDT_LABELS] = TilesOverlay(
+ MapTileProviderBasic(mapView.context, TiandituTileSource(layer = layers.label, key = key)),
+ mapView.context
+ ).apply {
+ applyTileOverlayDefaults()
+ setUseDataConnection(true)
+ }
+ requestTileRefresh(mapView)
+}
+
+private data class TiandituLayers(val base: String, val label: String)
+
+private fun tiandituLayers(mapSource: String): TiandituLayers? = when (MapSource.normalize(mapSource)) {
+ MapSource.TIANDITU_VECTOR -> TiandituLayers(base = "vec", label = "cva")
+ MapSource.TIANDITU_IMAGE -> TiandituLayers(base = "img", label = "cia")
+ else -> null
+}
+
+private fun TilesOverlay.applyTileOverlayDefaults() {
+ setColorFilter(null)
+ setLoadingBackgroundColor(Color.TRANSPARENT)
+ setLoadingLineColor(Color.TRANSPARENT)
+}
+
+private fun requestTileRefresh(mapView: MapView) {
+ mapView.post {
+ mapView.requestLayout()
+ mapView.invalidate()
+ mapView.postInvalidate()
+ }
+ mapView.postDelayed({
+ mapView.invalidate()
+ mapView.postInvalidate()
+ }, TILE_REFRESH_DELAY_MS)
+}
+
+private fun buildTiandituWmtsUrl(
+ baseUrl: String,
+ layer: String,
+ zoom: Int,
+ row: Int,
+ col: Int,
+ encodedKey: String
+): String = "$baseUrl?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=$layer" +
+ "&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX=$zoom&TILEROW=$row&TILECOL=$col&tk=$encodedKey"
+
+private class TiandituTileSource(
+ private val layer: String,
+ private val key: String
+) : OnlineTileSourceBase(
+ "tianditu-wmts-https-$layer",
+ 1,
+ 18,
+ 256,
+ ".png",
+ Array(8) { index -> "https://t$index.tianditu.gov.cn/${layer}_w/wmts" },
+ "Tianditu"
+) {
+ private val encodedKey = URLEncoder.encode(key, Charsets.UTF_8.name())
+
+ override fun getTileURLString(pMapTileIndex: Long): String {
+ val zoom = MapTileIndex.getZoom(pMapTileIndex)
+ val row = MapTileIndex.getY(pMapTileIndex)
+ val col = MapTileIndex.getX(pMapTileIndex)
+ return buildTiandituWmtsUrl(
+ baseUrl = getBaseUrl(),
+ layer = layer,
+ zoom = zoom,
+ row = row,
+ col = col,
+ encodedKey = encodedKey
+ )
+ }
+}
+// endregion
+
// region Map overlay helpers
private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
try {
@@ -282,6 +415,8 @@ private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
/** Pool of reusable Marker objects keyed by satellite name, to avoid re-creation every frame */
private val markerPool = HashMap()
private var lastMapView: MapView? = null
+private var lastSatelliteTrack: List>? = null
+private var satelliteTrackOverlay: FolderOverlay? = null
private fun setPositions(
posMap: Map,
@@ -294,8 +429,9 @@ private fun setPositions(
lastMapView = mapView
markerPool.clear()
iconCache.evictAll()
- footprintPolyline = null
- footprintPoints = null
+ lastSatelliteTrack = null
+ satelliteTrackOverlay = null
+ footprintOverlay = null
}
// Reuse the existing FolderOverlay — creating a new one and replacing it
// causes osmdroid to detach shared Marker objects, making them invisible.
@@ -357,6 +493,10 @@ private fun getCachedTextIcon(name: String, mapView: MapView): Drawable {
}
private fun setSatelliteTrack(satTrack: List>, mapView: MapView) {
+ val cachedOverlay = satelliteTrackOverlay
+ if (lastSatelliteTrack === satTrack && cachedOverlay != null && mapView.overlays[OVERLAY_TRACK] === cachedOverlay) {
+ return
+ }
val trackOverlay = FolderOverlay()
try {
satTrack.forEach { track ->
@@ -366,61 +506,76 @@ private fun setSatelliteTrack(satTrack: List>, mapView: MapView) {
trackOverlay.add(this)
}
}
+ lastSatelliteTrack = satTrack
+ satelliteTrackOverlay = trackOverlay
mapView.overlays[OVERLAY_TRACK] = trackOverlay
} catch (e: Exception) {
println(e)
}
}
-/** Reusable footprint Polyline — created once, points updated in-place each frame */
-private var footprintPolyline: Polyline? = null
-private var footprintPoints: ArrayList? = null
+/** Reusable footprint overlay - split at the date line so wrapped maps don't drop the circle. */
+private var footprintOverlay: FolderOverlay? = null
private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
try {
val rangeCircle = orbitalPos.getRangeCircle()
- var pts = footprintPoints
- if (pts == null || pts.size != rangeCircle.size) {
- pts = ArrayList(rangeCircle.size)
- for (gp in rangeCircle) pts.add(GeoPoint(gp.latitude, gp.longitude))
- footprintPoints = pts
- } else {
- for (i in rangeCircle.indices) {
- pts[i].latitude = rangeCircle[i].latitude
- pts[i].longitude = rangeCircle[i].longitude
+ val overlay = footprintOverlay ?: FolderOverlay().also { footprintOverlay = it }
+ overlay.items.clear()
+ splitClosedPathOnDateLine(rangeCircle).forEach { segment ->
+ if (segment.size > 1) {
+ overlay.add(
+ Polyline().apply {
+ setPoints(segment.map { GeoPoint(it.latitude, it.longitude) })
+ outlinePaint.set(footprintOutlinePaint)
+ }
+ )
}
}
- val polyline = footprintPolyline ?: Polyline().apply {
- outlinePaint.set(footprintPaint)
- footprintPolyline = this
- }
- polyline.setPoints(pts)
- mapView.overlays[OVERLAY_FOOTPRINT] = polyline
+ mapView.overlays[OVERLAY_FOOTPRINT] = overlay
} catch (e: Exception) {
println(e)
}
}
-/**
- * Update the NightOverlay with the current sub-solar position.
- * The overlay is created once and kept in OVERLAY_TERMINATOR; only its
- * sunLatDeg/sunLonDeg fields are updated each tick so osmdroid redraws it.
- */
-private fun setTerminator(sunLatDeg: Double, sunLonDeg: Double, mapView: MapView) {
- try {
- val overlay = mapView.overlays[OVERLAY_TERMINATOR]
- if (overlay is MapNightOverlay) {
- overlay.sunLatDeg = sunLatDeg
- overlay.sunLonDeg = sunLonDeg
- } else {
- mapView.overlays[OVERLAY_TERMINATOR] = MapNightOverlay().apply {
- this.sunLatDeg = sunLatDeg
- this.sunLonDeg = sunLonDeg
+private fun splitClosedPathOnDateLine(points: List): List> {
+ if (points.isEmpty()) return emptyList()
+ val segments = mutableListOf>()
+ val segment = mutableListOf()
+ var previous = points.first()
+ segment.add(previous)
+ (points.drop(1) + points.first()).forEach { current ->
+ when {
+ previous.longitude < -170.0 && current.longitude > 170.0 -> {
+ val edgeLatitude = getDateLineLatitude(previous, current, -180.0)
+ segment.add(GeoPos(edgeLatitude, -180.0))
+ segments.add(segment.toList())
+ segment.clear()
+ segment.add(GeoPos(edgeLatitude, 180.0))
+ }
+ previous.longitude > 170.0 && current.longitude < -170.0 -> {
+ val edgeLatitude = getDateLineLatitude(previous, current, 180.0)
+ segment.add(GeoPos(edgeLatitude, 180.0))
+ segments.add(segment.toList())
+ segment.clear()
+ segment.add(GeoPos(edgeLatitude, -180.0))
}
}
- } catch (e: Exception) {
- println(e)
+ segment.add(current)
+ previous = current
}
+ if (segment.size > 1) segments.add(segment)
+ return segments
+}
+
+private fun getDateLineLatitude(previous: GeoPos, current: GeoPos, edgeLongitude: Double): Double {
+ val currentLongitude = when {
+ previous.longitude < -170.0 && current.longitude > 170.0 -> current.longitude - 360.0
+ previous.longitude > 170.0 && current.longitude < -170.0 -> current.longitude + 360.0
+ else -> current.longitude
+ }
+ val fraction = (edgeLongitude - previous.longitude) / (currentLongitude - previous.longitude)
+ return previous.latitude + (current.latitude - previous.latitude) * fraction
}
/** Place an ic_sun icon marker at the sub-solar point. */
@@ -482,40 +637,62 @@ private fun setMoonPosition(moonLatDeg: Double, moonLonDeg: Double, mapView: Map
// region MapView lifecycle
@Composable
private fun rememberMapViewWithLifecycle(): MapView {
- val tileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray())
val context = LocalContext.current
val isVertical = isVerticalLayout()
val mapView = remember {
+ configureOsmdroidCache(context)
MapView(context).apply {
setMultiTouchControls(true)
setUseDataConnection(false)
- setTileSource(tileSource)
+ setTileSource(offlineTileSource)
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels, isVertical)
- maxZoomLevel = 7.0
+ maxZoomLevel = OFFLINE_MAX_ZOOM
controller.setCenter(GeoPoint(48.8575, 6.3514))
controller.setZoom(minZoomLevel + 2)
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
overlayManager.tilesOverlay.setColorFilter(createColorFilter())
+ setHorizontalMapRepetitionEnabled(true)
+ setVerticalMapRepetitionEnabled(false)
setScrollableAreaLimitLatitude(maxLat, minLat, 0)
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
+ addOnFirstLayoutListener { _, _, _, _, _ -> requestTileRefresh(this) }
}
}
val lifecycleObserver = rememberMapViewLifecycleObserver(mapView)
val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) {
lifecycle.addObserver(lifecycleObserver)
+ if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
+ mapView.onResume()
+ requestTileRefresh(mapView)
+ }
onDispose { lifecycle.removeObserver(lifecycleObserver) }
}
return mapView
}
+private fun configureOsmdroidCache(context: android.content.Context) {
+ val cacheRoot = File(context.filesDir, "osmdroid")
+ val tileCache = File(cacheRoot, "tiles")
+ Configuration.getInstance().apply {
+ setUserAgentValue(TILE_USER_AGENT)
+ setOsmdroidBasePath(cacheRoot)
+ setOsmdroidTileCache(tileCache)
+ setTileFileSystemCacheMaxBytes(TILE_CACHE_MAX_BYTES)
+ setTileFileSystemCacheTrimBytes(TILE_CACHE_TRIM_BYTES)
+ }
+}
+
@Composable
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
LifecycleEventObserver { _, event ->
when (event) {
- Lifecycle.Event.ON_RESUME -> mapView.onResume()
+ Lifecycle.Event.ON_RESUME -> {
+ mapView.onResume()
+ requestTileRefresh(mapView)
+ }
Lifecycle.Event.ON_PAUSE -> mapView.onPause()
else -> {}
}
diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapState.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapState.kt
index e1274618..92e826a0 100644
--- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapState.kt
+++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapState.kt
@@ -26,6 +26,8 @@ data class MapState(
val mapData: MapData? = null,
val isLightUi: Boolean = false,
val isUtc: Boolean = false,
+ val mapSource: String = "",
+ val tiandituKey: String = "",
val stationPosition: GeoPos? = null,
val orbitalPass: OrbitalPass,
val track: List>? = null,
diff --git a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt
index 118a089d..1b95d5f3 100644
--- a/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt
+++ b/feature/map/src/main/java/com/rtbishop/look4sat/feature/map/MapViewModel.kt
@@ -61,6 +61,8 @@ class MapViewModel(
MapState(
isLightUi = settingsRepo.otherSettings.value.stateOfLightTheme,
isUtc = settingsRepo.otherSettings.value.stateOfUtc,
+ mapSource = settingsRepo.otherSettings.value.mapSource,
+ tiandituKey = settingsRepo.otherSettings.value.tiandituKey,
orbitalPass = defaultPass
)
)
@@ -74,7 +76,13 @@ class MapViewModel(
init {
viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings ->
- _uiState.update { it.copy(isUtc = settings.stateOfUtc) }
+ _uiState.update {
+ it.copy(
+ isUtc = settings.stateOfUtc,
+ mapSource = settings.mapSource,
+ tiandituKey = settings.tiandituKey
+ )
+ }
}
}
val (selectedCatNum, _) = satelliteRepo.selectedPass.value
@@ -269,29 +277,45 @@ class MapViewModel(
val satTracks = mutableListOf>()
val currentTrack = mutableListOf()
val endDate = Date(date.time + (orbitalObject.data.orbitalPeriod * 2.4 * 60000L).toLong())
- var oldLongitude = 0.0
+ var previousPosition: GeoPos? = null
satelliteRepo.getTrack(orbitalObject, pos, date.time, endDate.time).forEach { satPos ->
val currentPosition = satPos.toMapGeoPos()
- if (oldLongitude < -170.0 && currentPosition.longitude > 170.0) {
+ val previous = previousPosition
+ if (previous != null && previous.longitude < -170.0 && currentPosition.longitude > 170.0) {
// adding left terminal position
- currentTrack.add(GeoPos(currentPosition.latitude, -180.0))
+ val edgeLatitude = getDateLineLatitude(previous, currentPosition)
+ currentTrack.add(GeoPos(edgeLatitude, -180.0))
val finishedTrack = mutableListOf().apply { addAll(currentTrack) }
satTracks.add(finishedTrack)
currentTrack.clear()
- } else if (oldLongitude > 170.0 && currentPosition.longitude < -170.0) {
+ currentTrack.add(GeoPos(edgeLatitude, 180.0))
+ } else if (previous != null && previous.longitude > 170.0 && currentPosition.longitude < -170.0) {
// adding right terminal position
- currentTrack.add(GeoPos(currentPosition.latitude, 180.0))
+ val edgeLatitude = getDateLineLatitude(previous, currentPosition)
+ currentTrack.add(GeoPos(edgeLatitude, 180.0))
val finishedTrack = mutableListOf().apply { addAll(currentTrack) }
satTracks.add(finishedTrack)
currentTrack.clear()
+ currentTrack.add(GeoPos(edgeLatitude, -180.0))
}
- oldLongitude = currentPosition.longitude
+ previousPosition = currentPosition
currentTrack.add(currentPosition)
}
satTracks.add(currentTrack)
_uiState.update { it.copy(track = satTracks) }
}
+ private fun getDateLineLatitude(previous: GeoPos, current: GeoPos): Double {
+ val currentLon = if (previous.longitude < 0.0 && current.longitude > 0.0) {
+ current.longitude - 360.0
+ } else {
+ current.longitude + 360.0
+ }
+ val edgeLon = if (previous.longitude < 0.0) -180.0 else 180.0
+ val fraction = (edgeLon - previous.longitude) / (currentLon - previous.longitude)
+ return previous.latitude + (current.latitude - previous.latitude) * fraction
+ }
+
companion object {
/** Number of parallel chunks for satellite position computation */
private const val PARALLEL_CHUNKS = 4
diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt
index 4d96e05a..8687fdec 100644
--- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt
+++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsScreen.kt
@@ -34,10 +34,14 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ElevatedCard
+import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -56,11 +60,13 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
+import com.rtbishop.look4sat.core.domain.model.MapSource
import com.rtbishop.look4sat.core.domain.model.OtherSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
@@ -280,6 +286,7 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
)
}
item { OtherCard(uiState.otherSettings, onAction) }
+ item { MapSettingsCard(uiState.otherSettings, onAction) }
item { CardCredits() }
}
}
@@ -462,6 +469,106 @@ private fun OtherCardPreview() = MainTheme {
OtherCard(settings = values) {}
}
+@Composable
+private fun MapSettingsCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
+ var keyValue by remember(settings.tiandituKey) { mutableStateOf(settings.tiandituKey) }
+ LaunchedEffect(keyValue) {
+ kotlinx.coroutines.delay(400)
+ if (keyValue.trim() != settings.tiandituKey) {
+ onAction(SettingsAction.SetTiandituKey(keyValue))
+ }
+ }
+ ElevatedCard(modifier = Modifier.fillMaxWidth()) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
+ ) {
+ Text(
+ text = stringResource(id = R.string.prefs_map_title),
+ color = MaterialTheme.colorScheme.primary
+ )
+ Spacer(modifier = Modifier.height(6.dp))
+ val currentMapSource = MapSource.normalize(settings.mapSource)
+ MapSourceRow(
+ currentMapSource = currentMapSource,
+ firstSource = MapSource.OSM,
+ firstTitle = stringResource(id = R.string.prefs_map_source_osm),
+ secondSource = MapSource.TIANDITU_VECTOR,
+ secondTitle = stringResource(id = R.string.prefs_map_source_tianditu_vector),
+ onAction = onAction
+ )
+ Spacer(modifier = Modifier.height(6.dp))
+ MapSourceRow(
+ currentMapSource = currentMapSource,
+ firstSource = MapSource.TIANDITU_IMAGE,
+ firstTitle = stringResource(id = R.string.prefs_map_source_tianditu_image),
+ onAction = onAction
+ )
+ Spacer(modifier = Modifier.height(6.dp))
+ OutlinedTextField(
+ value = keyValue,
+ onValueChange = { keyValue = it },
+ label = { Text(text = stringResource(id = R.string.prefs_map_tianditu_key)) },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+ Spacer(modifier = Modifier.height(6.dp))
+ CardButton(
+ onClick = { onAction(SettingsAction.SetTiandituKey(keyValue)) },
+ text = stringResource(id = R.string.prefs_map_save_key),
+ modifier = Modifier.fillMaxWidth()
+ )
+ }
+ }
+}
+
+@Composable
+private fun MapSourceRow(
+ currentMapSource: String,
+ firstSource: String,
+ firstTitle: String,
+ secondSource: String? = null,
+ secondTitle: String? = null,
+ onAction: (SettingsAction) -> Unit
+) {
+ Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
+ MapSourceButton(
+ onClick = { onAction(SettingsAction.SetMapSource(firstSource)) },
+ text = firstTitle,
+ selected = currentMapSource == firstSource,
+ modifier = Modifier.weight(1f)
+ )
+ if (secondSource != null && secondTitle != null) {
+ MapSourceButton(
+ onClick = { onAction(SettingsAction.SetMapSource(secondSource)) },
+ text = secondTitle,
+ selected = currentMapSource == secondSource,
+ modifier = Modifier.weight(1f)
+ )
+ } else {
+ Spacer(modifier = Modifier.weight(1f))
+ }
+ }
+}
+
+@Composable
+private fun MapSourceButton(onClick: () -> Unit, text: String, selected: Boolean, modifier: Modifier = Modifier) {
+ ElevatedButton(
+ onClick = onClick,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = if (selected) MaterialTheme.colorScheme.primary
+ else MaterialTheme.colorScheme.surfaceVariant,
+ contentColor = if (selected) MaterialTheme.colorScheme.onPrimary
+ else MaterialTheme.colorScheme.onSurfaceVariant
+ ),
+ shape = MaterialTheme.shapes.small,
+ modifier = modifier,
+ contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)
+ ) {
+ Text(text = text, fontSize = 16.sp, textAlign = TextAlign.Center)
+ }
+}
+
@Composable
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
ElevatedCard(
diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt
index 8943ab2d..37af26fc 100644
--- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt
+++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsState.kt
@@ -61,6 +61,8 @@ sealed interface SettingsAction {
data class ToggleSensor(val value: Boolean) : SettingsAction
data class ToggleLightTheme(val value: Boolean) : SettingsAction
data class ToggleNightMode(val value: Boolean) : SettingsAction
+ data class SetMapSource(val value: String) : SettingsAction
+ data class SetTiandituKey(val value: String) : SettingsAction
// Remote control
data class UpdateRC(val settings: RCSettings) : SettingsAction
diff --git a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt
index 6a685155..89d9763c 100644
--- a/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt
+++ b/feature/settings/src/main/java/com/rtbishop/look4sat/feature/settings/SettingsViewModel.kt
@@ -121,6 +121,8 @@ class SettingsViewModel(
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = action.value) }
+ is SettingsAction.SetMapSource -> settingsRepo.updateOtherSettings { it.copy(mapSource = action.value) }
+ is SettingsAction.SetTiandituKey -> settingsRepo.updateOtherSettings { it.copy(tiandituKey = action.value.trim()) }
// Remote control & data sources
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
diff --git a/gradle.properties b/gradle.properties
index 6308f0e2..8ecdada7 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -3,3 +3,6 @@ org.gradle.caching=true
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.workers.max=4
+
+# Enabled parallel sync for Gradle 9.4+
+org.gradle.tooling.parallel=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b49a71a7..99fbb3d9 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -10,7 +10,7 @@ minSdk = "24"
#noinspection UnusedVersionCatalogEntry
jdkVersion = "21"
#noinspection UnusedVersionCatalogEntry
-packageName = "com.rtbishop.look4sat"
+packageName = "cn.bh2vsq.look4sat"
android-gradle-plugin = "9.2.1"
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index a8002a4a..75457f39 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,7 +1,7 @@
#Sat Jun 27 12:51:12 BST 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
+distributionUrl=https\://mirrors.aliyun.com/gradle/distributions/v9.6.1/gradle-9.6.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME