Compare commits

...
1 Commits
Author SHA1 Message Date
BH2VSQ cf23d53ee8 Add TianDiTu 2026-07-31 03:28:35 +08:00
26 changed files with 453 additions and 247 deletions

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
-keep class org.osmdroid.** { *; }
-keep class com.rtbishop.look4sat.feature.map.*TiandituTileSource* { *; }
-dontwarn org.osmdroid.**
+4 -3
View File
@@ -15,14 +15,15 @@
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<application <application
android:name=".MainApplication" android:name="cn.bh2vsq.look4sat.MainApplication"
android:allowBackup="false" android:allowBackup="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"> android:roundIcon="@mipmap/ic_launcher_round">
<activity <activity
android:name=".MainActivity" android:name="cn.bh2vsq.look4sat.MainActivity"
android:exported="true" android:exported="true"
android:theme="@style/Theme.Look4Sat.SplashScreen"> android:theme="@style/Theme.Look4Sat.SplashScreen">
<intent-filter> <intent-filter>
@@ -41,7 +42,7 @@
<meta-data <meta-data
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED" android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
android:value="com.rtbishop.look4sat" /> android:value="cn.bh2vsq.look4sat" />
</application> </application>
</manifest> </manifest>
@@ -15,7 +15,7 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package com.rtbishop.look4sat package cn.bh2vsq.look4sat
import android.content.Context import android.content.Context
import android.content.res.Configuration import android.content.res.Configuration
@@ -15,7 +15,7 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package com.rtbishop.look4sat package cn.bh2vsq.look4sat
import android.app.Application import android.app.Application
import com.rtbishop.look4sat.core.data.injection.MainContainer import com.rtbishop.look4sat.core.data.injection.MainContainer
@@ -15,7 +15,7 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package com.rtbishop.look4sat package cn.bh2vsq.look4sat
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">tianditu.gov.cn</domain>
</domain-config>
</network-security-config>
@@ -69,7 +69,7 @@ internal fun Project.setupAndroidApp() {
release { release {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
} }
} }
androidResources { androidResources {
@@ -25,6 +25,7 @@ import androidx.core.location.LocationListenerCompat
import androidx.core.location.LocationManagerCompat import androidx.core.location.LocationManagerCompat
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
import com.rtbishop.look4sat.core.domain.model.DatabaseState 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.OtherSettings
import com.rtbishop.look4sat.core.domain.model.PassesSettings import com.rtbishop.look4sat.core.domain.model.PassesSettings
import com.rtbishop.look4sat.core.domain.model.RCSettings import com.rtbishop.look4sat.core.domain.model.RCSettings
@@ -82,6 +83,8 @@ class SettingsRepo(
private val keyShouldSeeWarning = "shouldSeeWarning" private val keyShouldSeeWarning = "shouldSeeWarning"
private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName" private val keyShouldSeeWhatsNew = "shouldSeeWhatsNew_v$appVersionName"
private val keySstvMode = "sstvMode" private val keySstvMode = "sstvMode"
private val keyMapSource = "mapSource"
private val keyTiandituKey = "tiandituKey"
private val keyUseCustomTle = "useCustomTle" private val keyUseCustomTle = "useCustomTle"
private val keyUseCustomTransceivers = "useCustomTransceivers" private val keyUseCustomTransceivers = "useCustomTransceivers"
private val keyTleUrl = "tleUrl" private val keyTleUrl = "tleUrl"
@@ -335,6 +338,8 @@ class SettingsRepo(
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning) putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew) putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
putString(keySstvMode, new.sstvMode) putString(keySstvMode, new.sstvMode)
putString(keyMapSource, MapSource.normalize(new.mapSource))
putString(keyTiandituKey, new.tiandituKey)
} }
new new
} }
@@ -349,7 +354,9 @@ class SettingsRepo(
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false), stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true), shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, 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 //endregion
@@ -57,9 +57,24 @@ data class OtherSettings(
val stateOfNightMode: Boolean = false, val stateOfNightMode: Boolean = false,
val shouldSeeWarning: Boolean, val shouldSeeWarning: Boolean,
val shouldSeeWhatsNew: 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( data class DataSourcesSettings(
val useCustomTLE: Boolean, val useCustomTLE: Boolean,
val useCustomTransceivers: Boolean, val useCustomTransceivers: Boolean,
@@ -38,7 +38,7 @@
<string name="pass_empty_list_message"> <string name="pass_empty_list_message">
Asegúrate de que tus filtros sean correctos y hayas seleccionado satélites</string> Asegúrate de que tus filtros sean correctos y hayas seleccionado satélites</string>
<string name="pass_error_message">No hay pases de satélite próximos para mostrar</string> <string name="pass_error_message">No hay pases de satélite próximos para mostrar</string>
<string name="pass_welcome_title">Bienvenido a Look4Sat\!</string> <string name="pass_welcome_title">Bienvenido a Look4Sat-CNMap\!</string>
<string name="pass_welcome_message"> <string name="pass_welcome_message">
Asegúrate de configurar tu posición mediante GPS, Latitud/Longitud o QTH en Configuración. 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.</string> \n\nActualiza la base de datos al menos una vez por semana para obtener predicciones precisas.</string>
@@ -37,7 +37,7 @@
<string name="pass_empty_list_message"> <string name="pass_empty_list_message">
Убедитесь, что ваши фильтры настроены правильно и вы выбрали спутники</string> Убедитесь, что ваши фильтры настроены правильно и вы выбрали спутники</string>
<string name="pass_error_message">Нет предстоящих пролётов спутников</string> <string name="pass_error_message">Нет предстоящих пролётов спутников</string>
<string name="pass_welcome_title">Добро пожаловать в Look4Sat\!</string> <string name="pass_welcome_title">Добро пожаловать в Look4Sat-CNMap\!</string>
<string name="pass_welcome_message"> <string name="pass_welcome_message">
Обязательно укажите свое местоположение по GPS, широте/долготе или QTH в настройках. Обязательно укажите свое местоположение по GPS, широте/долготе или QTH в настройках.
\n\nПожалуйста, обновляйте базу данных как минимум раз в неделю, чтобы получать точные прогнозы.</string> \n\nПожалуйста, обновляйте базу данных как минимум раз в неделю, чтобы получать точные прогнозы.</string>
@@ -38,7 +38,7 @@
<string name="pass_empty_list_message"> <string name="pass_empty_list_message">
ඔබගේ පෙරහන් නිවැරදි බවත් ඔබ චන්ද්‍රිකා තෝරාගෙන ඇති බවත් සහතික කර ගන්න</string> ඔබගේ පෙරහන් නිවැරදි බවත් ඔබ චන්ද්‍රිකා තෝරාගෙන ඇති බවත් සහතික කර ගන්න</string>
<string name="pass_error_message">ප්‍රදර්ශනය කිරීමට ඉදිරියට එන චන්ද්‍රිකා පසුකර යෑම් නැත.</string> <string name="pass_error_message">ප්‍රදර්ශනය කිරීමට ඉදිරියට එන චන්ද්‍රිකා පසුකර යෑම් නැත.</string>
<string name="pass_welcome_title">Look4Sat වෙත සාදරයෙන් පිළිගනිමු\!</string> <string name="pass_welcome_title">Look4Sat-CNMap වෙත සාදරයෙන් පිළිගනිමු\!</string>
<string name="pass_welcome_message"> <string name="pass_welcome_message">
සැකසුම් තුළ GPS, Lat/Lon හෝ QTH හරහා ඔබේ ස්ථානය සැකසීමට වග බලා ගන්න. සැකසුම් තුළ GPS, Lat/Lon හෝ QTH හරහා ඔබේ ස්ථානය සැකසීමට වග බලා ගන්න.
\n\nනිවැරදි අනාවැකි ලබා ගැනීම සඳහා අවම වශයෙන් සතිපතා දත්ත සමුදාය යාවත්කාලීන කරන්න.</string> \n\nනිවැරදි අනාවැකි ලබා ගැනීම සඳහා අවම වශයෙන් සතිපතා දත්ත සමුදාය යාවත්කාලීන කරන්න.</string>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Commmon --> <!-- Commmon -->
<string name="app_name" translatable="false">Look4Sat</string> <string name="app_name" translatable="false">Look4Sat-CNMap</string>
<string name="btn_accept">Kabul et</string> <string name="btn_accept">Kabul et</string>
<string name="btn_cancel">İptal</string> <string name="btn_cancel">İptal</string>
<string name="empty_list_message">Gösterilecek veri bulunamadı</string> <string name="empty_list_message">Gösterilecek veri bulunamadı</string>
@@ -42,11 +42,11 @@
<string name="pass_empty_list_message"> <string name="pass_empty_list_message">
Filtrelerinizin doğru olduğundan ve uydu seçtiğinizden emin olun</string> Filtrelerinizin doğru olduğundan ve uydu seçtiğinizden emin olun</string>
<string name="pass_error_message">Gösterilecek yaklaşan uydu geçişi bulunmuyor</string> <string name="pass_error_message">Gösterilecek yaklaşan uydu geçişi bulunmuyor</string>
<string name="pass_welcome_title">Look4Sat\'a hoş geldiniz\!</string> <string name="pass_welcome_title">Look4Sat-CNMap\'a hoş geldiniz\!</string>
<string name="pass_welcome_message"> <string name="pass_welcome_message">
Ayarlar\'dan GPS, Enlem/Boylam veya QTH kullanarak konumunuzu belirlediğinizden emin olun. 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.</string> \n\nDoğru tahminler alabilmek için veritabanını en az haftada bir güncelleyin.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string> <string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat-CNMap</string>
<string name="pass_whatsnew_message" translatable="false"> <string name="pass_whatsnew_message" translatable="false">
* Switched to short-form commands in NetworkReporter, by theojalba * Switched to short-form commands in NetworkReporter, by theojalba
\n* Added CAT radio control for full-duplex SAT operation, by jcmerg \n* Added CAT radio control for full-duplex SAT operation, by jcmerg
@@ -84,7 +84,8 @@
<!-- Map screen --> <!-- Map screen -->
<string name="map_prev">Önceki</string> <string name="map_prev">Önceki</string>
<string name="map_next">Sonraki</string> <string name="map_next">Sonraki</string>
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string> <string name="map_copyright_osm" translatable="false">© OpenStreetMap contributors</string>
<string name="map_copyright_tianditu" translatable="false">© Tianditu</string>
<string name="map_azimuth">Azimut: %.1f°</string> <string name="map_azimuth">Azimut: %.1f°</string>
<string name="map_elevation">Yükseklik açısı: %.1f°</string> <string name="map_elevation">Yükseklik açısı: %.1f°</string>
<string name="map_altitude">İrtifa: %.0f km</string> <string name="map_altitude">İrtifa: %.0f km</string>
@@ -100,12 +101,12 @@
<string name="map_visible">Görünür</string> <string name="map_visible">Görünür</string>
<!-- Settings screen --> <!-- Settings screen -->
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string> <string name="prefs_app_title" translatable="false">Look4Sat-CNMap v%s</string>
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string> <string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=cn.bh2vsq.look4sat</string>
<string name="prefs_donate_title">Bağış yap</string> <string name="prefs_donate_title">Bağış yap</string>
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string> <string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
<string name="prefs_fdroid_title" translatable="false">F-Droid</string> <string name="prefs_fdroid_title" translatable="false">F-Droid</string>
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string> <string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/cn.bh2vsq.look4sat/</string>
<string name="prefs_github_title" translatable="false">GitHub</string> <string name="prefs_github_title" translatable="false">GitHub</string>
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string> <string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
<string name="prefs_license_title" translatable="false">GPLv3</string> <string name="prefs_license_title" translatable="false">GPLv3</string>
@@ -38,7 +38,7 @@
<string name="pass_empty_list_message"> <string name="pass_empty_list_message">
Переконайтеся, що ваші фільтри правильні, і ви вибрали супутники</string> Переконайтеся, що ваші фільтри правильні, і ви вибрали супутники</string>
<string name="pass_error_message">Немає майбутніх прольотів супутників</string> <string name="pass_error_message">Немає майбутніх прольотів супутників</string>
<string name="pass_welcome_title">Ласкаво просимо до Look4Sat\!</string> <string name="pass_welcome_title">Ласкаво просимо до Look4Sat-CNMap\!</string>
<string name="pass_welcome_message"> <string name="pass_welcome_message">
Обов’язково встановіть своє місцезнаходження за допомогою GPS, широти/довготи або QTH у налаштуваннях. Обов’язково встановіть своє місцезнаходження за допомогою GPS, широти/довготи або QTH у налаштуваннях.
\n\nБудь ласка, оновлюйте базу даних принаймні раз на тиждень, щоб отримувати точні прогнози.</string> \n\nБудь ласка, оновлюйте базу даних принаймні раз на тиждень, щоб отримувати точні прогнози.</string>
@@ -32,7 +32,7 @@
<string name="pass_altitude">高度:%d km</string> <string name="pass_altitude">高度:%d km</string>
<string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string> <string name="pass_empty_list_message">请确保您的过滤器设置正确,并且已选择卫星</string>
<string name="pass_error_message">没有即将过境的卫星</string> <string name="pass_error_message">没有即将过境的卫星</string>
<string name="pass_welcome_title">欢迎来到 Look4Sat\!</string> <string name="pass_welcome_title">欢迎来到 Look4Sat-CNMap\!</string>
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string> <string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
<!-- Radar screen --> <!-- Radar screen -->
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Commmon --> <!-- Commmon -->
<string name="app_name" translatable="false">Look4Sat</string> <string name="app_name" translatable="false">Look4Sat-CNMap</string>
<string name="btn_accept">Accept</string> <string name="btn_accept">Accept</string>
<string name="btn_cancel">Cancel</string> <string name="btn_cancel">Cancel</string>
<string name="empty_list_message">No data to show</string> <string name="empty_list_message">No data to show</string>
@@ -43,11 +43,11 @@
<string name="pass_empty_list_message"> <string name="pass_empty_list_message">
Make sure your filters are correct and you have selected satellites</string> Make sure your filters are correct and you have selected satellites</string>
<string name="pass_error_message">There are no upcoming satellite passes to display</string> <string name="pass_error_message">There are no upcoming satellite passes to display</string>
<string name="pass_welcome_title">Welcome to Look4Sat\!</string> <string name="pass_welcome_title">Welcome to Look4Sat-CNMap\!</string>
<string name="pass_welcome_message"> <string name="pass_welcome_message">
Make sure to set your position via GPS, Lat/Lon or QTH in Settings. 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.</string> \n\nPlease update the database at least weekly to get accurate predictions.</string>
<string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat</string> <string name="pass_whatsnew_title" translatable="false">What\'s new in Look4Sat-CNMap</string>
<string name="pass_whatsnew_message" translatable="false"> <string name="pass_whatsnew_message" translatable="false">
* Added required tweaks to support Android 17 (API 37) * Added required tweaks to support Android 17 (API 37)
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output \n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output
@@ -82,7 +82,8 @@
<!-- Map screen --> <!-- Map screen -->
<string name="map_prev">Prev</string> <string name="map_prev">Prev</string>
<string name="map_next">Next</string> <string name="map_next">Next</string>
<string name="map_copyright" translatable="false">© OpenStreetMap contributors</string> <string name="map_copyright_osm" translatable="false">© OpenStreetMap contributors</string>
<string name="map_copyright_tianditu" translatable="false">© Tianditu</string>
<string name="map_azimuth">Azimuth: %.1f°</string> <string name="map_azimuth">Azimuth: %.1f°</string>
<string name="map_elevation">Elevation: %.1f°</string> <string name="map_elevation">Elevation: %.1f°</string>
<string name="map_altitude">Altitude: %.0f km</string> <string name="map_altitude">Altitude: %.0f km</string>
@@ -98,12 +99,12 @@
<string name="map_visible">Visible</string> <string name="map_visible">Visible</string>
<!-- Settings screen --> <!-- Settings screen -->
<string name="prefs_app_title" translatable="false">Look4Sat v%s</string> <string name="prefs_app_title" translatable="false">Look4Sat-CNMap v%s</string>
<string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=com.rtbishop.look4sat</string> <string name="prefs_app_url" translatable="false">https://play.google.com/store/apps/details?id=cn.bh2vsq.look4sat</string>
<string name="prefs_donate_title">Donate</string> <string name="prefs_donate_title">Donate</string>
<string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string> <string name="prefs_donate_url" translatable="false">https://ko-fi.com/rt_bishop</string>
<string name="prefs_fdroid_title" translatable="false">F-Droid</string> <string name="prefs_fdroid_title" translatable="false">F-Droid</string>
<string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</string> <string name="prefs_fdroid_url" translatable="false">https://f-droid.org/en/packages/cn.bh2vsq.look4sat/</string>
<string name="prefs_github_title" translatable="false">GitHub</string> <string name="prefs_github_title" translatable="false">GitHub</string>
<string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string> <string name="prefs_github_url" translatable="false">https://github.com/rt-bishop/Look4Sat/</string>
<string name="prefs_license_title" translatable="false">GPLv3</string> <string name="prefs_license_title" translatable="false">GPLv3</string>
@@ -147,6 +148,13 @@
<string name="prefs_bt_output">Bluetooth</string> <string name="prefs_bt_output">Bluetooth</string>
<string name="prefs_cat_output">CAT</string> <string name="prefs_cat_output">CAT</string>
<string name="prefs_map_title">Map settings</string>
<string name="prefs_map_source_osm" translatable="false">OSM</string>
<string name="prefs_map_source_tianditu_vector" translatable="false">天地图-矢量</string>
<string name="prefs_map_source_tianditu_image" translatable="false">天地图-影像</string>
<string name="prefs_map_tianditu_key" translatable="false">天地图密钥</string>
<string name="prefs_map_save_key">保存密钥</string>
<!-- Radio Control --> <!-- Radio Control -->
<string name="nav_radiocontrol">Radio Control</string> <string name="nav_radiocontrol">Radio Control</string>
<string name="rc_settings_title">CAT Radio Control</string> <string name="rc_settings_title">CAT Radio Control</string>
@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}
@@ -60,6 +60,7 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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.GeoPos
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos 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.TopBar
import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding 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.tileprovider.tilesource.XYTileSource
import org.osmdroid.util.GeoPoint import org.osmdroid.util.GeoPoint
import org.osmdroid.util.MapTileIndex
import org.osmdroid.views.CustomZoomButtonsController import org.osmdroid.views.CustomZoomButtonsController
import org.osmdroid.views.MapView import org.osmdroid.views.MapView
import org.osmdroid.views.overlay.FolderOverlay import org.osmdroid.views.overlay.FolderOverlay
import org.osmdroid.views.overlay.Marker import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polyline import org.osmdroid.views.overlay.Polyline
import org.osmdroid.views.overlay.TilesOverlay
import java.io.File
import java.net.URLEncoder
// Overlay indices // Overlay indices
private const val OVERLAY_STATION = 0 private const val OVERLAY_TDT_LABELS = 0
private const val OVERLAY_TRACK = 1 private const val OVERLAY_STATION = 1
private const val OVERLAY_FOOTPRINT = 2 private const val OVERLAY_TRACK = 2
private const val OVERLAY_POSITIONS = 3 private const val OVERLAY_FOOTPRINT = 3
private const val OVERLAY_TERMINATOR = 4 private const val OVERLAY_POSITIONS = 4
private const val OVERLAY_SUN = 5 private const val OVERLAY_SUN = 5
private const val OVERLAY_MOON = 6 private const val OVERLAY_MOON = 6
private const val OVERLAY_COUNT = 7 private const val OVERLAY_COUNT = 7
private val minLat = MapView.getTileSystem().minLatitude private val minLat = MapView.getTileSystem().minLatitude
private val maxLat = MapView.getTileSystem().maxLatitude private val maxLat = MapView.getTileSystem().maxLatitude
private val offlineTileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
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 { private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
strokeWidth = 3f strokeWidth = 4f
style = Paint.Style.STROKE style = Paint.Style.STROKE
color = Color.RED color = "#1565C0".toColorInt()
strokeCap = Paint.Cap.ROUND strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND strokeJoin = Paint.Join.ROUND
} }
private val footprintPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { private val footprintOutlinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
strokeWidth = 3f strokeWidth = 4f
style = Paint.Style.FILL_AND_STROKE style = Paint.Style.STROKE
color = "#FFE082".toColorInt() color = "#00897B".toColorInt()
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
} }
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 36f textSize = 36f
@@ -134,11 +154,17 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
val rotateMod = Modifier.rotate(180f) val rotateMod = Modifier.rotate(180f)
val timeString = uiState.mapData?.aosTime ?: "00:00:00" val timeString = uiState.mapData?.aosTime ?: "00:00:00"
val isTimeAos = uiState.mapData?.isTimeAos ?: true 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) { LaunchedEffect(uiState.track) {
val firstPos = uiState.track?.firstOrNull()?.firstOrNull() ?: return@LaunchedEffect val firstPos = uiState.track?.firstOrNull()?.firstOrNull() ?: return@LaunchedEffect
mapView.controller.animateTo(GeoPoint(firstPos.latitude, firstPos.longitude)) 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)) { Column(modifier = Modifier.layoutPadding(), verticalArrangement = Arrangement.spacedBy(6.dp)) {
val isVertical = isVerticalLayout() val isVertical = isVerticalLayout()
if (isVertical) { if (isVertical) {
@@ -163,13 +189,12 @@ private fun MapScreen(uiState: MapState, onAction: (MapAction) -> Unit, mapView:
uiState.track?.let { setSatelliteTrack(it, view) } uiState.track?.let { setSatelliteTrack(it, view) }
uiState.footprint?.let { setFootprint(it, view) } uiState.footprint?.let { setFootprint(it, view) }
uiState.positions?.let { setPositions(it, view) { item -> onAction(MapAction.SelectItem(item)) } } uiState.positions?.let { setPositions(it, view) { item -> onAction(MapAction.SelectItem(item)) } }
setTerminator(uiState.sunLatDeg, uiState.sunLonDeg, view)
setSubSolarPoint(uiState.sunLatDeg, uiState.sunLonDeg, view) setSubSolarPoint(uiState.sunLatDeg, uiState.sunLonDeg, view)
setMoonPosition(uiState.moonLatDeg, uiState.moonLonDeg, view) setMoonPosition(uiState.moonLatDeg, uiState.moonLonDeg, view)
view.invalidate() view.invalidate()
} }
uiState.mapData?.let { mapData -> 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 // region Map data composables
@Composable @Composable
private fun MapDataCard(data: MapData) { private fun MapDataCard(data: MapData, copyrightResId: Int) {
val textColor = MaterialTheme.colorScheme.primary val textColor = MaterialTheme.colorScheme.primary
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest) val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = stringResource(R.string.map_copyright), fontSize = 14.sp) Text(text = stringResource(copyrightResId), fontSize = 14.sp)
Card(colors = cardColors) { Card(colors = cardColors) {
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) { Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
MapDataRow( MapDataRow(
@@ -220,7 +245,7 @@ private fun MapDataRow(
} }
@Composable @Composable
private fun MapDataCards(data: MapData) { private fun MapDataCards(data: MapData, copyrightResId: Int) {
val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest) val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLowest)
val paddingMod = Modifier val paddingMod = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 8.dp, vertical = 4.dp)
@@ -246,7 +271,7 @@ private fun MapDataCards(data: MapData) {
} }
} }
Text( Text(
text = stringResource(R.string.map_copyright), text = stringResource(copyrightResId),
fontSize = 14.sp, fontSize = 14.sp,
modifier = Modifier.align(Alignment.BottomCenter) modifier = Modifier.align(Alignment.BottomCenter)
) )
@@ -260,6 +285,114 @@ private fun MapDataCards(data: MapData) {
} }
// endregion // 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 // region Map overlay helpers
private fun setStationPosition(stationPos: GeoPos, mapView: MapView) { private fun setStationPosition(stationPos: GeoPos, mapView: MapView) {
try { 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 */ /** Pool of reusable Marker objects keyed by satellite name, to avoid re-creation every frame */
private val markerPool = HashMap<String, Marker>() private val markerPool = HashMap<String, Marker>()
private var lastMapView: MapView? = null private var lastMapView: MapView? = null
private var lastSatelliteTrack: List<List<GeoPos>>? = null
private var satelliteTrackOverlay: FolderOverlay? = null
private fun setPositions( private fun setPositions(
posMap: Map<OrbitalObject, GeoPos>, posMap: Map<OrbitalObject, GeoPos>,
@@ -294,8 +429,9 @@ private fun setPositions(
lastMapView = mapView lastMapView = mapView
markerPool.clear() markerPool.clear()
iconCache.evictAll() iconCache.evictAll()
footprintPolyline = null lastSatelliteTrack = null
footprintPoints = null satelliteTrackOverlay = null
footprintOverlay = null
} }
// Reuse the existing FolderOverlay — creating a new one and replacing it // Reuse the existing FolderOverlay — creating a new one and replacing it
// causes osmdroid to detach shared Marker objects, making them invisible. // 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<List<GeoPos>>, mapView: MapView) { private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
val cachedOverlay = satelliteTrackOverlay
if (lastSatelliteTrack === satTrack && cachedOverlay != null && mapView.overlays[OVERLAY_TRACK] === cachedOverlay) {
return
}
val trackOverlay = FolderOverlay() val trackOverlay = FolderOverlay()
try { try {
satTrack.forEach { track -> satTrack.forEach { track ->
@@ -366,61 +506,76 @@ private fun setSatelliteTrack(satTrack: List<List<GeoPos>>, mapView: MapView) {
trackOverlay.add(this) trackOverlay.add(this)
} }
} }
lastSatelliteTrack = satTrack
satelliteTrackOverlay = trackOverlay
mapView.overlays[OVERLAY_TRACK] = trackOverlay mapView.overlays[OVERLAY_TRACK] = trackOverlay
} catch (e: Exception) { } catch (e: Exception) {
println(e) println(e)
} }
} }
/** Reusable footprint Polyline — created once, points updated in-place each frame */ /** Reusable footprint overlay - split at the date line so wrapped maps don't drop the circle. */
private var footprintPolyline: Polyline? = null private var footprintOverlay: FolderOverlay? = null
private var footprintPoints: ArrayList<GeoPoint>? = null
private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) { private fun setFootprint(orbitalPos: OrbitalPos, mapView: MapView) {
try { try {
val rangeCircle = orbitalPos.getRangeCircle() val rangeCircle = orbitalPos.getRangeCircle()
var pts = footprintPoints val overlay = footprintOverlay ?: FolderOverlay().also { footprintOverlay = it }
if (pts == null || pts.size != rangeCircle.size) { overlay.items.clear()
pts = ArrayList(rangeCircle.size) splitClosedPathOnDateLine(rangeCircle).forEach { segment ->
for (gp in rangeCircle) pts.add(GeoPoint(gp.latitude, gp.longitude)) if (segment.size > 1) {
footprintPoints = pts overlay.add(
} else { Polyline().apply {
for (i in rangeCircle.indices) { setPoints(segment.map { GeoPoint(it.latitude, it.longitude) })
pts[i].latitude = rangeCircle[i].latitude outlinePaint.set(footprintOutlinePaint)
pts[i].longitude = rangeCircle[i].longitude }
)
} }
} }
val polyline = footprintPolyline ?: Polyline().apply { mapView.overlays[OVERLAY_FOOTPRINT] = overlay
outlinePaint.set(footprintPaint)
footprintPolyline = this
}
polyline.setPoints(pts)
mapView.overlays[OVERLAY_FOOTPRINT] = polyline
} catch (e: Exception) { } catch (e: Exception) {
println(e) println(e)
} }
} }
/** private fun splitClosedPathOnDateLine(points: List<GeoPos>): List<List<GeoPos>> {
* Update the NightOverlay with the current sub-solar position. if (points.isEmpty()) return emptyList()
* The overlay is created once and kept in OVERLAY_TERMINATOR; only its val segments = mutableListOf<List<GeoPos>>()
* sunLatDeg/sunLonDeg fields are updated each tick so osmdroid redraws it. val segment = mutableListOf<GeoPos>()
*/ var previous = points.first()
private fun setTerminator(sunLatDeg: Double, sunLonDeg: Double, mapView: MapView) { segment.add(previous)
try { (points.drop(1) + points.first()).forEach { current ->
val overlay = mapView.overlays[OVERLAY_TERMINATOR] when {
if (overlay is MapNightOverlay) { previous.longitude < -170.0 && current.longitude > 170.0 -> {
overlay.sunLatDeg = sunLatDeg val edgeLatitude = getDateLineLatitude(previous, current, -180.0)
overlay.sunLonDeg = sunLonDeg segment.add(GeoPos(edgeLatitude, -180.0))
} else { segments.add(segment.toList())
mapView.overlays[OVERLAY_TERMINATOR] = MapNightOverlay().apply { segment.clear()
this.sunLatDeg = sunLatDeg segment.add(GeoPos(edgeLatitude, 180.0))
this.sunLonDeg = sunLonDeg }
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) { segment.add(current)
println(e) 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. */ /** 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 // region MapView lifecycle
@Composable @Composable
private fun rememberMapViewWithLifecycle(): MapView { private fun rememberMapViewWithLifecycle(): MapView {
val tileSource = XYTileSource("tiles", 0, 6, 256, ".webp", emptyArray<String>())
val context = LocalContext.current val context = LocalContext.current
val isVertical = isVerticalLayout() val isVertical = isVerticalLayout()
val mapView = remember { val mapView = remember {
configureOsmdroidCache(context)
MapView(context).apply { MapView(context).apply {
setMultiTouchControls(true) setMultiTouchControls(true)
setUseDataConnection(false) setUseDataConnection(false)
setTileSource(tileSource) setTileSource(offlineTileSource)
minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels, isVertical) minZoomLevel = getMinZoom(resources.displayMetrics.heightPixels, isVertical)
maxZoomLevel = 7.0 maxZoomLevel = OFFLINE_MAX_ZOOM
controller.setCenter(GeoPoint(48.8575, 6.3514)) controller.setCenter(GeoPoint(48.8575, 6.3514))
controller.setZoom(minZoomLevel + 2) controller.setZoom(minZoomLevel + 2)
zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER) zoomController.setVisibility(CustomZoomButtonsController.Visibility.NEVER)
overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT overlayManager.tilesOverlay.loadingBackgroundColor = Color.TRANSPARENT
overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT overlayManager.tilesOverlay.loadingLineColor = Color.TRANSPARENT
overlayManager.tilesOverlay.setColorFilter(createColorFilter()) overlayManager.tilesOverlay.setColorFilter(createColorFilter())
setHorizontalMapRepetitionEnabled(true)
setVerticalMapRepetitionEnabled(false)
setScrollableAreaLimitLatitude(maxLat, minLat, 0) setScrollableAreaLimitLatitude(maxLat, minLat, 0)
overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() }) overlays.addAll(Array(OVERLAY_COUNT) { FolderOverlay() })
addOnFirstLayoutListener { _, _, _, _, _ -> requestTileRefresh(this) }
} }
} }
val lifecycleObserver = rememberMapViewLifecycleObserver(mapView) val lifecycleObserver = rememberMapViewLifecycleObserver(mapView)
val lifecycle = LocalLifecycleOwner.current.lifecycle val lifecycle = LocalLifecycleOwner.current.lifecycle
DisposableEffect(lifecycle) { DisposableEffect(lifecycle) {
lifecycle.addObserver(lifecycleObserver) lifecycle.addObserver(lifecycleObserver)
if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
mapView.onResume()
requestTileRefresh(mapView)
}
onDispose { lifecycle.removeObserver(lifecycleObserver) } onDispose { lifecycle.removeObserver(lifecycleObserver) }
} }
return mapView 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 @Composable
private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) { private fun rememberMapViewLifecycleObserver(mapView: MapView) = remember(mapView) {
LifecycleEventObserver { _, event -> LifecycleEventObserver { _, event ->
when (event) { when (event) {
Lifecycle.Event.ON_RESUME -> mapView.onResume() Lifecycle.Event.ON_RESUME -> {
mapView.onResume()
requestTileRefresh(mapView)
}
Lifecycle.Event.ON_PAUSE -> mapView.onPause() Lifecycle.Event.ON_PAUSE -> mapView.onPause()
else -> {} else -> {}
} }
@@ -26,6 +26,8 @@ data class MapState(
val mapData: MapData? = null, val mapData: MapData? = null,
val isLightUi: Boolean = false, val isLightUi: Boolean = false,
val isUtc: Boolean = false, val isUtc: Boolean = false,
val mapSource: String = "",
val tiandituKey: String = "",
val stationPosition: GeoPos? = null, val stationPosition: GeoPos? = null,
val orbitalPass: OrbitalPass, val orbitalPass: OrbitalPass,
val track: List<List<GeoPos>>? = null, val track: List<List<GeoPos>>? = null,
@@ -61,6 +61,8 @@ class MapViewModel(
MapState( MapState(
isLightUi = settingsRepo.otherSettings.value.stateOfLightTheme, isLightUi = settingsRepo.otherSettings.value.stateOfLightTheme,
isUtc = settingsRepo.otherSettings.value.stateOfUtc, isUtc = settingsRepo.otherSettings.value.stateOfUtc,
mapSource = settingsRepo.otherSettings.value.mapSource,
tiandituKey = settingsRepo.otherSettings.value.tiandituKey,
orbitalPass = defaultPass orbitalPass = defaultPass
) )
) )
@@ -74,7 +76,13 @@ class MapViewModel(
init { init {
viewModelScope.launch { viewModelScope.launch {
settingsRepo.otherSettings.collectLatest { settings -> 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 val (selectedCatNum, _) = satelliteRepo.selectedPass.value
@@ -269,29 +277,45 @@ class MapViewModel(
val satTracks = mutableListOf<List<GeoPos>>() val satTracks = mutableListOf<List<GeoPos>>()
val currentTrack = mutableListOf<GeoPos>() val currentTrack = mutableListOf<GeoPos>()
val endDate = Date(date.time + (orbitalObject.data.orbitalPeriod * 2.4 * 60000L).toLong()) 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 -> satelliteRepo.getTrack(orbitalObject, pos, date.time, endDate.time).forEach { satPos ->
val currentPosition = satPos.toMapGeoPos() 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 // 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<GeoPos>().apply { addAll(currentTrack) } val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
satTracks.add(finishedTrack) satTracks.add(finishedTrack)
currentTrack.clear() 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 // 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<GeoPos>().apply { addAll(currentTrack) } val finishedTrack = mutableListOf<GeoPos>().apply { addAll(currentTrack) }
satTracks.add(finishedTrack) satTracks.add(finishedTrack)
currentTrack.clear() currentTrack.clear()
currentTrack.add(GeoPos(edgeLatitude, -180.0))
} }
oldLongitude = currentPosition.longitude previousPosition = currentPosition
currentTrack.add(currentPosition) currentTrack.add(currentPosition)
} }
satTracks.add(currentTrack) satTracks.add(currentTrack)
_uiState.update { it.copy(track = satTracks) } _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 { companion object {
/** Number of parallel chunks for satellite position computation */ /** Number of parallel chunks for satellite position computation */
private const val PARALLEL_CHUNKS = 4 private const val PARALLEL_CHUNKS = 4
@@ -34,10 +34,14 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 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.ElevatedCard
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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.model.OtherSettings
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider 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 { OtherCard(uiState.otherSettings, onAction) }
item { MapSettingsCard(uiState.otherSettings, onAction) }
item { CardCredits() } item { CardCredits() }
} }
} }
@@ -462,6 +469,106 @@ private fun OtherCardPreview() = MainTheme {
OtherCard(settings = values) {} 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 @Composable
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) { private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
ElevatedCard( ElevatedCard(
@@ -61,6 +61,8 @@ sealed interface SettingsAction {
data class ToggleSensor(val value: Boolean) : SettingsAction data class ToggleSensor(val value: Boolean) : SettingsAction
data class ToggleLightTheme(val value: Boolean) : SettingsAction data class ToggleLightTheme(val value: Boolean) : SettingsAction
data class ToggleNightMode(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 // Remote control
data class UpdateRC(val settings: RCSettings) : SettingsAction data class UpdateRC(val settings: RCSettings) : SettingsAction
@@ -121,6 +121,8 @@ class SettingsViewModel(
is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) } is SettingsAction.ToggleSensor -> settingsRepo.updateOtherSettings { it.copy(stateOfSensors = action.value) }
is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) } is SettingsAction.ToggleLightTheme -> settingsRepo.updateOtherSettings { it.copy(stateOfLightTheme = action.value) }
is SettingsAction.ToggleNightMode -> settingsRepo.updateOtherSettings { it.copy(stateOfNightMode = 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 // Remote control & data sources
is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings) is SettingsAction.UpdateRC -> settingsRepo.updateRCSettings(action.settings)
is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings) is SettingsAction.UpdateRadioControl -> settingsRepo.updateRadioControlSettings(action.settings)
+3
View File
@@ -3,3 +3,6 @@ org.gradle.caching=true
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
org.gradle.parallel=true org.gradle.parallel=true
org.gradle.workers.max=4 org.gradle.workers.max=4
# Enabled parallel sync for Gradle 9.4+
org.gradle.tooling.parallel=true
+1 -1
View File
@@ -10,7 +10,7 @@ minSdk = "24"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
jdkVersion = "21" jdkVersion = "21"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
packageName = "com.rtbishop.look4sat" packageName = "cn.bh2vsq.look4sat"
android-gradle-plugin = "9.2.1" android-gradle-plugin = "9.2.1"
+1 -1
View File
@@ -1,7 +1,7 @@
#Sat Jun 27 12:51:12 BST 2026 #Sat Jun 27 12:51:12 BST 2026
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists 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 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME