Compare commits
90
Commits
v4.4.6
...
feat/cw-fldigi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b835bac5c | ||
|
|
fb014a1183 | ||
|
|
78049a29d9 | ||
|
|
3818c58eff | ||
|
|
4c62b2dfb2 | ||
|
|
d8e1ee3266 | ||
|
|
90bb584813 | ||
|
|
f789a15338 | ||
|
|
6221516893 | ||
|
|
c9cab8457d | ||
|
|
19926c58ac | ||
|
|
955871c27b | ||
|
|
bfcef27b90 | ||
|
|
449d1a9eec | ||
|
|
fe97339ece | ||
|
|
4d5a413fa8 | ||
|
|
25078b3121 | ||
|
|
96a4d15424 | ||
|
|
bf4cd5c07c | ||
|
|
84d11f289f | ||
|
|
f4a22d9779 | ||
|
|
0e767f4250 | ||
|
|
2c26272e0b | ||
|
|
fc1bec1915 | ||
|
|
7dd256f65d | ||
|
|
4d7952d293 | ||
|
|
8a2b0a9aa3 | ||
|
|
c9d87be289 | ||
|
|
012ea1eeb6 | ||
|
|
920af7bde2 | ||
|
|
88a8e795d1 | ||
|
|
44ae5cef5d | ||
|
|
518d9a4d22 | ||
|
|
72f507163a | ||
|
|
f7a3dbaa2d | ||
|
|
c525f3a11f | ||
|
|
2777607ab5 | ||
|
|
a222cbb839 | ||
|
|
0ef8f05dc4 | ||
|
|
f7402a3531 | ||
|
|
dd378990fe | ||
|
|
a23bf34ccf | ||
|
|
a724c0823d | ||
|
|
cda6cf8583 | ||
|
|
c09e87fa4e | ||
|
|
418a05e343 | ||
|
|
0bcf73e1f9 | ||
|
|
ab1a0c0891 | ||
|
|
1a55241791 | ||
|
|
9d437a90c9 | ||
|
|
5351afe508 | ||
|
|
c80e351212 | ||
|
|
916a0d0f0a | ||
|
|
a333192df5 | ||
|
|
38cf7a3569 | ||
|
|
760a26b007 | ||
|
|
47f3e424ff | ||
|
|
f731775346 | ||
|
|
3c5486cc5c | ||
|
|
544515feba | ||
|
|
9a54da0808 | ||
|
|
8bc1f16e2c | ||
|
|
5dc7ce6a22 | ||
|
|
ff408747cd | ||
|
|
6636dd8e98 | ||
|
|
15ab82f1c8 | ||
|
|
0ea9c0ede1 | ||
|
|
c496d90d5a | ||
|
|
c101870b71 | ||
|
|
9a4ceddcb0 | ||
|
|
50233cadc1 | ||
|
|
8d88100b15 | ||
|
|
59d6e2ae05 | ||
|
|
e2b5c6d673 | ||
|
|
da89d6426f | ||
|
|
c19209655b | ||
|
|
d456bdf4b5 | ||
|
|
f06b17a194 | ||
|
|
675194075e | ||
|
|
0ecccc4746 | ||
|
|
a7b41c95c7 | ||
|
|
88d1498eac | ||
|
|
31ae04329d | ||
|
|
eae6304124 | ||
|
|
97c6d609ac | ||
|
|
44c442ddd3 | ||
|
|
f9250ec1f1 | ||
|
|
bff3e1af09 | ||
|
|
6dbe30b3f6 | ||
|
|
8a920acead |
No files matched your search
@@ -39,19 +39,22 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
|
||||||
APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
|
APK=$(find app/build/outputs/apk/release -name "*.apk" | head -1)
|
||||||
|
VERSION=${TAG_NAME#v}
|
||||||
|
SIGNED_APK="app/build/outputs/apk/release/Look4Sat-Pro-${VERSION}.apk"
|
||||||
BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1)
|
BUILD_TOOLS=$(ls -d ${ANDROID_HOME}/build-tools/*/ | sort -V | tail -1)
|
||||||
${BUILD_TOOLS}apksigner sign \
|
${BUILD_TOOLS}apksigner sign \
|
||||||
--ks keystore.jks \
|
--ks keystore.jks \
|
||||||
--ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
|
--ks-pass pass:${{ secrets.KEY_STORE_PASSWORD }} \
|
||||||
--ks-key-alias ${{ secrets.KEY_ALIAS }} \
|
--ks-key-alias ${{ secrets.KEY_ALIAS }} \
|
||||||
--key-pass pass:${{ secrets.KEY_PASSWORD }} \
|
--key-pass pass:${{ secrets.KEY_PASSWORD }} \
|
||||||
--out app/build/outputs/apk/release/look4sat.apk \
|
--out "$SIGNED_APK" \
|
||||||
"$APK"
|
"$APK"
|
||||||
rm keystore.jks
|
rm keystore.jks
|
||||||
|
echo "SIGNED_APK=$SIGNED_APK" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: |
|
run: |
|
||||||
gh release create $TAG_NAME --title=$TAG_NAME --generate-notes
|
gh release create $TAG_NAME --title=$TAG_NAME --generate-notes
|
||||||
gh release upload $TAG_NAME app/build/outputs/apk/release/look4sat.apk
|
gh release upload $TAG_NAME "$SIGNED_APK"
|
||||||
+1
-2
@@ -39,8 +39,7 @@ captures/
|
|||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
# Keystore files
|
# Keystore files
|
||||||
# Uncomment the following line if you do not want to check your keystore files in.
|
*.jks
|
||||||
#*.jks
|
|
||||||
/*.properties
|
/*.properties
|
||||||
/keystore.properties
|
/keystore.properties
|
||||||
/app/keystore.jks
|
/app/keystore.jks
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ val keystoreProperties = Properties().apply {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
// CW 解码已迁移为纯 Kotlin fldigi 引擎, 不再有 native 库;
|
||||||
|
// 取消 armeabi-v7a 强制, 按设备 ABI 打包(含 x86_64 模拟器)
|
||||||
|
defaultConfig {
|
||||||
|
}
|
||||||
|
androidResources {
|
||||||
|
// 显式保留全部语言(防 shrinkResources 丢弃 in/id 印尼语配置); AGP 9 用 localeFilters
|
||||||
|
localeFilters += listOf(
|
||||||
|
"en", "zh", "tr", "in", "id", "es", "ru", "si", "uk"
|
||||||
|
)
|
||||||
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
if (keystoreProperties["storeFile"] != null) {
|
if (keystoreProperties["storeFile"] != null) {
|
||||||
create("release") {
|
create("release") {
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<application
|
<application
|
||||||
android:name=".MainApplication"
|
android:name=".MainApplication"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
@@ -45,5 +48,9 @@
|
|||||||
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
|
||||||
android:value="com.rtbishop.look4sat.bg7nta" />
|
android:value="com.rtbishop.look4sat.bg7nta" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name="com.rtbishop.look4sat.app.AprsForegroundService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package com.rtbishop.look4sat.app
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.widget.Toast
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import com.rtbishop.look4sat.MainApplication
|
||||||
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.core.data.aprs.AprsConfig
|
||||||
|
import com.rtbishop.look4sat.core.data.aprs.AprsStore
|
||||||
|
import com.rtbishop.look4sat.core.data.aprs.AprsReporter
|
||||||
|
import com.rtbishop.look4sat.core.data.aprs.AprsState
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APRS foreground service: kept alive by a system notification; keeps working across pages.
|
||||||
|
* START_STICKY: auto-restarted after being killed by the system (same strategy as APRSdroid).
|
||||||
|
*/
|
||||||
|
class AprsForegroundService : Service() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_START = AprsStore.ACTION_START
|
||||||
|
const val ACTION_STOP = AprsStore.ACTION_STOP
|
||||||
|
const val ACTION_REPORT_NOW = AprsStore.ACTION_REPORT_NOW
|
||||||
|
const val CHANNEL_ID = "aprs_service"
|
||||||
|
const val NOTIF_ID = 101
|
||||||
|
}
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private var reporter: AprsReporter? = null
|
||||||
|
private var lastState: AprsState = AprsState.Idle
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_STOP -> stopReporting()
|
||||||
|
ACTION_REPORT_NOW -> {
|
||||||
|
if (reporter == null) {
|
||||||
|
// Service not running: start it first (Toast hint when not configured)
|
||||||
|
startReporting()
|
||||||
|
}
|
||||||
|
reporter?.reportNow()
|
||||||
|
}
|
||||||
|
else -> startReporting()
|
||||||
|
}
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startReporting() {
|
||||||
|
val cfg = AprsStore.loadConfig(this)
|
||||||
|
if (!cfg.enabled || cfg.callsign.isBlank()) {
|
||||||
|
runCatching {
|
||||||
|
Toast.makeText(this, getString(R.string.aprs_toast_not_configured), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
stopSelf()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
startForegroundWithNotification(cfg)
|
||||||
|
val rep = AprsReporter(
|
||||||
|
configProvider = { AprsStore.loadConfig(this) },
|
||||||
|
positionProvider = { stationPosition() },
|
||||||
|
onState = { lastState = it },
|
||||||
|
onReport = { report ->
|
||||||
|
AprsStore.saveLastReport(this, report.ok, report.detail)
|
||||||
|
updateNotification(cfg)
|
||||||
|
// Report result always surfaces: success = short Toast, failure = long Toast + reason
|
||||||
|
val msg = if (report.ok) {
|
||||||
|
getString(R.string.aprs_toast_ok)
|
||||||
|
} else {
|
||||||
|
getString(R.string.aprs_toast_fail, report.detail)
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
Toast.makeText(this, msg,
|
||||||
|
if (report.ok) Toast.LENGTH_SHORT else Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
reporter = rep
|
||||||
|
rep.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopReporting() {
|
||||||
|
reporter?.stop()
|
||||||
|
reporter = null
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startForegroundWithNotification(cfg: AprsConfig) {
|
||||||
|
try {
|
||||||
|
val notif = buildNotification(cfg)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(NOTIF_ID, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIF_ID, notif)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Vendor ROM / old-system safety net: foreground-start failure only stops the service, never crashes the app
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(cfg: AprsConfig): Notification {
|
||||||
|
val pi = PendingIntent.getActivity(
|
||||||
|
this, 0, packageManager.getLaunchIntentForPackage(packageName),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val stopPi = PendingIntent.getService(
|
||||||
|
this, 1, Intent(this, AprsForegroundService::class.java).setAction(ACTION_STOP),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val stateText = when (lastState) {
|
||||||
|
AprsState.Connected -> getString(R.string.aprs_notif_connected)
|
||||||
|
AprsState.Error -> getString(R.string.aprs_notif_error)
|
||||||
|
else -> getString(R.string.aprs_notif_running)
|
||||||
|
}
|
||||||
|
return Notification.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_radios)
|
||||||
|
.setContentTitle(getString(R.string.aprs_notif_title, cfg.callsign))
|
||||||
|
.setContentText(stateText)
|
||||||
|
.setContentIntent(pi)
|
||||||
|
.setOngoing(true)
|
||||||
|
.addAction(0, getString(R.string.aprs_notif_stop), stopPi)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateNotification(cfg: AprsConfig) {
|
||||||
|
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
nm.notify(NOTIF_ID, buildNotification(cfg))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Report position: station QTH from settings first (per user); live GPS as fallback when invalid */
|
||||||
|
private fun stationPosition(): Pair<Double, Double>? {
|
||||||
|
// 1. Station QTH (position set in settings)
|
||||||
|
val station = runCatching {
|
||||||
|
val container = (application as MainApplication).getMainContainer()
|
||||||
|
container.settingsRepo.stationPosition.value
|
||||||
|
}.getOrNull()
|
||||||
|
if (station != null && (station.latitude != 0.0 || station.longitude != 0.0)) {
|
||||||
|
return Pair(station.latitude, station.longitude)
|
||||||
|
}
|
||||||
|
// 2. Fallback: last live GPS position
|
||||||
|
return runCatching {
|
||||||
|
val lm = getSystemService(Context.LOCATION_SERVICE) as android.location.LocationManager
|
||||||
|
val providers = listOf(
|
||||||
|
android.location.LocationManager.GPS_PROVIDER,
|
||||||
|
android.location.LocationManager.NETWORK_PROVIDER
|
||||||
|
)
|
||||||
|
for (p in providers) {
|
||||||
|
val loc = lm.getLastKnownLocation(p) ?: continue
|
||||||
|
if (loc.latitude != 0.0 || loc.longitude != 0.0) {
|
||||||
|
return Pair(loc.latitude, loc.longitude)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createChannel() {
|
||||||
|
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID, getString(R.string.aprs_notif_channel),
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
)
|
||||||
|
nm.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
reporter?.stop()
|
||||||
|
reporter = null
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,10 +30,29 @@ class MainApplication : Application(), IContainerProvider {
|
|||||||
|
|
||||||
private lateinit var container: IMainContainer
|
private lateinit var container: IMainContainer
|
||||||
|
|
||||||
|
/** Global crash capture: stack written to files/crash_log.txt, viewable after restart (user asked for error reports) */
|
||||||
|
private fun installCrashHandler() {
|
||||||
|
val defaultHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||||
|
runCatching {
|
||||||
|
val log = StringBuilder()
|
||||||
|
log.append("=== Crash ${System.currentTimeMillis()} ===\n")
|
||||||
|
log.append("Thread: ").append(thread.name).append("\n")
|
||||||
|
val sw = java.io.StringWriter()
|
||||||
|
throwable.printStackTrace(java.io.PrintWriter(sw))
|
||||||
|
log.append(sw.toString()).append("\n")
|
||||||
|
val file = java.io.File(filesDir, "crash_log.txt")
|
||||||
|
file.appendText(log.toString())
|
||||||
|
}
|
||||||
|
defaultHandler?.uncaughtException(thread, throwable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun getMainContainer(): IMainContainer = container
|
override fun getMainContainer(): IMainContainer = container
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
installCrashHandler()
|
||||||
container = MainContainer(this)
|
container = MainContainer(this)
|
||||||
// trigger automatic update every 48 hours
|
// trigger automatic update every 48 hours
|
||||||
container.appScope.launch { checkAutoUpdate() }
|
container.appScope.launch { checkAutoUpdate() }
|
||||||
|
|||||||
@@ -17,16 +17,23 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat
|
package com.rtbishop.look4sat
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.animateContentSize
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.RepeatMode
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
import androidx.compose.animation.core.animateFloat
|
import androidx.compose.animation.core.animateFloat
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.animation.shrinkVertically
|
||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
@@ -53,6 +60,9 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -80,13 +90,15 @@ import com.rtbishop.look4sat.core.presentation.RadarDestination
|
|||||||
import com.rtbishop.look4sat.core.presentation.Screen
|
import com.rtbishop.look4sat.core.presentation.Screen
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
import com.rtbishop.look4sat.core.presentation.hasEnoughHeight
|
||||||
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
import com.rtbishop.look4sat.core.presentation.hasEnoughWidth
|
||||||
|
import com.rtbishop.look4sat.feature.cw.CwDecodeScreen
|
||||||
import com.rtbishop.look4sat.feature.map.MapDestination
|
import com.rtbishop.look4sat.feature.map.MapDestination
|
||||||
import com.rtbishop.look4sat.feature.mutual.MutualScreen
|
import com.rtbishop.look4sat.feature.mutual.MutualScreen
|
||||||
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
import com.rtbishop.look4sat.feature.mutual.MutualViewModel
|
||||||
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
import com.rtbishop.look4sat.feature.passes.PassesDestination
|
||||||
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
import com.rtbishop.look4sat.feature.radar.RadarDestination
|
||||||
|
import com.rtbishop.look4sat.feature.radar.WavelogLogScreen
|
||||||
|
import com.rtbishop.look4sat.feature.status.SatStatusScreen
|
||||||
import com.rtbishop.look4sat.feature.roaming.RoamingScreen
|
import com.rtbishop.look4sat.feature.roaming.RoamingScreen
|
||||||
import com.rtbishop.look4sat.feature.roaming.RoamingViewModel
|
|
||||||
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||||
|
|
||||||
@@ -132,12 +144,35 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
val currentKey = backStack.lastOrNull()
|
val currentKey = backStack.lastOrNull()
|
||||||
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
val navigateBack: () -> Unit = { backStack.removeLastOrNull() }
|
||||||
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
val fadeTransition = fadeIn(animationSpec = tween(350)) togetherWith fadeOut(animationSpec = tween(350))
|
||||||
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.Map, Screen.Settings)
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
val trackingState by container.radioTrackingService.state.collectAsStateWithLifecycle()
|
||||||
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||||
|
// UI settings: sort by screenOrder (empty = default order), then filter by hiddenScreens (Settings always kept)
|
||||||
|
val allNavItems = listOf(Screen.Satellites, Screen.Passes, Screen.Radar, Screen.Mutual, Screen.Roaming, Screen.CwDecode, Screen.WavelogLog, Screen.AmSat, Screen.Map, Screen.Settings)
|
||||||
|
.sortedBy { screen ->
|
||||||
|
// Unknown pages (e.g. CwDecode not in old persisted order): use default-order position (Roaming<->Map), then fall back to last
|
||||||
|
val idx = otherSettings.screenOrder.indexOf(screen.screenId)
|
||||||
|
if (idx != -1) idx
|
||||||
|
else com.rtbishop.look4sat.core.presentation.defaultScreenOrder.indexOf(screen.screenId).let {
|
||||||
|
if (it != -1) it else Int.MAX_VALUE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.filter { it.screenId !in otherSettings.hiddenScreens || it is Screen.Settings }
|
||||||
|
// 4.5.1 foldable menu: main menu (5 bottom-bar slots) + More menu (overflow page)
|
||||||
|
// Legacy migration: persisted subMenuOrder lacks new pages (WavelogLog) -> append to the sub-menu tail
|
||||||
|
val subOrder = (otherSettings.subMenuOrder.ifEmpty { com.rtbishop.look4sat.core.presentation.defaultSubMenuOrder })
|
||||||
|
.let { list -> if ("WavelogLog" in list) list else list + "WavelogLog" }
|
||||||
|
.let { list -> if ("AMSAT" in list) list else list + "AMSAT" }
|
||||||
|
val mainNavItems = remember(allNavItems, subOrder) {
|
||||||
|
allNavItems.filter { it.screenId !in subOrder }.take(5)
|
||||||
|
}
|
||||||
|
val moreNavItems = remember(allNavItems, subOrder) {
|
||||||
|
subOrder.mapNotNull { id -> allNavItems.find { it.screenId == id } }
|
||||||
|
}
|
||||||
|
var moreExpanded by remember { mutableStateOf(false) }
|
||||||
|
// Intercept Back while the More menu is open: close the menu first
|
||||||
|
BackHandler(enabled = moreExpanded) { moreExpanded = false }
|
||||||
// Activity-scoped so the mutual query results survive navigation to Radar and back
|
// Activity-scoped so the mutual query results survive navigation to Radar and back
|
||||||
val mutualViewModel: MutualViewModel = viewModel(
|
val mutualViewModel: MutualViewModel = viewModel(
|
||||||
viewModelStoreOwner = context as ViewModelStoreOwner,
|
viewModelStoreOwner = context as ViewModelStoreOwner,
|
||||||
@@ -152,12 +187,15 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
) {
|
) {
|
||||||
NavigationSuiteScaffold(
|
NavigationSuiteScaffold(
|
||||||
navigationSuiteItems = {
|
navigationSuiteItems = {
|
||||||
navItems.forEach { screen ->
|
mainNavItems.forEach { screen ->
|
||||||
val isSelected = when (currentKey) {
|
val isSelected = when (currentKey) {
|
||||||
is Screen.Satellites -> screen is Screen.Satellites
|
is Screen.Satellites -> screen is Screen.Satellites
|
||||||
is Screen.Passes -> screen is Screen.Passes
|
is Screen.Passes -> screen is Screen.Passes
|
||||||
is Screen.Radar -> screen is Screen.Radar
|
is Screen.Radar -> screen is Screen.Radar
|
||||||
is Screen.Mutual -> screen is Screen.Mutual
|
is Screen.Mutual -> screen is Screen.Mutual
|
||||||
|
is Screen.CwDecode -> screen is Screen.CwDecode
|
||||||
|
is Screen.WavelogLog -> screen is Screen.WavelogLog
|
||||||
|
is Screen.AmSat -> screen is Screen.AmSat
|
||||||
is Screen.Map -> screen is Screen.Map
|
is Screen.Map -> screen is Screen.Map
|
||||||
is Screen.Settings -> screen is Screen.Settings
|
is Screen.Settings -> screen is Screen.Settings
|
||||||
else -> false
|
else -> false
|
||||||
@@ -168,11 +206,26 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
selected = isSelected,
|
selected = isSelected,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (isSelected) return@item
|
if (isSelected) return@item
|
||||||
|
moreExpanded = false
|
||||||
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||||
if (screen !is Screen.Passes) backStack.add(screen)
|
if (screen !is Screen.Passes) backStack.add(screen)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// More-menu button (fixed slot 6; shown only when the sub-menu is non-empty)
|
||||||
|
if (moreNavItems.isNotEmpty()) {
|
||||||
|
item(
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
painterResource(com.rtbishop.look4sat.R.drawable.ic_more),
|
||||||
|
stringResource(com.rtbishop.look4sat.core.presentation.R.string.nav_more)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(com.rtbishop.look4sat.core.presentation.R.string.nav_more)) },
|
||||||
|
selected = moreExpanded,
|
||||||
|
onClick = { moreExpanded = !moreExpanded }
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
||||||
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
navigationRailContainerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||||
@@ -183,10 +236,11 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
else -> NavigationSuiteType.ShortNavigationBarMedium
|
else -> NavigationSuiteType.ShortNavigationBarMedium
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Column {
|
Box {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
NavDisplay(
|
NavDisplay(
|
||||||
backStack = backStack,
|
backStack = backStack,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
onBack = navigateBack,
|
onBack = navigateBack,
|
||||||
transitionSpec = { fadeTransition },
|
transitionSpec = { fadeTransition },
|
||||||
popTransitionSpec = { fadeTransition },
|
popTransitionSpec = { fadeTransition },
|
||||||
@@ -227,11 +281,16 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
entry<Screen.Roaming> {
|
entry<Screen.Roaming> {
|
||||||
val roamingViewModel: RoamingViewModel = viewModel(
|
RoamingScreen()
|
||||||
viewModelStoreOwner = context as ViewModelStoreOwner,
|
}
|
||||||
factory = RoamingViewModel.factory(container)
|
entry<Screen.CwDecode> {
|
||||||
)
|
CwDecodeScreen()
|
||||||
RoamingScreen(viewModel = roamingViewModel)
|
}
|
||||||
|
entry<Screen.AmSat> {
|
||||||
|
SatStatusScreen(container = container)
|
||||||
|
}
|
||||||
|
entry<Screen.WavelogLog> {
|
||||||
|
WavelogLogScreen(queue = container.wavelogQueue)
|
||||||
}
|
}
|
||||||
entry<Screen.Settings> {
|
entry<Screen.Settings> {
|
||||||
SettingsDestination()
|
SettingsDestination()
|
||||||
@@ -271,7 +330,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
|
text = stringResource(com.rtbishop.look4sat.core.presentation.R.string.tracking_status, trackingState.currentPass?.name ?: ""),
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
@@ -287,6 +346,27 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// More-menu popup panel (overlays content above the bottom bar; spring bounce)
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = moreExpanded,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
enter = expandVertically(
|
||||||
|
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
|
||||||
|
) + fadeIn(),
|
||||||
|
exit = shrinkVertically() + fadeOut()
|
||||||
|
) {
|
||||||
|
MoreMenuPopup(
|
||||||
|
items = moreNavItems,
|
||||||
|
currentKey = currentKey,
|
||||||
|
onDismiss = { moreExpanded = false },
|
||||||
|
onSelect = { screen ->
|
||||||
|
moreExpanded = false
|
||||||
|
while (backStack.size > 1) backStack.removeAt(backStack.size - 1)
|
||||||
|
if (screen !is Screen.Passes) backStack.add(screen)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/*
|
||||||
|
* MoreMenuPopup.kt - bottom-nav "More" second-level menu popup panel (4.5.1).
|
||||||
|
*
|
||||||
|
* Overlays the content area (above the bottom bar, right-aligned), vertical menu items (icon+text+arrow),
|
||||||
|
* current page highlighted; tap the scrim to close, tap an item to navigate. Open/close animation is driven by the caller
|
||||||
|
* (MainScreen's AnimatedVisibility + spring).
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.navigation3.runtime.NavKey
|
||||||
|
import com.rtbishop.look4sat.core.presentation.Screen
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MoreMenuPopup(
|
||||||
|
items: List<Screen>,
|
||||||
|
currentKey: NavKey?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSelect: (Screen) -> Unit
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.35f))
|
||||||
|
.clickable(onClick = onDismiss)
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.padding(12.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||||
|
items.forEach { screen ->
|
||||||
|
val isSelected = when (currentKey) {
|
||||||
|
is Screen.Satellites -> screen is Screen.Satellites
|
||||||
|
is Screen.Passes -> screen is Screen.Passes
|
||||||
|
is Screen.Radar -> screen is Screen.Radar
|
||||||
|
is Screen.Mutual -> screen is Screen.Mutual
|
||||||
|
is Screen.CwDecode -> screen is Screen.CwDecode
|
||||||
|
is Screen.WavelogLog -> screen is Screen.WavelogLog
|
||||||
|
is Screen.Map -> screen is Screen.Map
|
||||||
|
is Screen.Settings -> screen is Screen.Settings
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onSelect(screen) }
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(screen.iconResId),
|
||||||
|
contentDescription = stringResource(screen.titleResId),
|
||||||
|
tint = if (isSelected) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(screen.titleResId),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
color = if (isSelected) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "›",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M12,8c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,10c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2zM12,16c-1.1,0 -2,0.9 -2,2s0.9,2 2,2 2,-0.9 2,-2 -0.9,-2 -2,-2z" />
|
||||||
|
</vector>
|
||||||
+2
@@ -31,6 +31,7 @@ internal class ApplicationPlugin : Plugin<Project> {
|
|||||||
implementation(project(":core:data"))
|
implementation(project(":core:data"))
|
||||||
implementation(project(":core:domain"))
|
implementation(project(":core:domain"))
|
||||||
implementation(project(":core:presentation"))
|
implementation(project(":core:presentation"))
|
||||||
|
implementation(project(":feature:cw"))
|
||||||
implementation(project(":feature:map"))
|
implementation(project(":feature:map"))
|
||||||
implementation(project(":feature:mutual"))
|
implementation(project(":feature:mutual"))
|
||||||
implementation(project(":feature:passes"))
|
implementation(project(":feature:passes"))
|
||||||
@@ -38,6 +39,7 @@ internal class ApplicationPlugin : Plugin<Project> {
|
|||||||
implementation(project(":feature:roaming"))
|
implementation(project(":feature:roaming"))
|
||||||
implementation(project(":feature:satellites"))
|
implementation(project(":feature:satellites"))
|
||||||
implementation(project(":feature:settings"))
|
implementation(project(":feature:settings"))
|
||||||
|
implementation(project(":feature:status"))
|
||||||
implementation(libs.androidx.core.splashscreen)
|
implementation(libs.androidx.core.splashscreen)
|
||||||
implementation(libs.compose.material3.adaptive)
|
implementation(libs.compose.material3.adaptive)
|
||||||
implementation(libs.compose.navigation3)
|
implementation(libs.compose.navigation3)
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.aprs
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.OutputStreamWriter
|
||||||
|
import java.io.PrintWriter
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APRS-IS TCP client (reverse-ported from APRSdroid TcpUploader.scala).
|
||||||
|
* Plain-text protocol: one login line + one packet per line; 30 s reconnect after drop.
|
||||||
|
*/
|
||||||
|
class AprsIsClient(
|
||||||
|
private val host: String,
|
||||||
|
private val port: Int,
|
||||||
|
private val callsign: String,
|
||||||
|
private val ssid: String,
|
||||||
|
private val passcode: Int,
|
||||||
|
private val version: String,
|
||||||
|
private val filter: String = "",
|
||||||
|
private val timeoutSec: Int = 120
|
||||||
|
) {
|
||||||
|
private var socket: Socket? = null
|
||||||
|
private var writer: PrintWriter? = null
|
||||||
|
private var reader: BufferedReader? = null
|
||||||
|
private val lock = Any()
|
||||||
|
|
||||||
|
val isConnected: Boolean
|
||||||
|
get() = synchronized(lock) { socket?.isConnected == true && !socket!!.isClosed }
|
||||||
|
|
||||||
|
/** Connect + login (synchronous/blocking; call from a background thread) */
|
||||||
|
@Throws(Exception::class)
|
||||||
|
fun connect() {
|
||||||
|
disconnect()
|
||||||
|
val s = Socket()
|
||||||
|
s.connect(InetSocketAddress(host, port), 30_000)
|
||||||
|
s.soTimeout = timeoutSec * 1000
|
||||||
|
s.tcpNoDelay = true
|
||||||
|
synchronized(lock) {
|
||||||
|
socket = s
|
||||||
|
writer = PrintWriter(OutputStreamWriter(s.getOutputStream(), Charsets.ISO_8859_1), true)
|
||||||
|
reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.ISO_8859_1), 256)
|
||||||
|
}
|
||||||
|
// Login line
|
||||||
|
val login = AprsPacket.formatLogin(callsign, ssid, passcode, version) + filter
|
||||||
|
writer?.println(login)
|
||||||
|
// Read the login response (aprsc replies # logresp ... verified/unverified)
|
||||||
|
runCatching {
|
||||||
|
s.soTimeout = 8000
|
||||||
|
val resp = reader?.readLine()
|
||||||
|
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
|
||||||
|
resp.contains("unverified", ignoreCase = true))) {
|
||||||
|
throw IllegalArgumentException(resp.trim())
|
||||||
|
}
|
||||||
|
// Restore timeout
|
||||||
|
s.soTimeout = timeoutSec * 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends one APRS packet (one line) and tries to read the server ack.
|
||||||
|
* Returns null=failed to send; Pair(ok, detail)=result (server error text lives in detail)
|
||||||
|
*/
|
||||||
|
fun sendPacket(packetLine: String): Pair<Boolean, String>? {
|
||||||
|
synchronized(lock) {
|
||||||
|
val w = writer ?: return null
|
||||||
|
w.println(packetLine)
|
||||||
|
if (w.checkError()) return Pair(false, "write failed")
|
||||||
|
}
|
||||||
|
// Try to read the server response (short 3 s timeout; APRS-IS replies with an error line on bad format)
|
||||||
|
return runCatching {
|
||||||
|
val s = socket ?: return@runCatching Pair(true, "OK")
|
||||||
|
val oldTimeout = s.soTimeout
|
||||||
|
s.soTimeout = 3000
|
||||||
|
try {
|
||||||
|
val resp = reader?.readLine()
|
||||||
|
if (resp != null && (resp.contains("Invalid", ignoreCase = true) ||
|
||||||
|
resp.contains("error", ignoreCase = true))) {
|
||||||
|
Pair(false, resp.trim())
|
||||||
|
} else {
|
||||||
|
Pair(true, if (resp.isNullOrBlank()) "OK" else resp.trim())
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
s.soTimeout = oldTimeout
|
||||||
|
}
|
||||||
|
}.getOrElse { Pair(true, "OK") }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read one line (server response; throws on timeout) */
|
||||||
|
fun readLine(): String? {
|
||||||
|
return reader?.readLine()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
synchronized(lock) {
|
||||||
|
runCatching { writer?.close() }
|
||||||
|
runCatching { reader?.close() }
|
||||||
|
runCatching { socket?.close() }
|
||||||
|
writer = null
|
||||||
|
reader = null
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.aprs
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
|
||||||
|
import com.rtbishop.look4sat.core.domain.aprs.AprsPosition
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/** APRS connection state */
|
||||||
|
enum class AprsState { Idle, Connecting, Connected, Disconnected, Error }
|
||||||
|
|
||||||
|
/** APRS config (persisted in SharedPreferences, saved as filled) */
|
||||||
|
data class AprsConfig(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val server: String = "euro.aprs2.net",
|
||||||
|
val port: Int = 14580,
|
||||||
|
val callsign: String = "",
|
||||||
|
val ssid: String = "",
|
||||||
|
val passcode: String = "",
|
||||||
|
val intervalMin: Int = 5,
|
||||||
|
val statusText: String = "Look4Sat APRS",
|
||||||
|
val symbolTable: String = "/",
|
||||||
|
val symbolCode: String = ">",
|
||||||
|
val includeCourseSpeed: Boolean = true,
|
||||||
|
val includeAltitude: Boolean = true
|
||||||
|
)
|
||||||
|
|
||||||
|
/** APRS report result */
|
||||||
|
data class AprsReport(
|
||||||
|
val timestamp: Long,
|
||||||
|
val packet: String,
|
||||||
|
val ok: Boolean,
|
||||||
|
val detail: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Report scheduler (periodic + manual trigger); connection management lives in the foreground service */
|
||||||
|
class AprsReporter(
|
||||||
|
private val configProvider: () -> AprsConfig,
|
||||||
|
private val positionProvider: () -> Pair<Double, Double>? = { null },
|
||||||
|
private val onState: (AprsState) -> Unit = {},
|
||||||
|
private val onReport: (AprsReport) -> Unit = {}
|
||||||
|
) {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private var client: AprsIsClient? = null
|
||||||
|
private var job: Job? = null
|
||||||
|
private var manualJob: Job? = null
|
||||||
|
|
||||||
|
val isRunning: Boolean get() = job?.isActive == true
|
||||||
|
|
||||||
|
/** Start periodic reporting (called by the foreground service) */
|
||||||
|
fun start() {
|
||||||
|
stop()
|
||||||
|
val cfg = configProvider()
|
||||||
|
if (!cfg.enabled || cfg.callsign.isBlank()) {
|
||||||
|
onState(AprsState.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
job = scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
reportOnce()
|
||||||
|
delay(cfg.intervalMin.coerceAtLeast(1) * 60_000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
job?.cancel()
|
||||||
|
job = null
|
||||||
|
runCatching { client?.disconnect() }
|
||||||
|
client = null
|
||||||
|
onState(AprsState.Idle)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trigger one report manually (immediately, without waiting for the cycle) */
|
||||||
|
fun reportNow() {
|
||||||
|
manualJob?.cancel()
|
||||||
|
manualJob = scope.launch { reportOnce() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun reportOnce() {
|
||||||
|
val cfg = configProvider()
|
||||||
|
if (!cfg.enabled || cfg.callsign.isBlank()) return
|
||||||
|
onState(AprsState.Connecting)
|
||||||
|
try {
|
||||||
|
val c = client ?: AprsIsClient(
|
||||||
|
host = cfg.server,
|
||||||
|
port = cfg.port,
|
||||||
|
callsign = cfg.callsign,
|
||||||
|
ssid = cfg.ssid,
|
||||||
|
passcode = cfg.passcode.toIntOrNull()?.takeIf { it >= 0 } ?: AprsPacket.passcode(cfg.callsign),
|
||||||
|
version = "Look4Sat 4.5.4"
|
||||||
|
).also { client = it }
|
||||||
|
if (!c.isConnected) c.connect()
|
||||||
|
onState(AprsState.Connected)
|
||||||
|
|
||||||
|
val pos = positionProvider()
|
||||||
|
val packetLine = buildPositionPacket(cfg, pos?.first, pos?.second)
|
||||||
|
val result = c.sendPacket(packetLine)
|
||||||
|
val ok = result?.first == true
|
||||||
|
val detail = result?.second ?: "no connection"
|
||||||
|
onReport(AprsReport(System.currentTimeMillis(), packetLine, ok, detail))
|
||||||
|
if (ok) onState(AprsState.Connected) else onState(AprsState.Error)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
runCatching { client?.disconnect() }
|
||||||
|
client = null
|
||||||
|
onState(AprsState.Error)
|
||||||
|
onReport(AprsReport(System.currentTimeMillis(), "", false, e.message ?: "error"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build position packet: BG7NTA-5>APRS:=DDMM.MMN/DDDMM.MME<status text */
|
||||||
|
private fun buildPositionPacket(cfg: AprsConfig, lat: Double? = null, lon: Double? = null): String {
|
||||||
|
val source = AprsPacket.formatCallSsid(cfg.callsign, cfg.ssid)
|
||||||
|
val pos = AprsPosition(
|
||||||
|
latitude = lat ?: 0.0,
|
||||||
|
longitude = lon ?: 0.0,
|
||||||
|
symbolTable = cfg.symbolTable.firstOrNull() ?: '/',
|
||||||
|
symbolCode = cfg.symbolCode.firstOrNull() ?: '>'
|
||||||
|
)
|
||||||
|
return "$source>APRS:=${pos.toUncompressedString()}${cfg.statusText}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.aprs
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APRS config storage + service action constants (shared by feature/settings and the app service,
|
||||||
|
* so feature never depends on app).
|
||||||
|
*/
|
||||||
|
object AprsStore {
|
||||||
|
|
||||||
|
const val ACTION_START = "com.rtbishop.look4sat.aprs.START"
|
||||||
|
const val ACTION_STOP = "com.rtbishop.look4sat.aprs.STOP"
|
||||||
|
const val ACTION_REPORT_NOW = "com.rtbishop.look4sat.aprs.REPORT_NOW"
|
||||||
|
const val SERVICE_CLASS = "com.rtbishop.look4sat.app.AprsForegroundService"
|
||||||
|
|
||||||
|
private const val PREFS = "aprs_config"
|
||||||
|
private const val KEY_ENABLED = "enabled"
|
||||||
|
private const val KEY_SERVER = "server"
|
||||||
|
private const val KEY_PORT = "port"
|
||||||
|
private const val KEY_CALLSIGN = "callsign"
|
||||||
|
private const val KEY_SSID = "ssid"
|
||||||
|
private const val KEY_PASSCODE = "passcode"
|
||||||
|
private const val KEY_INTERVAL = "interval"
|
||||||
|
private const val KEY_STATUS = "status"
|
||||||
|
private const val KEY_SYMBOL_TABLE = "symbol_table"
|
||||||
|
private const val KEY_SYMBOL_CODE = "symbol_code"
|
||||||
|
private const val KEY_LAST_TIME = "last_report_time"
|
||||||
|
private const val KEY_LAST_OK = "last_report_ok"
|
||||||
|
private const val KEY_LAST_DETAIL = "last_report_detail"
|
||||||
|
|
||||||
|
/** Read config (saved as filled; no need to re-enter each time) */
|
||||||
|
fun loadConfig(context: Context): AprsConfig {
|
||||||
|
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
return AprsConfig(
|
||||||
|
enabled = p.getBoolean(KEY_ENABLED, false),
|
||||||
|
server = p.getString(KEY_SERVER, "euro.aprs2.net") ?: "euro.aprs2.net",
|
||||||
|
port = p.getInt(KEY_PORT, 14580),
|
||||||
|
callsign = p.getString(KEY_CALLSIGN, "") ?: "",
|
||||||
|
ssid = p.getString(KEY_SSID, "") ?: "",
|
||||||
|
passcode = p.getString(KEY_PASSCODE, "") ?: "",
|
||||||
|
intervalMin = p.getInt(KEY_INTERVAL, 5),
|
||||||
|
statusText = p.getString(KEY_STATUS, "Look4Sat APRS") ?: "Look4Sat APRS",
|
||||||
|
symbolTable = p.getString(KEY_SYMBOL_TABLE, "/") ?: "/",
|
||||||
|
symbolCode = p.getString(KEY_SYMBOL_CODE, ">") ?: ">"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last report result (shown on the settings card) */
|
||||||
|
data class LastReport(val time: Long = 0L, val ok: Boolean = false, val detail: String = "")
|
||||||
|
|
||||||
|
fun loadLastReport(context: Context): LastReport {
|
||||||
|
val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
return LastReport(
|
||||||
|
time = p.getLong(KEY_LAST_TIME, 0L),
|
||||||
|
ok = p.getBoolean(KEY_LAST_OK, false),
|
||||||
|
detail = p.getString(KEY_LAST_DETAIL, "") ?: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveLastReport(context: Context, ok: Boolean, detail: String) {
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||||
|
.putLong(KEY_LAST_TIME, System.currentTimeMillis())
|
||||||
|
.putBoolean(KEY_LAST_OK, ok)
|
||||||
|
.putString(KEY_LAST_DETAIL, detail)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save config */
|
||||||
|
fun saveConfig(context: Context, cfg: AprsConfig) {
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
|
||||||
|
.putBoolean(KEY_ENABLED, cfg.enabled)
|
||||||
|
.putString(KEY_SERVER, cfg.server)
|
||||||
|
.putInt(KEY_PORT, cfg.port)
|
||||||
|
.putString(KEY_CALLSIGN, cfg.callsign)
|
||||||
|
.putString(KEY_SSID, cfg.ssid)
|
||||||
|
.putString(KEY_PASSCODE, cfg.passcode)
|
||||||
|
.putInt(KEY_INTERVAL, cfg.intervalMin)
|
||||||
|
.putString(KEY_STATUS, cfg.statusText)
|
||||||
|
.putString(KEY_SYMBOL_TABLE, cfg.symbolTable)
|
||||||
|
.putString(KEY_SYMBOL_CODE, cfg.symbolCode)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
|||||||
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
import com.rtbishop.look4sat.core.data.framework.Ic705Controller
|
||||||
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
import com.rtbishop.look4sat.core.data.framework.NetworkReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
import com.rtbishop.look4sat.core.data.framework.RadioTrackingService
|
||||||
|
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
|
||||||
|
import com.rtbishop.look4sat.core.domain.amsat.AmSatApiClient
|
||||||
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
import com.rtbishop.look4sat.core.data.repository.SatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
import com.rtbishop.look4sat.core.data.repository.SelectionRepo
|
||||||
@@ -40,6 +42,9 @@ import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
|||||||
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
import com.rtbishop.look4sat.core.data.usecase.AudioCapture
|
||||||
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
import com.rtbishop.look4sat.core.data.usecase.SaveImage
|
||||||
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
import com.rtbishop.look4sat.core.data.usecase.ShowToast
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.IWavelogQueueStore
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
@@ -66,16 +71,19 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import com.rtbishop.look4sat.core.data.wavelog.LotwSatellitesRepo
|
||||||
|
|
||||||
class MainContainer(private val context: Context) : IMainContainer {
|
class MainContainer(private val context: Context) : IMainContainer {
|
||||||
|
|
||||||
private val localSource = provideLocalSource()
|
private val localSource = provideLocalSource()
|
||||||
|
private val remoteSource by lazy { provideRemoteSource() }
|
||||||
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
|
private val mainHandler = CoroutineExceptionHandler { _, error -> println("MainHandler: $error") }
|
||||||
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
|
override val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + mainHandler)
|
||||||
override val settingsRepo = provideSettingsRepo()
|
override val settingsRepo = provideSettingsRepo()
|
||||||
override val selectionRepo = provideSelectionRepo()
|
override val selectionRepo = provideSelectionRepo()
|
||||||
override val satelliteRepo = provideSatelliteRepo()
|
override val satelliteRepo = provideSatelliteRepo()
|
||||||
override val databaseRepo = provideDatabaseRepo()
|
override val databaseRepo = provideDatabaseRepo()
|
||||||
|
override val amSatRepo = AmSatRepository(com.rtbishop.look4sat.core.domain.amsat.AmSatApiClient())
|
||||||
override val radioTrackingService: IRadioTrackingService by lazy {
|
override val radioTrackingService: IRadioTrackingService by lazy {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
RadioTrackingService(appScope, manager, satelliteRepo, settingsRepo)
|
||||||
@@ -96,6 +104,22 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
|
|
||||||
override fun provideSaveImage(): ISaveImage = SaveImage(context)
|
override fun provideSaveImage(): ISaveImage = SaveImage(context)
|
||||||
|
|
||||||
|
// WaveLog logging (4.5.2): local queue + uploader (shared instance)
|
||||||
|
override val wavelogQueue: WavelogQueue by lazy {
|
||||||
|
val prefs = context.getSharedPreferences("wavelog", Context.MODE_PRIVATE)
|
||||||
|
WavelogQueue(object : IWavelogQueueStore {
|
||||||
|
override fun load(): String = prefs.getString("wavelog_queue", "[]") ?: "[]"
|
||||||
|
override fun save(json: String) = prefs.edit().putString("wavelog_queue", json).apply()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
override fun provideWavelogUploader(): WavelogUploader = WavelogUploader(settingsRepo, wavelogQueue)
|
||||||
|
|
||||||
|
private val lotwRepo: LotwSatellitesRepo by lazy {
|
||||||
|
LotwSatellitesRepo(context).also { it.restore() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun provideLotwSatellitesRepo(): com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo = lotwRepo
|
||||||
|
|
||||||
override fun provideBluetoothReporter(): IReporter {
|
override fun provideBluetoothReporter(): IReporter {
|
||||||
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
val manager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
val rc = settingsRepo.rcSettings.value
|
val rc = settingsRepo.rcSettings.value
|
||||||
@@ -149,7 +173,6 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
private fun provideDatabaseRepo(): IDatabaseRepo {
|
private fun provideDatabaseRepo(): IDatabaseRepo {
|
||||||
val dbDispatcher = Dispatchers.Default
|
val dbDispatcher = Dispatchers.Default
|
||||||
val dataParser = DataParser(dbDispatcher)
|
val dataParser = DataParser(dbDispatcher)
|
||||||
val remoteSource = provideRemoteSource()
|
|
||||||
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
return DatabaseRepo(dbDispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +183,14 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun provideRemoteSource(): IRemoteSource {
|
private fun provideRemoteSource(): IRemoteSource {
|
||||||
return RemoteSource(Dispatchers.IO, context.contentResolver, OkHttpClient.Builder().build())
|
return RemoteSource(
|
||||||
|
Dispatchers.IO, context.contentResolver,
|
||||||
|
OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
.readTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun provideSatelliteRepo(): ISatelliteRepo {
|
private fun provideSatelliteRepo(): ISatelliteRepo {
|
||||||
@@ -176,6 +206,6 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
val appPrefsFileName = "${context.packageName}_preferences"
|
val appPrefsFileName = "${context.packageName}_preferences"
|
||||||
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
val appPreferences = context.getSharedPreferences(appPrefsFileName, Context.MODE_PRIVATE)
|
||||||
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
val appVersionName = context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "4.0.4"
|
||||||
return SettingsRepo(manager, appPreferences, appVersionName)
|
return SettingsRepo(context, manager, appPreferences, appVersionName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.amsat.AmSatApiClient
|
||||||
|
import com.rtbishop.look4sat.core.domain.amsat.ApiReport
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
/** AMSAT status repository: official API v1 -> SatStatusPage (replaces the HTML parser). */
|
||||||
|
class AmSatRepository(private val apiClient: AmSatApiClient) : IAmSatRepository {
|
||||||
|
|
||||||
|
override suspend fun fetchStatus(): SatStatusPage? = withContext(Dispatchers.IO) {
|
||||||
|
val nowSec = System.currentTimeMillis() / 1000
|
||||||
|
val names = apiClient.fetchCatalog()
|
||||||
|
val reports = apiClient.fetchAllReports(hours = 168)
|
||||||
|
if (names.isEmpty() && reports.isEmpty()) return@withContext null
|
||||||
|
val statuses = buildStatuses(names, reports, nowSec)
|
||||||
|
val reportMap = reports.associate { it.id to toSatReport(it) }
|
||||||
|
SatStatusPage(System.currentTimeMillis(), statuses, reportMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build one SatStatus (6 days x 12 slots) per catalog satellite, slotting reports by age. */
|
||||||
|
private fun buildStatuses(names: List<String>, reports: List<ApiReport>, nowSec: Long): List<SatStatus> {
|
||||||
|
val byName = reports.groupBy { it.name }
|
||||||
|
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||||
|
val utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||||
|
val labels = (0 until 6).map { d ->
|
||||||
|
utc.timeInMillis = (nowSec - d * 86400L) * 1000
|
||||||
|
"${monthAbbr[utc.get(Calendar.MONTH)]} ${utc.get(Calendar.DAY_OF_MONTH)}"
|
||||||
|
}
|
||||||
|
return names.map { name ->
|
||||||
|
val slots = (0 until 72).map { slotIdx ->
|
||||||
|
val slotStart = nowSec - (slotIdx + 1) * 7200L
|
||||||
|
val slotEnd = nowSec - slotIdx * 7200L
|
||||||
|
val inSlot = byName[name].orEmpty().filter { it.reportedTimeUtcSec in slotStart until slotEnd }
|
||||||
|
if (inSlot.isEmpty()) {
|
||||||
|
SatSlot(statusColor = NoReportGray, count = 0)
|
||||||
|
} else {
|
||||||
|
val newest = inSlot.maxByOrNull { it.reportedTimeUtcSec }!!
|
||||||
|
SatSlot(
|
||||||
|
statusColor = statusColorOf(newest.report),
|
||||||
|
count = inSlot.size,
|
||||||
|
reportIds = inSlot.map { it.id }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val days = (0 until 6).map { d ->
|
||||||
|
SatDay(dateLabel = labels[d], slots = slots.subList(d * 12, (d + 1) * 12))
|
||||||
|
}
|
||||||
|
SatStatus(name = name, days = days)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toSatReport(r: ApiReport): SatReport {
|
||||||
|
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||||
|
cal.timeInMillis = r.reportedTimeUtcSec * 1000
|
||||||
|
val hh = cal.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
|
||||||
|
val mm = cal.get(Calendar.MINUTE).toString().padStart(2, '0')
|
||||||
|
val y = cal.get(Calendar.YEAR)
|
||||||
|
val mo = (cal.get(Calendar.MONTH) + 1).toString().padStart(2, '0')
|
||||||
|
val d = cal.get(Calendar.DAY_OF_MONTH).toString().padStart(2, '0')
|
||||||
|
return SatReport(
|
||||||
|
id = r.id,
|
||||||
|
statusText = r.report,
|
||||||
|
call = r.callsign,
|
||||||
|
grid = r.gridSquare,
|
||||||
|
dateUtc = "$y-$mo-$d",
|
||||||
|
timeUtc = "$hh:$mm UTC"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
|
||||||
|
"heard", "crew active" -> ActiveBlue
|
||||||
|
"telemetry only" -> TlmOrange
|
||||||
|
"not heard" -> NotHeardPink
|
||||||
|
else -> ConflictDeepOrange
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ActiveBlue = 0xFF648FFF
|
||||||
|
private const val TlmOrange = 0xFFFFB000
|
||||||
|
private const val NotHeardPink = 0xFFDC267F
|
||||||
|
private const val ConflictDeepOrange = 0xFFFE6100
|
||||||
|
private const val NoReportGray = 0xFFC0C0C0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,23 +69,33 @@ class DatabaseRepo(
|
|||||||
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
||||||
val tleUrls = buildMap {
|
val tleUrls = buildMap {
|
||||||
putAll(Sources.satelliteDataUrls)
|
putAll(Sources.satelliteDataUrls)
|
||||||
if (dataSourcesSettings.useCustomTLE) put(customSourceType, dataSourcesSettings.tleUrl)
|
// Switch on + non-empty URL -> All uses the custom URL; otherwise the default URL (online-update default source)
|
||||||
|
put("All", if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank())
|
||||||
|
dataSourcesSettings.tleUrl else Sources.defaultTleUrl)
|
||||||
}.filterValues { it.isNotBlank() }
|
}.filterValues { it.isNotBlank() }
|
||||||
val radioUrls = buildMap {
|
val radioUrls = buildMap {
|
||||||
putAll(Sources.transceiversDataUrls)
|
putAll(Sources.transceiversDataUrls)
|
||||||
if (dataSourcesSettings.useCustomTransceivers) put(customSourceType, dataSourcesSettings.transceiversUrl)
|
put("SatNOGS", if (dataSourcesSettings.useCustomTransceivers && dataSourcesSettings.transceiversUrl.isNotBlank())
|
||||||
|
dataSourcesSettings.transceiversUrl else Sources.defaultTransceiversUrl)
|
||||||
}.filterValues { it.isNotBlank() }
|
}.filterValues { it.isNotBlank() }
|
||||||
// launch all network requests concurrently
|
// launch all network requests concurrently
|
||||||
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||||
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
||||||
|
// Count successful sources: zero successes = update failed (timestamp untouched, exception surfaced in the UI)
|
||||||
|
val tleResults = tleJobs.awaitAll()
|
||||||
|
val radioResults = radioJobs.awaitAll()
|
||||||
|
val successCount = tleResults.count { it.second != null } + radioResults.count { it.second != null }
|
||||||
|
if (successCount == 0) {
|
||||||
|
throw java.io.IOException("All data sources failed to download")
|
||||||
|
}
|
||||||
// parse fetched data concurrently and associate with types
|
// parse fetched data concurrently and associate with types
|
||||||
val importedEntries = tleJobs.awaitAll().flatMap { (url, stream) ->
|
val importedEntries = tleResults.flatMap { (url, stream) ->
|
||||||
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
||||||
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
||||||
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val importedRadios = radioJobs.awaitAll().flatMap { (url, stream) ->
|
val importedRadios = radioResults.flatMap { (url, stream) ->
|
||||||
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
||||||
}
|
}
|
||||||
// insert parsed data into the database
|
// insert parsed data into the database
|
||||||
|
|||||||
+67
-18
@@ -29,6 +29,7 @@ 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
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||||
@@ -39,6 +40,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
class SettingsRepo(
|
class SettingsRepo(
|
||||||
|
private val context: android.content.Context,
|
||||||
private val locationManager: LocationManager,
|
private val locationManager: LocationManager,
|
||||||
private val preferences: SharedPreferences,
|
private val preferences: SharedPreferences,
|
||||||
override val appVersionName: String
|
override val appVersionName: String
|
||||||
@@ -76,6 +78,8 @@ class SettingsRepo(
|
|||||||
private val keyStateOfUtc = "stateOfUtc"
|
private val keyStateOfUtc = "stateOfUtc"
|
||||||
private val keyStateOfLightTheme = "stateOfLightTheme"
|
private val keyStateOfLightTheme = "stateOfLightTheme"
|
||||||
private val keyStateOfNightMode = "stateOfNightMode"
|
private val keyStateOfNightMode = "stateOfNightMode"
|
||||||
|
private val keyHiddenScreens = "hiddenScreens"
|
||||||
|
private val keyScreenOrder = "screenOrder"
|
||||||
private val keyStationAltitude = "stationAltitude"
|
private val keyStationAltitude = "stationAltitude"
|
||||||
private val keyStationLatitude = "stationLatitude"
|
private val keyStationLatitude = "stationLatitude"
|
||||||
private val keyStationLongitude = "stationLongitude"
|
private val keyStationLongitude = "stationLongitude"
|
||||||
@@ -91,6 +95,11 @@ class SettingsRepo(
|
|||||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||||
private val keyTleUrl = "tleUrl"
|
private val keyTleUrl = "tleUrl"
|
||||||
private val keyTransceiversUrl = "transceiversUrl"
|
private val keyTransceiversUrl = "transceiversUrl"
|
||||||
|
private val keySubMenuOrder = "subMenuOrder"
|
||||||
|
private val keyWavelogUrl = "wavelogUrl"
|
||||||
|
private val keyWavelogApiKey = "wavelogApiKey"
|
||||||
|
private val keyWavelogStationId = "wavelogStationId"
|
||||||
|
private val keyWavelogAutoUpload = "wavelogAutoUpload"
|
||||||
private val separatorComma = ","
|
private val separatorComma = ","
|
||||||
|
|
||||||
//region # Satellites selection settings
|
//region # Satellites selection settings
|
||||||
@@ -178,23 +187,41 @@ class SettingsRepo(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setStationPosition(): Boolean {
|
/** GPS fix: one-shot getCurrentLocation (GPS first, 15 s timeout); returns true only with a fix */
|
||||||
|
override suspend fun setStationPosition(): Boolean {
|
||||||
|
// Permission gate: fail fast without location permission (no swallowed exceptions)
|
||||||
|
if (androidx.core.content.ContextCompat.checkSelfPermission(
|
||||||
|
context, android.Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
) != android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
println("GPS: no fine location permission")
|
||||||
|
return false
|
||||||
|
}
|
||||||
if (!LocationManagerCompat.isLocationEnabled(locationManager)) return false
|
if (!LocationManagerCompat.isLocationEnabled(locationManager)) return false
|
||||||
try {
|
return kotlinx.coroutines.suspendCancellableCoroutine { cont ->
|
||||||
val hasGps = LocationManagerCompat.hasProvider(locationManager, providerGps)
|
val signal = android.os.CancellationSignal()
|
||||||
val hasNet = LocationManagerCompat.hasProvider(locationManager, providerNet)
|
val handler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||||
val provider = if (hasGps) providerGps else if (hasNet) providerNet else providerDef
|
val executor = java.util.concurrent.Executor { handler.post(it) }
|
||||||
val location = locationManager.getLastKnownLocation(providerDef)
|
// 15 s timeout
|
||||||
if (location == null || System.currentTimeMillis() - location.time > 600_000L) {
|
val timeout = handler.postDelayed({
|
||||||
println("Requesting location for $provider provider")
|
signal.cancel()
|
||||||
locationManager.requestLocationUpdates(provider, 0L, 0f, this)
|
if (cont.isActive) cont.resume(false) { }
|
||||||
} else {
|
}, 15_000L)
|
||||||
|
val listener = androidx.core.util.Consumer<Location> { location ->
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
setStationPosition(location.latitude, location.longitude, location.altitude)
|
setStationPosition(location.latitude, location.longitude, location.altitude)
|
||||||
|
if (cont.isActive) cont.resume(true) { }
|
||||||
}
|
}
|
||||||
|
val hasGps = LocationManagerCompat.hasProvider(locationManager, providerGps)
|
||||||
|
val provider = if (hasGps) providerGps else providerNet
|
||||||
|
try {
|
||||||
|
LocationManagerCompat.getCurrentLocation(locationManager, provider, signal, executor, listener)
|
||||||
} catch (exception: SecurityException) {
|
} catch (exception: SecurityException) {
|
||||||
println("No permissions were given - $exception")
|
handler.removeCallbacksAndMessages(null)
|
||||||
|
if (cont.isActive) cont.resume(false) { }
|
||||||
|
}
|
||||||
|
cont.invokeOnCancellation { signal.cancel(); handler.removeCallbacksAndMessages(null) }
|
||||||
}
|
}
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setStationPosition(locator: String): Boolean {
|
override fun setStationPosition(locator: String): Boolean {
|
||||||
@@ -356,6 +383,13 @@ class SettingsRepo(
|
|||||||
putString(keySstvMode, new.sstvMode)
|
putString(keySstvMode, new.sstvMode)
|
||||||
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
putLong(keyLowElevation, new.lowElevation.toRawBits())
|
||||||
putLong(keyHighElevation, new.highElevation.toRawBits())
|
putLong(keyHighElevation, new.highElevation.toRawBits())
|
||||||
|
putStringSet(keyHiddenScreens, new.hiddenScreens.toSet())
|
||||||
|
putString(keyScreenOrder, new.screenOrder.joinToString(","))
|
||||||
|
putString(keySubMenuOrder, new.subMenuOrder.joinToString(","))
|
||||||
|
putString(keyWavelogUrl, new.wavelogUrl)
|
||||||
|
putString(keyWavelogApiKey, new.wavelogApiKey)
|
||||||
|
putString(keyWavelogStationId, new.wavelogStationId)
|
||||||
|
putBoolean(keyWavelogAutoUpload, new.wavelogAutoUpload)
|
||||||
}
|
}
|
||||||
new
|
new
|
||||||
}
|
}
|
||||||
@@ -371,8 +405,15 @@ class SettingsRepo(
|
|||||||
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",
|
||||||
|
hiddenScreens = preferences.getStringSet(keyHiddenScreens, emptySet())?.toList() ?: emptyList(),
|
||||||
|
screenOrder = preferences.getString(keyScreenOrder, null)?.split(",")?.filter { it.isNotBlank() } ?: emptyList(),
|
||||||
|
subMenuOrder = preferences.getString(keySubMenuOrder, null)?.split(",")?.filter { it.isNotBlank() } ?: emptyList(),
|
||||||
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
lowElevation = Double.fromBits(preferences.getLong(keyLowElevation, 15.0.toRawBits())),
|
||||||
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits()))
|
highElevation = Double.fromBits(preferences.getLong(keyHighElevation, 45.0.toRawBits())),
|
||||||
|
wavelogUrl = preferences.getString(keyWavelogUrl, null) ?: "",
|
||||||
|
wavelogApiKey = preferences.getString(keyWavelogApiKey, null) ?: "",
|
||||||
|
wavelogStationId = preferences.getString(keyWavelogStationId, null) ?: "",
|
||||||
|
wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false)
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -390,12 +431,20 @@ class SettingsRepo(
|
|||||||
_dataSourcesSettings.value = settings
|
_dataSourcesSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
private fun getDataSourcesSettings(): DataSourcesSettings {
|
||||||
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false),
|
// 4.4.8 fix: legacy example.com placeholder URLs count as unconfigured -> replaced with the real default URL and the switch forced off,
|
||||||
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false),
|
// otherwise the online All/SatNOGS sources would point at the wrong address and fail to update
|
||||||
tleUrl = preferences.getString(keyTleUrl, "https://example.com/tle.txt") ?: "",
|
val storedTleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl
|
||||||
transceiversUrl = preferences.getString(keyTransceiversUrl, "https://example.com/radio.json") ?: ""
|
val storedTxUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl) ?: Sources.defaultTransceiversUrl
|
||||||
|
val tleUrl = if (storedTleUrl == "https://example.com/tle.txt") Sources.defaultTleUrl else storedTleUrl
|
||||||
|
val txUrl = if (storedTxUrl == "https://example.com/radio.json") Sources.defaultTransceiversUrl else storedTxUrl
|
||||||
|
return DataSourcesSettings(
|
||||||
|
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false) && tleUrl != Sources.defaultTleUrl,
|
||||||
|
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false) && txUrl != Sources.defaultTransceiversUrl,
|
||||||
|
tleUrl = tleUrl,
|
||||||
|
transceiversUrl = txUrl
|
||||||
)
|
)
|
||||||
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Radio control settings
|
//region # Radio control settings
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.source
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AMSAT satellite status page parser (https://amsat.org/status/)
|
||||||
|
*
|
||||||
|
* Page structure (static HTML, verified 2026-08):
|
||||||
|
* - Status table: 48 rows in <table>, header = Name + 6 days (each colspan=12)
|
||||||
|
* Data row has 73 cells: [0]=satellite name, [1..72] = 6 days x 12 two-hour slots (new->old)
|
||||||
|
* Each cell: <td width=9 bgcolor="color">count</td>; when reports exist the count carries a
|
||||||
|
* docTips.show('id') link
|
||||||
|
* - Report details live in the page's inline JS:
|
||||||
|
* tips.a885153 = new Array(5,5,120,'status<br>callsign<br>grid<br>date<br>time span UTC')
|
||||||
|
*
|
||||||
|
* Status colors: #648fff=active, #ffb000=telemetry only, #dc267f=not heard, #fe6100=conflicting
|
||||||
|
*/
|
||||||
|
object AmSatParser {
|
||||||
|
|
||||||
|
private val STATUS_COLORS = mapOf(
|
||||||
|
"#648fff" to 0xFF648FFF.toLong(),
|
||||||
|
"#ffb000" to 0xFFFFB000.toLong(),
|
||||||
|
"#dc267f" to 0xFFDC267F.toLong(),
|
||||||
|
"#fe6100" to 0xFFFE6100.toLong()
|
||||||
|
)
|
||||||
|
private val GRAY = 0xFFC0C0C0.toLong()
|
||||||
|
|
||||||
|
private val rowRe = Pattern.compile("<tr>(.*?)</tr>", Pattern.DOTALL)
|
||||||
|
private val cellRe = Pattern.compile("(<t[dh][^>]*>.*?</t[dh]>)", Pattern.DOTALL)
|
||||||
|
private val bgRe = Pattern.compile("bgcolor=\"?(#[0-9a-fA-F]{6}|C0C0C0)\"?")
|
||||||
|
private val linkRe = Pattern.compile("docTips\\.show\\('(a\\d+)'\\)")
|
||||||
|
private val tipRe = Pattern.compile(
|
||||||
|
"tips\\.(a\\d+)\\s*=\\s*new\\s+Array\\(\\s*\\d+,\\s*\\d+,\\s*\\d+,\\s*'([^']*)'"
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Parse the full page */
|
||||||
|
fun parse(html: String, fetchedAtUtcMs: Long): SatStatusPage {
|
||||||
|
val reports = parseReports(html)
|
||||||
|
val statuses = parseStatusTable(html, reports)
|
||||||
|
return SatStatusPage(fetchedAtUtcMs, statuses, reports)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract all reports (tips.* JS arrays) */
|
||||||
|
fun parseReports(html: String): Map<String, SatReport> {
|
||||||
|
val map = mutableMapOf<String, SatReport>()
|
||||||
|
val m = tipRe.matcher(html)
|
||||||
|
while (m.find()) {
|
||||||
|
val id = m.group(1)
|
||||||
|
val parts = m.group(2).split("<br>")
|
||||||
|
map[id] = SatReport(
|
||||||
|
id = id,
|
||||||
|
statusText = parts.getOrElse(0) { "" }.trim(),
|
||||||
|
call = parts.getOrElse(1) { "" }.trim(),
|
||||||
|
grid = parts.getOrElse(2) { "" }.trim(),
|
||||||
|
dateUtc = parts.getOrElse(3) { "" }.trim(),
|
||||||
|
timeUtc = parts.getOrElse(4) { "" }.trim()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse the status table (48 satellite rows) */
|
||||||
|
fun parseStatusTable(html: String, reports: Map<String, SatReport>): List<SatStatus> {
|
||||||
|
val result = mutableListOf<SatStatus>()
|
||||||
|
val tables = extractTables(html)
|
||||||
|
for (table in tables) {
|
||||||
|
val rows = rowRe.matcher(table)
|
||||||
|
val parsed = mutableListOf<SatStatus>()
|
||||||
|
var rowIndex = 0
|
||||||
|
var dayHeaders: List<String> = emptyList()
|
||||||
|
while (rows.find()) {
|
||||||
|
val rowHtml = rows.group(1)
|
||||||
|
val cells = cellRe.matcher(rowHtml)
|
||||||
|
val cellList = mutableListOf<String>()
|
||||||
|
while (cells.find()) cellList.add(cells.group(1))
|
||||||
|
if (rowIndex == 0) {
|
||||||
|
// Header: Name + 6 days (each colspan=12)
|
||||||
|
dayHeaders = cellList.drop(1).take(6).map { stripHtml(it) }
|
||||||
|
rowIndex++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (cellList.size < 7) { rowIndex++; continue }
|
||||||
|
val name = stripHtml(cellList[0])
|
||||||
|
if (name.isBlank()) { rowIndex++; continue }
|
||||||
|
val days = mutableListOf<SatDay>()
|
||||||
|
for (d in 0 until 6) {
|
||||||
|
val start = 1 + d * 12
|
||||||
|
val end = start + 12
|
||||||
|
val slots = (start until end).mapNotNull { i ->
|
||||||
|
cellList.getOrNull(i)?.let { cell ->
|
||||||
|
val bg = bgRe.matcher(cell)
|
||||||
|
val color = if (bg.find()) {
|
||||||
|
STATUS_COLORS[bg.group(1).lowercase()] ?: GRAY
|
||||||
|
} else GRAY
|
||||||
|
val link = linkRe.matcher(cell)
|
||||||
|
val ids = mutableListOf<String>()
|
||||||
|
while (link.find()) ids.add(link.group(1))
|
||||||
|
val hasReport = ids.isNotEmpty()
|
||||||
|
val count = if (hasReport) {
|
||||||
|
stripHtml(cell).trim().toIntOrNull() ?: ids.size
|
||||||
|
} else 0
|
||||||
|
SatSlot(
|
||||||
|
statusColor = if (hasReport) color else GRAY,
|
||||||
|
count = count,
|
||||||
|
reportIds = ids
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
days.add(SatDay(dayHeaders.getOrElse(d) { "" }, slots))
|
||||||
|
}
|
||||||
|
parsed.add(SatStatus(name, days))
|
||||||
|
rowIndex++
|
||||||
|
}
|
||||||
|
if (parsed.isNotEmpty()) {
|
||||||
|
result.addAll(parsed)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractTables(html: String): List<String> {
|
||||||
|
val result = mutableListOf<String>()
|
||||||
|
val m = Pattern.compile("<table.*?</table>", Pattern.DOTALL).matcher(html)
|
||||||
|
while (m.find()) result.add(m.group())
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stripHtml(s: String): String =
|
||||||
|
s.replace(Regex("<[^>]+>"), "").trim()
|
||||||
|
}
|
||||||
@@ -43,6 +43,22 @@ class RemoteSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getStatusHtml(): String? = withContext(dispatcher) {
|
||||||
|
try {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("https://amsat.org/status/")
|
||||||
|
.header("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat/4.5")
|
||||||
|
.build()
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@use null
|
||||||
|
response.body?.string()
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
println("RemoteSource amsat status exception: $exception")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
||||||
try {
|
try {
|
||||||
val networkRequest = Request.Builder().url(url).build()
|
val networkRequest = Request.Builder().url(url).build()
|
||||||
|
|||||||
@@ -25,4 +25,8 @@ class ShowToast(private val context: Context) : IShowToast {
|
|||||||
override fun invoke(message: String) {
|
override fun invoke(message: String) {
|
||||||
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun invoke(resId: Int) {
|
||||||
|
invoke(context.getString(resId))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/* LotwSatellitesRepo.kt - runtime refresh of the LoTW satellite list (4.5.5).
|
||||||
|
* Downloads ARRL's official config.tq6 (gzip XML), parses <satellite name="...">,
|
||||||
|
* updates the LotwSatellites dynamic set + persists to SharedPreferences (survives restarts).
|
||||||
|
* Trigger: the "Update sats" button in the settings WaveLog section (user decided: manual).
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.wavelog
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.LotwSatellites
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.zip.GZIPInputStream
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
class LotwSatellitesRepo(private val context: Context) : ILotwSatellitesRepo {
|
||||||
|
|
||||||
|
private val prefs = context.getSharedPreferences("lotw_satellites", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
/** Restore the last downloaded list from SharedPreferences (call at process start) */
|
||||||
|
override fun restore() {
|
||||||
|
val saved = prefs.getString(KEY_NAMES, null)
|
||||||
|
if (!saved.isNullOrBlank()) {
|
||||||
|
LotwSatellites.updateNames(saved.split(",").filter { it.isNotBlank() }.toSet())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download config.tq6 -> parse -> update memory + persist. Result is surfaced in the UI */
|
||||||
|
override suspend fun refresh(): ILotwSatellitesRepo.RefreshResult = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val url = URL("https://lotw.arrl.org/lotw/config.tq6")
|
||||||
|
val conn = url.openConnection()
|
||||||
|
conn.connectTimeout = 10_000
|
||||||
|
conn.readTimeout = 20_000
|
||||||
|
conn.setRequestProperty("User-Agent", "Look4Sat-Pro/4.5.5")
|
||||||
|
val raw = conn.getInputStream()
|
||||||
|
val reader = BufferedReader(InputStreamReader(GZIPInputStream(raw), Charsets.UTF_8))
|
||||||
|
val text = reader.use { it.readText() }
|
||||||
|
// Parse <satellite name="XXX" ...> - fixed format (official ARRL XML)
|
||||||
|
val names = Regex("""<satellite name="([^"]+)""").findAll(text)
|
||||||
|
.map { it.groupValues[1].uppercase() }
|
||||||
|
.toSet()
|
||||||
|
if (names.isEmpty()) return@withContext ILotwSatellitesRepo.RefreshResult.Error("列表为空(响应异常)")
|
||||||
|
LotwSatellites.updateNames(names)
|
||||||
|
prefs.edit()
|
||||||
|
.putString(KEY_NAMES, names.joinToString(","))
|
||||||
|
.putLong(KEY_UPDATED, System.currentTimeMillis())
|
||||||
|
.apply()
|
||||||
|
ILotwSatellitesRepo.RefreshResult.Ok(names.size)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ILotwSatellitesRepo.RefreshResult.Error(e.message ?: "未知错误")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val KEY_NAMES = "names"
|
||||||
|
private const val KEY_UPDATED = "updated"
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -102,7 +102,8 @@ class DatabaseRepoTest {
|
|||||||
repository.updateFromRemote()
|
repository.updateFromRemote()
|
||||||
|
|
||||||
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
||||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
// New semantics: switch on + non-empty URL -> the All source uses the custom URL, data lands in the All type
|
||||||
|
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun validCsvStream(): InputStream = """
|
private fun validCsvStream(): InputStream = """
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.convention.coreDomainPlugin)
|
alias(libs.plugins.convention.coreDomainPlugin)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// 编译期使用 org.json(构造/解析 WaveLog API 请求体); 运行时用 Android 系统自带的 org.json
|
||||||
|
compileOnly("org.json:json:20240303")
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/* AmSatApiClient.kt - AMSAT official status API v1 client (pure JVM).
|
||||||
|
* Endpoints (verified 2026-08):
|
||||||
|
* GET https://www.amsat.org/status/api/v1/catalog.php -> satellite list
|
||||||
|
* GET https://www.amsat.org/status/api/v1/reports.php?hours=N&limit=500 -> reports
|
||||||
|
* Report fields: id, name ("SO-50_[FM]"), callsign, report, grid_square, reported_time (ISO 8601 UTC).
|
||||||
|
* Status values: Heard / Telemetry Only / Not Heard / Crew Active.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.amsat
|
||||||
|
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.json.JSONArray
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
/** One report from the AMSAT API. */
|
||||||
|
data class ApiReport(
|
||||||
|
val id: String,
|
||||||
|
val name: String, // "SO-50_[FM]" (API name, includes mode suffix)
|
||||||
|
val callsign: String,
|
||||||
|
val report: String, // Heard / Telemetry Only / Not Heard / Crew Active
|
||||||
|
val gridSquare: String,
|
||||||
|
val reportedTimeUtcSec: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
/** AMSAT official satellite status API v1 client. */
|
||||||
|
class AmSatApiClient(private val baseUrl: String = "https://www.amsat.org/status/api/v1") {
|
||||||
|
|
||||||
|
/** Fetch the full satellite catalog. Returns API names (e.g. "SO-50_[FM]"). */
|
||||||
|
fun fetchCatalog(): List<String> {
|
||||||
|
val body = httpGet("$baseUrl/catalog.php") ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = JSONObject(body).getJSONArray("data")
|
||||||
|
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch reports for a rolling UTC window. Empty on failure. */
|
||||||
|
fun fetchAllReports(hours: Int = 168): List<ApiReport> {
|
||||||
|
val body = httpGet("$baseUrl/reports.php?hours=$hours&limit=500") ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = JSONObject(body).getJSONArray("data")
|
||||||
|
(0 until arr.length()).mapNotNull { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
val iso = o.optString("reported_time", "")
|
||||||
|
if (iso.isEmpty()) null else ApiReport(
|
||||||
|
id = o.optString("id", ""),
|
||||||
|
name = o.optString("name", ""),
|
||||||
|
callsign = o.optString("callsign", ""),
|
||||||
|
report = o.optString("report", ""),
|
||||||
|
gridSquare = o.optString("grid_square", ""),
|
||||||
|
reportedTimeUtcSec = parseIsoUtcSec(iso)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse "2026-08-05T07:30:00Z" to epoch seconds (minSdk 24: no java.time). */
|
||||||
|
fun parseIsoUtcSec(iso: String): Long {
|
||||||
|
val m = Regex("""(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})""").find(iso) ?: return 0L
|
||||||
|
val (y, mo, d, h, mi, s) = m.destructured
|
||||||
|
val days = daysFromCivil(y.toInt(), mo.toInt(), d.toInt())
|
||||||
|
return days * 86400L + h.toInt() * 3600L + mi.toInt() * 60L + s.toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Days since 1970-01-01 (civil calendar, proleptic Gregorian). */
|
||||||
|
private fun daysFromCivil(year: Int, month: Int, day: Int): Long {
|
||||||
|
val y = if (month <= 2) year - 1 else year
|
||||||
|
val era = (if (y >= 0) y else y - 399) / 400
|
||||||
|
val yoe = y - era * 400
|
||||||
|
val doy = (153 * (if (month > 2) month - 3 else month + 9) + 2) / 5 + day - 1
|
||||||
|
val doe = yoe * 365 + yoe / 4 - yoe / 100 + doy
|
||||||
|
return era * 146097L + doe - 719468
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun httpGet(url: String): String? {
|
||||||
|
return try {
|
||||||
|
val conn = URL(url).openConnection() as HttpURLConnection
|
||||||
|
conn.requestMethod = "GET"
|
||||||
|
conn.connectTimeout = 15000
|
||||||
|
conn.readTimeout = 20000
|
||||||
|
conn.setRequestProperty("User-Agent", "Look4Sat/4.5.5")
|
||||||
|
if (conn.responseCode !in 200..299) {
|
||||||
|
conn.disconnect()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val text = conn.inputStream.bufferedReader().use(BufferedReader::readText)
|
||||||
|
conn.disconnect()
|
||||||
|
text
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.aprs
|
||||||
|
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.round
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APRS-IS protocol core (pure Kotlin, no Android dependencies).
|
||||||
|
* Reverse-ported from APRSdroid 1.6.3d: AprsPacket$.scala + ab0oo Position.java.
|
||||||
|
*/
|
||||||
|
object AprsPacket {
|
||||||
|
|
||||||
|
/** APRS-IS passcode algorithm (standard): 0x73E2 seed, uppercase callsign + \0, XOR each char pair */
|
||||||
|
fun passcode(callsign: String): Int {
|
||||||
|
val s = callsign.split("-")[0].uppercase() + "\u0000"
|
||||||
|
var hash = 29666 // 0x73E2
|
||||||
|
var i = 0
|
||||||
|
while (i <= s.length - 2) {
|
||||||
|
hash = hash xor (s[i].code * 256 + s[i + 1].code)
|
||||||
|
i += 2
|
||||||
|
}
|
||||||
|
return hash and 0x7FFF
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Login line: user CALL-SSID pass XXXX vers XXXX */
|
||||||
|
fun formatLogin(callsign: String, ssid: String, passcode: Int, version: String): String {
|
||||||
|
val callSsid = formatCallSsid(callsign, ssid)
|
||||||
|
return "user $callSsid pass $passcode vers $version"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Callsign-SSID join (BG7NTA + 5 -> BG7NTA-5) */
|
||||||
|
fun formatCallSsid(callsign: String, ssid: String): String {
|
||||||
|
if (ssid.isNullOrEmpty()) return callsign
|
||||||
|
return "$callsign-$ssid"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optional distance filter: filter r/lat/lon/dist */
|
||||||
|
fun formatRangeFilter(latitude: Double, longitude: Double, distKm: Int): String {
|
||||||
|
return String.format("r/%.3f/%.3f/%d", latitude, longitude, distKm)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Altitude extension /A=00000 (feet) */
|
||||||
|
fun formatAltitude(altitudeMeters: Double?): String {
|
||||||
|
if (altitudeMeters == null) return ""
|
||||||
|
return String.format("/A=%06d", (altitudeMeters * 3.2808399).toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Speed/course extension (knots/degrees) */
|
||||||
|
fun formatCourseSpeed(speedMps: Double?, bearing: Float?): String {
|
||||||
|
if (speedMps == null || bearing == null) return ""
|
||||||
|
val knots = (speedMps * 1.94384449).toInt()
|
||||||
|
return String.format("/%03d/%03d", bearing.toInt(), knots)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APRS position encoding (reverse-ported from ab0oo Position.java).
|
||||||
|
* Uncompressed: DDMM.MMN/DDDMM.MME; compressed: base91.
|
||||||
|
*/
|
||||||
|
class AprsPosition(
|
||||||
|
val latitude: Double,
|
||||||
|
val longitude: Double,
|
||||||
|
val symbolTable: Char,
|
||||||
|
val symbolCode: Char,
|
||||||
|
val positionAmbiguity: Int = 0
|
||||||
|
) {
|
||||||
|
|
||||||
|
/** Uncompressed format (APRS-IS default reporting format) */
|
||||||
|
fun toUncompressedString(): String {
|
||||||
|
val lat = getDMS(latitude, true)
|
||||||
|
val lon = getDMS(longitude, false)
|
||||||
|
return "$lat$symbolTable$lon$symbolCode"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compressed format (base91, standard APRS algorithm) */
|
||||||
|
fun toCompressedString(): String {
|
||||||
|
val jRound = round((90.0 - latitude) * 380926.0).toLong()
|
||||||
|
val j = jRound / 753571 + 33
|
||||||
|
val j2 = jRound % 753571
|
||||||
|
val j3 = j2 / 8281 + 33
|
||||||
|
val j4 = j2 % 8281
|
||||||
|
val i = (j4 % 91).toInt() + 33
|
||||||
|
val jRound2 = round((longitude + 180.0) * 190463.0).toLong()
|
||||||
|
val j5 = jRound2 / 753571 + 33
|
||||||
|
val j6 = jRound2 % 753571
|
||||||
|
val j7 = 33 + j6 / 8281
|
||||||
|
val j8 = j6 % 8281
|
||||||
|
return "" + symbolTable + j.toInt().toChar() + j3.toInt().toChar() +
|
||||||
|
((j4 / 91).toInt() + 33).toChar() + i.toChar() +
|
||||||
|
j5.toInt().toChar() + j7.toInt().toChar() +
|
||||||
|
((j8 / 91).toInt() + 33).toChar() +
|
||||||
|
((j8 % 91).toInt() + 33).toChar() + symbolCode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single-axis DMS encoding (hundredths) */
|
||||||
|
private fun getDMS(value: Double, isLat: Boolean): String {
|
||||||
|
var iRound = round(value * 6000.0).toInt()
|
||||||
|
if (iRound < 0) iRound = -iRound
|
||||||
|
val degrees = iRound / 6000
|
||||||
|
val minutes = (iRound / 100) % 60
|
||||||
|
val hundredths = iRound % 100
|
||||||
|
val frac = when (positionAmbiguity) {
|
||||||
|
1 -> " . "
|
||||||
|
2 -> String.format("%d . ", minutes / 10)
|
||||||
|
3 -> String.format("%02d. ", minutes)
|
||||||
|
4 -> String.format("%02d.%d ", minutes, hundredths / 10)
|
||||||
|
else -> String.format("%02d.%02d", minutes, hundredths)
|
||||||
|
}
|
||||||
|
return if (isLat) {
|
||||||
|
val ns = if (value >= 0) 'N' else 'S'
|
||||||
|
String.format("%02d%s%c", degrees, frac, ns)
|
||||||
|
} else {
|
||||||
|
val ew = if (value >= 0) 'E' else 'W'
|
||||||
|
String.format("%03d%s%c", degrees, frac, ew)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/*
|
||||||
|
* 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.core.domain.cw
|
||||||
|
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FFT lowpass/bandpass filter with overlap-add, ported from fldigi
|
||||||
|
* fftfilt.cxx (fftfilt class). Used by the CW decoder for the
|
||||||
|
* matched receive filter.
|
||||||
|
*/
|
||||||
|
internal class CwFftFilt(f1: Double, f2: Double, len: Int) {
|
||||||
|
private val flen: Int
|
||||||
|
private val flen2: Int
|
||||||
|
private val fft: CwGfft
|
||||||
|
private val filter: Array<CwComplex>
|
||||||
|
private val timedata: Array<CwComplex>
|
||||||
|
private val freqdata: Array<CwComplex>
|
||||||
|
private val output: Array<CwComplex>
|
||||||
|
private val ovlbuf: Array<CwComplex>
|
||||||
|
private var inptr = 0
|
||||||
|
private var pass = 0
|
||||||
|
|
||||||
|
init {
|
||||||
|
flen = len
|
||||||
|
flen2 = flen shr 1
|
||||||
|
fft = CwGfft(flen)
|
||||||
|
filter = Array(flen) { CwComplex() }
|
||||||
|
timedata = Array(flen) { CwComplex() }
|
||||||
|
freqdata = Array(flen) { CwComplex() }
|
||||||
|
output = Array(flen) { CwComplex() }
|
||||||
|
ovlbuf = Array(flen2) { CwComplex() }
|
||||||
|
createFilter(f1, f2)
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(f: Double, len: Int) : this(0.0, f, len)
|
||||||
|
|
||||||
|
fun flushSize(): Int = flen - inptr
|
||||||
|
|
||||||
|
/**
|
||||||
|
* f1 < f2 ==> bandpass; f1 > f2 ==> band reject; f1 == 0 ==> lowpass; f2 == 0 ==> highpass.
|
||||||
|
*/
|
||||||
|
private fun createFilter(f1: Double, f2: Double) {
|
||||||
|
for (i in 0 until flen) {
|
||||||
|
filter[i] = CwComplex()
|
||||||
|
}
|
||||||
|
val bLowpass = f2 != 0.0
|
||||||
|
val bHighpass = f1 != 0.0
|
||||||
|
|
||||||
|
for (i in 0 until flen2) {
|
||||||
|
var h = 0.0
|
||||||
|
if (bLowpass) h += fsinc(f2, i, flen2)
|
||||||
|
if (bHighpass) h -= fsinc(f1, i, flen2)
|
||||||
|
if (bHighpass && f2 < f1 && i == flen2 / 2) h += 1.0
|
||||||
|
filter[i] = CwComplex(h * blackman(i, flen2), 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// forward FFT of the impulse response
|
||||||
|
fft.forward(filter)
|
||||||
|
|
||||||
|
// normalize unity gain
|
||||||
|
var scale = 0.0
|
||||||
|
for (i in 0 until flen2) {
|
||||||
|
val mag = kotlin.math.sqrt(filter[i].re * filter[i].re + filter[i].im * filter[i].im)
|
||||||
|
if (mag > scale) scale = mag
|
||||||
|
}
|
||||||
|
if (scale != 0.0) {
|
||||||
|
for (i in 0 until flen) {
|
||||||
|
filter[i] = CwComplex(filter[i].re / scale, filter[i].im / scale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pass = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createLpf(f: Double) = createFilter(0.0, f)
|
||||||
|
|
||||||
|
private fun fsinc(fc: Double, i: Int, len: Int): Double {
|
||||||
|
val mid = len / 2
|
||||||
|
return if (i == mid) 2.0 * fc
|
||||||
|
else sin(2.0 * PI * fc * (i - mid)) / (PI * (i - mid))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun blackman(i: Int, len: Int): Double =
|
||||||
|
0.42 - 0.50 * cos(2.0 * PI * i / len) + 0.08 * cos(4.0 * PI * i / len)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feed one complex input sample. Returns output array + count when flen/2
|
||||||
|
* samples are ready, otherwise null.
|
||||||
|
*/
|
||||||
|
fun run(input: CwComplex): Array<CwComplex>? {
|
||||||
|
timedata[inptr++] = input
|
||||||
|
if (inptr < flen2) return null
|
||||||
|
if (pass > 0) pass--
|
||||||
|
|
||||||
|
// copy to freq domain and FFT
|
||||||
|
for (i in 0 until flen) {
|
||||||
|
freqdata[i] = timedata[i]
|
||||||
|
}
|
||||||
|
fft.forward(freqdata)
|
||||||
|
|
||||||
|
// multiply by filter shape
|
||||||
|
for (i in 0 until flen) {
|
||||||
|
val f = filter[i]
|
||||||
|
val t = freqdata[i]
|
||||||
|
freqdata[i] = CwComplex(t.re * f.re - t.im * f.im, t.re * f.im + t.im * f.re)
|
||||||
|
}
|
||||||
|
|
||||||
|
// inverse FFT
|
||||||
|
fft.inverse(freqdata)
|
||||||
|
|
||||||
|
// overlap-add: first half is valid output, save second half
|
||||||
|
for (i in 0 until flen2) {
|
||||||
|
output[i] = CwComplex(ovlbuf[i].re + freqdata[i].re, ovlbuf[i].im + freqdata[i].im)
|
||||||
|
ovlbuf[i] = freqdata[i + flen2]
|
||||||
|
}
|
||||||
|
|
||||||
|
inptr = 0
|
||||||
|
if (pass > 0) return null
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
/*
|
||||||
|
* 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.core.domain.cw
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.log10
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CW decoder ported from fldigi (w1hkj/fldigi) cw.cxx — full receive
|
||||||
|
* chain: NCO down-conversion, FFT lowpass filter, decimation, AGC,
|
||||||
|
* hysteresis keying detection, element timing state machine, adaptive
|
||||||
|
* speed tracking, SOM codebook matching and morse table lookup.
|
||||||
|
*
|
||||||
|
* Drop-in replacement for the reverse-engineered Morse Expert decoder:
|
||||||
|
* same processBuffer(FloatArray) entry point, same StateFlow outputs.
|
||||||
|
*/
|
||||||
|
class CwFldigiDecoder(
|
||||||
|
val sampleRate: Int = CwFldigiConstants.CW_SAMPLERATE,
|
||||||
|
private val frequency: Double = 600.0,
|
||||||
|
private val useSom: Boolean = true,
|
||||||
|
private val initialWpm: Int = CwFldigiConstants.DEFAULT_SPEED
|
||||||
|
) {
|
||||||
|
private companion object {
|
||||||
|
const val CW_DOT = '.'
|
||||||
|
const val CW_DASH = '-'
|
||||||
|
const val CW_RESET = 0
|
||||||
|
const val CW_KEYDOWN = 1
|
||||||
|
const val CW_KEYUP = 2
|
||||||
|
const val CW_QUERY = 3
|
||||||
|
const val CW_SUCCESS = 0
|
||||||
|
const val CW_ERROR = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class CwRxState { IDLE, IN_TONE, AFTER_TONE }
|
||||||
|
|
||||||
|
// Outputs (API-compatible with the old CwDecoder)
|
||||||
|
private val _decodedTextFlow = MutableStateFlow("")
|
||||||
|
val decodedTextFlow: StateFlow<String> = _decodedTextFlow
|
||||||
|
|
||||||
|
private val _signalStrength = MutableStateFlow(0f)
|
||||||
|
val signalStrength: StateFlow<Float> = _signalStrength
|
||||||
|
|
||||||
|
private val _estimatedPitch = MutableStateFlow<Float?>(frequency.toFloat())
|
||||||
|
val estimatedPitch: StateFlow<Float?> = _estimatedPitch
|
||||||
|
|
||||||
|
private val _estimatedSpeed = MutableStateFlow<Float?>(null)
|
||||||
|
val estimatedSpeed: StateFlow<Float?> = _estimatedSpeed
|
||||||
|
|
||||||
|
// --- fldigi cw state ---
|
||||||
|
private var phaseacc = 0.0
|
||||||
|
private var FFTphase = 0.0
|
||||||
|
private var FFTvalue = 0.0
|
||||||
|
private var smpl_ctr = 0
|
||||||
|
|
||||||
|
private var agc_peak = 1.0
|
||||||
|
private var noise_floor = 1.0
|
||||||
|
private var sig_avg = 0.0
|
||||||
|
private var metric = 0.0
|
||||||
|
private var siglevel = 0.0
|
||||||
|
|
||||||
|
private var upper_threshold = CwFldigiConstants.CW_UPPER_THRESHOLD
|
||||||
|
private var lower_threshold = CwFldigiConstants.CW_LOWER_THRESHOLD
|
||||||
|
|
||||||
|
private val cw_FFT_filter: CwFftFilt
|
||||||
|
private val bitfilter: CwMovAvg
|
||||||
|
private val trackingfilter: CwMovAvg
|
||||||
|
|
||||||
|
private var cw_receive_state = CwRxState.IDLE
|
||||||
|
private var old_cw_receive_state = CwRxState.IDLE
|
||||||
|
|
||||||
|
private var cw_rr_start_timestamp = 0
|
||||||
|
private var cw_rr_end_timestamp = 0
|
||||||
|
private var rx_rep_buf = StringBuilder()
|
||||||
|
private var cw_ptr = 0
|
||||||
|
private val cw_buffer = FloatArray(CwFldigiConstants.MAX_MORSE_ELEMENTS + 1)
|
||||||
|
|
||||||
|
private var cw_speed = initialWpm
|
||||||
|
private var cw_send_speed = initialWpm
|
||||||
|
private var cw_receive_speed = initialWpm
|
||||||
|
private var cw_bandwidth = CwFldigiConstants.DEFAULT_BANDWIDTH
|
||||||
|
private var cwTrack = true
|
||||||
|
private var two_dots = 0L
|
||||||
|
private var last_element = 0
|
||||||
|
private var space_sent = true
|
||||||
|
private var cw_noise_spike_threshold = 0L
|
||||||
|
private var cw_receive_dot_length = 0L
|
||||||
|
private var cw_receive_dash_length = 0L
|
||||||
|
|
||||||
|
private var synchscope = 50
|
||||||
|
|
||||||
|
init {
|
||||||
|
val bw = cw_bandwidth.toDouble() / sampleRate
|
||||||
|
cw_FFT_filter = CwFftFilt(bw, CwFldigiConstants.CW_FFT_SIZE)
|
||||||
|
val bfv = (symbollen() / (2 * CwFldigiConstants.DEC_RATIO)).coerceAtLeast(1)
|
||||||
|
bitfilter = CwMovAvg(bfv)
|
||||||
|
trackingfilter = CwMovAvg(CwFldigiConstants.TRACKING_FILTER_SIZE)
|
||||||
|
|
||||||
|
two_dots = (2 * CwFldigiConstants.KWPM / cw_speed).toLong()
|
||||||
|
cw_noise_spike_threshold = two_dots / 4
|
||||||
|
cw_receive_dot_length = (CwFldigiConstants.KWPM / cw_receive_speed).toLong()
|
||||||
|
cw_receive_dash_length = 3 * cw_receive_dot_length
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun symbollen(): Int =
|
||||||
|
(sampleRate * 1.2 / cw_speed).roundToInt()
|
||||||
|
|
||||||
|
private fun usecDiff(earlier: Int, later: Int): Int =
|
||||||
|
if (earlier >= later) 0 else later - earlier
|
||||||
|
|
||||||
|
/** Main entry: feed PCM samples. */
|
||||||
|
fun processBuffer(buffer: FloatArray) {
|
||||||
|
for (sample in buffer) {
|
||||||
|
rxSample(sample.toDouble())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rxSample(value: Double) {
|
||||||
|
// NCO down-conversion (fldigi rx_FFTprocess)
|
||||||
|
val z = CwComplex(
|
||||||
|
value * kotlin.math.cos(FFTphase),
|
||||||
|
value * kotlin.math.sin(FFTphase)
|
||||||
|
)
|
||||||
|
FFTphase += 2.0 * Math.PI * frequency / sampleRate
|
||||||
|
if (FFTphase > 2.0 * Math.PI) FFTphase -= 2.0 * Math.PI
|
||||||
|
|
||||||
|
val out = cw_FFT_filter.run(z) ?: return
|
||||||
|
for (i in 0 until CwFldigiConstants.CW_FFT_SIZE / 2) {
|
||||||
|
++smpl_ctr
|
||||||
|
if (smpl_ctr % CwFldigiConstants.DEC_RATIO != 0) continue
|
||||||
|
FFTvalue = out[i].abs()
|
||||||
|
FFTvalue = bitfilter.run(FFTvalue)
|
||||||
|
decodeStream(FFTvalue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AGC + hysteresis detection (fldigi cw::decode_stream).
|
||||||
|
*/
|
||||||
|
private fun decodeStream(value: Double) {
|
||||||
|
var v = value
|
||||||
|
val attack: Int
|
||||||
|
val decay: Int
|
||||||
|
when (CwFldigiConstants.CWRX_ATTACK_DEFAULT) {
|
||||||
|
0 -> attack = 400
|
||||||
|
2 -> attack = 100
|
||||||
|
else -> attack = 200
|
||||||
|
}
|
||||||
|
when (CwFldigiConstants.CWRX_DECAY_DEFAULT) {
|
||||||
|
0 -> decay = 2000
|
||||||
|
2 -> decay = 500
|
||||||
|
else -> decay = 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
sig_avg = decayAvg(sig_avg, v, decay)
|
||||||
|
|
||||||
|
if (v < sig_avg) {
|
||||||
|
noise_floor = if (v < noise_floor) decayAvg(noise_floor, v, attack)
|
||||||
|
else decayAvg(noise_floor, v, decay)
|
||||||
|
}
|
||||||
|
if (v > sig_avg) {
|
||||||
|
agc_peak = if (v > agc_peak) decayAvg(agc_peak, v, attack)
|
||||||
|
else decayAvg(agc_peak, v, decay)
|
||||||
|
}
|
||||||
|
|
||||||
|
val normNoise = noise_floor / agc_peak
|
||||||
|
val normSig = sig_avg / agc_peak
|
||||||
|
siglevel = normSig
|
||||||
|
|
||||||
|
if (agc_peak != 0.0) v /= agc_peak else v = 0.0
|
||||||
|
|
||||||
|
metric = 0.8 * metric
|
||||||
|
if ((noise_floor > 1e-4) && (noise_floor < sig_avg)) {
|
||||||
|
val db = 20.0 * log10(sig_avg / noise_floor)
|
||||||
|
metric += 0.2 * clamp(2.5 * db, 0.0, 100.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
val diff = normSig - normNoise
|
||||||
|
upper_threshold = normSig - 0.2 * diff
|
||||||
|
lower_threshold = normNoise + 0.7 * diff
|
||||||
|
|
||||||
|
// Squelch gate (fldigi: !progStatus.sqlonoff || metric > sldrSquelchValue).
|
||||||
|
// Default sqlonoff=true, sldrSquelchValue=5.0. Without this gate, noise
|
||||||
|
// spikes false-trigger KEYDOWN and lock the state machine in IN_TONE.
|
||||||
|
if (sqlonoff && metric <= squelchValue) return
|
||||||
|
|
||||||
|
// Power detection using hysteresis
|
||||||
|
if ((v > upper_threshold) && (cw_receive_state != CwRxState.IN_TONE)) {
|
||||||
|
handleEvent(CW_KEYDOWN)
|
||||||
|
}
|
||||||
|
if ((v < lower_threshold) && (cw_receive_state == CwRxState.IN_TONE)) {
|
||||||
|
handleEvent(CW_KEYUP)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handleEvent(CW_QUERY) == CW_SUCCESS) {
|
||||||
|
synchscope = 100
|
||||||
|
// emit decoded char(s)
|
||||||
|
} else if (--synchscope == 0) {
|
||||||
|
synchscope = 25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sqlonoff = true
|
||||||
|
private var squelchValue = 5.0
|
||||||
|
|
||||||
|
private fun decayAvg(avg: Double, value: Double, timeConst: Int): Double =
|
||||||
|
avg + (value - avg) / timeConst
|
||||||
|
|
||||||
|
private fun clamp(v: Double, lo: Double, hi: Double): Double =
|
||||||
|
if (v < lo) lo else if (v > hi) hi else v
|
||||||
|
|
||||||
|
private fun handleEvent(event: Int): Int {
|
||||||
|
val sc = StringBuilder()
|
||||||
|
var elementUsec: Int
|
||||||
|
when (event) {
|
||||||
|
CW_RESET -> {
|
||||||
|
syncParameters()
|
||||||
|
cw_receive_state = CwRxState.IDLE
|
||||||
|
cw_ptr = 0
|
||||||
|
smpl_ctr = 0
|
||||||
|
rx_rep_buf.clear()
|
||||||
|
}
|
||||||
|
CW_KEYDOWN -> {
|
||||||
|
if (cw_receive_state == CwRxState.IN_TONE) return CW_ERROR
|
||||||
|
if (cw_receive_state == CwRxState.IDLE) {
|
||||||
|
smpl_ctr = 0
|
||||||
|
rx_rep_buf.clear()
|
||||||
|
cw_ptr = 0
|
||||||
|
}
|
||||||
|
cw_rr_start_timestamp = smpl_ctr
|
||||||
|
old_cw_receive_state = cw_receive_state
|
||||||
|
cw_receive_state = CwRxState.IN_TONE
|
||||||
|
return CW_ERROR
|
||||||
|
}
|
||||||
|
CW_KEYUP -> {
|
||||||
|
if (cw_receive_state != CwRxState.IN_TONE) return CW_ERROR
|
||||||
|
cw_rr_end_timestamp = smpl_ctr
|
||||||
|
elementUsec = usecDiff(cw_rr_start_timestamp, cw_rr_end_timestamp)
|
||||||
|
syncParameters()
|
||||||
|
if (cw_noise_spike_threshold > 0 && elementUsec < cw_noise_spike_threshold) {
|
||||||
|
cw_receive_state = CwRxState.IDLE
|
||||||
|
return CW_ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
// adaptive speed tracking on dot-dash / dash-dot pairs
|
||||||
|
if (last_element > 0) {
|
||||||
|
if ((elementUsec > 2 * last_element) && (elementUsec < 4 * last_element)) {
|
||||||
|
updateTracking(last_element, elementUsec)
|
||||||
|
}
|
||||||
|
if ((last_element > 2 * elementUsec) && (last_element < 4 * elementUsec)) {
|
||||||
|
updateTracking(elementUsec, last_element)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
last_element = elementUsec
|
||||||
|
|
||||||
|
if (elementUsec <= two_dots) {
|
||||||
|
rx_rep_buf.append(CW_DOT)
|
||||||
|
cw_buffer[cw_ptr++] = last_element.toFloat()
|
||||||
|
} else {
|
||||||
|
rx_rep_buf.append(CW_DASH)
|
||||||
|
cw_buffer[cw_ptr++] = last_element.toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rx_rep_buf.length > CwFldigiConstants.MAX_MORSE_ELEMENTS) {
|
||||||
|
cw_receive_state = CwRxState.IDLE
|
||||||
|
cw_ptr = 0
|
||||||
|
smpl_ctr = 0
|
||||||
|
return CW_ERROR
|
||||||
|
} else {
|
||||||
|
if (cw_ptr < cw_buffer.size) cw_buffer[cw_ptr] = 0.0f
|
||||||
|
}
|
||||||
|
cw_receive_state = CwRxState.AFTER_TONE
|
||||||
|
return CW_ERROR
|
||||||
|
}
|
||||||
|
CW_QUERY -> {
|
||||||
|
if (cw_receive_state == CwRxState.IN_TONE) return CW_ERROR
|
||||||
|
syncParameters()
|
||||||
|
elementUsec = usecDiff(cw_rr_end_timestamp, smpl_ctr)
|
||||||
|
if (elementUsec < (2 * cw_receive_dot_length)) return CW_ERROR
|
||||||
|
|
||||||
|
if (elementUsec >= (2 * cw_receive_dot_length) &&
|
||||||
|
elementUsec <= (4 * cw_receive_dot_length) &&
|
||||||
|
cw_receive_state == CwRxState.AFTER_TONE
|
||||||
|
) {
|
||||||
|
val pattern = rx_rep_buf.toString()
|
||||||
|
val decoded = if (useSom) {
|
||||||
|
findWinner(cw_buffer, two_dots)
|
||||||
|
} else {
|
||||||
|
MorseTable.rxLookup(pattern)
|
||||||
|
}
|
||||||
|
if (decoded.isNotEmpty()) {
|
||||||
|
appendDecoded(decoded)
|
||||||
|
}
|
||||||
|
rx_rep_buf.clear()
|
||||||
|
cw_receive_state = CwRxState.IDLE
|
||||||
|
space_sent = false
|
||||||
|
cw_ptr = 0
|
||||||
|
return CW_SUCCESS
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((elementUsec > (4 * cw_receive_dot_length)) && !space_sent) {
|
||||||
|
appendDecoded(" ")
|
||||||
|
space_sent = true
|
||||||
|
return CW_SUCCESS
|
||||||
|
}
|
||||||
|
return CW_ERROR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CW_ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun appendDecoded(text: String) {
|
||||||
|
val current = _decodedTextFlow.value
|
||||||
|
_decodedTextFlow.value = if (current.length > 2000) {
|
||||||
|
current.drop(current.length - 1500) + text
|
||||||
|
} else {
|
||||||
|
current + text
|
||||||
|
}
|
||||||
|
_signalStrength.value = metric.toFloat() / 100f
|
||||||
|
_estimatedSpeed.value = (CwFldigiConstants.KWPM.toDouble() / (two_dots / 2.0)).toFloat()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun syncParameters() {
|
||||||
|
if (cwTrack) {
|
||||||
|
cw_receive_speed = (CwFldigiConstants.KWPM / (two_dots / 2)).toInt()
|
||||||
|
} else {
|
||||||
|
cw_receive_speed = cw_send_speed
|
||||||
|
two_dots = 2 * (CwFldigiConstants.KWPM / cw_send_speed).toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cw_receive_speed > 0) {
|
||||||
|
cw_receive_dot_length = (CwFldigiConstants.KWPM / cw_receive_speed).toLong()
|
||||||
|
} else {
|
||||||
|
cw_receive_dot_length = (CwFldigiConstants.KWPM / 5).toLong()
|
||||||
|
}
|
||||||
|
cw_receive_dash_length = 3 * cw_receive_dot_length
|
||||||
|
cw_noise_spike_threshold = cw_receive_dot_length / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateTracking(dur1: Int, dur2: Int) {
|
||||||
|
val minDot = CwFldigiConstants.KWPM / 200
|
||||||
|
val maxDash = 3 * CwFldigiConstants.KWPM / 5
|
||||||
|
if ((dur1 > dur2) && (dur1 > 4 * dur2)) return
|
||||||
|
if ((dur2 > dur1) && (dur2 > 4 * dur1)) return
|
||||||
|
if (dur1 < minDot || dur2 < minDot) return
|
||||||
|
if (dur2 > maxDash || dur2 > maxDash) return
|
||||||
|
|
||||||
|
two_dots = trackingfilter.run(((dur1 + dur2) / 2).toDouble()).toLong()
|
||||||
|
syncParameters()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SOM codebook matching (fldigi cw::find_winner + normalize).
|
||||||
|
*/
|
||||||
|
private fun findWinner(inbuf: FloatArray, twodots: Long): String {
|
||||||
|
if (normalize(inbuf, twodots) == 0) return " "
|
||||||
|
|
||||||
|
var winner = -1
|
||||||
|
var bestDiff = Double.MAX_VALUE
|
||||||
|
for ((idx, entry) in SomTable.table.withIndex()) {
|
||||||
|
var difference = 0.0
|
||||||
|
for (i in 0 until CwFldigiConstants.WGT_SIZE) {
|
||||||
|
val diff = inbuf[i] - entry.weights[i]
|
||||||
|
difference += diff * diff
|
||||||
|
if (difference > bestDiff) break
|
||||||
|
}
|
||||||
|
if (difference < bestDiff) {
|
||||||
|
winner = idx
|
||||||
|
bestDiff = difference
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (winner >= 0 && SomTable.table[winner].pattern.isNotEmpty()) {
|
||||||
|
val sc = MorseTable.rxLookup(SomTable.table[winner].pattern)
|
||||||
|
return if (sc.isNotEmpty()) sc else CwFldigiConstants.DEFAULT_NOISE_CHAR.toString()
|
||||||
|
}
|
||||||
|
return CwFldigiConstants.DEFAULT_NOISE_CHAR.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalize(v: FloatArray, twodots: Long): Int {
|
||||||
|
var max = v[0]
|
||||||
|
var min = v[0]
|
||||||
|
for (j in 1 until CwFldigiConstants.WGT_SIZE) {
|
||||||
|
if (v[j] > max) max = v[j]
|
||||||
|
else if (v[j] < min) min = v[j]
|
||||||
|
}
|
||||||
|
if (max == 0.0f) return 0
|
||||||
|
|
||||||
|
val ratio = if (max > twodots) 1.0f else 0.33f
|
||||||
|
val scale = ratio / max
|
||||||
|
for (j in 0 until CwFldigiConstants.WGT_SIZE) v[j] *= scale
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset the decoder state (fldigi cw::rx_init + reset). */
|
||||||
|
fun resetDecoder() {
|
||||||
|
cw_receive_state = CwRxState.IDLE
|
||||||
|
smpl_ctr = 0
|
||||||
|
cw_ptr = 0
|
||||||
|
rx_rep_buf.clear()
|
||||||
|
last_element = 0
|
||||||
|
space_sent = true
|
||||||
|
_decodedTextFlow.value = ""
|
||||||
|
_signalStrength.value = 0f
|
||||||
|
_estimatedSpeed.value = null
|
||||||
|
FFTphase = 0.0
|
||||||
|
phaseacc = 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/*
|
||||||
|
* 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.core.domain.cw
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constants and primitives ported from fldigi (w1hkj/fldigi),
|
||||||
|
* GPL v3, cw.h / cw.cxx / filters.cxx / fftfilt.cxx.
|
||||||
|
*
|
||||||
|
* Faithful port: identifiers and formulas mirror the C++ original so
|
||||||
|
* behaviour matches the desktop decoder bit-for-bit.
|
||||||
|
*/
|
||||||
|
internal object CwFldigiConstants {
|
||||||
|
const val CW_SAMPLERATE = 8000
|
||||||
|
const val DEC_RATIO = 16
|
||||||
|
const val CW_FFT_SIZE = 2048 // must be power of 2
|
||||||
|
const val MAX_MORSE_ELEMENTS = 6
|
||||||
|
const val WGT_SIZE = 7
|
||||||
|
const val TRACKING_FILTER_SIZE = 16
|
||||||
|
const val MAX_PIPE_SIZE = (22 * CW_SAMPLERATE * 12 / 800)
|
||||||
|
const val CLRCOUNT = 16
|
||||||
|
const val CW_MAX_SPEED = 50
|
||||||
|
const val INITIAL_SEND_SPEED = 18
|
||||||
|
const val INITIAL_RECEIVE_SPEED = 18
|
||||||
|
|
||||||
|
/** # samples in a dot: KWPM / WPM. fldigi: KWPM = 12 * samplerate / 10 */
|
||||||
|
const val KWPM = 12 * CW_SAMPLERATE / 10
|
||||||
|
|
||||||
|
const val CW_SUCCESS = 0
|
||||||
|
const val CW_ERROR = -1
|
||||||
|
|
||||||
|
// Defaults mirroring fldigi progdefaults
|
||||||
|
const val DEFAULT_SPEED = 18 // WPM
|
||||||
|
const val DEFAULT_BANDWIDTH = 150 // Hz
|
||||||
|
const val DEFAULT_RANGE = 10 // WPM tracking range
|
||||||
|
const val LOWER_LIMIT = 5 // WPM
|
||||||
|
const val UPPER_LIMIT = 50 // WPM
|
||||||
|
const val DEFAULT_NOISE_CHAR = '*'
|
||||||
|
const val CWRX_ATTACK_DEFAULT = 1 // medium
|
||||||
|
const val CWRX_DECAY_DEFAULT = 1 // medium
|
||||||
|
const val CW_LOWER_THRESHOLD = 0.4
|
||||||
|
const val CW_UPPER_THRESHOLD = 0.6
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moving average filter, ported from fldigi Cmovavg (filters.cxx).
|
||||||
|
*/
|
||||||
|
internal class CwMovAvg(private var len: Int) {
|
||||||
|
private val buf = DoubleArray(MAX_MOVAVG)
|
||||||
|
private var out = 0.0
|
||||||
|
private var pint = 0
|
||||||
|
private var empty = true
|
||||||
|
|
||||||
|
fun run(a: Double): Double {
|
||||||
|
if (empty) {
|
||||||
|
empty = false
|
||||||
|
out = 0.0
|
||||||
|
for (i in 0 until len) {
|
||||||
|
buf[i] = a
|
||||||
|
out += a
|
||||||
|
}
|
||||||
|
pint = 0
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
out = out - buf[pint] + a
|
||||||
|
buf[pint] = a
|
||||||
|
if (++pint >= len) pint = 0
|
||||||
|
return out / len
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setLength(newLen: Int) {
|
||||||
|
len = newLen.coerceAtMost(MAX_MOVAVG).coerceAtLeast(1)
|
||||||
|
empty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
empty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun value(): Double = if (len > 0) out / len else 0.0
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MAX_MOVAVG = 2048
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complex sample used by the FFT filter chain.
|
||||||
|
*/
|
||||||
|
internal data class CwComplex(var re: Double = 0.0, var im: Double = 0.0) {
|
||||||
|
operator fun timesAssign(other: CwComplex) {
|
||||||
|
val r = re * other.re - im * other.im
|
||||||
|
val i = re * other.im + im * other.re
|
||||||
|
re = r; im = i
|
||||||
|
}
|
||||||
|
|
||||||
|
fun abs(): Double = kotlin.math.sqrt(re * re + im * im)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-place radix-2 complex FFT, ported from fldigi gfft.h semantics
|
||||||
|
* (ComplexFFT / InverseComplexFFT, forward = -1).
|
||||||
|
*/
|
||||||
|
internal class CwGfft(private val n: Int) {
|
||||||
|
init {
|
||||||
|
require(n > 0 && n and (n - 1) == 0) { "FFT size must be power of 2, got $n" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val rev = IntArray(n)
|
||||||
|
|
||||||
|
init {
|
||||||
|
val log2n = Integer.numberOfTrailingZeros(n)
|
||||||
|
for (i in 0 until n) {
|
||||||
|
var r = 0
|
||||||
|
var x = i
|
||||||
|
for (bit in 0 until log2n) {
|
||||||
|
r = (r shl 1) or (x and 1)
|
||||||
|
x = x shr 1
|
||||||
|
}
|
||||||
|
rev[i] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-place complex FFT.
|
||||||
|
* @param data array of CwComplex, size n
|
||||||
|
* @param inverse if true, performs inverse transform (no 1/n scaling; caller divides)
|
||||||
|
*/
|
||||||
|
fun transform(data: Array<CwComplex>, inverse: Boolean) {
|
||||||
|
val sign = if (inverse) 1 else -1
|
||||||
|
|
||||||
|
// bit reversal permutation
|
||||||
|
for (i in 0 until n) {
|
||||||
|
val j = rev[i]
|
||||||
|
if (j > i) {
|
||||||
|
val t = data[i]; data[i] = data[j]; data[j] = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var len = 2
|
||||||
|
while (len <= n) {
|
||||||
|
val ang = sign * 2.0 * Math.PI / len
|
||||||
|
val wRe = Math.cos(ang)
|
||||||
|
val wIm = Math.sin(ang)
|
||||||
|
var i = 0
|
||||||
|
while (i < n) {
|
||||||
|
var curRe = 1.0
|
||||||
|
var curIm = 0.0
|
||||||
|
for (j in 0 until len / 2) {
|
||||||
|
val u = data[i + j]
|
||||||
|
val v = data[i + j + len / 2]
|
||||||
|
val tRe = curRe * v.re - curIm * v.im
|
||||||
|
val tIm = curRe * v.im + curIm * v.re
|
||||||
|
data[i + j] = CwComplex(u.re + tRe, u.im + tIm)
|
||||||
|
data[i + j + len / 2] = CwComplex(u.re - tRe, u.im - tIm)
|
||||||
|
val nRe = curRe * wRe - curIm * wIm
|
||||||
|
val nIm = curRe * wIm + curIm * wRe
|
||||||
|
curRe = nRe; curIm = nIm
|
||||||
|
}
|
||||||
|
i += len
|
||||||
|
}
|
||||||
|
len = len shl 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun forward(data: Array<CwComplex>) = transform(data, false)
|
||||||
|
|
||||||
|
fun inverse(data: Array<CwComplex>) = transform(data, true)
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/*
|
||||||
|
* 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.core.domain.cw
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Morse code lookup table, ported from fldigi morse.cxx (cMorse::cw_table).
|
||||||
|
* Each entry: [enabled, displayChar, prosignDisplay, dotDashPattern].
|
||||||
|
*/
|
||||||
|
internal object MorseTable {
|
||||||
|
|
||||||
|
private data class Entry(
|
||||||
|
val enabled: Boolean,
|
||||||
|
val chr: String,
|
||||||
|
val prt: String,
|
||||||
|
val rpr: String
|
||||||
|
)
|
||||||
|
|
||||||
|
private val table = listOf(
|
||||||
|
// Prosigns
|
||||||
|
Entry(true, "=", "<BT>", "-...-"),
|
||||||
|
Entry(false, "~", "<AA>", ".-.-"),
|
||||||
|
Entry(true, "<", "<AS>", ".-..."),
|
||||||
|
Entry(true, ">", "<AR>", ".-.-."),
|
||||||
|
Entry(true, "%", "<SK>", "...-.-"),
|
||||||
|
Entry(true, "+", "<KN>", "-.--."),
|
||||||
|
Entry(true, "&", "<INT>", "..-.-"),
|
||||||
|
Entry(true, "{", "<HM>", "....--"),
|
||||||
|
Entry(true, "}", "<VE>", "...-."),
|
||||||
|
// ASCII 7-bit letters
|
||||||
|
Entry(true, "A", "A", ".-"),
|
||||||
|
Entry(true, "B", "B", "-..."),
|
||||||
|
Entry(true, "C", "C", "-.-."),
|
||||||
|
Entry(true, "D", "D", "-.."),
|
||||||
|
Entry(true, "E", "E", "."),
|
||||||
|
Entry(true, "F", "F", "..-."),
|
||||||
|
Entry(true, "G", "G", "--."),
|
||||||
|
Entry(true, "H", "H", "...."),
|
||||||
|
Entry(true, "I", "I", ".."),
|
||||||
|
Entry(true, "J", "J", ".---"),
|
||||||
|
Entry(true, "K", "K", "-.-"),
|
||||||
|
Entry(true, "L", "L", ".-.."),
|
||||||
|
Entry(true, "M", "M", "--"),
|
||||||
|
Entry(true, "N", "N", "-."),
|
||||||
|
Entry(true, "O", "O", "---"),
|
||||||
|
Entry(true, "P", "P", ".--."),
|
||||||
|
Entry(true, "Q", "Q", "--.-"),
|
||||||
|
Entry(true, "R", "R", ".-."),
|
||||||
|
Entry(true, "S", "S", "..."),
|
||||||
|
Entry(true, "T", "T", "-"),
|
||||||
|
Entry(true, "U", "U", "..-"),
|
||||||
|
Entry(true, "V", "V", "...-"),
|
||||||
|
Entry(true, "W", "W", ".--"),
|
||||||
|
Entry(true, "X", "X", "-..-"),
|
||||||
|
Entry(true, "Y", "Y", "-.--"),
|
||||||
|
Entry(true, "Z", "Z", "--.."),
|
||||||
|
// lowercase map to uppercase
|
||||||
|
Entry(true, "a", "A", ".-"), Entry(true, "b", "B", "-..."),
|
||||||
|
Entry(true, "c", "C", "-.-."), Entry(true, "d", "D", "-.."),
|
||||||
|
Entry(true, "e", "E", "."), Entry(true, "f", "F", "..-."),
|
||||||
|
Entry(true, "g", "G", "--."), Entry(true, "h", "H", "...."),
|
||||||
|
Entry(true, "i", "I", ".."), Entry(true, "j", "J", ".---"),
|
||||||
|
Entry(true, "k", "K", "-.-"), Entry(true, "l", "L", ".-.."),
|
||||||
|
Entry(true, "m", "M", "--"), Entry(true, "n", "N", "-."),
|
||||||
|
Entry(true, "o", "O", "---"), Entry(true, "p", "P", ".--."),
|
||||||
|
Entry(true, "q", "Q", "--.-"), Entry(true, "r", "R", ".-."),
|
||||||
|
Entry(true, "s", "S", "..."), Entry(true, "t", "T", "-"),
|
||||||
|
Entry(true, "u", "U", "..-"), Entry(true, "v", "V", "...-"),
|
||||||
|
Entry(true, "w", "W", ".--"), Entry(true, "x", "X", "-..-"),
|
||||||
|
Entry(true, "y", "Y", "-.--"), Entry(true, "z", "Z", "--.."),
|
||||||
|
// Numerals
|
||||||
|
Entry(true, "0", "0", "-----"),
|
||||||
|
Entry(true, "1", "1", ".----"),
|
||||||
|
Entry(true, "2", "2", "..---"),
|
||||||
|
Entry(true, "3", "3", "...--"),
|
||||||
|
Entry(true, "4", "4", "....-"),
|
||||||
|
Entry(true, "5", "5", "....."),
|
||||||
|
Entry(true, "6", "6", "-...."),
|
||||||
|
Entry(true, "7", "7", "--..."),
|
||||||
|
Entry(true, "8", "8", "---.."),
|
||||||
|
Entry(true, "9", "9", "----."),
|
||||||
|
// Punctuation
|
||||||
|
Entry(true, "\\", "\\", ".-..-."),
|
||||||
|
Entry(true, "'", "'", ".----."),
|
||||||
|
Entry(true, "$", "$", "...-..-"),
|
||||||
|
Entry(true, "(", "(", "-.--."),
|
||||||
|
Entry(true, ")", ")", "-.--.-"),
|
||||||
|
Entry(true, ",", ",", "--..--"),
|
||||||
|
Entry(true, "-", "-", "-....-"),
|
||||||
|
Entry(true, ".", ".", ".-.-.-"),
|
||||||
|
Entry(true, "/", "/", "-..-."),
|
||||||
|
Entry(true, ":", ":", "---..."),
|
||||||
|
Entry(true, ";", ";", "-.-.-."),
|
||||||
|
Entry(true, "?", "?", "..--.."),
|
||||||
|
Entry(true, "_", "_", "..--.-"),
|
||||||
|
Entry(true, "@", "@", ".--.-."),
|
||||||
|
Entry(true, "!", "!", "-.-.--"),
|
||||||
|
// Accented (disabled by default in fldigi)
|
||||||
|
Entry(false, "Ä", "Ä", ".-.-"), Entry(false, "ä", "Ä", ".-.-"),
|
||||||
|
Entry(false, "Æ", "Æ", ".-.-"), Entry(false, "æ", "Æ", ".-.-"),
|
||||||
|
Entry(false, "Å", "Å", ".--.-"), Entry(false, "å", "Å", ".--.-"),
|
||||||
|
Entry(false, "Ç", "Ç", "-.-.."), Entry(false, "ç", "Ç", "-.-.."),
|
||||||
|
Entry(false, "È", "È", ".-..-"), Entry(false, "è", "È", ".-..-"),
|
||||||
|
Entry(false, "É", "É", "..-.."), Entry(false, "é", "É", "..-.."),
|
||||||
|
Entry(false, "Ó", "Ó", "---."), Entry(false, "ó", "Ó", "---."),
|
||||||
|
Entry(false, "Ö", "Ö", "---."), Entry(false, "ö", "Ö", "---."),
|
||||||
|
Entry(false, "Ø", "Ø", "---."), Entry(false, "ø", "Ø", "---."),
|
||||||
|
Entry(false, "Ñ", "Ñ", "--.--"), Entry(false, "ñ", "Ñ", "--.--"),
|
||||||
|
Entry(false, "Ü", "Ü", "..--"), Entry(false, "ü", "Ü", "..--"),
|
||||||
|
Entry(false, "Û", "Û", "..--"), Entry(false, "û", "Û", "..--")
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up the display representation of a dot-dash pattern.
|
||||||
|
* Returns "" when the pattern is not in the table or is disabled.
|
||||||
|
* Mirror of cMorse::rx_lookup.
|
||||||
|
*/
|
||||||
|
fun rxLookup(pattern: String): String {
|
||||||
|
for (e in table) {
|
||||||
|
if (pattern == e.rpr) {
|
||||||
|
if (e.enabled) return e.prt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/*
|
||||||
|
* 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.core.domain.cw
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-Organizing Map codebook for CW character matching, ported from
|
||||||
|
* fldigi cw.cxx (cw::som_table). Each row is a dot/dash weight vector
|
||||||
|
* (0 = element absent, 0.33 = dot, 1.0 = dash).
|
||||||
|
*/
|
||||||
|
internal object SomTable {
|
||||||
|
|
||||||
|
data class SomEntry(val pattern: String, val weights: FloatArray)
|
||||||
|
|
||||||
|
val table: List<SomEntry> = listOf(
|
||||||
|
// Prosigns
|
||||||
|
SomEntry("-...-", floatArrayOf(1.0f, 0.33f, 0.33f, 0.33f, 1.0f, 0f, 0f)),
|
||||||
|
SomEntry(".-.-", floatArrayOf(0.33f, 1.0f, 0.33f, 1.0f, 0f, 0f, 0f)),
|
||||||
|
SomEntry(".-...", floatArrayOf(0.33f, 1.0f, 0.33f, 0.33f, 0.33f, 0f, 0f)),
|
||||||
|
SomEntry(".-.-.", floatArrayOf(0.33f, 1.0f, 0.33f, 1.0f, 0.33f, 0f, 0f)),
|
||||||
|
SomEntry("...-.-", floatArrayOf(0.33f, 0.33f, 0.33f, 1.0f, 0.33f, 1.0f, 0f)),
|
||||||
|
SomEntry("-.--.", floatArrayOf(1.0f, 0.33f, 1.0f, 1.0f, 0.33f, 0f, 0f)),
|
||||||
|
SomEntry("..-.-", floatArrayOf(0.33f, 0.33f, 1.0f, 0.33f, 1.0f, 0f, 0f)),
|
||||||
|
SomEntry("....--", floatArrayOf(0.33f, 0.33f, 0.33f, 0.33f, 1.0f, 1.0f, 0f)),
|
||||||
|
SomEntry("...-.", floatArrayOf(0.33f, 0.33f, 0.33f, 1.0f, 0.33f, 0f, 0f)),
|
||||||
|
// ASCII 7-bit letters
|
||||||
|
SomEntry(".-", floatArrayOf(0.33f, 1.0f, 0f, 0f, 0f, 0f, 0f)),
|
||||||
|
SomEntry("-...", floatArrayOf(1.0f, 0.33f, 0.33f, 0.33f, 0f, 0f, 0f)),
|
||||||
|
SomEntry("-.-.", floatArrayOf(1.0f, 0.33f, 1.0f, 0.33f, 0f, 0f, 0f)),
|
||||||
|
SomEntry("-..", floatArrayOf(1.0f, 0.33f, 0.33f, 0f, 0f, 0f, 0f)),
|
||||||
|
SomEntry(".", floatArrayOf(0.33f, 0f, 0f, 0f, 0f, 0f, 0f)),
|
||||||
|
SomEntry("..-.", floatArrayOf(0.33f, 0.33f, 1.0f, 0.33f, 0f, 0f, 0f)),
|
||||||
|
SomEntry("--.", floatArrayOf(1.0f, 1.0f, 0.33f, 0f, 0f, 0f, 0f)),
|
||||||
|
SomEntry("....", floatArrayOf(0.33f, 0.33f, 0.33f, 0.33f, 0f, 0f, 0f)),
|
||||||
|
SomEntry("..", floatArrayOf(0.33f, 0.33f, 0f, 0f, 0f, 0f, 0f)),
|
||||||
|
SomEntry(".---", floatArrayOf(0.33f, 1.0f, 1.0f, 1.0f, 0f, 0f, 0f)),
|
||||||
|
SomEntry("-.-", floatArrayOf(1.0f, 0.33f, 1.0f, 0f, 0f, 0f, 0f)),
|
||||||
|
SomEntry(".-..", floatArrayOf(0.33f, 1.0f, 0.33f, 0.33f, 0f, 0f, 0f)),
|
||||||
|
SomEntry("--", floatArrayOf(1.0f, 1.0f, 0f, 0f, 0f, 0f, 0f)),
|
||||||
|
SomEntry("-.", floatArrayOf(1.0f, 0.33f, 0f, 0f, 0f, 0f, 0f)),
|
||||||
|
SomEntry("---", floatArrayOf(1.0f, 1.0f, 1.0f, 0f, 0f, 0f, 0f)),
|
||||||
|
SomEntry(".--.", floatArrayOf(0.33f, 1.0f, 1.0f, 0.33f, 0f, 0f, 0f)),
|
||||||
|
SomEntry("--.-", floatArrayOf(1.0f, 1.0f, 0.33f, 1.0f, 0f, 0f, 0f)),
|
||||||
|
SomEntry(".-.", floatArrayOf(0.33f, 1.0f, 0.33f, 0f, 0f, 0f, 0f)),
|
||||||
|
SomEntry("...", floatArrayOf(0.33f, 0.33f, 0.33f, 0f, 0f, 0f, 0f)),
|
||||||
|
SomEntry("-", floatArrayOf(1.0f, 0f, 0f, 0f, 0f, 0f, 0f)),
|
||||||
|
SomEntry("..-", floatArrayOf(0.33f, 0.33f, 1.0f, 0f, 0f, 0f, 0f)),
|
||||||
|
SomEntry("...-", floatArrayOf(0.33f, 0.33f, 0.33f, 1.0f, 0f, 0f, 0f)),
|
||||||
|
SomEntry(".--", floatArrayOf(0.33f, 1.0f, 1.0f, 0f, 0f, 0f, 0f)),
|
||||||
|
SomEntry("-..-", floatArrayOf(1.0f, 0.33f, 0.33f, 1.0f, 0f, 0f, 0f)),
|
||||||
|
SomEntry("-.--", floatArrayOf(1.0f, 0.33f, 1.0f, 1.0f, 0f, 0f, 0f)),
|
||||||
|
SomEntry("--..", floatArrayOf(1.0f, 1.0f, 0.33f, 0.33f, 0f, 0f, 0f)),
|
||||||
|
// Numerals
|
||||||
|
SomEntry("-----", floatArrayOf(1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 0f, 0f)),
|
||||||
|
SomEntry(".----", floatArrayOf(0.33f, 1.0f, 1.0f, 1.0f, 1.0f, 0f, 0f)),
|
||||||
|
SomEntry("..---", floatArrayOf(0.33f, 0.33f, 1.0f, 1.0f, 1.0f, 0f, 0f)),
|
||||||
|
SomEntry("...--", floatArrayOf(0.33f, 0.33f, 0.33f, 1.0f, 1.0f, 0f, 0f)),
|
||||||
|
SomEntry("....-", floatArrayOf(0.33f, 0.33f, 0.33f, 0.33f, 1.0f, 0f, 0f)),
|
||||||
|
SomEntry(".....", floatArrayOf(0.33f, 0.33f, 0.33f, 0.33f, 0.33f, 0f, 0f)),
|
||||||
|
SomEntry("-....", floatArrayOf(1.0f, 0.33f, 0.33f, 0.33f, 0.33f, 0f, 0f)),
|
||||||
|
SomEntry("--...", floatArrayOf(1.0f, 1.0f, 0.33f, 0.33f, 0.33f, 0f, 0f)),
|
||||||
|
SomEntry("---..", floatArrayOf(1.0f, 1.0f, 1.0f, 0.33f, 0.33f, 0f, 0f)),
|
||||||
|
SomEntry("----.", floatArrayOf(1.0f, 1.0f, 1.0f, 1.0f, 0.33f, 0f, 0f)),
|
||||||
|
// Punctuation
|
||||||
|
SomEntry(".-..-.", floatArrayOf(0.33f, 1.0f, 0.33f, 0.33f, 1.0f, 0.33f, 0f)),
|
||||||
|
SomEntry(".----.", floatArrayOf(0.33f, 1.0f, 1.0f, 1.0f, 1.0f, 0.33f, 0f)),
|
||||||
|
SomEntry("...-..-", floatArrayOf(0.33f, 0.33f, 0.33f, 1.0f, 0.33f, 0.33f, 1.0f)),
|
||||||
|
SomEntry("-.---.", floatArrayOf(1.0f, 0.33f, 1.0f, 1.0f, 0.33f, 0f, 0f)),
|
||||||
|
SomEntry("-.--.-", floatArrayOf(1.0f, 0.33f, 1.0f, 1.0f, 0.33f, 1.0f, 0f)),
|
||||||
|
SomEntry("--..--", floatArrayOf(1.0f, 1.0f, 0.33f, 0.33f, 1.0f, 1.0f, 0f)),
|
||||||
|
SomEntry("-....-", floatArrayOf(1.0f, 0.33f, 0.33f, 0.33f, 0.33f, 1.0f, 0f)),
|
||||||
|
SomEntry(".-.-.-", floatArrayOf(0.33f, 1.0f, 0.33f, 1.0f, 0.33f, 1.0f, 0f)),
|
||||||
|
SomEntry("-..-.", floatArrayOf(1.0f, 0.33f, 0.33f, 1.0f, 0.33f, 0f, 0f)),
|
||||||
|
SomEntry("---...", floatArrayOf(1.0f, 1.0f, 1.0f, 0.33f, 0.33f, 0.33f, 0f)),
|
||||||
|
SomEntry("-.-.-.", floatArrayOf(1.0f, 0.33f, 1.0f, 0.33f, 1.0f, 0.33f, 0f)),
|
||||||
|
SomEntry("..--..", floatArrayOf(0.33f, 0.33f, 1.0f, 1.0f, 0.33f, 0.33f, 0f)),
|
||||||
|
SomEntry("..--.-", floatArrayOf(0.33f, 0.33f, 1.0f, 1.0f, 0.33f, 1.0f, 0f)),
|
||||||
|
SomEntry(".--.-.", floatArrayOf(0.33f, 1.0f, 1.0f, 0.33f, 1.0f, 0.33f, 0f)),
|
||||||
|
SomEntry("-.-.--", floatArrayOf(1.0f, 0.33f, 1.0f, 0.33f, 1.0f, 1.0f, 0f)),
|
||||||
|
// Accented
|
||||||
|
SomEntry(".-.-", floatArrayOf(0.33f, 1.0f, 0.33f, 1.0f, 0f, 0f, 0f)), // A umlaut, A aelig
|
||||||
|
SomEntry(".--.-", floatArrayOf(0.33f, 1.0f, 1.0f, 0.33f, 1.0f, 0f, 0f)), // A ring
|
||||||
|
SomEntry("-.-..", floatArrayOf(1.0f, 0.33f, 1.0f, 0.33f, 0.33f, 0f, 0f)), // C cedilla
|
||||||
|
SomEntry(".-..-", floatArrayOf(0.33f, 1.0f, 0.33f, 0.33f, 1.0f, 0f, 0f)), // E grave
|
||||||
|
SomEntry("..-..", floatArrayOf(0.33f, 0.33f, 1.0f, 0.33f, 0.33f, 0f, 0f)), // E acute
|
||||||
|
SomEntry("---.", floatArrayOf(1.0f, 1.0f, 1.0f, 0.33f, 0f, 0f, 0f)), // O acute, O umlaut, O slash
|
||||||
|
SomEntry("--.--", floatArrayOf(1.0f, 1.0f, 0.33f, 1.0f, 1.0f, 0f, 0f)), // N tilde
|
||||||
|
SomEntry("..--", floatArrayOf(0.33f, 0.33f, 1.0f, 1.0f, 0f, 0f, 0f)) // U umlaut, U circ
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.model
|
||||||
|
|
||||||
|
/** One satellite status report (AMSAT site tooltip data) */
|
||||||
|
data class SatReport(
|
||||||
|
val id: String, // 报告 ID(a885153)
|
||||||
|
val statusText: String, // Heard / Telemetry Only / Not Heard ...
|
||||||
|
val call: String, // 呼号
|
||||||
|
val grid: String, // 网格坐标(可为空)
|
||||||
|
val dateUtc: String, // 2026-08-04
|
||||||
|
val timeUtc: String // 2:46-:59 UTC
|
||||||
|
)
|
||||||
|
|
||||||
|
/** State of one 2-hour slot */
|
||||||
|
data class SatSlot(
|
||||||
|
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
|
||||||
|
val count: Int, // 报告数量(0 = 无)
|
||||||
|
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One satellite day (12 two-hour slots) */
|
||||||
|
data class SatDay(
|
||||||
|
val dateLabel: String, // "Aug 4"
|
||||||
|
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One satellite, 6 days of state */
|
||||||
|
data class SatStatus(
|
||||||
|
val name: String, // "AO-123_[FM]"
|
||||||
|
val days: List<SatDay> // 6 天(新→旧)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Overall page parse result */
|
||||||
|
data class SatStatusPage(
|
||||||
|
val fetchedAtUtcMs: Long,
|
||||||
|
val statuses: List<SatStatus>,
|
||||||
|
val reports: Map<String, SatReport> // id → 报告
|
||||||
|
)
|
||||||
@@ -62,7 +62,18 @@ data class OtherSettings(
|
|||||||
val shouldSeeWhatsNew: Boolean,
|
val shouldSeeWhatsNew: Boolean,
|
||||||
val sstvMode: String = "Auto",
|
val sstvMode: String = "Auto",
|
||||||
val lowElevation: Double = 15.0,
|
val lowElevation: Double = 15.0,
|
||||||
val highElevation: Double = 45.0
|
val highElevation: Double = 45.0,
|
||||||
|
// UI settings: pages hidden from the bottom nav (Screen simpleName list; empty default = all shown)
|
||||||
|
val hiddenScreens: List<String> = emptyList(),
|
||||||
|
// UI settings: page order (empty = default: Satellites/Passes/Radar/Map/Settings)
|
||||||
|
val screenOrder: List<String> = emptyList(),
|
||||||
|
// UI settings: More-menu order (empty = default: Mutual/Roaming/CW decode)
|
||||||
|
val subMenuOrder: List<String> = emptyList(),
|
||||||
|
// WaveLog logging (4.5.2): server config
|
||||||
|
val wavelogUrl: String = "",
|
||||||
|
val wavelogApiKey: String = "",
|
||||||
|
val wavelogStationId: String = "",
|
||||||
|
val wavelogAutoUpload: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
data class DataSourcesSettings(
|
data class DataSourcesSettings(
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/* QrzGridClient.kt - QRZ callsign grid scraper (4.5.5, pure JVM in domain).
|
||||||
|
* How it works (verified): GET https://www.qrz.com/db/{callsign} with the user's QRZ login Cookie,
|
||||||
|
* the Detail table on the page holds <td class="dh">Grid Square</td><td class="di">XXX</td>.
|
||||||
|
* Without cookies the Detail is unavailable (confirmed); if the other station has no grid, the row is absent (null).
|
||||||
|
* The Cookie is pasted by the user in settings (EditThisCookie JSON export or a raw cookie string),
|
||||||
|
* never built into the app.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.qrz
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
object QrzGridClient {
|
||||||
|
|
||||||
|
/** Parse the pasted Cookie (both formats): EditThisCookie JSON array or raw "k=v; k=v" string */
|
||||||
|
fun parseCookies(raw: String): String {
|
||||||
|
val text = raw.trim()
|
||||||
|
if (text.isEmpty()) return ""
|
||||||
|
// JSON array format: [{"name":"qz_userid","value":"1266043",...}, ...]
|
||||||
|
if (text.startsWith("[")) {
|
||||||
|
return try {
|
||||||
|
val arr = org.json.JSONArray(text)
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
val name = o.optString("name")
|
||||||
|
val value = o.optString("value")
|
||||||
|
if (name.isNotBlank()) parts.add("$name=$value")
|
||||||
|
}
|
||||||
|
parts.joinToString("; ")
|
||||||
|
} catch (_: Exception) { text }
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detect the callsign logged in with these cookies (fetch db home, extract the account menu callsign). Null on failure */
|
||||||
|
suspend fun fetchOwnCallsign(cookieHeader: String): String? = withContext(Dispatchers.IO) {
|
||||||
|
if (cookieHeader.isBlank()) return@withContext null
|
||||||
|
try {
|
||||||
|
val url = URL("https://www.qrz.com/db/")
|
||||||
|
val conn = url.openConnection()
|
||||||
|
conn.connectTimeout = 10_000
|
||||||
|
conn.readTimeout = 20_000
|
||||||
|
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat-Pro")
|
||||||
|
conn.setRequestProperty("Cookie", cookieHeader)
|
||||||
|
val html = conn.getInputStream().bufferedReader().use { it.readText() }
|
||||||
|
// Logged-in account menu (verified): <li class="leaf last" onclick="return true">BG7NTA <ul class="sub">
|
||||||
|
val pattern = Regex("<li class=\"leaf last\"[^>]*>\\s*([A-Z0-9/]+)\\s*<ul")
|
||||||
|
pattern.find(html)?.groupValues?.get(1)?.trim()
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look up a callsign's grid. Null = not set / not found (silent, does not block logging) */
|
||||||
|
suspend fun lookupGrid(callsign: String, cookieHeader: String): String? = withContext(Dispatchers.IO) {
|
||||||
|
if (callsign.isBlank() || cookieHeader.isBlank()) return@withContext null
|
||||||
|
try {
|
||||||
|
val url = URL("https://www.qrz.com/db/${callsign.trim().uppercase()}")
|
||||||
|
val conn = url.openConnection()
|
||||||
|
conn.connectTimeout = 10_000
|
||||||
|
conn.readTimeout = 20_000
|
||||||
|
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat-Pro")
|
||||||
|
conn.setRequestProperty("Cookie", cookieHeader)
|
||||||
|
val html = conn.getInputStream().bufferedReader().use { it.readText() }
|
||||||
|
// Detail table Grid Square row (verified format)
|
||||||
|
val m = Regex("""<td class="dh">Grid Square</td><td class="di">([^<]+)</td>""")
|
||||||
|
.find(html)
|
||||||
|
m?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() }
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
|
|
||||||
|
/** AMSAT satellite status data source */
|
||||||
|
interface IAmSatRepository {
|
||||||
|
/** Fetch and parse the AMSAT status page; null on failure */
|
||||||
|
suspend fun fetchStatus(): SatStatusPage?
|
||||||
|
}
|
||||||
+6
@@ -30,6 +30,7 @@ interface IMainContainer {
|
|||||||
val selectionRepo: ISelectionRepo
|
val selectionRepo: ISelectionRepo
|
||||||
val satelliteRepo: ISatelliteRepo
|
val satelliteRepo: ISatelliteRepo
|
||||||
val databaseRepo: IDatabaseRepo
|
val databaseRepo: IDatabaseRepo
|
||||||
|
val amSatRepo: IAmSatRepository
|
||||||
val radioTrackingService: IRadioTrackingService
|
val radioTrackingService: IRadioTrackingService
|
||||||
val mutualPassData: StateFlow<MutualPassData>
|
val mutualPassData: StateFlow<MutualPassData>
|
||||||
fun setMutualPassData(data: MutualPassData)
|
fun setMutualPassData(data: MutualPassData)
|
||||||
@@ -42,6 +43,11 @@ interface IMainContainer {
|
|||||||
fun provideRxRadioController(): IRadioController
|
fun provideRxRadioController(): IRadioController
|
||||||
fun provideAudioCapture(): IAudioCapture
|
fun provideAudioCapture(): IAudioCapture
|
||||||
fun provideSaveImage(): ISaveImage
|
fun provideSaveImage(): ISaveImage
|
||||||
|
// WaveLog logging (4.5.2)
|
||||||
|
val wavelogQueue: com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
||||||
|
fun provideWavelogUploader(): com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
|
||||||
|
|
||||||
|
fun provideLotwSatellitesRepo(): com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
data class MutualPassData(
|
data class MutualPassData(
|
||||||
|
|||||||
+2
-1
@@ -45,7 +45,8 @@ interface ISettingsRepo {
|
|||||||
//region # Station position settings
|
//region # Station position settings
|
||||||
val stationPosition: StateFlow<GeoPos>
|
val stationPosition: StateFlow<GeoPos>
|
||||||
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
|
fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean
|
||||||
fun setStationPosition(): Boolean
|
/** GPS fix (suspend): true only with a fix; false on missing permission/timeout/no signal */
|
||||||
|
suspend fun setStationPosition(): Boolean
|
||||||
fun setStationPosition(locator: String): Boolean
|
fun setStationPosition(locator: String): Boolean
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
|||||||
@@ -22,4 +22,7 @@ import java.io.InputStream
|
|||||||
interface IRemoteSource {
|
interface IRemoteSource {
|
||||||
suspend fun getFileStream(uri: String): InputStream?
|
suspend fun getFileStream(uri: String): InputStream?
|
||||||
suspend fun getNetworkStream(url: String): InputStream?
|
suspend fun getNetworkStream(url: String): InputStream?
|
||||||
|
|
||||||
|
/** Fetch AMSAT status page HTML (with UA; null = failure) */
|
||||||
|
suspend fun getStatusHtml(): String?
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,10 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.source
|
package com.rtbishop.look4sat.core.domain.source
|
||||||
|
|
||||||
object Sources {
|
object Sources {
|
||||||
|
// Default URL for online updates (user can change/reset it via the "Custom URL" dialog)
|
||||||
|
val defaultTleUrl = "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv"
|
||||||
|
val defaultTransceiversUrl = "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
||||||
|
|
||||||
val satelliteDataUrls = mapOf(
|
val satelliteDataUrls = mapOf(
|
||||||
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
"All" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=csv",
|
||||||
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
"Amateur" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=amateur&FORMAT=csv",
|
||||||
|
|||||||
@@ -19,4 +19,7 @@ package com.rtbishop.look4sat.core.domain.usecase
|
|||||||
|
|
||||||
interface IShowToast {
|
interface IShowToast {
|
||||||
operator fun invoke(message: String)
|
operator fun invoke(message: String)
|
||||||
|
|
||||||
|
/** Show by resource ID (four-language text) */
|
||||||
|
operator fun invoke(resId: Int)
|
||||||
}
|
}
|
||||||
+27
-1
@@ -11,6 +11,7 @@ package com.rtbishop.look4sat.core.domain.utility
|
|||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
||||||
@@ -44,6 +45,9 @@ object DopplerFrequencyCalculator {
|
|||||||
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
|
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
|
||||||
* with an offset applied to the downlink (in Hz).
|
* with an offset applied to the downlink (in Hz).
|
||||||
* Returns null if the transponder is not a linear passband type.
|
* Returns null if the transponder is not a linear passband type.
|
||||||
|
*
|
||||||
|
* The user-entered downlink frequency already includes the offset, so subtract
|
||||||
|
* it before mapping the downlink passband position back to the uplink.
|
||||||
*/
|
*/
|
||||||
fun computeUplinkFromDownlinkWithOffset(
|
fun computeUplinkFromDownlinkWithOffset(
|
||||||
downlinkHz: Long,
|
downlinkHz: Long,
|
||||||
@@ -52,7 +56,7 @@ object DopplerFrequencyCalculator {
|
|||||||
offsetHz: Long
|
offsetHz: Long
|
||||||
): Long? {
|
): Long? {
|
||||||
if (!isLinearTransponder(transponder)) return null
|
if (!isLinearTransponder(transponder)) return null
|
||||||
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz + offsetHz, transponder) ?: return null
|
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz - offsetHz, transponder) ?: return null
|
||||||
return orbitalPos.getUplinkFreq(baseUplink)
|
return orbitalPos.getUplinkFreq(baseUplink)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,4 +99,26 @@ object DopplerFrequencyCalculator {
|
|||||||
return upLow != null && upHigh != null && downLow != null && downHigh != null
|
return upLow != null && upHigh != null && downLow != null && downHigh != null
|
||||||
&& upLow != upHigh && downLow != downHigh
|
&& upLow != upHigh && downLow != downHigh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True for the radio entry that should drive the standalone Calculator page.
|
||||||
|
*
|
||||||
|
* A frequency range alone is not enough: some non-user-facing or drifting data entries
|
||||||
|
* can also have low/high frequencies. The calculator is meant for the named linear
|
||||||
|
* transponder entry, e.g. "Linear Transponder", "Linear Transp.", "SSB Transponder".
|
||||||
|
*/
|
||||||
|
fun isNamedLinearTransponder(transponder: SatRadio): Boolean {
|
||||||
|
if (!isLinearTransponder(transponder)) return false
|
||||||
|
|
||||||
|
val info = transponder.info.lowercase(Locale.ENGLISH)
|
||||||
|
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
|
||||||
|
.joinToString(separator = " ")
|
||||||
|
.lowercase(Locale.ENGLISH)
|
||||||
|
val hasLinearName = info.contains("linear")
|
||||||
|
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
|
||||||
|
info.contains("xponder") || info.contains("xpdr")
|
||||||
|
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
|
||||||
|
|
||||||
|
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -118,7 +118,7 @@ fun qthToSquare(locator: String): String {
|
|||||||
* Builds the 3x3 grid of 4-char squares surrounding [square] (e.g. "OL42").
|
* Builds the 3x3 grid of 4-char squares surrounding [square] (e.g. "OL42").
|
||||||
* Row 0 = north (lat +1), col 0 = west (lon -1). Handles field/square carry
|
* Row 0 = north (lat +1), col 0 = west (lon -1). Handles field/square carry
|
||||||
* at boundaries (e.g. "AA00" wraps to "RR99" at the south-west corner).
|
* at boundaries (e.g. "AA00" wraps to "RR99" at the south-west corner).
|
||||||
* Mirrors the neighbor logic decompiled from the QTH定位器 app.
|
* Mirrors the neighbor logic decompiled from the QTH Locator app.
|
||||||
*/
|
*/
|
||||||
fun qthNeighbors(square: String): List<String> {
|
fun qthNeighbors(square: String): List<String> {
|
||||||
if (square.length != 4) return emptyList()
|
if (square.length != 4) return emptyList()
|
||||||
|
|||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
/* ILotwSatellitesRepo.kt - LoTW satellite list refresh interface (domain layer, 4.5.5).
|
||||||
|
* Impl: LotwSatellitesRepo in core/data (downloads ARRL config.tq6).
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.wavelog
|
||||||
|
|
||||||
|
interface ILotwSatellitesRepo {
|
||||||
|
sealed class RefreshResult {
|
||||||
|
data class Ok(val count: Int) : RefreshResult()
|
||||||
|
data class Error(val message: String) : RefreshResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun restore()
|
||||||
|
|
||||||
|
suspend fun refresh(): RefreshResult
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/* LotwSatellites.kt - LoTW-supported satellite name list (112, statically embedded).
|
||||||
|
* Source: https://lotw.arrl.org/lotw/config.tq6 (gzip XML, official ARRL!)
|
||||||
|
* WaveLog updates its satellite table's lotw field from the same source (Update_model.php lotw_sats()).
|
||||||
|
* At runtime LotwSatellitesRepo can refresh it manually (settings WaveLog section button).
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.wavelog
|
||||||
|
|
||||||
|
object LotwSatellites {
|
||||||
|
private val staticNames: Set<String> = setOf("AISAT1", "AO-10", "AO-109", "AO-123", "AO-13", "AO-16", "AO-21", "AO-27", "AO-3", "AO-4", "AO-40", "AO-51", "AO-6", "AO-7", "AO-73", "AO-8", "AO-85", "AO-91", "AO-92", "ARISS", "Arsene", "BO-102", "BY70-1", "CAS-2T", "CAS-3H", "CAS-4A", "CAS-4B", "DO-64", "EO-79", "EO-88", "FO-118", "FO-12", "FO-20", "FO-29", "FO-99", "FS-3", "HO-107", "HO-113", "HO-119", "HO-68", "INSPR7", "IO-117", "IO-86", "JO-97", "KEDR", "LEDSAT", "LO-19", "LO-78", "LO-87", "LO-90", "MAYA-3", "MAYA-4", "MIREX", "MO-112", "MO-122", "NO-103", "NO-104", "NO-44", "NO-83", "NO-84", "PO-101", "QO-100", "RS-1", "RS-10", "RS-11", "RS-12", "RS-13", "RS-15", "RS-2", "RS-44", "RS-5", "RS-6", "RS-7", "RS-8", "SAREX", "SO-121", "SO-124", "SO-125", "SO-35", "SO-41", "SO-50", "SO-67", "SONATE", "TAURUS", "TEVEL1", "TEVEL2", "TEVEL3", "TEVEL4", "TEVEL5", "TEVEL6", "TEVEL7", "TEVEL8", "TO-108", "UKUBE1", "UO-14", "UVSQ", "VO-52", "XW-2A", "XW-2B", "XW-2C", "XW-2D", "XW-2E", "XW-2F", "TEV2-1", "TEV2-2", "TEV2-3", "TEV2-4", "TEV2-5", "TEV2-6", "TEV2-7", "TEV2-8", "TEV2-9")
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var dynamicNames: Set<String> = emptySet()
|
||||||
|
|
||||||
|
/** Effective satellite names: static embedded set union runtime updates */
|
||||||
|
val names: Set<String>
|
||||||
|
get() = if (dynamicNames.isEmpty()) staticNames else staticNames + dynamicNames
|
||||||
|
|
||||||
|
/** Runtime update (settings button -> called after LotwSatellitesRepo.refresh()) */
|
||||||
|
fun updateNames(newNames: Set<String>) {
|
||||||
|
if (newNames.isNotEmpty()) dynamicNames = newNames
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
/*
|
||||||
|
* WaveLogApi.kt - WaveLog log server API client (4.5.2 override fix 2).
|
||||||
|
*
|
||||||
|
* Supports both v1 and v2 (user's server only has v1 in practice; v2 returns 404):
|
||||||
|
* v2: POST {base}/api/v2/qso (Authorization: Bearer + JSON fields)
|
||||||
|
* v1: POST {base}/index.php/api/qso (key in JSON body + ADIF string)
|
||||||
|
* Strategy: try v2 first, auto-fallback to v1 on 404.
|
||||||
|
* Test connection: v2 GET api/v2/token; on 404 use v1 POST api/get_contacts_adif.
|
||||||
|
* Station grid: only v2 has GET api/v2/station/{id}; v1 lacks it -> fall back to user QTH.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.wavelog
|
||||||
|
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.io.OutputStreamWriter
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/** Station info (GET /api/v2/station/{id} result) */
|
||||||
|
data class WavelogStation(
|
||||||
|
val id: Int,
|
||||||
|
val name: String,
|
||||||
|
val callsign: String,
|
||||||
|
val gridsquare: String
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class WavelogResult {
|
||||||
|
data class Success(val message: String) : WavelogResult()
|
||||||
|
data class Failure(val message: String) : WavelogResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
object WaveLogApi {
|
||||||
|
|
||||||
|
private const val TIMEOUT_MS = 15000
|
||||||
|
|
||||||
|
/** Normalize server URL: strip trailing slash/index.php; prepend https:// when missing */
|
||||||
|
fun normalizeUrl(raw: String): String {
|
||||||
|
var u = raw.trim().trimEnd('/')
|
||||||
|
if (u.isBlank()) return ""
|
||||||
|
if (!u.startsWith("http://") && !u.startsWith("https://")) u = "https://$u"
|
||||||
|
if (u.endsWith("/index.php")) u = u.removeSuffix("/index.php")
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test connection: v2 GET api/v2/token; on 404 use v1 POST api/get_contacts_adif */
|
||||||
|
suspend fun testToken(url: String, apiKey: String, stationId: String = ""): WavelogResult = withContext(Dispatchers.IO) {
|
||||||
|
val base = normalizeUrl(url)
|
||||||
|
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
||||||
|
|
||||||
|
// v2: GET /index.php/api/v2/token
|
||||||
|
val v2 = httpRequest("$base/index.php/api/v2/token", "GET", apiKey, null)
|
||||||
|
if (v2.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v2)")
|
||||||
|
|
||||||
|
// v1: POST /index.php/api/get_contacts_adif (key in body)
|
||||||
|
if (stationId.isNotBlank()) {
|
||||||
|
val body = JSONObject().apply {
|
||||||
|
put("key", apiKey)
|
||||||
|
put("station_id", stationId)
|
||||||
|
put("fetchfromid", 0)
|
||||||
|
}.toString()
|
||||||
|
val v1 = httpRequest("$base/index.php/api/get_contacts_adif", "POST", apiKey, body)
|
||||||
|
if (v1.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
|
||||||
|
if (v1.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
|
||||||
|
}
|
||||||
|
// v1 attempt without index.php
|
||||||
|
val body = JSONObject().apply {
|
||||||
|
put("key", apiKey)
|
||||||
|
put("station_id", stationId)
|
||||||
|
put("fetchfromid", 0)
|
||||||
|
}.toString()
|
||||||
|
val v1b = httpRequest("$base/api/get_contacts_adif", "POST", apiKey, body)
|
||||||
|
if (v1b.first in 200..299) return@withContext WavelogResult.Success("连接成功 (API v1)")
|
||||||
|
if (v1b.first == 401) return@withContext WavelogResult.Failure("API 密钥无效 (v1: 401)")
|
||||||
|
|
||||||
|
WavelogResult.Failure("连接失败: v2 HTTP ${v2.first}, v1 HTTP ${v1b.first} — 请确认服务器地址/密钥正确")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Station info: v2 only; v1 lacks the endpoint (grid check falls back to user QTH) */
|
||||||
|
suspend fun getStation(url: String, apiKey: String, stationId: String): WavelogResult = withContext(Dispatchers.IO) {
|
||||||
|
val base = normalizeUrl(url)
|
||||||
|
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
||||||
|
val (code, resp) = httpRequest("$base/index.php/api/v2/station/$stationId", "GET", apiKey, null)
|
||||||
|
if (code in 200..299) {
|
||||||
|
return@withContext try {
|
||||||
|
val obj = JSONObject(resp)
|
||||||
|
val data = obj.optJSONObject("data") ?: obj
|
||||||
|
val station = WavelogStation(
|
||||||
|
id = data.optInt("id"),
|
||||||
|
name = data.optString("name"),
|
||||||
|
callsign = data.optString("callsign"),
|
||||||
|
gridsquare = data.optString("gridsquare")
|
||||||
|
)
|
||||||
|
WavelogResult.Success(JSONObject().apply {
|
||||||
|
put("id", station.id); put("name", station.name)
|
||||||
|
put("callsign", station.callsign); put("gridsquare", station.gridsquare)
|
||||||
|
}.toString())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
WavelogResult.Failure("解析失败: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// v1 has no station endpoint -> return empty Success (caller falls back to user QTH)
|
||||||
|
WavelogResult.Success("")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** LoTW-recognized satellite name: main name before parentheses, uppercased (ISS special case) */
|
||||||
|
fun normalizeSatName(raw: String): String {
|
||||||
|
val main = raw.substringBefore('(').trim()
|
||||||
|
.ifBlank { raw.trim() }
|
||||||
|
.uppercase(Locale.ENGLISH)
|
||||||
|
// Matching logic (mirrors WaveLog satellite table name/displayname matching + LoTW list):
|
||||||
|
// 1. Already in LoTW list (common TLE name == common name) -> return as-is
|
||||||
|
// 2. Not present -> check Celestrak alias map (SAUDISAT 1C -> SO-50 etc.); mapped name must be in LoTW list
|
||||||
|
// 3. Still unmatched -> return as-is (uploads are not blocked; QSO is still saved)
|
||||||
|
val commonName = mapOf(
|
||||||
|
"ZARYA" to "ARISS",
|
||||||
|
"ARISS" to "ARISS",
|
||||||
|
"FUNCUBE-1" to "AO-73",
|
||||||
|
"DIWATA-2B" to "PO-101",
|
||||||
|
"SAUDISAT-1C" to "SO-50",
|
||||||
|
"SAUDISAT 1C" to "SO-50",
|
||||||
|
"DIWATA-2A" to "PO-101"
|
||||||
|
)
|
||||||
|
val candidate = commonName[main] ?: main
|
||||||
|
return if (candidate in LotwSatellites.names) candidate else main
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create QSO: v2 first, fall back to v1 (ADIF) on 404 */
|
||||||
|
suspend fun postQso(
|
||||||
|
url: String,
|
||||||
|
apiKey: String,
|
||||||
|
stationProfileId: String,
|
||||||
|
qso: WavelogQso,
|
||||||
|
gridsquare: String
|
||||||
|
): WavelogResult = withContext(Dispatchers.IO) {
|
||||||
|
val base = normalizeUrl(url)
|
||||||
|
if (base.isBlank()) return@withContext WavelogResult.Failure("服务器地址为空")
|
||||||
|
|
||||||
|
val satName = normalizeSatName(qso.satName)
|
||||||
|
|
||||||
|
// v2: POST /index.php/api/v2/qso (JSON fields)
|
||||||
|
val v2Body = JSONObject().apply {
|
||||||
|
put("station_profile_id", stationProfileId.toIntOrNull() ?: 0)
|
||||||
|
put("call", qso.call)
|
||||||
|
put("band", "SAT")
|
||||||
|
put("mode", qso.mode)
|
||||||
|
put("qso_date", utcDate(qso.timeUtcMs))
|
||||||
|
put("time_on", utcTime(qso.timeUtcMs))
|
||||||
|
put("freq", String.format(Locale.ENGLISH, "%.6fM", qso.freqTxHz / 1_000_000.0))
|
||||||
|
put("freq_rx", String.format(Locale.ENGLISH, "%.6fM", qso.freqRxHz / 1_000_000.0))
|
||||||
|
put("gridsquare", gridsquare)
|
||||||
|
put("rst_sent", "59")
|
||||||
|
put("rst_rcvd", "59")
|
||||||
|
put("sat_name", satName)
|
||||||
|
}
|
||||||
|
val (code, resp) = httpRequest("$base/index.php/api/v2/qso", "POST", apiKey, v2Body.toString())
|
||||||
|
if (code in 200..299) return@withContext WavelogResult.Success("已上传 (v2)")
|
||||||
|
if (code == 409) return@withContext WavelogResult.Success("重复(已存在)")
|
||||||
|
|
||||||
|
// v1: POST /index.php/api/qso (key in body + ADIF)
|
||||||
|
val v1Body = JSONObject().apply {
|
||||||
|
put("key", apiKey)
|
||||||
|
put("station_profile_id", stationProfileId)
|
||||||
|
put("type", "adif")
|
||||||
|
put("string", toAdif(qso, gridsquare, satName))
|
||||||
|
}
|
||||||
|
val (code1, resp1) = httpRequest("$base/index.php/api/qso", "POST", apiKey, v1Body.toString())
|
||||||
|
if (code1 in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
|
||||||
|
|
||||||
|
// v1 without index.php
|
||||||
|
val (code1b, resp1b) = httpRequest("$base/api/qso", "POST", apiKey, v1Body.toString())
|
||||||
|
if (code1b in 200..299) return@withContext WavelogResult.Success("已上传 (v1)")
|
||||||
|
|
||||||
|
WavelogResult.Failure("上传失败: v2 HTTP $code, v1 HTTP $code1 — ${shortError(resp1.ifBlank { resp1b })}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** v1 ADIF string (freq in MHz, length = UTF-8 byte count, sat_name normalized) */
|
||||||
|
private fun toAdif(qso: WavelogQso, gridsquare: String, satName: String): String {
|
||||||
|
fun field(name: String, value: String): String {
|
||||||
|
val bytes = value.toByteArray(Charsets.UTF_8).size
|
||||||
|
return "<$name:$bytes>$value"
|
||||||
|
}
|
||||||
|
return buildString {
|
||||||
|
append(field("call", qso.call))
|
||||||
|
append(field("band", "SAT"))
|
||||||
|
append(field("mode", qso.mode))
|
||||||
|
append(field("freq", String.format(Locale.ENGLISH, "%.6f", qso.freqTxHz / 1_000_000.0)))
|
||||||
|
if (qso.freqRxHz > 0) {
|
||||||
|
append(field("freq_rx", String.format(Locale.ENGLISH, "%.6f", qso.freqRxHz / 1_000_000.0)))
|
||||||
|
}
|
||||||
|
append(field("qso_date", utcDateCompact(qso.timeUtcMs)))
|
||||||
|
append(field("time_on", utcTimeCompact(qso.timeUtcMs)))
|
||||||
|
append(field("rst_sent", "59"))
|
||||||
|
append(field("rst_rcvd", "59"))
|
||||||
|
if (gridsquare.isNotBlank()) append(field("gridsquare", gridsquare.take(4)))
|
||||||
|
if (satName.isNotBlank()) {
|
||||||
|
append(field("sat_name", satName))
|
||||||
|
append(field("prop_mode", "SAT"))
|
||||||
|
}
|
||||||
|
append("<eor>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generic HTTP request (returns code + body) */
|
||||||
|
private fun httpRequest(url: String, method: String, apiKey: String, jsonBody: String?): Pair<Int, String> {
|
||||||
|
return try {
|
||||||
|
val conn = URL(url).openConnection() as HttpURLConnection
|
||||||
|
conn.requestMethod = method
|
||||||
|
conn.connectTimeout = TIMEOUT_MS
|
||||||
|
conn.readTimeout = TIMEOUT_MS
|
||||||
|
if (apiKey.isNotBlank()) conn.setRequestProperty("Authorization", "Bearer $apiKey")
|
||||||
|
if (jsonBody != null) {
|
||||||
|
conn.doOutput = true
|
||||||
|
conn.setRequestProperty("Content-Type", "application/json")
|
||||||
|
conn.setRequestProperty("Accept", "application/json")
|
||||||
|
OutputStreamWriter(conn.outputStream, Charsets.UTF_8).use { it.write(jsonBody) }
|
||||||
|
}
|
||||||
|
val code = conn.responseCode
|
||||||
|
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
|
||||||
|
val body = if (stream != null) {
|
||||||
|
BufferedReader(InputStreamReader(stream, Charsets.UTF_8)).use { it.readText() }
|
||||||
|
} else ""
|
||||||
|
code to body
|
||||||
|
} catch (e: Exception) {
|
||||||
|
-1 to (e.message ?: e.javaClass.simpleName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shortError(body: String): String {
|
||||||
|
if (body.startsWith("<")) return body.take(80) // HTML error page
|
||||||
|
return try {
|
||||||
|
val obj = JSONObject(body)
|
||||||
|
val err = obj.optJSONObject("error")
|
||||||
|
err?.optString("message")?.ifBlank { body.take(120) }
|
||||||
|
?: obj.optString("reason").ifBlank { obj.optString("message").ifBlank { body.take(120) } }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
body.take(120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun utcDate(ms: Long): String {
|
||||||
|
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||||
|
cal.timeInMillis = ms
|
||||||
|
return "%04d-%02d-%02d".format(
|
||||||
|
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
|
||||||
|
cal.get(java.util.Calendar.DAY_OF_MONTH)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun utcTime(ms: Long): String {
|
||||||
|
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||||
|
cal.timeInMillis = ms
|
||||||
|
return "%02d:%02d:%02d".format(
|
||||||
|
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
|
||||||
|
cal.get(java.util.Calendar.SECOND)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun utcDateCompact(ms: Long): String {
|
||||||
|
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||||
|
cal.timeInMillis = ms
|
||||||
|
return "%04d%02d%02d".format(
|
||||||
|
cal.get(java.util.Calendar.YEAR), cal.get(java.util.Calendar.MONTH) + 1,
|
||||||
|
cal.get(java.util.Calendar.DAY_OF_MONTH)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun utcTimeCompact(ms: Long): String {
|
||||||
|
val cal = java.util.Calendar.getInstance(java.util.TimeZone.getTimeZone("UTC"))
|
||||||
|
cal.timeInMillis = ms
|
||||||
|
return "%02d%02d%02d".format(
|
||||||
|
cal.get(java.util.Calendar.HOUR_OF_DAY), cal.get(java.util.Calendar.MINUTE),
|
||||||
|
cal.get(java.util.Calendar.SECOND)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/*
|
||||||
|
* WavelogQueue.kt - WaveLog local log queue (4.5.2).
|
||||||
|
*
|
||||||
|
* Pure Kotlin (no Android deps): storage goes through the IWavelogQueueStore interface,
|
||||||
|
* implemented with SharedPreferences in core/data.
|
||||||
|
* Queue capped at 500 entries (oldest dropped beyond that).
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.wavelog
|
||||||
|
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/** Storage abstraction (SharedPreferences impl lives in core/data) */
|
||||||
|
interface IWavelogQueueStore {
|
||||||
|
fun load(): String
|
||||||
|
fun save(json: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** QSO entry awaiting upload (local queue element, mirrors POST /api/v2/qso fields) */
|
||||||
|
data class WavelogQso(
|
||||||
|
val id: String, // 本地唯一 id(UUID)
|
||||||
|
val timeUtcMs: Long, // 回车时刻 UTC 毫秒(本地显示 + 组装 qso_date/time_on)
|
||||||
|
val call: String,
|
||||||
|
val mode: String,
|
||||||
|
val freqTxHz: Long, // 上行(回车那一秒多普勒修正)
|
||||||
|
val freqRxHz: Long, // 下行
|
||||||
|
val satName: String,
|
||||||
|
val sessionId: String = "", // 场次 ID: 卫星名-AOS 时间戳(过境仰角 0 秒), 空=未分组(旧数据)
|
||||||
|
val gridsquare: String = "", // 对方网格(QRZ 爬虫填入, 4.5.5), 空=未查到
|
||||||
|
val uploaded: Boolean = false // 是否已成功上传(4.5.2 修复: 成功后保留标记, 表格打勾)
|
||||||
|
)
|
||||||
|
|
||||||
|
class WavelogQueue(private val store: IWavelogQueueStore) {
|
||||||
|
|
||||||
|
private val key = "wavelog_queue"
|
||||||
|
|
||||||
|
fun all(): List<WavelogQso> {
|
||||||
|
val raw = store.load()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(raw)
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
WavelogQso(
|
||||||
|
id = o.getString("id"),
|
||||||
|
timeUtcMs = o.getLong("timeUtcMs"),
|
||||||
|
call = o.optString("call"),
|
||||||
|
mode = o.optString("mode"),
|
||||||
|
freqTxHz = o.optLong("freqTxHz"),
|
||||||
|
freqRxHz = o.optLong("freqRxHz"),
|
||||||
|
satName = o.optString("satName"),
|
||||||
|
sessionId = o.optString("sessionId"),
|
||||||
|
gridsquare = o.optString("gridsquare"),
|
||||||
|
uploaded = o.optBoolean("uploaded", false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun add(qso: WavelogQso) {
|
||||||
|
val list = all().toMutableList()
|
||||||
|
list.add(0, qso) // 最新在前
|
||||||
|
if (list.size > 500) list.removeAt(list.size - 1)
|
||||||
|
save(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun remove(id: String) {
|
||||||
|
save(all().filter { it.id != id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun removeAll(ids: Set<String>) {
|
||||||
|
save(all().filter { it.id !in ids })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark as uploaded (kept in the queue; checkmark in the table) */
|
||||||
|
@Synchronized
|
||||||
|
fun markUploaded(id: String) {
|
||||||
|
save(all().map { if (it.id == id) it.copy(uploaded = true) else it })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a QSO's counterpart grid (async backfill from the QRZ scraper, 4.5.5) */
|
||||||
|
fun updateGridsquare(id: String, grid: String) {
|
||||||
|
save(all().map { if (it.id == id) it.copy(gridsquare = grid) else it })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove all uploaded entries (optional; keeps the queue lean) */
|
||||||
|
@Synchronized
|
||||||
|
fun removeUploaded() {
|
||||||
|
save(all().filter { !it.uploaded })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun save(list: List<WavelogQso>) {
|
||||||
|
val arr = JSONArray()
|
||||||
|
list.forEach { q ->
|
||||||
|
arr.put(JSONObject().apply {
|
||||||
|
put("id", q.id); put("timeUtcMs", q.timeUtcMs); put("call", q.call)
|
||||||
|
put("mode", q.mode); put("freqTxHz", q.freqTxHz)
|
||||||
|
put("freqRxHz", q.freqRxHz); put("satName", q.satName)
|
||||||
|
put("sessionId", q.sessionId)
|
||||||
|
put("gridsquare", q.gridsquare)
|
||||||
|
put("uploaded", q.uploaded)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
store.save(arr.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
@@ -0,0 +1,92 @@
|
|||||||
|
/*
|
||||||
|
* WavelogUploader.kt - WaveLog queue upload scheduler (4.5.2).
|
||||||
|
*
|
||||||
|
* Manual/periodic uploads share: per-batch grid check (user QTH first 4 chars vs station grid first 4 chars)
|
||||||
|
* -> POST /api/v2/qso -> success removes from the queue.
|
||||||
|
* Grid mismatch: returns NeedConfirm (UI dialog "Ignore and upload / Cancel"),
|
||||||
|
* retrying the batch with force=true once confirmed.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.wavelog
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
sealed class UploadOutcome {
|
||||||
|
data class NeedConfirm(val stationGrid: String, val userGrid: String) : UploadOutcome()
|
||||||
|
data class Done(
|
||||||
|
val successCount: Int,
|
||||||
|
val failedCount: Int,
|
||||||
|
val message: String,
|
||||||
|
val firstError: String = ""
|
||||||
|
) : UploadOutcome()
|
||||||
|
}
|
||||||
|
|
||||||
|
class WavelogUploader(
|
||||||
|
private val settingsRepo: ISettingsRepo,
|
||||||
|
private val queue: WavelogQueue
|
||||||
|
) {
|
||||||
|
|
||||||
|
// Station grid cache (refreshed before each upload; stale value kept on failure)
|
||||||
|
private var cachedStationGrid: String? = null
|
||||||
|
|
||||||
|
/** Upload the whole queue. force=true skips the grid confirm (user chose "Ignore and upload") */
|
||||||
|
suspend fun uploadQueue(force: Boolean = false): UploadOutcome {
|
||||||
|
val settings = settingsRepo.otherSettings.value
|
||||||
|
val url = settings.wavelogUrl
|
||||||
|
val apiKey = settings.wavelogApiKey
|
||||||
|
val stationId = settings.wavelogStationId
|
||||||
|
if (url.isBlank() || apiKey.isBlank() || stationId.isBlank()) {
|
||||||
|
return UploadOutcome.Done(0, queue.all().size, "未配置 WaveLog 服务器")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Fetch station info (station grid); fall back to user QTH when v1 lacks the endpoint
|
||||||
|
val stationGrid = getStationGrid(url, apiKey, stationId) ?: userQthGrid()
|
||||||
|
if (stationGrid.isNullOrBlank()) {
|
||||||
|
return UploadOutcome.Done(0, queue.all().size, "无法获取站点信息(检查站点 ID/密钥权限)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Grid check: cloud station grid first 4 chars vs current station QTH first 4 chars
|
||||||
|
// (guards against a misconfigured station; unrelated to the QSO counterpart grid - per user)
|
||||||
|
if (!force) {
|
||||||
|
val userGrid = userQthGrid()
|
||||||
|
if (userGrid != null && stationGrid.take(4).lowercase() != userGrid.take(4).lowercase()) {
|
||||||
|
return UploadOutcome.NeedConfirm(stationGrid, userGrid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Upload one by one. ADIF gridsquare = counterpart grid (the QSO partner); blank until the scraper lands
|
||||||
|
val entries = queue.all()
|
||||||
|
var ok = 0
|
||||||
|
var fail = 0
|
||||||
|
var firstError = ""
|
||||||
|
for (qso in entries) {
|
||||||
|
if (qso.uploaded) { ok++; continue }
|
||||||
|
val result = WaveLogApi.postQso(url, apiKey, stationId, qso, qso.gridsquare)
|
||||||
|
if (result is WavelogResult.Success) {
|
||||||
|
ok++
|
||||||
|
queue.markUploaded(qso.id)
|
||||||
|
} else {
|
||||||
|
fail++
|
||||||
|
if (firstError.isBlank()) firstError = (result as? WavelogResult.Failure)?.message ?: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val message = if (fail == 0) "成功上传 $ok 条" else "成功 $ok 条, 失败 $fail 条(保留待重试)"
|
||||||
|
return UploadOutcome.Done(ok, fail, message, firstError)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun getStationGrid(url: String, apiKey: String, stationId: String): String? {
|
||||||
|
val result = WaveLogApi.getStation(url, apiKey, stationId)
|
||||||
|
if (result is WavelogResult.Success) {
|
||||||
|
return try {
|
||||||
|
JSONObject(result.message).optString("gridsquare").takeIf { it.isNotBlank() }
|
||||||
|
?: cachedStationGrid
|
||||||
|
} catch (_: Exception) { cachedStationGrid }
|
||||||
|
}
|
||||||
|
return cachedStationGrid
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User's current QTH grid (first 4 chars; null when no QTH = check skipped) */
|
||||||
|
private fun userQthGrid(): String? {
|
||||||
|
return settingsRepo.stationPosition.value.qthLocator?.takeIf { it.length >= 4 }
|
||||||
|
}
|
||||||
|
}
|
||||||
+76
-4
@@ -13,12 +13,15 @@ class DopplerFrequencyCalculatorTest {
|
|||||||
upHigh: Long = 145_500_000L,
|
upHigh: Long = 145_500_000L,
|
||||||
downLow: Long = 435_000_000L,
|
downLow: Long = 435_000_000L,
|
||||||
downHigh: Long? = 435_500_000L,
|
downHigh: Long? = 435_500_000L,
|
||||||
inverted: Boolean = false
|
inverted: Boolean = false,
|
||||||
|
info: String = "Linear Transponder",
|
||||||
|
downlinkMode: String? = "USB",
|
||||||
|
uplinkMode: String? = "LSB"
|
||||||
) = SatRadio(
|
) = SatRadio(
|
||||||
uuid = "linear", info = "Linear Transponder", isAlive = true,
|
uuid = "linear", info = info, isAlive = true,
|
||||||
downlinkLow = downLow, downlinkHigh = downHigh,
|
downlinkLow = downLow, downlinkHigh = downHigh,
|
||||||
downlinkMode = "USB", uplinkLow = upLow, uplinkHigh = upHigh,
|
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
|
||||||
uplinkMode = "LSB", isInverted = inverted, catnum = 12345
|
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun fmTransponder() = SatRadio(
|
private fun fmTransponder() = SatRadio(
|
||||||
@@ -48,6 +51,28 @@ class DopplerFrequencyCalculatorTest {
|
|||||||
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr))
|
assertFalse(DopplerFrequencyCalculator.isLinearTransponder(xpdr))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsTrueForLinearTransponderName() {
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(linearTransponder()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsTrueForSsbTransponderName() {
|
||||||
|
val xpdr = linearTransponder(info = "Mode V/U SSB Transponder", downlinkMode = "USB", uplinkMode = "LSB")
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(xpdr))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsFalseForRangeEntryWithoutTransponderName() {
|
||||||
|
val driftingRangeEntry = linearTransponder(info = "Upper side band (drifting)")
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun computeUplinkFromDownlink_linear_noDoppler() {
|
fun computeUplinkFromDownlink_linear_noDoppler() {
|
||||||
val xpdr = linearTransponder()
|
val xpdr = linearTransponder()
|
||||||
@@ -93,6 +118,53 @@ class DopplerFrequencyCalculatorTest {
|
|||||||
assertNull(result)
|
assertNull(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeDownlinkFromUplink_withPositiveOffset_addsOffsetToDownlink() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||||
|
uplinkHz = 145_200_000L,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = 2_500L
|
||||||
|
)
|
||||||
|
assertEquals(435_202_500L, downlink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeUplinkFromDownlink_withPositiveOffset_subtractsOffsetBeforeMapping() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||||
|
downlinkHz = 435_202_500L,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = 2_500L
|
||||||
|
)
|
||||||
|
assertEquals(145_200_000L, uplink)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun computeOffsetRoundTrip_handlesNegativeOffset() {
|
||||||
|
val xpdr = linearTransponder()
|
||||||
|
val orbitalPos = pos(0.0)
|
||||||
|
val downlink = DopplerFrequencyCalculator.computeDownlinkFromUplinkWithOffset(
|
||||||
|
uplinkHz = 145_200_000L,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = -2_500L
|
||||||
|
)
|
||||||
|
assertEquals(435_197_500L, downlink)
|
||||||
|
|
||||||
|
val uplink = DopplerFrequencyCalculator.computeUplinkFromDownlinkWithOffset(
|
||||||
|
downlinkHz = downlink!!,
|
||||||
|
transponder = xpdr,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
offsetHz = -2_500L
|
||||||
|
)
|
||||||
|
assertEquals(145_200_000L, uplink)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun computeUplinkFromDownlink_invertedTransponder() {
|
fun computeUplinkFromDownlink_invertedTransponder() {
|
||||||
val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
|
val xpdr = linearTransponder(inverted = true, downHigh = 435_500_000L)
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ class QthConverterTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Given square returns correct 3x3 neighbors`() {
|
fun `Given square returns correct 3x3 neighbors`() {
|
||||||
// Reference grid from the QTH定位器 screenshot: OL42
|
// Reference grid from the QTH Locator screenshot: OL42
|
||||||
val neighbors = qthNeighbors("OL42")
|
val neighbors = qthNeighbors("OL42")
|
||||||
assert(neighbors == listOf(
|
assert(neighbors == listOf(
|
||||||
"OL33", "OL43", "OL53",
|
"OL33", "OL43", "OL53",
|
||||||
|
|||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
/*
|
||||||
|
* 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.core.domain.cw
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the fldigi-ported CW decoder. Generates synthetic CW audio
|
||||||
|
* (600 Hz tone, 8 kHz sample rate) and verifies the decoded text.
|
||||||
|
*/
|
||||||
|
class CwFldigiDecoderTest {
|
||||||
|
|
||||||
|
private val sampleRate = 8000
|
||||||
|
private val toneFreq = 600.0
|
||||||
|
|
||||||
|
/** Synthesize a CW audio buffer for a dot-dash pattern at given WPM. */
|
||||||
|
private fun synthPattern(pattern: String, wpm: Int): FloatArray {
|
||||||
|
val dotLen = CwFldigiConstants.KWPM / wpm // samples per dot
|
||||||
|
val samples = ArrayList<Float>()
|
||||||
|
for ((idx, ch) in pattern.withIndex()) {
|
||||||
|
val len = if (ch == '.') dotLen else 3 * dotLen
|
||||||
|
for (i in 0 until len) {
|
||||||
|
val t = i.toDouble() / sampleRate
|
||||||
|
samples.add((0.6 * sin(2.0 * PI * toneFreq * t)).toFloat())
|
||||||
|
}
|
||||||
|
if (idx < pattern.length - 1) {
|
||||||
|
for (i in 0 until dotLen) samples.add(0f) // intra-char gap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return samples.toFloatArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Append an inter-character gap (3 dot lengths) of silence. */
|
||||||
|
private fun interCharGap(wpm: Int): FloatArray {
|
||||||
|
val dotLen = CwFldigiConstants.KWPM / wpm
|
||||||
|
return FloatArray(3 * dotLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeSequence(chars: List<String>, wpm: Int, useSom: Boolean = true): String {
|
||||||
|
val decoder = CwFldigiDecoder(sampleRate = sampleRate, frequency = toneFreq, useSom = useSom)
|
||||||
|
val dotLen = CwFldigiConstants.KWPM / wpm
|
||||||
|
for ((ci, pattern) in chars.withIndex()) {
|
||||||
|
decoder.processBuffer(synthPattern(pattern, wpm))
|
||||||
|
decoder.processBuffer(interCharGap(wpm))
|
||||||
|
}
|
||||||
|
// trailing silence to flush the last char
|
||||||
|
decoder.processBuffer(FloatArray(8 * dotLen))
|
||||||
|
return decoder.decodedTextFlow.value
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decode_basicLetter_singleDot() {
|
||||||
|
val decoder = CwFldigiDecoder(sampleRate = sampleRate, frequency = toneFreq)
|
||||||
|
decoder.processBuffer(synthPattern(".", 18))
|
||||||
|
decoder.processBuffer(FloatArray(8 * CwFldigiConstants.KWPM / 18))
|
||||||
|
assertTrue("expected E in output, got: ${decoder.decodedTextFlow.value}",
|
||||||
|
decoder.decodedTextFlow.value.startsWith("E"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decode_word_CQ_18wpm() {
|
||||||
|
val text = decodeSequence(listOf("-.-.", "--.-"), 18)
|
||||||
|
assertTrue("expected CQ in output, got: $text", text.contains("C") && text.contains("Q"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decode_hello_20wpm() {
|
||||||
|
val text = decodeSequence(listOf("....", ".", ".-..", ".-..", "---"), 20)
|
||||||
|
assertTrue("expected HELLO in output, got: $text", text.contains("H"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decode_adaptive_speed_30wpm() {
|
||||||
|
// Realistic usage: decoder configured near the signal speed, tracking
|
||||||
|
// fine-tunes from there. Verify correct decode at 30 wpm.
|
||||||
|
val decoder = CwFldigiDecoder(sampleRate = sampleRate, frequency = toneFreq, initialWpm = 30)
|
||||||
|
val dotLen = CwFldigiConstants.KWPM / 30
|
||||||
|
for (pattern in listOf(".-", "-...", "-.-.", "-..", ".", "..-.", "--.", "....", "..", ".---")) {
|
||||||
|
decoder.processBuffer(synthPattern(pattern, 30))
|
||||||
|
decoder.processBuffer(interCharGap(30))
|
||||||
|
}
|
||||||
|
decoder.processBuffer(FloatArray(8 * dotLen))
|
||||||
|
val text = decoder.decodedTextFlow.value
|
||||||
|
// First char may be off while tracking converges; rest must be exact.
|
||||||
|
assertTrue("expected B-J at 30wpm, got: $text", text.contains("B") && text.contains("J"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reset_clearsState() {
|
||||||
|
val decoder = CwFldigiDecoder(sampleRate = sampleRate, frequency = toneFreq)
|
||||||
|
decoder.processBuffer(synthPattern(".", 18))
|
||||||
|
decoder.processBuffer(FloatArray(8 * CwFldigiConstants.KWPM / 18))
|
||||||
|
decoder.resetDecoder()
|
||||||
|
assertEquals("", decoder.decodedTextFlow.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun morseTable_lookup() {
|
||||||
|
assertEquals("A", MorseTable.rxLookup(".-"))
|
||||||
|
assertEquals("S", MorseTable.rxLookup("..."))
|
||||||
|
assertEquals("1", MorseTable.rxLookup(".----"))
|
||||||
|
assertEquals("?", MorseTable.rxLookup("..--.."))
|
||||||
|
assertEquals("", MorseTable.rxLookup("........"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun somTable_hasAllAsciiLetters() {
|
||||||
|
val patterns = SomTable.table.map { it.pattern }.toSet()
|
||||||
|
val letters = listOf(".-", "-...", "-.-.", "-..", ".", "..-.", "--.", "....",
|
||||||
|
"..", ".---", "-.-", ".-..", "--", "-.", "---", ".--.", "--.-", ".-.",
|
||||||
|
"...", "-", "..-", "...-", ".--", "-..-", "-.--", "--..")
|
||||||
|
for (l in letters) {
|
||||||
|
assertTrue("SOM table missing letter pattern $l", patterns.contains(l))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fftFilter_lowpass_passesTone() {
|
||||||
|
val filt = CwFftFilt(600.0 / 8000.0, 2048)
|
||||||
|
// 600 Hz tone -> should pass
|
||||||
|
val out = arrayListOf<Double>()
|
||||||
|
for (i in 0 until 4096) {
|
||||||
|
val t = i.toDouble() / 8000.0
|
||||||
|
val z = CwComplex(0.5 * sin(2.0 * PI * 600.0 * t), 0.0)
|
||||||
|
filt.run(z)?.let { o -> for (j in 0 until 1024) out.add(o[j].abs()) }
|
||||||
|
}
|
||||||
|
val maxVal = out.maxOrNull() ?: 0.0
|
||||||
|
assertTrue("expected signal to pass through lowpass, max=$maxVal", maxVal > 0.05)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-2
@@ -518,6 +518,5 @@ fun formatFrequency(frequencyHz: Long): String {
|
|||||||
if (frequencyHz <= 0) return "---"
|
if (frequencyHz <= 0) return "---"
|
||||||
val mhz = frequencyHz / 1_000_000
|
val mhz = frequencyHz / 1_000_000
|
||||||
val khz = (frequencyHz % 1_000_000) / 1_000
|
val khz = (frequencyHz % 1_000_000) / 1_000
|
||||||
val hz = frequencyHz % 1_000
|
return String.format(Locale.ENGLISH, "%d.%03d", mhz, khz)
|
||||||
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
|
|
||||||
}
|
}
|
||||||
+23
-8
@@ -21,30 +21,45 @@ import androidx.navigation3.runtime.NavKey
|
|||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
|
sealed class Screen(val iconResId: Int, val titleResId: Int, val screenId: String) : NavKey {
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat)
|
data object Satellites : Screen(R.drawable.ic_satellites, R.string.nav_sat, "Satellites")
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass)
|
data object Passes : Screen(R.drawable.ic_passes, R.string.nav_pass, "Passes")
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar)
|
data object Radar : Screen(R.drawable.ic_radar, R.string.nav_radar, "Radar")
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
|
data object Map : Screen(R.drawable.ic_map, R.string.nav_map, "Map")
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Mutual : Screen(R.drawable.ic_match, R.string.nav_mutual)
|
data object Mutual : Screen(R.drawable.ic_match, R.string.nav_mutual, "Mutual")
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Roaming : Screen(R.drawable.ic_roaming, R.string.nav_roaming)
|
data object Roaming : Screen(R.drawable.ic_roaming, R.string.nav_roaming, "Roaming")
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
|
data object CwDecode : Screen(R.drawable.ic_cw, R.string.nav_cw, "CwDecode")
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object WavelogLog : Screen(R.drawable.ic_log, R.string.nav_log, "WavelogLog")
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object AmSat : Screen(R.drawable.ic_satellites, R.string.nav_amsat, "AMSAT")
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs, "Settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UI settings: default main-menu page order (5 bottom-bar slots)
|
||||||
|
val defaultScreenOrder = listOf("Satellites", "Passes", "Radar", "Map", "Settings")
|
||||||
|
|
||||||
|
// UI settings: default More-menu order (pages behind "More")
|
||||||
|
val defaultSubMenuOrder = listOf("Mutual", "Roaming", "CwDecode", "WavelogLog", "AMSAT")
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object RadarDestination : NavKey
|
data object RadarDestination : NavKey
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<!-- Morse style icon: a dot above a dash -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M 8 4 L 16 4 L 16 8 L 8 8 Z" />
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M 3 16 L 21 16 L 21 20 L 3 20 Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M6,2h9l5,5v13c0,1.1 -0.9,2 -2,2H6c-1.1,0 -2,-0.9 -2,-2V4c0,-1.1 0.9,-2 2,-2zM14,3.5V8h4.5L14,3.5zM8,12h8v2H8v-2zM8,16h8v2H8v-2z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M12,6v3l4,-4 -4,-4v3c-4.42,0 -8,3.58 -8,8 0,1.57 0.46,3.03 1.24,4.26L6.7,14.8c-0.45,-0.83 -0.7,-1.79 -0.7,-2.8 0,-3.31 2.69,-6 6,-6zM18.76,7.74L17.3,9.2c0.44,0.84 0.7,1.79 0.7,2.8 0,3.31 -2.69,6 -6,6v-3l-4,4 4,4v-3c4.42,0 8,-3.58 8,-8 0,-1.57 -0.46,-3.03 -1.24,-4.26z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name" translatable="false">Look4Sat Pro</string>
|
||||||
|
<string name="btn_accept">Terima</string>
|
||||||
|
<string name="btn_cancel">Batal</string>
|
||||||
|
<string name="empty_list_message">Tidak ada data untuk ditampilkan</string>
|
||||||
|
<string name="nav_sat">Satelit</string>
|
||||||
|
<string name="nav_pass">Lintasan</string>
|
||||||
|
<string name="nav_radar">Radar</string>
|
||||||
|
<string name="nav_mutual">Pasangan</string>
|
||||||
|
<string name="nav_roaming">Jelajah</string>
|
||||||
|
<string name="nav_cw">Dekoder CW</string>
|
||||||
|
<string name="nav_map">Peta</string>
|
||||||
|
<string name="nav_prefs">Pengaturan</string>
|
||||||
|
<string name="prefs_aprs_title">Pelaporan APRS</string>
|
||||||
|
<string name="prefs_aprs_enable">Aktifkan pelaporan APRS</string>
|
||||||
|
<string name="prefs_aprs_summary">Server: %1$s:%2$d Panggilan: %3$s</string>
|
||||||
|
<string name="prefs_aprs_report_now">Lapor Sekarang</string>
|
||||||
|
<string name="prefs_aprs_settings_title">Pengaturan APRS</string>
|
||||||
|
<string name="prefs_aprs_server">Server</string>
|
||||||
|
<string name="prefs_aprs_port">Port</string>
|
||||||
|
<string name="prefs_aprs_callsign">Tanda panggilan</string>
|
||||||
|
<string name="prefs_aprs_ssid">SSID</string>
|
||||||
|
<string name="prefs_aprs_passcode">Kode sandi</string>
|
||||||
|
<string name="prefs_aprs_passcode_hint">Kosong = otomatis dari panggilan</string>
|
||||||
|
<string name="prefs_aprs_interval">Interval (menit)</string>
|
||||||
|
<string name="prefs_aprs_status">Teks status</string>
|
||||||
|
<string name="prefs_aprs_symbol_table">Tabel simbol</string>
|
||||||
|
<string name="prefs_aprs_symbol_code">Kode simbol</string>
|
||||||
|
<string name="prefs_aprs_save">Simpan</string>
|
||||||
|
<string name="prefs_aprs_cancel">Batal</string>
|
||||||
|
<string name="aprs_notif_channel">Layanan APRS</string>
|
||||||
|
<string name="aprs_notif_title">APRS: %1$s</string>
|
||||||
|
<string name="aprs_notif_running">Pelaporan berjalan</string>
|
||||||
|
<string name="aprs_notif_connected">Terhubung ke APRS-IS</string>
|
||||||
|
<string name="aprs_notif_error">Kesalahan koneksi</string>
|
||||||
|
<string name="aprs_notif_stop">Berhenti</string>
|
||||||
|
<string name="prefs_aprs_calc_passcode">Hitung kode sandi</string>
|
||||||
|
<string name="aprs_toast_ok">APRS: laporan terkirim</string>
|
||||||
|
<string name="aprs_toast_fail">APRS: laporan gagal - %1$s</string>
|
||||||
|
<string name="aprs_toast_not_configured">APRS belum dikonfigurasi - isi panggilan dulu</string>
|
||||||
|
<string name="prefs_aprs_last_report">Laporan terakhir: %1$s %2$s</string>
|
||||||
|
<string name="prefs_aprs_last_ok">OK</string>
|
||||||
|
<string name="prefs_aprs_last_fail">gagal - %1$s</string>
|
||||||
|
<string name="nav_more">Lainnya</string>
|
||||||
|
<string name="nav_log">Log</string>
|
||||||
|
<string name="nav_amsat">AMSAT</string>
|
||||||
|
<string name="amsat_title">Status Satelit AMSAT</string>
|
||||||
|
<string name="amsat_refresh">Segarkan</string>
|
||||||
|
<string name="amsat_retry">Coba lagi</string>
|
||||||
|
<string name="amsat_updated">Diperbarui:</string>
|
||||||
|
<string name="amsat_active">Aktif</string>
|
||||||
|
<string name="amsat_tlm">TLM/beacon</string>
|
||||||
|
<string name="amsat_not_heard">Tidak terdengar</string>
|
||||||
|
<string name="amsat_conflict">Konflik</string>
|
||||||
|
<string name="amsat_name">Name</string>
|
||||||
|
<string name="amsat_load_failed">Gagal memuat - periksa jaringan dan coba lagi</string>
|
||||||
|
<string name="amsat_no_reports">Tidak ada laporan untuk periode ini</string>
|
||||||
|
<string name="amsat_close">Tutup</string>
|
||||||
|
|
||||||
|
<string name="sat_type_hint">Jenis: %s</string>
|
||||||
|
<string name="sat_type_title">Pilih jenis satelit</string>
|
||||||
|
<string name="sat_search_hint">Id - Nama</string>
|
||||||
|
<string name="sat_search_clear">Bersihkan</string>
|
||||||
|
<string name="sat_clear_all">Bersihkan semua</string>
|
||||||
|
<string name="sat_select_all">Pilih semua</string>
|
||||||
|
<string name="sat_empty_list_message">Pastikan kata kunci pencarian Anda benar dan DB sudah diperbarui</string>
|
||||||
|
<string name="sat_warning_title">Peringatan\!</string>
|
||||||
|
<string name="sat_warning_message">
|
||||||
|
Ada lebih dari 9000 satelit yang terdaftar di aplikasi ini.
|
||||||
|
Tidak masuk akal untuk melacak semuanya sekaligus.
|
||||||
|
\n\nSelalu coba persempit daftar hanya ke satelit yang
|
||||||
|
Anda minati melalui pencarian dan pemilih jenis.</string>
|
||||||
|
<string name="pass_filter_title">Filter lintasan</string>
|
||||||
|
<string name="pass_filter_elev">Elevasi minimal</string>
|
||||||
|
<string name="pass_filter_hours">Waktu ke depan</string>
|
||||||
|
<string name="pass_filter_aos_time">Jendela AOS</string>
|
||||||
|
<string name="pass_filter_invert_time">Balik jendela AOS</string>
|
||||||
|
<string name="pass_filter_deep_space">DeepSpace (periode >225 mnt)</string>
|
||||||
|
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
||||||
|
<string name="pass_modes_title">Pilih jenis modulasi</string>
|
||||||
|
<string name="pass_satId" translatable="false">%05d</string>
|
||||||
|
<string name="pass_elevation">Elevasi: %.1f°</string>
|
||||||
|
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
||||||
|
<string name="pass_altitude">Ketinggian: %d km</string>
|
||||||
|
<string name="pass_aosLos" translatable="false">%03d° - %03d°</string>
|
||||||
|
<string name="pass_empty_list_message">
|
||||||
|
Pastikan filter Anda benar dan Anda sudah memilih satelit</string>
|
||||||
|
<string name="pass_error_message">Tidak ada lintasan satelit mendatang untuk ditampilkan</string>
|
||||||
|
<string name="pass_welcome_title">Selamat datang di Look4Sat\!</string>
|
||||||
|
<string name="pass_welcome_message">
|
||||||
|
Pastikan Anda mengatur posisi melalui GPS, Lat/Lon atau QTH di Pengaturan.
|
||||||
|
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
|
||||||
|
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
|
||||||
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
|
* BARU: Status satelit AMSAT kini menggunakan API resmi AMSAT (v1 JSON, lebih cepat dan andal)
|
||||||
|
* Halaman status AMSAT kini mendukung layar penuh (tombol refresh dan waktu selalu dapat diakses)
|
||||||
|
* WaveLog: pencarian grid lawan QRZ (dialog ⚙ tempel Cookie + uji query)
|
||||||
|
* WaveLog: tombol perbarui daftar satelit LoTW (112 satelit bawaan)
|
||||||
|
* WaveLog: laporan sinyal RST 59/59 untuk QSO (v1 dan v2)
|
||||||
|
</string>
|
||||||
|
<string name="radar_back">Kembali</string>
|
||||||
|
<string name="radar_notify">Beri tahu</string>
|
||||||
|
<string name="radar_az_text">Azimuth</string>
|
||||||
|
<string name="radar_az_value" translatable="false">%.1f°</string>
|
||||||
|
<string name="radar_el_text">Elevasi</string>
|
||||||
|
<string name="radar_el_value" translatable="false">%.1f°</string>
|
||||||
|
<string name="radar_alt_text">Ketinggian</string>
|
||||||
|
<string name="radar_alt_value" translatable="false">%.0f km</string>
|
||||||
|
<string name="radar_dist_text">Jarak</string>
|
||||||
|
<string name="radar_dist_value" translatable="false">%.0f km</string>
|
||||||
|
<string name="radar_eclipsed">Gerhana</string>
|
||||||
|
<string name="radar_downlink">Downlink</string>
|
||||||
|
<string name="radar_uplink">Uplink</string>
|
||||||
|
<string name="radar_link_low" translatable="false">%.4f</string>
|
||||||
|
<string name="radar_link_lowHigh" translatable="false">%.4f - %.4f</string>
|
||||||
|
<string name="radar_no_link" translatable="false">- - . - -</string>
|
||||||
|
<string name="radar_no_data">Satelit ini tidak memiliki transceiver</string>
|
||||||
|
<string name="radar_add">Tambah</string>
|
||||||
|
<string name="radar_data">Transceiver satelit:</string>
|
||||||
|
<string name="radar_inverted">Terbalik: %s</string>
|
||||||
|
<string name="radar_mode">Mode: %s</string>
|
||||||
|
<string name="radar_string_no">Tidak</string>
|
||||||
|
<string name="radar_string_yes">Ya</string>
|
||||||
|
<string name="radar_visible">Terlihat</string>
|
||||||
|
<string name="radar_doppler_calc">Kalkulator Frekuensi Doppler</string>
|
||||||
|
<string name="radar_doppler_tx_hint">Masukkan frekuensi TX (MHz)</string>
|
||||||
|
<string name="radar_doppler_rx_hint">Masukkan frekuensi RX (MHz)</string>
|
||||||
|
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
|
||||||
|
<string name="radar_doppler_info">Untuk transponder linier, ketik satu frekuensi untuk melihat frekuensi lainnya</string>
|
||||||
|
<string name="radar_cw_decoder">Dekoder CW</string>
|
||||||
|
<string name="radar_cw_start">Mulai</string>
|
||||||
|
<string name="radar_cw_stop">Berhenti</string>
|
||||||
|
<string name="radar_cw_reset">Bersihkan</string>
|
||||||
|
<string name="map_prev">Sebelumnya</string>
|
||||||
|
<string name="map_next">Berikutnya</string>
|
||||||
|
<string name="map_copyright" translatable="false">© Kontributor OpenStreetMap</string>
|
||||||
|
<string name="map_azimuth">Azimuth: %.1f°</string>
|
||||||
|
<string name="map_elevation">Elevasi: %.1f°</string>
|
||||||
|
<string name="map_altitude">Ketinggian: %.0f km</string>
|
||||||
|
<string name="map_distance">Jarak: %.0f km</string>
|
||||||
|
<string name="map_latitude">Lintang: %.1f°</string>
|
||||||
|
<string name="map_longitude">Bujur: %.1f°</string>
|
||||||
|
<string name="map_qth" translatable="false">QTH: %s</string>
|
||||||
|
<string name="map_phase">Fase: %.1f°</string>
|
||||||
|
<string name="map_eclipsed">Gerhana</string>
|
||||||
|
<string name="map_period">Periode: %.0f mnt</string>
|
||||||
|
<string name="map_velocity">Kecepatan: %.2f km/d</string>
|
||||||
|
<string name="map_visibility">Visibilitas: %s</string>
|
||||||
|
<string name="map_visible">Terlihat</string>
|
||||||
|
<string name="prefs_app_title" translatable="false">Look4Sat Pro 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_donate_title">Donasi</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_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</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_license_title" translatable="false">GPLv3</string>
|
||||||
|
<string name="prefs_license_url" translatable="false">https://www.gnu.org/licenses/gpl-3.0.html</string>
|
||||||
|
<string name="prefs_privacy_title">Kebijakan Privasi</string>
|
||||||
|
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
|
||||||
|
<string name="prefs_updated_title">Diperbarui: %s</string>
|
||||||
|
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
||||||
|
<string name="prefs_loc_title">Posisi stasiun</string>
|
||||||
|
<string name="prefs_loc_gps_title">GPS</string>
|
||||||
|
<string name="prefs_loc_gps_error">Tidak dapat mengambil lokasi - periksa izin dan sinyal GPS/jaringan</string>
|
||||||
|
<string name="prefs_loc_input_title">Input</string>
|
||||||
|
<string name="prefs_loc_input_error">Lokasi yang dimasukkan tidak valid</string>
|
||||||
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
|
<string name="prefs_loc_qth_error">Lokator QTH tidak valid</string>
|
||||||
|
<string name="prefs_loc_success">Posisi berhasil diperbarui</string>
|
||||||
|
<string name="prefs_station_title">Pengaturan posisi stasiun</string>
|
||||||
|
<string name="prefs_station_lat_text">Atur garis lintang stasiun Anda</string>
|
||||||
|
<string name="prefs_station_lon_text">Atur garis bujur stasiun Anda</string>
|
||||||
|
<string name="prefs_locator_title">Pengaturan lokator QTH</string>
|
||||||
|
<string name="prefs_locator_text">Atur posisi stasiun menggunakan lokator</string>
|
||||||
|
<string name="prefs_data_title">Data satelit</string>
|
||||||
|
<string name="prefs_data_entries">Satelit: %s</string>
|
||||||
|
<string name="prefs_data_radios">Transceiver: %s</string>
|
||||||
|
<string name="prefs_data_update">Perbarui</string>
|
||||||
|
<string name="prefs_data_import">URL Kustom</string>
|
||||||
|
<string name="prefs_data_clear">Bersihkan</string>
|
||||||
|
<string name="prefs_data_clear_success">Data berhasil dibersihkan</string>
|
||||||
|
<string name="prefs_data_update_success">Pembaruan berhasil diselesaikan</string>
|
||||||
|
<string name="prefs_data_update_failed">Pembaruan gagal - periksa jaringan Anda</string>
|
||||||
|
<string name="prefs_data_import_satellites_error">Tidak ada satelit yang diimpor. Pilih file TLE/3LE (.txt) atau OMM (.csv) yang valid.</string>
|
||||||
|
<string name="prefs_data_import_transceivers_error">Tidak ada transceiver yang diimpor. Pilih file SatNOGS (.json) yang valid.</string>
|
||||||
|
<string name="prefs_data_sources_title">Sumber data kustom</string>
|
||||||
|
<string name="prefs_data_sources_tle_switch">URL TLE Kustom</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_switch">URL transceiver kustom</string>
|
||||||
|
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
|
<string name="prefs_data_reset_url">Setel ulang ke URL bawaan</string>
|
||||||
|
<string name="prefs_data_output_title">Keluaran data</string>
|
||||||
|
<string name="prefs_net_output">Jaringan</string>
|
||||||
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
|
<string name="prefs_cat_output">CAT</string>
|
||||||
|
<string name="nav_radiocontrol">Kontrol Radio</string>
|
||||||
|
<string name="rc_settings_title">Kontrol Radio CAT</string>
|
||||||
|
<string name="rc_radio_model">Model Radio</string>
|
||||||
|
<string name="rc_tx_device_hint">Alamat BT Radio TX</string>
|
||||||
|
<string name="rc_rx_device_hint">Alamat BT Radio RX</string>
|
||||||
|
<string name="rc_tx_name_hint">Nama Radio TX</string>
|
||||||
|
<string name="rc_rx_name_hint">Nama Radio RX</string>
|
||||||
|
<string name="rc_enable_switch">Aktifkan Kontrol CAT</string>
|
||||||
|
<string name="prefs_net_title">Keluaran data jaringan</string>
|
||||||
|
<string name="prefs_net_rotator_switch">Aktifkan keluaran rotator</string>
|
||||||
|
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
||||||
|
<string name="prefs_net_rotator_format_hint">Format</string>
|
||||||
|
<string name="prefs_net_frequency_switch">Aktifkan keluaran frekuensi</string>
|
||||||
|
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||||
|
<string name="prefs_net_frequency_format_hint">Format</string>
|
||||||
|
<string name="prefs_bt_title">Keluaran data Bluetooth</string>
|
||||||
|
<string name="prefs_bt_rotator_switch">Aktifkan keluaran rotator</string>
|
||||||
|
<string name="prefs_bt_rotator_device_hint">Id perangkat</string>
|
||||||
|
<string name="prefs_bt_rotator_output_hint">Format data</string>
|
||||||
|
<string name="prefs_bt_frequency_switch">Aktifkan keluaran frekuensi</string>
|
||||||
|
<string name="prefs_bt_frequency_device_hint">Id perangkat</string>
|
||||||
|
<string name="prefs_bt_frequency_output_hint">Format data</string>
|
||||||
|
<string name="prefs_bt_perm_error">Periksa izin bluetooth Anda</string>
|
||||||
|
<string name="prefs_net_perm_error">Periksa izin jaringan Anda</string>
|
||||||
|
<string name="tracking_status">Melacak: %s</string>
|
||||||
|
<string name="loc_lat_text">Lintang: %s°</string>
|
||||||
|
<string name="loc_lon_text">Bujur: %s°</string>
|
||||||
|
<string name="loc_qth_text">QTH: %s</string>
|
||||||
|
<string name="connect_radio">Hubungkan</string>
|
||||||
|
<string name="disconnect_radio">Putuskan</string>
|
||||||
|
<string name="track_radio">Lacak</string>
|
||||||
|
<string name="stop_radio">Berhenti</string>
|
||||||
|
<string name="prefs_wavelog_title">Server log WaveLog</string>
|
||||||
|
<string name="prefs_wavelog_url_hint">Alamat server</string>
|
||||||
|
<string name="prefs_wavelog_key_hint">Kunci API</string>
|
||||||
|
<string name="prefs_wavelog_station_hint">ID stasiun</string>
|
||||||
|
<string name="prefs_wavelog_auto">Unggah otomatis</string>
|
||||||
|
<string name="prefs_wavelog_test">Uji koneksi</string>
|
||||||
|
<string name="prefs_wavelog_update_sats">Perbarui satelit</string>
|
||||||
|
<string name="prefs_wavelog_upload">Unggah sekarang</string>
|
||||||
|
<string name="wavelog_title">Log</string>
|
||||||
|
<string name="wavelog_select_transponder">Pilih transponder</string>
|
||||||
|
<string name="wavelog_call_hint">Tanda panggil</string>
|
||||||
|
<string name="wavelog_mode_hint">Mode</string>
|
||||||
|
<string name="wavelog_saved">Tersimpan ke log lokal</string>
|
||||||
|
<string name="wavelog_need_config">Konfigurasikan WaveLog di Pengaturan terlebih dahulu</string>
|
||||||
|
<string name="wavelog_empty">Belum ada entri log</string>
|
||||||
|
<string name="wavelog_grid_mismatch">Grid QTH (%1$s) tidak cocok dengan grid stasiun (%2$s). Tetap unggah?</string>
|
||||||
|
<string name="wavelog_ignore">Abaikan dan unggah</string>
|
||||||
|
<string name="wavelog_cancel">Batal</string>
|
||||||
|
<string name="prefs_wavelog_qrz_title">QRZ Cookie</string>
|
||||||
|
<string name="prefs_wavelog_qrz_hint">Tempel cookie login QRZ Anda di sini (ekspor EditThisCookie atau string "k=v; k=v"). Digunakan untuk mengambil grid square stasiun lawan saat unggah.</string>
|
||||||
|
<string name="prefs_wavelog_qrz_label">Cookie</string>
|
||||||
|
<string name="wavelog_copy">Salin</string>
|
||||||
|
<string name="wavelog_delete_undo">Urungkan</string>
|
||||||
|
<string name="wavelog_col_time">Waktu</string>
|
||||||
|
<string name="wavelog_col_freq">Frek.</string>
|
||||||
|
<string name="wavelog_col_sat">Satelit</string>
|
||||||
|
<string name="wavelog_col_call">Panggilan</string>
|
||||||
|
<string name="wavelog_col_uploaded">Terkirim</string>
|
||||||
|
<string name="prefs_other_title">Pengaturan lainnya</string>
|
||||||
|
<string name="prefs_ui_title">Pengaturan UI</string>
|
||||||
|
<string name="prefs_ui_order_title">Urutan halaman</string>
|
||||||
|
<string name="prefs_ui_main_title">Menu utama (bilah bawah)</string>
|
||||||
|
<string name="prefs_ui_sub_title">Menu lainnya</string>
|
||||||
|
<string name="prefs_ui_move_out">Pindah ke menu lainnya</string>
|
||||||
|
<string name="prefs_ui_move_back">Kembalikan ke menu utama</string>
|
||||||
|
<string name="prefs_ui_order_locked">Tetap (tidak dapat dipindahkan)</string>
|
||||||
|
<string name="prefs_ui_order_reset">Setel ulang urutan</string>
|
||||||
|
<string name="prefs_other_switch_utc">Tampilkan waktu lintasan dalam UTC</string>
|
||||||
|
<string name="prefs_other_switch_update">Aktifkan pembaruan data otomatis</string>
|
||||||
|
<string name="prefs_other_switch_sweep">Aktifkan animasi sapuan radar</string>
|
||||||
|
<string name="prefs_other_switch_sensors">Gunakan sensor untuk memutar tampilan radar</string>
|
||||||
|
<string name="prefs_other_switch_night_mode">Aktifkan filter mode malam merah</string>
|
||||||
|
<string name="prefs_highlight_title">Sorotan elevasi</string>
|
||||||
|
<string name="prefs_highlight_low">Rendah < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">Sedang %1$d–%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">Tinggi > %1$d°</string>
|
||||||
|
<string name="prefs_outro_title">BG7NTA & penulis asli mengucapkan terima kasih</string>
|
||||||
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
|
* Pengguna dan kontributor Look4Sat!
|
||||||
|
\n* David A. B. Johnson (predict4java)
|
||||||
|
\n* Dave Moten (predict4java)
|
||||||
|
\n* Alexandru Csete (Gpredict)
|
||||||
|
\n* Dr T.S. Kelso (Celestrak)
|
||||||
|
\n* Libre Space Foundation (SatNOGS)
|
||||||
|
\n* xdsopl dan kontributor Robot36!
|
||||||
|
\n* BA7OPF (fitur pencocokan lintasan)
|
||||||
|
\n* BG7NTA</string>
|
||||||
|
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name" translatable="false">Look4Sat Pro</string>
|
||||||
|
<string name="btn_accept">Terima</string>
|
||||||
|
<string name="btn_cancel">Batal</string>
|
||||||
|
<string name="empty_list_message">Tidak ada data untuk ditampilkan</string>
|
||||||
|
<string name="nav_sat">Satelit</string>
|
||||||
|
<string name="nav_pass">Lintasan</string>
|
||||||
|
<string name="nav_radar">Radar</string>
|
||||||
|
<string name="nav_mutual">Pasangan</string>
|
||||||
|
<string name="nav_roaming">Jelajah</string>
|
||||||
|
<string name="nav_cw">Dekoder CW</string>
|
||||||
|
<string name="nav_map">Peta</string>
|
||||||
|
<string name="nav_prefs">Pengaturan</string>
|
||||||
|
<string name="prefs_aprs_title">Pelaporan APRS</string>
|
||||||
|
<string name="prefs_aprs_enable">Aktifkan pelaporan APRS</string>
|
||||||
|
<string name="prefs_aprs_summary">Server: %1$s:%2$d Panggilan: %3$s</string>
|
||||||
|
<string name="prefs_aprs_report_now">Lapor Sekarang</string>
|
||||||
|
<string name="prefs_aprs_settings_title">Pengaturan APRS</string>
|
||||||
|
<string name="prefs_aprs_server">Server</string>
|
||||||
|
<string name="prefs_aprs_port">Port</string>
|
||||||
|
<string name="prefs_aprs_callsign">Tanda panggilan</string>
|
||||||
|
<string name="prefs_aprs_ssid">SSID</string>
|
||||||
|
<string name="prefs_aprs_passcode">Kode sandi</string>
|
||||||
|
<string name="prefs_aprs_passcode_hint">Kosong = otomatis dari panggilan</string>
|
||||||
|
<string name="prefs_aprs_interval">Interval (menit)</string>
|
||||||
|
<string name="prefs_aprs_status">Teks status</string>
|
||||||
|
<string name="prefs_aprs_symbol_table">Tabel simbol</string>
|
||||||
|
<string name="prefs_aprs_symbol_code">Kode simbol</string>
|
||||||
|
<string name="prefs_aprs_save">Simpan</string>
|
||||||
|
<string name="prefs_aprs_cancel">Batal</string>
|
||||||
|
<string name="aprs_notif_channel">Layanan APRS</string>
|
||||||
|
<string name="aprs_notif_title">APRS: %1$s</string>
|
||||||
|
<string name="aprs_notif_running">Pelaporan berjalan</string>
|
||||||
|
<string name="aprs_notif_connected">Terhubung ke APRS-IS</string>
|
||||||
|
<string name="aprs_notif_error">Kesalahan koneksi</string>
|
||||||
|
<string name="aprs_notif_stop">Berhenti</string>
|
||||||
|
<string name="prefs_aprs_calc_passcode">Hitung kode sandi</string>
|
||||||
|
<string name="aprs_toast_ok">APRS: laporan terkirim</string>
|
||||||
|
<string name="aprs_toast_fail">APRS: laporan gagal - %1$s</string>
|
||||||
|
<string name="aprs_toast_not_configured">APRS belum dikonfigurasi - isi panggilan dulu</string>
|
||||||
|
<string name="prefs_aprs_last_report">Laporan terakhir: %1$s %2$s</string>
|
||||||
|
<string name="prefs_aprs_last_ok">OK</string>
|
||||||
|
<string name="prefs_aprs_last_fail">gagal - %1$s</string>
|
||||||
|
<string name="nav_more">Lainnya</string>
|
||||||
|
<string name="nav_log">Log</string>
|
||||||
|
<string name="nav_amsat">AMSAT</string>
|
||||||
|
<string name="amsat_title">Status Satelit AMSAT</string>
|
||||||
|
<string name="amsat_refresh">Segarkan</string>
|
||||||
|
<string name="amsat_retry">Coba lagi</string>
|
||||||
|
<string name="amsat_updated">Diperbarui:</string>
|
||||||
|
<string name="amsat_active">Aktif</string>
|
||||||
|
<string name="amsat_tlm">TLM/beacon</string>
|
||||||
|
<string name="amsat_not_heard">Tidak terdengar</string>
|
||||||
|
<string name="amsat_conflict">Konflik</string>
|
||||||
|
<string name="amsat_name">Name</string>
|
||||||
|
<string name="amsat_load_failed">Gagal memuat - periksa jaringan dan coba lagi</string>
|
||||||
|
<string name="amsat_no_reports">Tidak ada laporan untuk periode ini</string>
|
||||||
|
<string name="amsat_close">Tutup</string>
|
||||||
|
<string name="sat_type_hint">Jenis: %s</string>
|
||||||
|
<string name="sat_type_title">Pilih jenis satelit</string>
|
||||||
|
<string name="sat_search_hint">Id - Nama</string>
|
||||||
|
<string name="sat_search_clear">Bersihkan</string>
|
||||||
|
<string name="sat_clear_all">Bersihkan semua</string>
|
||||||
|
<string name="sat_select_all">Pilih semua</string>
|
||||||
|
<string name="sat_empty_list_message">Pastikan kata kunci pencarian Anda benar dan DB sudah diperbarui</string>
|
||||||
|
<string name="sat_warning_title">Peringatan\!</string>
|
||||||
|
<string name="sat_warning_message">
|
||||||
|
Ada lebih dari 9000 satelit yang terdaftar di aplikasi ini.
|
||||||
|
Tidak masuk akal untuk melacak semuanya sekaligus.
|
||||||
|
\n\nSelalu coba persempit daftar hanya ke satelit yang
|
||||||
|
Anda minati melalui pencarian dan pemilih jenis.</string>
|
||||||
|
<string name="pass_filter_title">Filter lintasan</string>
|
||||||
|
<string name="pass_filter_elev">Elevasi minimal</string>
|
||||||
|
<string name="pass_filter_hours">Waktu ke depan</string>
|
||||||
|
<string name="pass_filter_aos_time">Jendela AOS</string>
|
||||||
|
<string name="pass_filter_invert_time">Balik jendela AOS</string>
|
||||||
|
<string name="pass_filter_deep_space">DeepSpace (periode >225 mnt)</string>
|
||||||
|
<string name="pass_time_placeholder" translatable="false"> -- : -- : -- </string>
|
||||||
|
<string name="pass_modes_title">Pilih jenis modulasi</string>
|
||||||
|
<string name="pass_satId" translatable="false">%05d</string>
|
||||||
|
<string name="pass_elevation">Elevasi: %.1f°</string>
|
||||||
|
<string name="pass_deep_space" translatable="false">DeepSpace</string>
|
||||||
|
<string name="pass_altitude">Ketinggian: %d km</string>
|
||||||
|
<string name="pass_aosLos" translatable="false">%03d° - %03d°</string>
|
||||||
|
<string name="pass_empty_list_message">
|
||||||
|
Pastikan filter Anda benar dan Anda sudah memilih satelit</string>
|
||||||
|
<string name="pass_error_message">Tidak ada lintasan satelit mendatang untuk ditampilkan</string>
|
||||||
|
<string name="pass_welcome_title">Selamat datang di Look4Sat\!</string>
|
||||||
|
<string name="pass_welcome_message">
|
||||||
|
Pastikan Anda mengatur posisi melalui GPS, Lat/Lon atau QTH di Pengaturan.
|
||||||
|
\n\nPerbarui database setidaknya seminggu sekali untuk prediksi yang akurat.</string>
|
||||||
|
<string name="pass_whatsnew_title" translatable="false">Apa yang baru di Look4Sat</string>
|
||||||
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
|
* BARU: Status satelit AMSAT kini menggunakan API resmi AMSAT (v1 JSON, lebih cepat dan andal)
|
||||||
|
* Halaman status AMSAT kini mendukung layar penuh (tombol refresh dan waktu selalu dapat diakses)
|
||||||
|
* WaveLog: pencarian grid lawan QRZ (dialog ⚙ tempel Cookie + uji query)
|
||||||
|
* WaveLog: tombol perbarui daftar satelit LoTW (112 satelit bawaan)
|
||||||
|
* WaveLog: laporan sinyal RST 59/59 untuk QSO (v1 dan v2)
|
||||||
|
</string>
|
||||||
|
<string name="radar_back">Kembali</string>
|
||||||
|
<string name="radar_notify">Beri tahu</string>
|
||||||
|
<string name="radar_az_text">Azimuth</string>
|
||||||
|
<string name="radar_az_value" translatable="false">%.1f°</string>
|
||||||
|
<string name="radar_el_text">Elevasi</string>
|
||||||
|
<string name="radar_el_value" translatable="false">%.1f°</string>
|
||||||
|
<string name="radar_alt_text">Ketinggian</string>
|
||||||
|
<string name="radar_alt_value" translatable="false">%.0f km</string>
|
||||||
|
<string name="radar_dist_text">Jarak</string>
|
||||||
|
<string name="radar_dist_value" translatable="false">%.0f km</string>
|
||||||
|
<string name="radar_eclipsed">Gerhana</string>
|
||||||
|
<string name="radar_downlink">Downlink</string>
|
||||||
|
<string name="radar_uplink">Uplink</string>
|
||||||
|
<string name="radar_link_low" translatable="false">%.4f</string>
|
||||||
|
<string name="radar_link_lowHigh" translatable="false">%.4f - %.4f</string>
|
||||||
|
<string name="radar_no_link" translatable="false">- - . - -</string>
|
||||||
|
<string name="radar_no_data">Satelit ini tidak memiliki transceiver</string>
|
||||||
|
<string name="radar_add">Tambah</string>
|
||||||
|
<string name="radar_data">Transceiver satelit:</string>
|
||||||
|
<string name="radar_inverted">Terbalik: %s</string>
|
||||||
|
<string name="radar_mode">Mode: %s</string>
|
||||||
|
<string name="radar_string_no">Tidak</string>
|
||||||
|
<string name="radar_string_yes">Ya</string>
|
||||||
|
<string name="radar_visible">Terlihat</string>
|
||||||
|
<string name="radar_doppler_calc">Kalkulator Frekuensi Doppler</string>
|
||||||
|
<string name="radar_doppler_tx_hint">Masukkan frekuensi TX (MHz)</string>
|
||||||
|
<string name="radar_doppler_rx_hint">Masukkan frekuensi RX (MHz)</string>
|
||||||
|
<string name="radar_doppler_offset_hint">Offset (kHz)</string>
|
||||||
|
<string name="radar_doppler_info">Untuk transponder linier, ketik satu frekuensi untuk melihat frekuensi lainnya</string>
|
||||||
|
<string name="radar_cw_decoder">Dekoder CW</string>
|
||||||
|
<string name="radar_cw_start">Mulai</string>
|
||||||
|
<string name="radar_cw_stop">Berhenti</string>
|
||||||
|
<string name="radar_cw_reset">Bersihkan</string>
|
||||||
|
<string name="map_prev">Sebelumnya</string>
|
||||||
|
<string name="map_next">Berikutnya</string>
|
||||||
|
<string name="map_copyright" translatable="false">© Kontributor OpenStreetMap</string>
|
||||||
|
<string name="map_azimuth">Azimuth: %.1f°</string>
|
||||||
|
<string name="map_elevation">Elevasi: %.1f°</string>
|
||||||
|
<string name="map_altitude">Ketinggian: %.0f km</string>
|
||||||
|
<string name="map_distance">Jarak: %.0f km</string>
|
||||||
|
<string name="map_latitude">Lintang: %.1f°</string>
|
||||||
|
<string name="map_longitude">Bujur: %.1f°</string>
|
||||||
|
<string name="map_qth" translatable="false">QTH: %s</string>
|
||||||
|
<string name="map_phase">Fase: %.1f°</string>
|
||||||
|
<string name="map_eclipsed">Gerhana</string>
|
||||||
|
<string name="map_period">Periode: %.0f mnt</string>
|
||||||
|
<string name="map_velocity">Kecepatan: %.2f km/d</string>
|
||||||
|
<string name="map_visibility">Visibilitas: %s</string>
|
||||||
|
<string name="map_visible">Terlihat</string>
|
||||||
|
<string name="prefs_app_title" translatable="false">Look4Sat Pro 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_donate_title">Donasi</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_url" translatable="false">https://f-droid.org/en/packages/com.rtbishop.look4sat/</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_license_title" translatable="false">GPLv3</string>
|
||||||
|
<string name="prefs_license_url" translatable="false">https://www.gnu.org/licenses/gpl-3.0.html</string>
|
||||||
|
<string name="prefs_privacy_title">Kebijakan Privasi</string>
|
||||||
|
<string name="prefs_privacy_url" translatable="false">https://sites.google.com/view/look4sat-privacy-policy/home</string>
|
||||||
|
<string name="prefs_updated_title">Diperbarui: %s</string>
|
||||||
|
<string name="prefs_updated_time">d MMM yyyy - HH:mm:ss</string>
|
||||||
|
<string name="prefs_loc_title">Posisi stasiun</string>
|
||||||
|
<string name="prefs_loc_gps_title">GPS</string>
|
||||||
|
<string name="prefs_loc_gps_error">Tidak dapat mengambil lokasi - periksa izin dan sinyal GPS/jaringan</string>
|
||||||
|
<string name="prefs_loc_input_title">Input</string>
|
||||||
|
<string name="prefs_loc_input_error">Lokasi yang dimasukkan tidak valid</string>
|
||||||
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
|
<string name="prefs_loc_qth_error">Lokator QTH tidak valid</string>
|
||||||
|
<string name="prefs_loc_success">Posisi berhasil diperbarui</string>
|
||||||
|
<string name="prefs_station_title">Pengaturan posisi stasiun</string>
|
||||||
|
<string name="prefs_station_lat_text">Atur garis lintang stasiun Anda</string>
|
||||||
|
<string name="prefs_station_lon_text">Atur garis bujur stasiun Anda</string>
|
||||||
|
<string name="prefs_locator_title">Pengaturan lokator QTH</string>
|
||||||
|
<string name="prefs_locator_text">Atur posisi stasiun menggunakan lokator</string>
|
||||||
|
<string name="prefs_data_title">Data satelit</string>
|
||||||
|
<string name="prefs_data_entries">Satelit: %s</string>
|
||||||
|
<string name="prefs_data_radios">Transceiver: %s</string>
|
||||||
|
<string name="prefs_data_update">Perbarui</string>
|
||||||
|
<string name="prefs_data_import">URL Kustom</string>
|
||||||
|
<string name="prefs_data_clear">Bersihkan</string>
|
||||||
|
<string name="prefs_data_clear_success">Data berhasil dibersihkan</string>
|
||||||
|
<string name="prefs_data_update_success">Pembaruan berhasil diselesaikan</string>
|
||||||
|
<string name="prefs_data_update_failed">Pembaruan gagal - periksa jaringan Anda</string>
|
||||||
|
<string name="prefs_data_import_satellites_error">Tidak ada satelit yang diimpor. Pilih file TLE/3LE (.txt) atau OMM (.csv) yang valid.</string>
|
||||||
|
<string name="prefs_data_import_transceivers_error">Tidak ada transceiver yang diimpor. Pilih file SatNOGS (.json) yang valid.</string>
|
||||||
|
<string name="prefs_data_sources_title">Sumber data kustom</string>
|
||||||
|
<string name="prefs_data_sources_tle_switch">URL TLE Kustom</string>
|
||||||
|
<string name="prefs_data_sources_transceivers_switch">URL transceiver kustom</string>
|
||||||
|
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
|
<string name="prefs_data_reset_url">Setel ulang ke URL bawaan</string>
|
||||||
|
<string name="prefs_data_output_title">Keluaran data</string>
|
||||||
|
<string name="prefs_net_output">Jaringan</string>
|
||||||
|
<string name="prefs_bt_output">Bluetooth</string>
|
||||||
|
<string name="prefs_cat_output">CAT</string>
|
||||||
|
<string name="nav_radiocontrol">Kontrol Radio</string>
|
||||||
|
<string name="rc_settings_title">Kontrol Radio CAT</string>
|
||||||
|
<string name="rc_radio_model">Model Radio</string>
|
||||||
|
<string name="rc_tx_device_hint">Alamat BT Radio TX</string>
|
||||||
|
<string name="rc_rx_device_hint">Alamat BT Radio RX</string>
|
||||||
|
<string name="rc_tx_name_hint">Nama Radio TX</string>
|
||||||
|
<string name="rc_rx_name_hint">Nama Radio RX</string>
|
||||||
|
<string name="rc_enable_switch">Aktifkan Kontrol CAT</string>
|
||||||
|
<string name="prefs_net_title">Keluaran data jaringan</string>
|
||||||
|
<string name="prefs_net_rotator_switch">Aktifkan keluaran rotator</string>
|
||||||
|
<string name="prefs_net_rotator_address_hint">IP:Port</string>
|
||||||
|
<string name="prefs_net_rotator_format_hint">Format</string>
|
||||||
|
<string name="prefs_net_frequency_switch">Aktifkan keluaran frekuensi</string>
|
||||||
|
<string name="prefs_net_frequency_address_hint">IP:Port</string>
|
||||||
|
<string name="prefs_net_frequency_format_hint">Format</string>
|
||||||
|
<string name="prefs_bt_title">Keluaran data Bluetooth</string>
|
||||||
|
<string name="prefs_bt_rotator_switch">Aktifkan keluaran rotator</string>
|
||||||
|
<string name="prefs_bt_rotator_device_hint">Id perangkat</string>
|
||||||
|
<string name="prefs_bt_rotator_output_hint">Format data</string>
|
||||||
|
<string name="prefs_bt_frequency_switch">Aktifkan keluaran frekuensi</string>
|
||||||
|
<string name="prefs_bt_frequency_device_hint">Id perangkat</string>
|
||||||
|
<string name="prefs_bt_frequency_output_hint">Format data</string>
|
||||||
|
<string name="prefs_bt_perm_error">Periksa izin bluetooth Anda</string>
|
||||||
|
<string name="prefs_net_perm_error">Periksa izin jaringan Anda</string>
|
||||||
|
<string name="tracking_status">Melacak: %s</string>
|
||||||
|
<string name="loc_lat_text">Lintang: %s°</string>
|
||||||
|
<string name="loc_lon_text">Bujur: %s°</string>
|
||||||
|
<string name="loc_qth_text">QTH: %s</string>
|
||||||
|
<string name="connect_radio">Hubungkan</string>
|
||||||
|
<string name="disconnect_radio">Putuskan</string>
|
||||||
|
<string name="track_radio">Lacak</string>
|
||||||
|
<string name="stop_radio">Berhenti</string>
|
||||||
|
<string name="prefs_wavelog_title">Server log WaveLog</string>
|
||||||
|
<string name="prefs_wavelog_url_hint">Alamat server</string>
|
||||||
|
<string name="prefs_wavelog_key_hint">Kunci API</string>
|
||||||
|
<string name="prefs_wavelog_station_hint">ID stasiun</string>
|
||||||
|
<string name="prefs_wavelog_auto">Unggah otomatis</string>
|
||||||
|
<string name="prefs_wavelog_test">Uji koneksi</string>
|
||||||
|
<string name="prefs_wavelog_update_sats">Perbarui satelit</string>
|
||||||
|
<string name="prefs_wavelog_upload">Unggah sekarang</string>
|
||||||
|
<string name="wavelog_title">Log</string>
|
||||||
|
<string name="wavelog_select_transponder">Pilih transponder</string>
|
||||||
|
<string name="wavelog_call_hint">Tanda panggil</string>
|
||||||
|
<string name="wavelog_mode_hint">Mode</string>
|
||||||
|
<string name="wavelog_saved">Tersimpan ke log lokal</string>
|
||||||
|
<string name="wavelog_need_config">Konfigurasikan WaveLog di Pengaturan terlebih dahulu</string>
|
||||||
|
<string name="wavelog_empty">Belum ada entri log</string>
|
||||||
|
<string name="wavelog_grid_mismatch">Grid QTH (%1$s) tidak cocok dengan grid stasiun (%2$s). Tetap unggah?</string>
|
||||||
|
<string name="wavelog_ignore">Abaikan dan unggah</string>
|
||||||
|
<string name="wavelog_cancel">Batal</string>
|
||||||
|
<string name="prefs_wavelog_qrz_title">QRZ Cookie</string>
|
||||||
|
<string name="prefs_wavelog_qrz_hint">Tempel cookie login QRZ Anda di sini (ekspor EditThisCookie atau string "k=v; k=v"). Digunakan untuk mengambil grid square stasiun lawan saat unggah.</string>
|
||||||
|
<string name="prefs_wavelog_qrz_label">Cookie</string>
|
||||||
|
<string name="wavelog_copy">Salin</string>
|
||||||
|
<string name="wavelog_delete_undo">Urungkan</string>
|
||||||
|
<string name="wavelog_col_time">Waktu</string>
|
||||||
|
<string name="wavelog_col_freq">Frek.</string>
|
||||||
|
<string name="wavelog_col_sat">Satelit</string>
|
||||||
|
<string name="wavelog_col_call">Panggilan</string>
|
||||||
|
<string name="wavelog_col_uploaded">Terkirim</string>
|
||||||
|
<string name="wavelog_ungrouped">Tidak dikelompokkan</string>
|
||||||
|
<string name="prefs_other_title">Pengaturan lainnya</string>
|
||||||
|
<string name="prefs_ui_title">Pengaturan UI</string>
|
||||||
|
<string name="prefs_ui_order_title">Urutan halaman</string>
|
||||||
|
<string name="prefs_ui_main_title">Menu utama (bilah bawah)</string>
|
||||||
|
<string name="prefs_ui_sub_title">Menu lainnya</string>
|
||||||
|
<string name="prefs_ui_move_out">Pindah ke menu lainnya</string>
|
||||||
|
<string name="prefs_ui_move_back">Kembalikan ke menu utama</string>
|
||||||
|
<string name="prefs_ui_order_locked">Tetap (tidak dapat dipindahkan)</string>
|
||||||
|
<string name="prefs_ui_order_reset">Setel ulang urutan</string>
|
||||||
|
<string name="prefs_other_switch_utc">Tampilkan waktu lintasan dalam UTC</string>
|
||||||
|
<string name="prefs_other_switch_update">Aktifkan pembaruan data otomatis</string>
|
||||||
|
<string name="prefs_other_switch_sweep">Aktifkan animasi sapuan radar</string>
|
||||||
|
<string name="prefs_other_switch_sensors">Gunakan sensor untuk memutar tampilan radar</string>
|
||||||
|
<string name="prefs_other_switch_night_mode">Aktifkan filter mode malam merah</string>
|
||||||
|
<string name="prefs_highlight_title">Sorotan elevasi</string>
|
||||||
|
<string name="prefs_highlight_low">Rendah < %1$d°</string>
|
||||||
|
<string name="prefs_highlight_mid">Sedang %1$d–%2$d°</string>
|
||||||
|
<string name="prefs_highlight_high">Tinggi > %1$d°</string>
|
||||||
|
<string name="prefs_outro_title">BG7NTA & penulis asli mengucapkan terima kasih</string>
|
||||||
|
<string name="prefs_outro_thanks" translatable="false">
|
||||||
|
* Pengguna dan kontributor Look4Sat!
|
||||||
|
\n* David A. B. Johnson (predict4java)
|
||||||
|
\n* Dave Moten (predict4java)
|
||||||
|
\n* Alexandru Csete (Gpredict)
|
||||||
|
\n* Dr T.S. Kelso (Celestrak)
|
||||||
|
\n* Libre Space Foundation (SatNOGS)
|
||||||
|
\n* xdsopl dan kontributor Robot36!
|
||||||
|
\n* BA7OPF (fitur pencocokan lintasan)
|
||||||
|
\n* BG7NTA</string>
|
||||||
|
<string name="prefs_outro_license">Aplikasi ini hadir tanpa jaminan</string>
|
||||||
|
</resources>
|
||||||
@@ -10,8 +10,54 @@
|
|||||||
<string name="nav_sat">Uydular</string>
|
<string name="nav_sat">Uydular</string>
|
||||||
<string name="nav_pass">Geçişler</string>
|
<string name="nav_pass">Geçişler</string>
|
||||||
<string name="nav_radar">Radar</string>
|
<string name="nav_radar">Radar</string>
|
||||||
|
<string name="nav_cw">CW Dekoder</string>
|
||||||
<string name="nav_map">Harita</string>
|
<string name="nav_map">Harita</string>
|
||||||
<string name="nav_prefs">Ayarlar</string>
|
<string name="nav_prefs">Ayarlar</string>
|
||||||
|
<string name="prefs_aprs_title">APRS Raporlama</string>
|
||||||
|
<string name="prefs_aprs_enable">APRS raporlamayı etkinleştir</string>
|
||||||
|
<string name="prefs_aprs_summary">Sunucu: %1$s:%2$d Çağrı: %3$s</string>
|
||||||
|
<string name="prefs_aprs_report_now">Şimdi Raporla</string>
|
||||||
|
<string name="prefs_aprs_settings_title">APRS Ayarları</string>
|
||||||
|
<string name="prefs_aprs_server">Sunucu</string>
|
||||||
|
<string name="prefs_aprs_port">Port</string>
|
||||||
|
<string name="prefs_aprs_callsign">Çağrı işareti</string>
|
||||||
|
<string name="prefs_aprs_ssid">SSID</string>
|
||||||
|
<string name="prefs_aprs_passcode">Parola</string>
|
||||||
|
<string name="prefs_aprs_passcode_hint">Parola boş = çağrıdan otomatik hesaplanır</string>
|
||||||
|
<string name="prefs_aprs_interval">Aralık (dakika)</string>
|
||||||
|
<string name="prefs_aprs_status">Durum metni</string>
|
||||||
|
<string name="prefs_aprs_symbol_table">Sembol tablosu</string>
|
||||||
|
<string name="prefs_aprs_symbol_code">Sembol kodu</string>
|
||||||
|
<string name="prefs_aprs_save">Kaydet</string>
|
||||||
|
<string name="prefs_aprs_cancel">İptal</string>
|
||||||
|
<string name="aprs_notif_channel">APRS hizmeti</string>
|
||||||
|
<string name="aprs_notif_title">APRS: %1$s</string>
|
||||||
|
<string name="aprs_notif_running">Raporlama çalışıyor</string>
|
||||||
|
<string name="aprs_notif_connected">APRS-IS bağlı</string>
|
||||||
|
<string name="aprs_notif_error">Bağlantı hatası</string>
|
||||||
|
<string name="aprs_notif_stop">Durdur</string>
|
||||||
|
<string name="prefs_aprs_calc_passcode">Parolayı hesapla</string>
|
||||||
|
<string name="aprs_toast_ok">APRS: rapor gönderildi</string>
|
||||||
|
<string name="aprs_toast_fail">APRS: rapor başarısız - %1$s</string>
|
||||||
|
<string name="aprs_toast_not_configured">APRS yapılandırılmadı - önce çağrı girin</string>
|
||||||
|
<string name="prefs_aprs_last_report">Son rapor: %1$s %2$s</string>
|
||||||
|
<string name="prefs_aprs_last_ok">OK</string>
|
||||||
|
<string name="prefs_aprs_last_fail">başarısız - %1$s</string>
|
||||||
|
<string name="nav_more">Daha Fazla</string>
|
||||||
|
<string name="nav_log">Günlük</string>
|
||||||
|
<string name="nav_amsat">AMSAT</string>
|
||||||
|
<string name="amsat_title">AMSAT Uydu Durumu</string>
|
||||||
|
<string name="amsat_refresh">Yenile</string>
|
||||||
|
<string name="amsat_retry">Tekrar dene</string>
|
||||||
|
<string name="amsat_updated">Güncellendi:</string>
|
||||||
|
<string name="amsat_active">Aktif</string>
|
||||||
|
<string name="amsat_tlm">TLM/işaret</string>
|
||||||
|
<string name="amsat_not_heard">Duyulmadı</string>
|
||||||
|
<string name="amsat_conflict">Çelişkili</string>
|
||||||
|
<string name="amsat_name">Name</string>
|
||||||
|
<string name="amsat_load_failed">Yükleme başarısız - ağı kontrol edip tekrar deneyin</string>
|
||||||
|
<string name="amsat_no_reports">Bu dönem için rapor yok</string>
|
||||||
|
<string name="amsat_close">Kapat</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Uydu türü: %s</string>
|
<string name="sat_type_hint">Uydu türü: %s</string>
|
||||||
@@ -51,11 +97,11 @@
|
|||||||
\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</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* Switched to short-form commands in NetworkReporter, by theojalba
|
* YENİ: AMSAT uydu durumu resmi AMSAT API kullanıyor (v1 JSON, daha hızlı ve güvenilir)
|
||||||
\n* Added CAT radio control for full-duplex SAT operation, by jcmerg
|
* AMSAT durum sayfası tam ekran cihazlara uyumlu (yenileme düğmesi ve saat her zaman erişilebilir)
|
||||||
\n* Added normalized time for passes AOS/LOS, fixed refresh state bug
|
* WaveLog: QRZ karşı grid sorgulama (⚙ diyalogda Cookie yapıştır + test sorgusu)
|
||||||
\n* Added multiple performance tweaks, improved passes calculation
|
* WaveLog: LoTW uydu listesi güncelleme düğmesi (112 uydu yerleşik)
|
||||||
\n* Fixed UTC pass time problem mentioned in issue #201
|
* WaveLog: RST 59/59 sinyal raporu (v1 ve v2)
|
||||||
</string>
|
</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
@@ -103,7 +149,7 @@
|
|||||||
<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 Pro 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=com.rtbishop.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>
|
||||||
@@ -121,7 +167,7 @@
|
|||||||
|
|
||||||
<string name="prefs_loc_title">İstasyon konumu</string>
|
<string name="prefs_loc_title">İstasyon konumu</string>
|
||||||
<string name="prefs_loc_gps_title">GPS</string>
|
<string name="prefs_loc_gps_title">GPS</string>
|
||||||
<string name="prefs_loc_gps_error">Konum izinlerinizi kontrol edin</string>
|
<string name="prefs_loc_gps_error">Konum alınamıyor - izinleri ve GPS/ağ sinyalini kontrol edin</string>
|
||||||
<string name="prefs_loc_input_title">Manuel giriş</string>
|
<string name="prefs_loc_input_title">Manuel giriş</string>
|
||||||
<string name="prefs_loc_input_error">Geçersiz konum girildi</string>
|
<string name="prefs_loc_input_error">Geçersiz konum girildi</string>
|
||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
@@ -137,15 +183,17 @@
|
|||||||
<string name="prefs_data_entries">Uydular: %s</string>
|
<string name="prefs_data_entries">Uydular: %s</string>
|
||||||
<string name="prefs_data_radios">Transceiver\'lar: %s</string>
|
<string name="prefs_data_radios">Transceiver\'lar: %s</string>
|
||||||
<string name="prefs_data_update">Güncelle</string>
|
<string name="prefs_data_update">Güncelle</string>
|
||||||
<string name="prefs_data_import">İçe aktar</string>
|
<string name="prefs_data_import">Özel URL</string>
|
||||||
<string name="prefs_data_clear">Temizle</string>
|
<string name="prefs_data_clear">Temizle</string>
|
||||||
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string>
|
<string name="prefs_data_clear_success">Veriler başarıyla temizlendi</string>
|
||||||
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
|
<string name="prefs_data_update_success">Güncelleme başarıyla tamamlandı</string>
|
||||||
|
<string name="prefs_data_update_failed">Güncelleme başarısız - ağınızı kontrol edin</string>
|
||||||
|
|
||||||
<string name="prefs_data_sources_title">Özel veri kaynakları</string>
|
<string name="prefs_data_sources_title">Özel veri kaynakları</string>
|
||||||
<string name="prefs_data_sources_tle_switch">Özel TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">Özel TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Özel transceiver URL</string>
|
<string name="prefs_data_sources_transceivers_switch">Özel transceiver URL</string>
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
|
<string name="prefs_data_reset_url">Varsayılan URL\'ye sıfırla</string>
|
||||||
|
|
||||||
<string name="prefs_data_output_title">Veri aktarımı</string>
|
<string name="prefs_data_output_title">Veri aktarımı</string>
|
||||||
<string name="prefs_net_output">Ağ</string>
|
<string name="prefs_net_output">Ağ</string>
|
||||||
@@ -180,7 +228,52 @@
|
|||||||
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string>
|
<string name="prefs_bt_perm_error">Bluetooth izninizi kontrol edin</string>
|
||||||
<string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string>
|
<string name="prefs_net_perm_error">Ağ izninizi kontrol edin</string>
|
||||||
|
|
||||||
|
<string name="tracking_status">İzleniyor: %s</string>
|
||||||
|
<string name="loc_lat_text">Enlem: %s°</string>
|
||||||
|
<string name="loc_lon_text">Boylam: %s°</string>
|
||||||
|
<string name="loc_qth_text">QTH: %s</string>
|
||||||
|
<string name="connect_radio">Bağlan</string>
|
||||||
|
<string name="disconnect_radio">Bağlantıyı Kes</string>
|
||||||
|
<string name="track_radio">Takip</string>
|
||||||
|
<string name="stop_radio">Dur</string>
|
||||||
|
<string name="prefs_wavelog_title">WaveLog günlük sunucusu</string>
|
||||||
|
<string name="prefs_wavelog_url_hint">Sunucu adresi</string>
|
||||||
|
<string name="prefs_wavelog_key_hint">API anahtarı</string>
|
||||||
|
<string name="prefs_wavelog_station_hint">İstasyon kimliği</string>
|
||||||
|
<string name="prefs_wavelog_auto">Otomatik yükleme</string>
|
||||||
|
<string name="prefs_wavelog_test">Bağlantıyı test et</string>
|
||||||
|
<string name="prefs_wavelog_update_sats">Uydu güncelle</string>
|
||||||
|
<string name="prefs_wavelog_upload">Şimdi yükle</string>
|
||||||
|
<string name="wavelog_title">Günlük</string>
|
||||||
|
<string name="wavelog_select_transponder">Transponder seç</string>
|
||||||
|
<string name="wavelog_call_hint">Çağrı işareti</string>
|
||||||
|
<string name="wavelog_mode_hint">Mod</string>
|
||||||
|
<string name="wavelog_saved">Yerel günlüğe kaydedildi</string>
|
||||||
|
<string name="wavelog_need_config">Önce Ayarlar\'da WaveLog\'u yapılandırın</string>
|
||||||
|
<string name="wavelog_empty">Henüz günlük kaydı yok</string>
|
||||||
|
<string name="wavelog_grid_mismatch">QTH ızgarası (%1$s) istasyon ızgarasıyla (%2$s) eşleşmiyor. Yine de yüklensin mi?</string>
|
||||||
|
<string name="wavelog_ignore">Yoksay ve yükle</string>
|
||||||
|
<string name="wavelog_cancel">İptal</string>
|
||||||
|
<string name="prefs_wavelog_qrz_title">QRZ Cookie</string>
|
||||||
|
<string name="prefs_wavelog_qrz_hint">QRZ giriş çerezlerinizi buraya yapıştırın (EditThisCookie dışa aktarımı veya "k=v; k=v" biçimi). Yükleme için karşı istasyonun grid karesini getirmekte kullanılır.</string>
|
||||||
|
<string name="prefs_wavelog_qrz_label">Çerez</string>
|
||||||
|
<string name="wavelog_copy">Kopyala</string>
|
||||||
|
<string name="wavelog_delete_undo">Geri al</string>
|
||||||
|
<string name="wavelog_col_time">Zaman</string>
|
||||||
|
<string name="wavelog_col_freq">Frekans</string>
|
||||||
|
<string name="wavelog_col_sat">Uydu</string>
|
||||||
|
<string name="wavelog_col_call">Çağrı</string>
|
||||||
|
<string name="wavelog_col_uploaded">Gönderildi</string>
|
||||||
|
<string name="wavelog_ungrouped">Gruplanmamış</string>
|
||||||
<string name="prefs_other_title">Diğer ayarlar</string>
|
<string name="prefs_other_title">Diğer ayarlar</string>
|
||||||
|
<string name="prefs_ui_title">UI Ayarları</string>
|
||||||
|
<string name="prefs_ui_order_title">Sayfa sırası</string>
|
||||||
|
<string name="prefs_ui_main_title">Ana menü (alt çubuk)</string>
|
||||||
|
<string name="prefs_ui_sub_title">Daha fazla menü</string>
|
||||||
|
<string name="prefs_ui_move_out">Daha fazla menüye taşı</string>
|
||||||
|
<string name="prefs_ui_move_back">Ana menüye geri taşı</string>
|
||||||
|
<string name="prefs_ui_order_locked">Sabit (taşınamaz)</string>
|
||||||
|
<string name="prefs_ui_order_reset">Sıralamayı sıfırla</string>
|
||||||
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
|
<string name="prefs_other_switch_utc">Geçiş saatini UTC olarak göster</string>
|
||||||
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
|
<string name="prefs_other_switch_update">Otomatik veri güncellemeyi etkinleştir</string>
|
||||||
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
|
<string name="prefs_other_switch_sweep">Radar taramasını etkinleştir</string>
|
||||||
|
|||||||
@@ -10,8 +10,55 @@
|
|||||||
<string name="nav_pass">过境</string>
|
<string name="nav_pass">过境</string>
|
||||||
<string name="nav_radar">雷达</string>
|
<string name="nav_radar">雷达</string>
|
||||||
<string name="nav_mutual">匹配</string>
|
<string name="nav_mutual">匹配</string>
|
||||||
|
<string name="nav_roaming">漫游</string>
|
||||||
|
<string name="nav_cw">CW解码</string>
|
||||||
<string name="nav_map">地图</string>
|
<string name="nav_map">地图</string>
|
||||||
<string name="nav_prefs">设置</string>
|
<string name="nav_prefs">设置</string>
|
||||||
|
<string name="prefs_aprs_title">APRS 上报</string>
|
||||||
|
<string name="prefs_aprs_enable">启用 APRS 上报</string>
|
||||||
|
<string name="prefs_aprs_summary">服务器: %1$s:%2$d 呼号: %3$s</string>
|
||||||
|
<string name="prefs_aprs_report_now">立即上报</string>
|
||||||
|
<string name="prefs_aprs_settings_title">APRS 设置</string>
|
||||||
|
<string name="prefs_aprs_server">服务器</string>
|
||||||
|
<string name="prefs_aprs_port">端口</string>
|
||||||
|
<string name="prefs_aprs_callsign">呼号</string>
|
||||||
|
<string name="prefs_aprs_ssid">SSID</string>
|
||||||
|
<string name="prefs_aprs_passcode">验证码</string>
|
||||||
|
<string name="prefs_aprs_passcode_hint">验证码留空 = 按呼号自动计算</string>
|
||||||
|
<string name="prefs_aprs_interval">周期(分钟)</string>
|
||||||
|
<string name="prefs_aprs_status">状态文本</string>
|
||||||
|
<string name="prefs_aprs_symbol_table">符号表</string>
|
||||||
|
<string name="prefs_aprs_symbol_code">符号码</string>
|
||||||
|
<string name="prefs_aprs_save">保存</string>
|
||||||
|
<string name="prefs_aprs_cancel">取消</string>
|
||||||
|
<string name="aprs_notif_channel">APRS 服务</string>
|
||||||
|
<string name="aprs_notif_title">APRS: %1$s</string>
|
||||||
|
<string name="aprs_notif_running">上报运行中</string>
|
||||||
|
<string name="aprs_notif_connected">已连接 APRS-IS</string>
|
||||||
|
<string name="aprs_notif_error">连接错误</string>
|
||||||
|
<string name="aprs_notif_stop">停止</string>
|
||||||
|
<string name="prefs_aprs_calc_passcode">计算验证码</string>
|
||||||
|
<string name="aprs_toast_ok">APRS: 上报成功</string>
|
||||||
|
<string name="aprs_toast_fail">APRS: 上报失败 - %1$s</string>
|
||||||
|
<string name="aprs_toast_not_configured">APRS 未配置,请先填呼号</string>
|
||||||
|
<string name="prefs_aprs_last_report">上次上报: %1$s %2$s</string>
|
||||||
|
<string name="prefs_aprs_last_ok">成功</string>
|
||||||
|
<string name="prefs_aprs_last_fail">失败 - %1$s</string>
|
||||||
|
<string name="nav_more">更多</string>
|
||||||
|
<string name="nav_log">日志</string>
|
||||||
|
<string name="nav_amsat">AMSAT</string>
|
||||||
|
<string name="amsat_title">AMSAT 卫星状态</string>
|
||||||
|
<string name="amsat_refresh">刷新</string>
|
||||||
|
<string name="amsat_retry">重试</string>
|
||||||
|
<string name="amsat_updated">更新:</string>
|
||||||
|
<string name="amsat_active">活跃</string>
|
||||||
|
<string name="amsat_tlm">仅遥测</string>
|
||||||
|
<string name="amsat_not_heard">未听到</string>
|
||||||
|
<string name="amsat_conflict">矛盾</string>
|
||||||
|
<string name="amsat_name">Name</string>
|
||||||
|
<string name="amsat_load_failed">加载失败,请检查网络后重试</string>
|
||||||
|
<string name="amsat_no_reports">该时段无报告</string>
|
||||||
|
<string name="amsat_close">关闭</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">类型:%s</string>
|
<string name="sat_type_hint">类型:%s</string>
|
||||||
@@ -38,6 +85,14 @@
|
|||||||
<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\!</string>
|
||||||
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
<string name="pass_welcome_message">请务必在设置中通过GPS、经纬度或QTH定位您的位置\n建议至少每周更新一次数据库,以确保预测结果的准确性</string>
|
||||||
|
<string name="pass_whatsnew_title">Look4Sat Pro 更新内容</string>
|
||||||
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
|
* 新: AMSAT 卫星状态改用官方 AMSAT API(v1 JSON,更快更稳)
|
||||||
|
* AMSAT 状态页适配全面屏(刷新按钮和更新时间始终可点)
|
||||||
|
* WaveLog: QRZ 对方网格查询(⚙ 弹窗粘贴 Cookie + 测试查询)
|
||||||
|
* WaveLog: LoTW 卫星列表更新按钮(内置 112 颗卫星)
|
||||||
|
* WaveLog: 信号报告 59/59(v1 和 v2)
|
||||||
|
</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
<string name="radar_back">后退</string>
|
<string name="radar_back">后退</string>
|
||||||
@@ -92,28 +147,29 @@
|
|||||||
<string name="prefs_updated_title">更新:%s</string>
|
<string name="prefs_updated_title">更新:%s</string>
|
||||||
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
|
<string name="prefs_updated_time">yyyy MMM d - HH:mm:ss</string>
|
||||||
|
|
||||||
<string name="prefs_loc_title">站位</string>
|
<string name="prefs_loc_title">站点位置</string>
|
||||||
<string name="prefs_loc_gps_title">GPS 定位</string>
|
<string name="prefs_loc_gps_title">GPS 定位</string>
|
||||||
<string name="prefs_loc_gps_error">检查您的位置权限</string>
|
<string name="prefs_loc_gps_error">无法获取位置,请检查定位权限和GPS/网络信号</string>
|
||||||
<string name="prefs_loc_input_title">输入位置</string>
|
<string name="prefs_loc_input_title">输入位置</string>
|
||||||
<string name="prefs_loc_input_error">输入的位置无效</string>
|
<string name="prefs_loc_input_error">输入的位置无效</string>
|
||||||
<string name="prefs_loc_qth_title">QTH 网格</string>
|
<string name="prefs_loc_qth_title">QTH 网格</string>
|
||||||
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
|
<string name="prefs_loc_qth_error">无效的 QTH 网格</string>
|
||||||
<string name="prefs_loc_success">位置更新成功</string>
|
<string name="prefs_loc_success">位置更新成功</string>
|
||||||
<string name="prefs_station_title">站位设置</string>
|
<string name="prefs_station_title">站点位置设置</string>
|
||||||
<string name="prefs_station_lat_text">输入站位的纬度</string>
|
<string name="prefs_station_lat_text">输入站点的纬度</string>
|
||||||
<string name="prefs_station_lon_text">输入站位的经度</string>
|
<string name="prefs_station_lon_text">输入站点的经度</string>
|
||||||
<string name="prefs_locator_title">QTH 网格</string>
|
<string name="prefs_locator_title">QTH 网格</string>
|
||||||
<string name="prefs_locator_text">使用梅登黑德网格设置站位</string>
|
<string name="prefs_locator_text">使用梅登黑德网格设置站点位置</string>
|
||||||
|
|
||||||
<string name="prefs_data_title">卫星数据更新</string>
|
<string name="prefs_data_title">卫星数据更新</string>
|
||||||
<string name="prefs_data_entries">卫星:%s</string>
|
<string name="prefs_data_entries">卫星:%s</string>
|
||||||
<string name="prefs_data_radios">收发器:%s</string>
|
<string name="prefs_data_radios">收发器:%s</string>
|
||||||
<string name="prefs_data_update">在线更新</string>
|
<string name="prefs_data_update">在线更新</string>
|
||||||
<string name="prefs_data_import">导入文件</string>
|
<string name="prefs_data_import">自定义URL</string>
|
||||||
<string name="prefs_data_clear">清空</string>
|
<string name="prefs_data_clear">清空</string>
|
||||||
<string name="prefs_data_clear_success">数据清空成功</string>
|
<string name="prefs_data_clear_success">数据清空成功</string>
|
||||||
<string name="prefs_data_update_success">更新成功</string>
|
<string name="prefs_data_update_success">更新成功</string>
|
||||||
|
<string name="prefs_data_update_failed">更新失败,请检查网络</string>
|
||||||
|
|
||||||
<string name="prefs_data_import_satellites_error">未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。</string>
|
<string name="prefs_data_import_satellites_error">未导入卫星。请选择有效的 TLE/3LE (.txt) 或 OMM (.csv) 文件。</string>
|
||||||
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
|
<string name="prefs_data_import_transceivers_error">未导入收发器。请选择有效的 SatNOGS (.json) 文件。</string>
|
||||||
@@ -155,7 +211,52 @@
|
|||||||
<string name="rc_rx_name_hint">接收电台名称</string>
|
<string name="rc_rx_name_hint">接收电台名称</string>
|
||||||
<string name="rc_enable_switch">启用 CAT 控制</string>
|
<string name="rc_enable_switch">启用 CAT 控制</string>
|
||||||
|
|
||||||
|
<string name="tracking_status">跟踪: %s</string>
|
||||||
|
<string name="loc_lat_text">纬度: %s°</string>
|
||||||
|
<string name="loc_lon_text">经度: %s°</string>
|
||||||
|
<string name="loc_qth_text">QTH: %s</string>
|
||||||
|
<string name="connect_radio">连接</string>
|
||||||
|
<string name="disconnect_radio">断开</string>
|
||||||
|
<string name="track_radio">跟踪</string>
|
||||||
|
<string name="stop_radio">停止</string>
|
||||||
|
<string name="prefs_wavelog_title">WaveLog 日志服务器</string>
|
||||||
|
<string name="prefs_wavelog_url_hint">服务器地址</string>
|
||||||
|
<string name="prefs_wavelog_key_hint">API 密钥</string>
|
||||||
|
<string name="prefs_wavelog_station_hint">站点 ID</string>
|
||||||
|
<string name="prefs_wavelog_auto">自动上传</string>
|
||||||
|
<string name="prefs_wavelog_test">测试连接</string>
|
||||||
|
<string name="prefs_wavelog_update_sats">更新卫星列表</string>
|
||||||
|
<string name="prefs_wavelog_upload">手动上传</string>
|
||||||
|
<string name="wavelog_title">日志</string>
|
||||||
|
<string name="wavelog_select_transponder">选择转发器</string>
|
||||||
|
<string name="wavelog_call_hint">对方呼号</string>
|
||||||
|
<string name="wavelog_mode_hint">模式</string>
|
||||||
|
<string name="wavelog_saved">已存入本地日志</string>
|
||||||
|
<string name="wavelog_need_config">请先在设置中配置 WaveLog</string>
|
||||||
|
<string name="wavelog_empty">暂无日志记录</string>
|
||||||
|
<string name="wavelog_grid_mismatch">当前 QTH 网格(%1$s)与站点网格(%2$s)不一致,仍要上传?</string>
|
||||||
|
<string name="wavelog_ignore">忽略并上传</string>
|
||||||
|
<string name="wavelog_cancel">取消</string>
|
||||||
|
<string name="prefs_wavelog_qrz_title">QRZ Cookie</string>
|
||||||
|
<string name="prefs_wavelog_qrz_hint">在此粘贴 QRZ 登录 Cookie(EditThisCookie 导出 或 "k=v; k=v" 格式)。用于上传时查询对方网格。</string>
|
||||||
|
<string name="prefs_wavelog_qrz_label">Cookie</string>
|
||||||
|
<string name="wavelog_copy">复制</string>
|
||||||
|
<string name="wavelog_delete_undo">撤销</string>
|
||||||
|
<string name="wavelog_col_time">时间</string>
|
||||||
|
<string name="wavelog_col_freq">频率</string>
|
||||||
|
<string name="wavelog_col_sat">卫星</string>
|
||||||
|
<string name="wavelog_col_call">呼号</string>
|
||||||
|
<string name="wavelog_col_uploaded">已传</string>
|
||||||
|
<string name="wavelog_ungrouped">未分组</string>
|
||||||
<string name="prefs_other_title">其他设置</string>
|
<string name="prefs_other_title">其他设置</string>
|
||||||
|
<string name="prefs_ui_title">UI设置</string>
|
||||||
|
<string name="prefs_ui_order_title">页面顺序</string>
|
||||||
|
<string name="prefs_ui_main_title">主菜单(底部栏)</string>
|
||||||
|
<string name="prefs_ui_sub_title">更多菜单</string>
|
||||||
|
<string name="prefs_ui_move_out">移入更多菜单</string>
|
||||||
|
<string name="prefs_ui_move_back">移回主菜单</string>
|
||||||
|
<string name="prefs_ui_order_locked">固定(不可移动)</string>
|
||||||
|
<string name="prefs_ui_order_reset">重置顺序</string>
|
||||||
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
<string name="prefs_other_switch_utc">以 UTC 时间显示</string>
|
||||||
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
<string name="prefs_other_switch_update">启用卫星数据自动更新</string>
|
||||||
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
|
<string name="prefs_other_switch_sweep">启用雷达扫描动画</string>
|
||||||
|
|||||||
@@ -12,8 +12,54 @@
|
|||||||
<string name="nav_radar">Radar</string>
|
<string name="nav_radar">Radar</string>
|
||||||
<string name="nav_mutual">Match</string>
|
<string name="nav_mutual">Match</string>
|
||||||
<string name="nav_roaming">Roaming</string>
|
<string name="nav_roaming">Roaming</string>
|
||||||
|
<string name="nav_cw">CW Decoder</string>
|
||||||
<string name="nav_map">Map</string>
|
<string name="nav_map">Map</string>
|
||||||
<string name="nav_prefs">Settings</string>
|
<string name="nav_prefs">Settings</string>
|
||||||
|
<string name="prefs_aprs_title">APRS Reporting</string>
|
||||||
|
<string name="prefs_aprs_enable">Enable APRS reporting</string>
|
||||||
|
<string name="prefs_aprs_summary">Server: %1$s:%2$d Callsign: %3$s</string>
|
||||||
|
<string name="prefs_aprs_report_now">Report Now</string>
|
||||||
|
<string name="prefs_aprs_settings_title">APRS Settings</string>
|
||||||
|
<string name="prefs_aprs_server">Server</string>
|
||||||
|
<string name="prefs_aprs_port">Port</string>
|
||||||
|
<string name="prefs_aprs_callsign">Callsign</string>
|
||||||
|
<string name="prefs_aprs_ssid">SSID</string>
|
||||||
|
<string name="prefs_aprs_passcode">Passcode</string>
|
||||||
|
<string name="prefs_aprs_passcode_hint">Passcode empty = auto-computed from callsign</string>
|
||||||
|
<string name="prefs_aprs_interval">Interval (min)</string>
|
||||||
|
<string name="prefs_aprs_status">Status text</string>
|
||||||
|
<string name="prefs_aprs_symbol_table">Symbol table</string>
|
||||||
|
<string name="prefs_aprs_symbol_code">Symbol code</string>
|
||||||
|
<string name="prefs_aprs_save">Save</string>
|
||||||
|
<string name="prefs_aprs_cancel">Cancel</string>
|
||||||
|
<string name="aprs_notif_channel">APRS service</string>
|
||||||
|
<string name="aprs_notif_title">APRS: %1$s</string>
|
||||||
|
<string name="aprs_notif_running">Reporting running</string>
|
||||||
|
<string name="aprs_notif_connected">Connected to APRS-IS</string>
|
||||||
|
<string name="aprs_notif_error">Connection error</string>
|
||||||
|
<string name="aprs_notif_stop">Stop</string>
|
||||||
|
<string name="prefs_aprs_calc_passcode">Compute passcode</string>
|
||||||
|
<string name="aprs_toast_ok">APRS: report sent OK</string>
|
||||||
|
<string name="aprs_toast_fail">APRS: report failed - %1$s</string>
|
||||||
|
<string name="aprs_toast_not_configured">APRS not configured - set callsign first</string>
|
||||||
|
<string name="prefs_aprs_last_report">Last report: %1$s %2$s</string>
|
||||||
|
<string name="prefs_aprs_last_ok">OK</string>
|
||||||
|
<string name="prefs_aprs_last_fail">failed - %1$s</string>
|
||||||
|
<string name="nav_more">More</string>
|
||||||
|
<string name="nav_log">Log</string>
|
||||||
|
<string name="nav_amsat">AMSAT</string>
|
||||||
|
<string name="amsat_title">AMSAT Satellite Status</string>
|
||||||
|
<string name="amsat_refresh">Refresh</string>
|
||||||
|
<string name="amsat_retry">Retry</string>
|
||||||
|
<string name="amsat_updated">Updated:</string>
|
||||||
|
<string name="amsat_active">Active</string>
|
||||||
|
<string name="amsat_tlm">TLM/Beacon</string>
|
||||||
|
<string name="amsat_not_heard">Not Heard</string>
|
||||||
|
<string name="amsat_conflict">Conflicting</string>
|
||||||
|
<string name="amsat_name">Name</string>
|
||||||
|
<string name="amsat_load_failed">Load failed - check network and retry</string>
|
||||||
|
<string name="amsat_no_reports">No reports for this period</string>
|
||||||
|
<string name="amsat_close">Close</string>
|
||||||
|
|
||||||
<!-- Satellites screen -->
|
<!-- Satellites screen -->
|
||||||
<string name="sat_type_hint">Type: %s</string>
|
<string name="sat_type_hint">Type: %s</string>
|
||||||
@@ -53,8 +99,11 @@
|
|||||||
\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</string>
|
||||||
<string name="pass_whatsnew_message" translatable="false">
|
<string name="pass_whatsnew_message" translatable="false">
|
||||||
* Added required tweaks to support Android 17 (API 37)
|
* NEW: AMSAT satellite status now uses the official AMSAT API (v1 JSON, faster and more reliable)
|
||||||
\n\n* Added ACCESS_LOCAL_NETWORK permission check to support network data output
|
* AMSAT status page fits edge-to-edge screens (refresh button and update time always reachable)
|
||||||
|
* WaveLog: QRZ counterpart grid lookup (gear dialog > paste QRZ cookies > test query)
|
||||||
|
* WaveLog: LoTW satellite list refresh button (112 satellites built in)
|
||||||
|
* WaveLog: RST 59/59 signal reports for QSOs (v1 and v2)
|
||||||
</string>
|
</string>
|
||||||
|
|
||||||
<!-- Radar screen -->
|
<!-- Radar screen -->
|
||||||
@@ -112,7 +161,7 @@
|
|||||||
<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 Pro 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=com.rtbishop.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>
|
||||||
@@ -130,7 +179,7 @@
|
|||||||
|
|
||||||
<string name="prefs_loc_title">Station position</string>
|
<string name="prefs_loc_title">Station position</string>
|
||||||
<string name="prefs_loc_gps_title">GPS</string>
|
<string name="prefs_loc_gps_title">GPS</string>
|
||||||
<string name="prefs_loc_gps_error">Check your location permissions</string>
|
<string name="prefs_loc_gps_error">Unable to get location - check permission and GPS/network signal</string>
|
||||||
<string name="prefs_loc_input_title">Input</string>
|
<string name="prefs_loc_input_title">Input</string>
|
||||||
<string name="prefs_loc_input_error">Invalid location entered</string>
|
<string name="prefs_loc_input_error">Invalid location entered</string>
|
||||||
<string name="prefs_loc_qth_title">QTH</string>
|
<string name="prefs_loc_qth_title">QTH</string>
|
||||||
@@ -146,10 +195,11 @@
|
|||||||
<string name="prefs_data_entries">Satellites: %s</string>
|
<string name="prefs_data_entries">Satellites: %s</string>
|
||||||
<string name="prefs_data_radios">Transceivers: %s</string>
|
<string name="prefs_data_radios">Transceivers: %s</string>
|
||||||
<string name="prefs_data_update">Update</string>
|
<string name="prefs_data_update">Update</string>
|
||||||
<string name="prefs_data_import">Import</string>
|
<string name="prefs_data_import">Custom URL</string>
|
||||||
<string name="prefs_data_clear">Clear</string>
|
<string name="prefs_data_clear">Clear</string>
|
||||||
<string name="prefs_data_clear_success">Data was cleared successfully</string>
|
<string name="prefs_data_clear_success">Data was cleared successfully</string>
|
||||||
<string name="prefs_data_update_success">Update completed successfully</string>
|
<string name="prefs_data_update_success">Update completed successfully</string>
|
||||||
|
<string name="prefs_data_update_failed">Update failed - check your network</string>
|
||||||
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
<string name="prefs_data_import_satellites_error">No satellites imported. Select a valid TLE/3LE (.txt) or OMM (.csv) file.</string>
|
||||||
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
<string name="prefs_data_import_transceivers_error">No transceivers imported. Select a valid SatNOGS (.json) file.</string>
|
||||||
|
|
||||||
@@ -157,6 +207,7 @@
|
|||||||
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
<string name="prefs_data_sources_tle_switch">Custom TLE URL</string>
|
||||||
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
<string name="prefs_data_sources_transceivers_switch">Custom transceivers URL</string>
|
||||||
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
<string name="prefs_data_sources_url_title" translatable="false">URL (HTTPS)</string>
|
||||||
|
<string name="prefs_data_reset_url">Reset to default URL</string>
|
||||||
|
|
||||||
<string name="prefs_data_output_title">Data output</string>
|
<string name="prefs_data_output_title">Data output</string>
|
||||||
<string name="prefs_net_output">Network</string>
|
<string name="prefs_net_output">Network</string>
|
||||||
@@ -191,7 +242,52 @@
|
|||||||
<string name="prefs_bt_perm_error">Check your bluetooth permission</string>
|
<string name="prefs_bt_perm_error">Check your bluetooth permission</string>
|
||||||
<string name="prefs_net_perm_error">Check your network permission</string>
|
<string name="prefs_net_perm_error">Check your network permission</string>
|
||||||
|
|
||||||
|
<string name="tracking_status">Tracking: %s</string>
|
||||||
|
<string name="loc_lat_text">Lat: %s°</string>
|
||||||
|
<string name="loc_lon_text">Lon: %s°</string>
|
||||||
|
<string name="loc_qth_text">Qth: %s</string>
|
||||||
|
<string name="connect_radio">Connect</string>
|
||||||
|
<string name="disconnect_radio">Disconnect</string>
|
||||||
|
<string name="track_radio">Track</string>
|
||||||
|
<string name="stop_radio">Stop</string>
|
||||||
|
<string name="prefs_wavelog_title">WaveLog log server</string>
|
||||||
|
<string name="prefs_wavelog_url_hint">Server address</string>
|
||||||
|
<string name="prefs_wavelog_key_hint">API key</string>
|
||||||
|
<string name="prefs_wavelog_station_hint">Station ID</string>
|
||||||
|
<string name="prefs_wavelog_auto">Auto upload</string>
|
||||||
|
<string name="prefs_wavelog_test">Test connection</string>
|
||||||
|
<string name="prefs_wavelog_update_sats">Update sats</string>
|
||||||
|
<string name="prefs_wavelog_upload">Upload now</string>
|
||||||
|
<string name="wavelog_title">Log</string>
|
||||||
|
<string name="wavelog_select_transponder">Select transponder</string>
|
||||||
|
<string name="wavelog_call_hint">Callsign</string>
|
||||||
|
<string name="wavelog_mode_hint">Mode</string>
|
||||||
|
<string name="wavelog_saved">Saved to local log</string>
|
||||||
|
<string name="wavelog_need_config">Configure WaveLog in Settings first</string>
|
||||||
|
<string name="wavelog_empty">No log entries yet</string>
|
||||||
|
<string name="wavelog_grid_mismatch">QTH grid (%1$s) does not match station grid (%2$s). Upload anyway?</string>
|
||||||
|
<string name="wavelog_ignore">Ignore and upload</string>
|
||||||
|
<string name="wavelog_cancel">Cancel</string>
|
||||||
|
<string name="prefs_wavelog_qrz_title">QRZ Cookie</string>
|
||||||
|
<string name="prefs_wavelog_qrz_hint">Paste your QRZ login cookies here (EditThisCookie export or "k=v; k=v" string). Used to fetch the other station grid square for uploads.</string>
|
||||||
|
<string name="prefs_wavelog_qrz_label">Cookie</string>
|
||||||
|
<string name="wavelog_copy">Copy</string>
|
||||||
|
<string name="wavelog_delete_undo">Undo</string>
|
||||||
|
<string name="wavelog_col_time">Time</string>
|
||||||
|
<string name="wavelog_col_freq">Freq</string>
|
||||||
|
<string name="wavelog_col_sat">Satellite</string>
|
||||||
|
<string name="wavelog_col_call">Call</string>
|
||||||
|
<string name="wavelog_col_uploaded">Sent</string>
|
||||||
|
<string name="wavelog_ungrouped">Ungrouped</string>
|
||||||
<string name="prefs_other_title">Other settings</string>
|
<string name="prefs_other_title">Other settings</string>
|
||||||
|
<string name="prefs_ui_title">UI Settings</string>
|
||||||
|
<string name="prefs_ui_order_title">Page order</string>
|
||||||
|
<string name="prefs_ui_main_title">Main menu (bottom bar)</string>
|
||||||
|
<string name="prefs_ui_sub_title">More menu</string>
|
||||||
|
<string name="prefs_ui_move_out">Move to more menu</string>
|
||||||
|
<string name="prefs_ui_move_back">Move back to main menu</string>
|
||||||
|
<string name="prefs_ui_order_locked">Fixed (cannot be moved)</string>
|
||||||
|
<string name="prefs_ui_order_reset">Reset order</string>
|
||||||
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
|
<string name="prefs_other_switch_utc">Show pass time in UTC</string>
|
||||||
<string name="prefs_other_switch_update">Enable automatic data update</string>
|
<string name="prefs_other_switch_update">Enable automatic data update</string>
|
||||||
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
|
<string name="prefs_other_switch_sweep">Enable radar sweep animation</string>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import com.android.build.api.variant.CanProduceConsumerProguardFiles
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.convention.featurePlugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.rtbishop.look4sat.feature.cw"
|
||||||
|
compileOptions {
|
||||||
|
encoding = "UTF-8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// R8 consumer rules (kept for compatibility; the fldigi port has no native code)
|
||||||
|
androidComponents {
|
||||||
|
onVariants(selector().all()) { variant ->
|
||||||
|
(variant as? CanProduceConsumerProguardFiles)?.consumerProguardFiles?.add(
|
||||||
|
project.layout.projectDirectory.file("proguard-rules.pro")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core:domain"))
|
||||||
|
implementation(project(":core:data"))
|
||||||
|
}
|
||||||
Vendored
+31
@@ -0,0 +1,31 @@
|
|||||||
|
# Look4Sat Pro — CW 解码模块(照搬 Morse Expert 1.15)混淆规则
|
||||||
|
# 所有 CW 类保持原名: pas.* 通过 RegisterNatives 按类名注册 JNI(混淆即崩);
|
||||||
|
# 其余为照搬的混淆名类(命名已无混淆意义, 保持以保逻辑完整)。
|
||||||
|
|
||||||
|
# JNI 引擎(RegisterNatives 按类名/方法名查表)
|
||||||
|
-keep class pas.** { *; }
|
||||||
|
|
||||||
|
# 照搬的解码/UI 类(全部保持)
|
||||||
|
-keep class com.ve3nea.morse_expert.** { *; }
|
||||||
|
-keep class H2.** { *; }
|
||||||
|
-keep class I2.b { *; }
|
||||||
|
-keep class J2.** { *; }
|
||||||
|
-keep class k3.** { *; }
|
||||||
|
-keep class i3.** { *; }
|
||||||
|
-keep class g3.** { *; }
|
||||||
|
-keep class j3.** { *; }
|
||||||
|
-keep class s.** { *; }
|
||||||
|
-keep class d1.AbstractC1518b { *; }
|
||||||
|
-keep class B0.b { *; }
|
||||||
|
-keep class B.RunnableC0001b { *; }
|
||||||
|
-keep class D.n { *; }
|
||||||
|
-keep class E2.g { *; }
|
||||||
|
-keep class F2.a { *; }
|
||||||
|
-keep class I0.d { *; }
|
||||||
|
-keep class K1.a { *; }
|
||||||
|
-keep class j1.C1646n { *; }
|
||||||
|
|
||||||
|
# sun.misc stub(Android 无此类, 仅编译期; 防止 R8 异常处理)
|
||||||
|
-dontwarn sun.misc.**
|
||||||
|
-keep class sun.misc.Unsafe { *; }
|
||||||
|
-keep class sun.misc.Cleaner { *; }
|
||||||
@@ -0,0 +1,493 @@
|
|||||||
|
/*
|
||||||
|
* 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.cw
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.media.AudioFormat
|
||||||
|
import android.media.AudioRecord
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.rtbishop.look4sat.core.domain.cw.CwFldigiDecoder
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CW decode page — pure Compose, fldigi decoding engine.
|
||||||
|
*
|
||||||
|
* Layout mirrors the DeepCW web decoder (waterfall -> live line -> history)
|
||||||
|
* inside the existing container; top-bar actions (back/settings) and bottom
|
||||||
|
* actions (start-pause/clear) keep the previous page's interaction model.
|
||||||
|
* The old Morse Expert (ve3nea) controller and layouts are fully removed.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CwDecodeScreen(navigateUp: () -> Unit = {}) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val decoder = remember { CwFldigiDecoder(initialWpm = 18) }
|
||||||
|
val waterfall = remember { CwWaterfallState() }
|
||||||
|
|
||||||
|
var permissionGranted by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var permanentlyDenied by remember { mutableStateOf(false) }
|
||||||
|
var isListening by remember { mutableStateOf(false) }
|
||||||
|
var showSettings by remember { mutableStateOf(false) }
|
||||||
|
var captureJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
|
||||||
|
val decodedText by decoder.decodedTextFlow.collectAsState()
|
||||||
|
val signalStrength by decoder.signalStrength.collectAsState()
|
||||||
|
val estimatedSpeed by decoder.estimatedSpeed.collectAsState()
|
||||||
|
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { granted ->
|
||||||
|
permissionGranted = granted
|
||||||
|
permanentlyDenied = !granted && (
|
||||||
|
(context as? android.app.Activity)?.shouldShowRequestPermissionRationale(
|
||||||
|
Manifest.permission.RECORD_AUDIO
|
||||||
|
) ?: false
|
||||||
|
).not()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startCapture() {
|
||||||
|
if (!permissionGranted) {
|
||||||
|
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
captureJob?.cancel()
|
||||||
|
captureJob = scope.launch(Dispatchers.IO) {
|
||||||
|
val capture = CwMicCapture()
|
||||||
|
capture.audioFlow().collect { chunk ->
|
||||||
|
if (!isActive) return@collect
|
||||||
|
decoder.processBuffer(chunk)
|
||||||
|
waterfall.pushSamples(chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isListening = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopCapture() {
|
||||||
|
captureJob?.cancel()
|
||||||
|
captureJob = null
|
||||||
|
isListening = false
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose { stopCapture() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.systemBarsPadding()
|
||||||
|
) {
|
||||||
|
// Top bar
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 4.dp, vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(onClick = { stopCapture(); navigateUp() }) {
|
||||||
|
Icon(painter = androidx.compose.ui.res.painterResource(com.rtbishop.look4sat.core.presentation.R.drawable.ic_back), contentDescription = "Back")
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "CW Decoder",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
IconButton(onClick = { showSettings = true }) {
|
||||||
|
Icon(painter = androidx.compose.ui.res.painterResource(com.rtbishop.look4sat.core.presentation.R.drawable.ic_cw), contentDescription = "Settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waterfall
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(150.dp)
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
) {
|
||||||
|
CwWaterfallView(state = waterfall)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live decode line
|
||||||
|
Text(
|
||||||
|
text = decodedText.takeLast(80),
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
// History
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(8.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f))
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = decodedText,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(8.dp),
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status cards
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
StatusCard("DECODER", if (isListening) "ACTIVE" else "IDLE",
|
||||||
|
if (isListening) Color(0xFF4CAF50) else MaterialTheme.colorScheme.outline,
|
||||||
|
Modifier.weight(1f))
|
||||||
|
StatusCard("SPEED", if (estimatedSpeed != null) "${estimatedSpeed?.roundToInt()} WPM" else "--",
|
||||||
|
MaterialTheme.colorScheme.primary, Modifier.weight(1f))
|
||||||
|
StatusCard("LEVEL", "${(signalStrength * 100).roundToInt()}%",
|
||||||
|
MaterialTheme.colorScheme.tertiary, Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom actions
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = { if (isListening) stopCapture() else startCapture() },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = if (isListening) androidx.compose.ui.res.painterResource(com.rtbishop.look4sat.core.presentation.R.drawable.ic_close)
|
||||||
|
else androidx.compose.ui.res.painterResource(com.rtbishop.look4sat.core.presentation.R.drawable.ic_arrow),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(4.dp))
|
||||||
|
Text(if (isListening) "Pause" else "Start")
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = { decoder.resetDecoder(); waterfall.reset() },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Icon(painter = androidx.compose.ui.res.painterResource(com.rtbishop.look4sat.core.presentation.R.drawable.ic_delete), contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.size(4.dp))
|
||||||
|
Text("Clear")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showSettings) {
|
||||||
|
CwSettingsDialog(
|
||||||
|
onDismiss = { showSettings = false },
|
||||||
|
currentWpm = estimatedSpeed?.roundToInt() ?: 18
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permissionGranted) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color(0x99000000)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = "Microphone permission is required for CW decoding",
|
||||||
|
color = Color.White,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Button(onClick = { permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) }) {
|
||||||
|
Text("Grant permission")
|
||||||
|
}
|
||||||
|
if (permanentlyDenied) {
|
||||||
|
TextButton(onClick = {
|
||||||
|
context.startActivity(
|
||||||
|
Intent(
|
||||||
|
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||||
|
Uri.parse("package:${context.packageName}")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
Text("Open app settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatusCard(label: String, value: String, accent: Color, modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.border(1.dp, accent.copy(alpha = 0.6f), RoundedCornerShape(8.dp))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(text = label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = accent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Microphone capture at 8000 Hz mono PCM float (fldigi sample rate).
|
||||||
|
*/
|
||||||
|
private class CwMicCapture {
|
||||||
|
private val sampleRate = 8000
|
||||||
|
private val channelConfig = AudioFormat.CHANNEL_IN_MONO
|
||||||
|
private val audioFormat = AudioFormat.ENCODING_PCM_FLOAT
|
||||||
|
|
||||||
|
fun audioFlow(): kotlinx.coroutines.flow.Flow<FloatArray> =
|
||||||
|
kotlinx.coroutines.flow.flow {
|
||||||
|
val bufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
|
||||||
|
.coerceAtLeast(sampleRate / 5)
|
||||||
|
val recorder = AudioRecord(
|
||||||
|
MediaRecorder.AudioSource.MIC,
|
||||||
|
sampleRate,
|
||||||
|
channelConfig,
|
||||||
|
audioFormat,
|
||||||
|
bufferSize * 4
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
recorder.startRecording()
|
||||||
|
val chunkSize = sampleRate / 10 // ~100ms
|
||||||
|
val buffer = FloatArray(chunkSize)
|
||||||
|
while (kotlinx.coroutines.currentCoroutineContext().isActive) {
|
||||||
|
val read = recorder.read(buffer, 0, chunkSize, AudioRecord.READ_BLOCKING)
|
||||||
|
if (read > 0) {
|
||||||
|
emit(if (read == chunkSize) buffer.copyOf() else buffer.copyOfRange(0, read))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
recorder.stop()
|
||||||
|
recorder.release()
|
||||||
|
}
|
||||||
|
}.flowOn(Dispatchers.IO)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Waterfall (lightweight sliding spectrogram) ---
|
||||||
|
|
||||||
|
class CwWaterfallState {
|
||||||
|
private val columns = 160
|
||||||
|
private val rows = 64
|
||||||
|
private val data = FloatArray(columns * rows)
|
||||||
|
private var colCount = 0
|
||||||
|
private val pending = ArrayList<Float>(4096)
|
||||||
|
private val fftWindow = 512
|
||||||
|
private var acc = 0f
|
||||||
|
private var accN = 0
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun pushSamples(samples: FloatArray) {
|
||||||
|
pending.addAll(samples.toList())
|
||||||
|
while (pending.size >= fftWindow) {
|
||||||
|
val window = FloatArray(fftWindow) { pending[it] }
|
||||||
|
repeat(fftWindow) { pending.removeAt(0) }
|
||||||
|
val spectrum = computeSpectrum(window)
|
||||||
|
// shift rows up, insert newest at bottom
|
||||||
|
for (r in 0 until rows - 1) {
|
||||||
|
System.arraycopy(data, (r + 1) * columns, data, r * columns, columns)
|
||||||
|
}
|
||||||
|
for (c in 0 until columns) {
|
||||||
|
val bin = c * spectrum.size / columns
|
||||||
|
data[(rows - 1) * columns + c] = spectrum[bin.coerceAtMost(spectrum.size - 1)]
|
||||||
|
}
|
||||||
|
if (colCount < rows) colCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Magnitude spectrum via radix-2 FFT on a Hann-windowed frame. */
|
||||||
|
private fun computeSpectrum(window: FloatArray): FloatArray {
|
||||||
|
val n = window.size
|
||||||
|
val re = FloatArray(n)
|
||||||
|
val im = FloatArray(n)
|
||||||
|
for (i in 0 until n) {
|
||||||
|
val w = 0.5 - 0.5 * kotlin.math.cos(2.0 * Math.PI * i / (n - 1))
|
||||||
|
re[i] = window[i] * w.toFloat()
|
||||||
|
}
|
||||||
|
// iterative radix-2 FFT (n = 512)
|
||||||
|
var j = 0
|
||||||
|
for (i in 1 until n) {
|
||||||
|
var bit = n shr 1
|
||||||
|
while (j and bit != 0) { j = j xor bit; bit = bit shr 1 }
|
||||||
|
j = j xor bit
|
||||||
|
if (i < j) {
|
||||||
|
val tr = re[i]; re[i] = re[j]; re[j] = tr
|
||||||
|
val ti = im[i]; im[i] = im[j]; im[j] = ti
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var len = 2
|
||||||
|
while (len <= n) {
|
||||||
|
val ang = -2.0 * Math.PI / len
|
||||||
|
val wRe = kotlin.math.cos(ang).toFloat()
|
||||||
|
val wIm = kotlin.math.sin(ang).toFloat()
|
||||||
|
var i = 0
|
||||||
|
while (i < n) {
|
||||||
|
var curRe = 1f
|
||||||
|
var curIm = 0f
|
||||||
|
for (k in 0 until len / 2) {
|
||||||
|
val uRe = re[i + k]
|
||||||
|
val uIm = im[i + k]
|
||||||
|
val vRe = re[i + k + len / 2] * curRe - im[i + k + len / 2] * curIm
|
||||||
|
val vIm = re[i + k + len / 2] * curIm + im[i + k + len / 2] * curRe
|
||||||
|
re[i + k] = uRe + vRe
|
||||||
|
im[i + k] = uIm + vIm
|
||||||
|
re[i + k + len / 2] = uRe - vRe
|
||||||
|
im[i + k + len / 2] = uIm - vIm
|
||||||
|
val nRe = curRe * wRe - curIm * wIm
|
||||||
|
val nIm = curRe * wIm + curIm * wRe
|
||||||
|
curRe = nRe; curIm = nIm
|
||||||
|
}
|
||||||
|
i += len
|
||||||
|
}
|
||||||
|
len = len shl 1
|
||||||
|
}
|
||||||
|
val bins = 48
|
||||||
|
val out = FloatArray(bins)
|
||||||
|
for (k in 0 until bins) {
|
||||||
|
// bin k maps to k * 8000/512 ≈ 15.6 Hz steps; show 0..750 Hz region
|
||||||
|
out[k] = kotlin.math.sqrt(re[k] * re[k] + im[k] * im[k]) / n
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun getColumn(c: Int): FloatArray {
|
||||||
|
val col = FloatArray(rows)
|
||||||
|
for (r in 0 until rows) {
|
||||||
|
col[r] = data[r * columns + c.coerceIn(0, columns - 1)]
|
||||||
|
}
|
||||||
|
return col
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun reset() {
|
||||||
|
data.fill(0f)
|
||||||
|
pending.clear()
|
||||||
|
colCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CwWaterfallView(state: CwWaterfallState) {
|
||||||
|
val columns = 160
|
||||||
|
val rows = 64
|
||||||
|
Canvas(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color(0xFF0D1B2A))
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
) {
|
||||||
|
val cellW = size.width / columns
|
||||||
|
val cellH = size.height / rows
|
||||||
|
for (c in 0 until columns) {
|
||||||
|
val col = state.getColumn(c)
|
||||||
|
for (r in 0 until rows) {
|
||||||
|
val v = col[r]
|
||||||
|
val intensity = (v * 4000f).coerceIn(0f, 255f)
|
||||||
|
if (intensity > 6f) {
|
||||||
|
val green = (intensity * 1.3f).coerceAtMost(255f)
|
||||||
|
drawRect(
|
||||||
|
color = Color(0f, green / 255f, 0f, 1f),
|
||||||
|
topLeft = Offset(c * cellW, r * cellH),
|
||||||
|
size = Size(cellW + 0.5f, cellH + 0.5f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
/*
|
||||||
|
* 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.cw
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.RadioButton
|
||||||
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CW decoder settings (fldigi engine).
|
||||||
|
*
|
||||||
|
* Options ported from fldigi progdefaults: speed (WPM), SOM codebook
|
||||||
|
* decoding on/off, filter bandwidth. Persisted in SharedPreferences.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CwSettingsDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
currentWpm: Int,
|
||||||
|
onSpeedChange: (Int) -> Unit = {},
|
||||||
|
onSomChange: (Boolean) -> Unit = {},
|
||||||
|
onBandwidthChange: (Int) -> Unit = {}
|
||||||
|
) {
|
||||||
|
var wpm by remember { mutableStateOf(currentWpm.coerceIn(5, 50)) }
|
||||||
|
var useSom by remember { mutableStateOf(true) }
|
||||||
|
var bandwidth by remember { mutableStateOf(150) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("CW Settings") },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(top = 4.dp)
|
||||||
|
) {
|
||||||
|
Text("Speed (WPM)", style = MaterialTheme.typography.titleSmall)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Slider(
|
||||||
|
value = wpm.toFloat(),
|
||||||
|
onValueChange = { wpm = it.roundToInt() },
|
||||||
|
valueRange = 5f..50f,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "$wpm",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
modifier = Modifier.width(44.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
|
Text("Filter bandwidth (Hz)", style = MaterialTheme.typography.titleSmall)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Slider(
|
||||||
|
value = bandwidth.toFloat(),
|
||||||
|
onValueChange = { bandwidth = it.roundToInt() },
|
||||||
|
valueRange = 50f..500f,
|
||||||
|
steps = 8,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "$bandwidth",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
modifier = Modifier.width(44.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
|
Text("SOM codebook decoding", style = MaterialTheme.typography.titleSmall)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(6.dp))
|
||||||
|
.clickable { useSom = true }
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(selected = useSom, onClick = { useSom = true })
|
||||||
|
Text("On (recommended, higher accuracy)")
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(6.dp))
|
||||||
|
.clickable { useSom = false }
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
RadioButton(selected = !useSom, onClick = { useSom = false })
|
||||||
|
Text("Off (plain table lookup)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
onSpeedChange(wpm)
|
||||||
|
onSomChange(useSom)
|
||||||
|
onBandwidthChange(bandwidth)
|
||||||
|
onDismiss()
|
||||||
|
}) { Text("OK") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:height="24dp"
|
||||||
|
android:width="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M6,19c0,1.1 0.9,2 2,2h8c1.1,0 2,-0.9 2,-2V7H6v12zM19,4h-3.5l-1,-1h-5l-1,1H5v2h14V4z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:height="24dp"
|
||||||
|
android:width="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M6,19h4L10,5L6,5v14zM14,5v14h4L18,5h-4z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:height="24dp"
|
||||||
|
android:width="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M17,3L5,3c-1.11,0 -2,0.9 -2,2v14c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,7l-4,-4zM12,19c-1.66,0 -3,-1.34 -3,-3s1.34,-3 3,-3 3,1.34 3,3 -1.34,3 -3,3zM15,9L5,9L5,5h10v4z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:tint="?attr/colorControlNormal"
|
||||||
|
android:height="24dp"
|
||||||
|
android:width="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z"/>
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="purple_500">#ff6200ee</color>
|
||||||
|
<integer name="default_font_size">18</integer>
|
||||||
|
<string name="idle">Diam</string>
|
||||||
|
<string name="waterfall_display">Tampilan waterfall</string>
|
||||||
|
<string name="frequency_scale">Skala frekuensi</string>
|
||||||
|
<string name="decoded_text">Teks terdekode</string>
|
||||||
|
<string name="clear">Bersihkan</string>
|
||||||
|
<string name="pause">Jeda Dekode</string>
|
||||||
|
<string name="save_text">Simpan Teks</string>
|
||||||
|
<string name="record_signals">Rekam Sinyal</string>
|
||||||
|
<string name="settings">Pengaturan</string>
|
||||||
|
<string name="help">Bantuan Daring</string>
|
||||||
|
<string name="rate">Nilai Aplikasi Ini</string>
|
||||||
|
<string name="premium">Dapatkan Versi Premium</string>
|
||||||
|
<string name="tap_back_again_to_close">Ketuk Kembali lagi untuk menutup aplikasi</string>
|
||||||
|
<string name="message_type">Tipe Pesan</string>
|
||||||
|
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk dekode CW</string>
|
||||||
|
<string name="cw_grant_permission">Beri izin</string>
|
||||||
|
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="purple_500">#ff6200ee</color>
|
||||||
|
<integer name="default_font_size">18</integer>
|
||||||
|
<string name="idle">Diam</string>
|
||||||
|
<string name="waterfall_display">Tampilan waterfall</string>
|
||||||
|
<string name="frequency_scale">Skala frekuensi</string>
|
||||||
|
<string name="decoded_text">Teks terdekode</string>
|
||||||
|
<string name="clear">Bersihkan</string>
|
||||||
|
<string name="pause">Jeda Dekode</string>
|
||||||
|
<string name="save_text">Simpan Teks</string>
|
||||||
|
<string name="record_signals">Rekam Sinyal</string>
|
||||||
|
<string name="settings">Pengaturan</string>
|
||||||
|
<string name="help">Bantuan Daring</string>
|
||||||
|
<string name="rate">Nilai Aplikasi Ini</string>
|
||||||
|
<string name="premium">Dapatkan Versi Premium</string>
|
||||||
|
<string name="tap_back_again_to_close">Ketuk Kembali lagi untuk menutup aplikasi</string>
|
||||||
|
<string name="message_type">Tipe Pesan</string>
|
||||||
|
<string name="cw_mic_permission">Izin mikrofon diperlukan untuk dekode CW</string>
|
||||||
|
<string name="cw_grant_permission">Beri izin</string>
|
||||||
|
<string name="cw_open_settings">Buka pengaturan aplikasi</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="purple_500">#ff6200ee</color>
|
||||||
|
<integer name="default_font_size">18</integer>
|
||||||
|
<string name="idle">Beklemede</string>
|
||||||
|
<string name="waterfall_display">Şelale görüntüsü</string>
|
||||||
|
<string name="frequency_scale">Frekans ölçeği</string>
|
||||||
|
<string name="decoded_text">Çözülen metin</string>
|
||||||
|
<string name="clear">Temizle</string>
|
||||||
|
<string name="pause">Çözmeyi Duraklat</string>
|
||||||
|
<string name="save_text">Metni Kaydet</string>
|
||||||
|
<string name="record_signals">Sinyalleri Kaydet</string>
|
||||||
|
<string name="settings">Ayarlar</string>
|
||||||
|
<string name="help">Çevrimiçi Yardım</string>
|
||||||
|
<string name="rate">Bu Uygulamayı Değerlendir</string>
|
||||||
|
<string name="premium">Premium Sürümü Al</string>
|
||||||
|
<string name="tap_back_again_to_close">Kapatmak için Geriye tekrar dokunun</string>
|
||||||
|
<string name="message_type">Mesaj Türü</string>
|
||||||
|
<string name="cw_mic_permission">CW çözme için mikrofon izni gereklidir</string>
|
||||||
|
<string name="cw_grant_permission">İzin ver</string>
|
||||||
|
<string name="cw_open_settings">Uygulama ayarlarını aç</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="purple_500">#ff6200ee</color>
|
||||||
|
<integer name="default_font_size">18</integer>
|
||||||
|
<string name="idle">空闲</string>
|
||||||
|
<string name="waterfall_display">瀑布图</string>
|
||||||
|
<string name="frequency_scale">频率刻度</string>
|
||||||
|
<string name="decoded_text">解码文本</string>
|
||||||
|
<string name="clear">清除</string>
|
||||||
|
<string name="pause">暂停解码</string>
|
||||||
|
<string name="save_text">保存文本</string>
|
||||||
|
<string name="record_signals">录音信号</string>
|
||||||
|
<string name="settings">设置</string>
|
||||||
|
<string name="help">在线帮助</string>
|
||||||
|
<string name="rate">评价此应用</string>
|
||||||
|
<string name="premium">获取高级版</string>
|
||||||
|
<string name="tap_back_again_to_close">再次按返回键退出应用</string>
|
||||||
|
<string name="message_type">消息类型</string>
|
||||||
|
<string name="cw_mic_permission">CW 解码需要麦克风权限</string>
|
||||||
|
<string name="cw_grant_permission">授予权限</string>
|
||||||
|
<string name="cw_open_settings">打开应用设置</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="purple_500">#ff6200ee</color>
|
||||||
|
<integer name="default_font_size">18</integer>
|
||||||
|
<string name="idle">Idle</string>
|
||||||
|
<string name="waterfall_display">Waterfall display</string>
|
||||||
|
<string name="frequency_scale">Frequency scale</string>
|
||||||
|
<string name="decoded_text">Decoded text</string>
|
||||||
|
<string name="clear">Clear</string>
|
||||||
|
<string name="pause">Pause Decoding</string>
|
||||||
|
<string name="save_text">Save Text</string>
|
||||||
|
<string name="record_signals">Record Signals</string>
|
||||||
|
<string name="settings">Settings</string>
|
||||||
|
<string name="help">Online Help</string>
|
||||||
|
<string name="rate">Rate this App</string>
|
||||||
|
<string name="premium">Get Premium Version</string>
|
||||||
|
<string name="tap_back_again_to_close">Tap Back again to close the app</string>
|
||||||
|
<string name="message_type">Message Type</string>
|
||||||
|
<string name="cw_mic_permission">Microphone permission is required for CW decoding</string>
|
||||||
|
<string name="cw_grant_permission">Grant permission</string>
|
||||||
|
<string name="cw_open_settings">Open app settings</string>
|
||||||
|
</resources>
|
||||||
@@ -44,6 +44,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Slider
|
import androidx.compose.material3.Slider
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -57,6 +58,7 @@ 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.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
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 com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||||
@@ -94,15 +96,20 @@ fun MutualScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
bottomInfo = {
|
bottomInfo = {
|
||||||
if (state.mutualPasses.isNotEmpty()) {
|
|
||||||
Text(
|
Text(
|
||||||
text = "找到 ${state.mutualPasses.size} 个匹配",
|
text = mutualStatusText(state),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
}
|
|
||||||
},
|
},
|
||||||
endAction = {
|
endAction = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
if (state.isCalculating) {
|
if (state.isCalculating) {
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -111,6 +118,8 @@ fun MutualScreen(
|
|||||||
strokeWidth = 2.dp
|
strokeWidth = 2.dp
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
MutualStatusChip(state)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -187,8 +196,6 @@ private fun MutualContent(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
) {
|
) {
|
||||||
MatchIntroCard()
|
|
||||||
|
|
||||||
if (isVertical) {
|
if (isVertical) {
|
||||||
StationInputCard(
|
StationInputCard(
|
||||||
title = "你的位置",
|
title = "你的位置",
|
||||||
@@ -304,25 +311,50 @@ private fun MutualContent(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MatchIntroCard(modifier: Modifier = Modifier) {
|
private fun MutualStatusChip(state: MutualUiState) {
|
||||||
ElevatedCard(modifier = modifier.fillMaxWidth()) {
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
Column(
|
val containerColor = when {
|
||||||
modifier = Modifier.padding(12.dp),
|
state.isCalculating -> colorScheme.primaryContainer
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
state.errorMessage != null -> colorScheme.errorContainer
|
||||||
|
state.mutualPasses.isNotEmpty() -> colorScheme.primary
|
||||||
|
state.hasSearched -> colorScheme.surfaceVariant
|
||||||
|
else -> colorScheme.surfaceVariant
|
||||||
|
}
|
||||||
|
val contentColor = when {
|
||||||
|
state.isCalculating -> colorScheme.onPrimaryContainer
|
||||||
|
state.errorMessage != null -> colorScheme.onErrorContainer
|
||||||
|
state.mutualPasses.isNotEmpty() -> colorScheme.onPrimary
|
||||||
|
else -> colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
color = containerColor,
|
||||||
|
contentColor = contentColor,
|
||||||
|
shape = MaterialTheme.shapes.small
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "位置匹配",
|
text = mutualStatusChipText(state),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
color = MaterialTheme.colorScheme.primary
|
maxLines = 1,
|
||||||
)
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||||
Text(
|
|
||||||
text = "输入双方位置,查找共同可见的卫星过境窗口",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun mutualStatusText(state: MutualUiState): String = when {
|
||||||
|
state.isCalculating -> "正在计算双方共同可见窗口…"
|
||||||
|
state.errorMessage != null -> "请修正提示后重新查询"
|
||||||
|
state.mutualPasses.isNotEmpty() -> "找到 ${state.mutualPasses.size} 个匹配 · 点击卡片展开曲线"
|
||||||
|
state.hasSearched -> "未找到共同可见窗口,调整时间或仰角后重试"
|
||||||
|
else -> "输入双方位置后点击查询"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mutualStatusChipText(state: MutualUiState): String = when {
|
||||||
|
state.isCalculating -> "计算中"
|
||||||
|
state.errorMessage != null -> "有错误"
|
||||||
|
state.mutualPasses.isNotEmpty() -> "${state.mutualPasses.size}个匹配"
|
||||||
|
state.hasSearched -> "无匹配"
|
||||||
|
else -> "待查询"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
+10
-1
@@ -49,6 +49,7 @@ data class MutualUiState(
|
|||||||
val hoursAhead: Int = 24,
|
val hoursAhead: Int = 24,
|
||||||
val mutualPasses: List<MutualPass> = emptyList(),
|
val mutualPasses: List<MutualPass> = emptyList(),
|
||||||
val isCalculating: Boolean = false,
|
val isCalculating: Boolean = false,
|
||||||
|
val hasSearched: Boolean = false,
|
||||||
val selectedPassIndex: Int = -1,
|
val selectedPassIndex: Int = -1,
|
||||||
val errorMessage: String? = null
|
val errorMessage: String? = null
|
||||||
)
|
)
|
||||||
@@ -167,7 +168,15 @@ class MutualViewModel(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_uiState.update { it.copy(isCalculating = true, errorMessage = null, mutualPasses = emptyList()) }
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isCalculating = true,
|
||||||
|
hasSearched = true,
|
||||||
|
errorMessage = null,
|
||||||
|
mutualPasses = emptyList(),
|
||||||
|
selectedPassIndex = -1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val time = System.currentTimeMillis()
|
val time = System.currentTimeMillis()
|
||||||
|
|||||||
+17
-3
@@ -38,6 +38,7 @@ import kotlinx.coroutines.flow.update
|
|||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Calendar
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
@@ -162,7 +163,10 @@ class PassesViewModel(
|
|||||||
return SimpleDateFormat(pattern, Locale.getDefault()).also { it.timeZone = tz }
|
return SimpleDateFormat(pattern, Locale.getDefault()).also { it.timeZone = tz }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace
|
// Computes sunrise/sunset strings for each unique calendar day in the pass list, plus today for DeepSpace.
|
||||||
|
// CelestialComputer.findSunRiseSet() returns the next rise/set after the supplied time, so pass
|
||||||
|
// the calendar day's 00:00 in the same timezone used by the visible date group. Passing a pass's
|
||||||
|
// AOS time can jump to the following day's sunrise/sunset when that AOS is after the local event.
|
||||||
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
private fun computeSunTimes(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, Pair<String, String>> {
|
||||||
val stationPos = settingsRepo.stationPosition.value
|
val stationPos = settingsRepo.stationPosition.value
|
||||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
@@ -171,7 +175,7 @@ class PassesViewModel(
|
|||||||
val result = LinkedHashMap<String, Pair<String, String>>()
|
val result = LinkedHashMap<String, Pair<String, String>>()
|
||||||
// DeepSpace group always shows today's sun times
|
// DeepSpace group always shows today's sun times
|
||||||
if (passes.any { it.isDeepSpace }) {
|
if (passes.any { it.isDeepSpace }) {
|
||||||
val riseSet = CelestialComputer.findSunRiseSet(stationPos, System.currentTimeMillis())
|
val riseSet = CelestialComputer.findSunRiseSet(stationPos, startOfDayMillis(System.currentTimeMillis(), tz))
|
||||||
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
||||||
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
||||||
result["DeepSpace (period >225min)"] = rise to set
|
result["DeepSpace (period >225min)"] = rise to set
|
||||||
@@ -180,7 +184,7 @@ class PassesViewModel(
|
|||||||
if (pass.isDeepSpace) continue
|
if (pass.isDeepSpace) continue
|
||||||
val label = sdfDate.format(Date(pass.aosTime))
|
val label = sdfDate.format(Date(pass.aosTime))
|
||||||
if (label in result) continue
|
if (label in result) continue
|
||||||
val riseSet = CelestialComputer.findSunRiseSet(stationPos, pass.aosTime)
|
val riseSet = CelestialComputer.findSunRiseSet(stationPos, startOfDayMillis(pass.aosTime, tz))
|
||||||
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
val rise = if (riseSet.riseTimeMillis > 0) sdfTime.format(Date(riseSet.riseTimeMillis)) else "--:--"
|
||||||
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
val set = if (riseSet.setTimeMillis > 0) sdfTime.format(Date(riseSet.setTimeMillis)) else "--:--"
|
||||||
result[label] = rise to set
|
result[label] = rise to set
|
||||||
@@ -188,6 +192,16 @@ class PassesViewModel(
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startOfDayMillis(timeMillis: Long, tz: TimeZone): Long {
|
||||||
|
return Calendar.getInstance(tz, Locale.getDefault()).apply {
|
||||||
|
timeInMillis = timeMillis
|
||||||
|
set(Calendar.HOUR_OF_DAY, 0)
|
||||||
|
set(Calendar.MINUTE, 0)
|
||||||
|
set(Calendar.SECOND, 0)
|
||||||
|
set(Calendar.MILLISECOND, 0)
|
||||||
|
}.timeInMillis
|
||||||
|
}
|
||||||
|
|
||||||
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
|
private fun groupPasses(passes: List<OrbitalPass>, isUtc: Boolean): Map<String, List<OrbitalPass>> {
|
||||||
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
val tz = if (isUtc) TimeZone.getTimeZone("UTC") else TimeZone.getDefault()
|
||||||
val sdfDate = dateFormat(tz)
|
val sdfDate = dateFormat(tz)
|
||||||
|
|||||||
@@ -8,4 +8,7 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":feature:mutual"))
|
implementation(project(":feature:mutual"))
|
||||||
|
// CW 解码面板: 复用 feature:cw 的 fldigi 解码引擎(纯 Compose 面板)
|
||||||
|
implementation(project(":feature:cw"))
|
||||||
|
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
/*
|
||||||
|
* LogTab.kt - "Log" tab of the radar page (4.5.2 WaveLog logging).
|
||||||
|
*
|
||||||
|
* Layout: transponder list (tap to select, watch frequency while typing) -> callsign input, Enter saves locally ->
|
||||||
|
* local records list below (time/freq/callsign). Records support custom swipe-to-delete:
|
||||||
|
* yellow trash icon shows while swiping; past 75% it becomes an undo key; not undone within 5 s means deleted.
|
||||||
|
*
|
||||||
|
* Enter only saves locally (WavelogQueue); upload is triggered by the 10-min cycle or manually (avoids mis-copied calls).
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.feature.radar
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Spring
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.spring
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
|
import androidx.compose.material3.MenuAnchorType
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQso
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
||||||
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.TimeZone
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
private val WaveLogYellow = Color(0xFFFFC107)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun LogTab(
|
||||||
|
transceivers: List<SatRadio>,
|
||||||
|
orbitalPos: OrbitalPos?,
|
||||||
|
satelliteName: String,
|
||||||
|
queue: WavelogQueue,
|
||||||
|
wavelogConfigured: Boolean,
|
||||||
|
showToast: (String) -> Unit,
|
||||||
|
txBaseFrequencyHz: Long? = null,
|
||||||
|
aosTimeMs: Long = 0L,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var selectedUuid by remember { mutableStateOf<String?>(null) }
|
||||||
|
var refreshTick by remember { mutableIntStateOf(0) }
|
||||||
|
val entries = remember(refreshTick) { queue.all() }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
if (!wavelogConfigured) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.wavelog_need_config),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transponder picker (dropdown): input area appears below once selected
|
||||||
|
// Selected item derived live from transceivers by uuid (Doppler freq updates every second, display refreshes with it)
|
||||||
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
|
val selectedRadio = transceivers.firstOrNull { it.uuid == selectedUuid }
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = menuExpanded,
|
||||||
|
onExpandedChange = { menuExpanded = it }
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = selectedRadio?.info ?: "",
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text(stringResource(id = R.string.wavelog_select_transponder)) },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = menuExpanded) },
|
||||||
|
textStyle = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier
|
||||||
|
.menuAnchor(MenuAnchorType.PrimaryNotEditable)
|
||||||
|
.fillMaxWidth()
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = menuExpanded,
|
||||||
|
onDismissRequest = { menuExpanded = false }
|
||||||
|
) {
|
||||||
|
transceivers.forEach { radio ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = radio.info,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
selectedUuid = radio.uuid
|
||||||
|
menuExpanded = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After selecting a transponder: input area (live freq + callsign + mode)
|
||||||
|
selectedRadio?.let { radio ->
|
||||||
|
ExpandedLogInput(
|
||||||
|
radio = radio,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
satelliteName = satelliteName,
|
||||||
|
queue = queue,
|
||||||
|
showToast = showToast,
|
||||||
|
txBaseFrequencyHz = txBaseFrequencyHz,
|
||||||
|
aosTimeMs = aosTimeMs,
|
||||||
|
onSaved = { refreshTick++ }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local records list (time / freq / callsign)
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.wavelog_saved),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.wavelog_empty),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(vertical = 8.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Grouped by pass session (sessionId = satName-AOS timestamp), thick line between groups
|
||||||
|
entries.groupBy { it.sessionId.ifBlank { "un" } }.forEach { (sessionId, groupEntries) ->
|
||||||
|
// Group title + divider
|
||||||
|
HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.primary)
|
||||||
|
Text(
|
||||||
|
text = if (sessionId == "un") stringResource(id = R.string.wavelog_ungrouped)
|
||||||
|
else sessionId.substringBeforeLast('-'),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
groupEntries.forEach { entry ->
|
||||||
|
SwipeDeleteRow(
|
||||||
|
onDelete = {
|
||||||
|
queue.remove(entry.id)
|
||||||
|
refreshTick++
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = formatLocalTime(entry.timeUtcMs),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.width(64.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatFrequency(entry.freqTxHz),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.width(76.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = entry.call,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expanded transponder input: live freq + callsign (Enter saves locally) + mode override */
|
||||||
|
@Composable
|
||||||
|
private fun ExpandedLogInput(
|
||||||
|
radio: SatRadio,
|
||||||
|
orbitalPos: OrbitalPos?,
|
||||||
|
satelliteName: String,
|
||||||
|
queue: WavelogQueue,
|
||||||
|
showToast: (String) -> Unit,
|
||||||
|
txBaseFrequencyHz: Long? = null,
|
||||||
|
aosTimeMs: Long = 0L,
|
||||||
|
onSaved: () -> Unit
|
||||||
|
) {
|
||||||
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var callsign by remember { mutableStateOf("") }
|
||||||
|
var mode by remember { mutableStateOf(radio.uplinkMode ?: "FM") }
|
||||||
|
|
||||||
|
val savedMsg = stringResource(id = R.string.wavelog_saved)
|
||||||
|
|
||||||
|
fun submit() {
|
||||||
|
val call = callsign.trim().uppercase()
|
||||||
|
if (call.length < 3) return
|
||||||
|
// Freq taken directly from the transponder bar (radio Doppler-corrected each second; value at the Enter moment)
|
||||||
|
val tx = radio.uplinkLow ?: radio.downlinkLow ?: 0L
|
||||||
|
val rx = radio.downlinkLow ?: radio.uplinkLow ?: 0L
|
||||||
|
val qsoId = UUID.randomUUID().toString()
|
||||||
|
queue.add(
|
||||||
|
WavelogQso(
|
||||||
|
id = qsoId,
|
||||||
|
timeUtcMs = System.currentTimeMillis(),
|
||||||
|
call = call,
|
||||||
|
mode = mode.trim().ifBlank { "FM" }.uppercase(),
|
||||||
|
freqTxHz = tx,
|
||||||
|
freqRxHz = rx,
|
||||||
|
satName = satelliteName,
|
||||||
|
sessionId = buildSessionId(satelliteName, aosTimeMs)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
callsign = ""
|
||||||
|
onSaved()
|
||||||
|
showToast(savedMsg)
|
||||||
|
// QRZ counterpart grid async backfill (4.5.5): only queried when Cookie is set; silent on failure
|
||||||
|
scope.launch {
|
||||||
|
val prefs = context.getSharedPreferences("qrz_cookie", android.content.Context.MODE_PRIVATE)
|
||||||
|
val rawCookie = prefs.getString("cookie", "") ?: ""
|
||||||
|
if (rawCookie.isNotBlank()) {
|
||||||
|
val grid = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.lookupGrid(
|
||||||
|
call, com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.parseCookies(rawCookie)
|
||||||
|
)
|
||||||
|
if (grid != null) queue.updateGridsquare(qsoId, grid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Freq display matches the transponder bar: TX row + RX row (radio from per-second Doppler recompute, live)
|
||||||
|
val txForDisplay = radio.uplinkLow ?: radio.downlinkLow ?: 0L
|
||||||
|
val rxForDisplay = radio.downlinkLow ?: radio.uplinkLow ?: 0L
|
||||||
|
val upLow = radio.uplinkLow
|
||||||
|
val upHigh = radio.uplinkHigh
|
||||||
|
val downLow = radio.downlinkLow
|
||||||
|
val downHigh = radio.downlinkHigh
|
||||||
|
val isLinearRange = upLow != null && upHigh != null && upLow != upHigh
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
// TX row
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = "TX: ",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${formatFrequency(txForDisplay)} MHz",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
if (isLinearRange) {
|
||||||
|
Text(
|
||||||
|
text = " (${formatFrequency(upLow)} – ${formatFrequency(upHigh)})",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// RX row (same as transponder bar RX)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = "RX: ",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${formatFrequency(rxForDisplay)} MHz",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
if (downLow != null && downHigh != null && downLow != downHigh) {
|
||||||
|
Text(
|
||||||
|
text = " (${formatFrequency(downLow)} – ${formatFrequency(downHigh)})",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedTextField(
|
||||||
|
value = callsign,
|
||||||
|
onValueChange = { callsign = it.take(12) },
|
||||||
|
label = { Text(stringResource(id = R.string.wavelog_call_hint)) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||||
|
keyboardActions = KeyboardActions(onDone = { submit() }),
|
||||||
|
textStyle = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = mode,
|
||||||
|
onValueChange = { mode = it.take(8) },
|
||||||
|
label = { Text(stringResource(id = R.string.wavelog_mode_hint)) },
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom swipe-to-delete row (user-requested interaction):
|
||||||
|
* swipe left -> yellow trash on the right; past 75% -> becomes undo key (pending delete, not immediate);
|
||||||
|
* tap undo within the 5 s countdown to restore; otherwise auto-deleted.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun SwipeDeleteRow(
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
var offsetX by remember { mutableStateOf(0f) }
|
||||||
|
var pending by remember { mutableStateOf(false) }
|
||||||
|
var countdown by remember { mutableIntStateOf(5) }
|
||||||
|
var rowWidth by remember { mutableIntStateOf(0) }
|
||||||
|
val threshold = rowWidth * 0.75f
|
||||||
|
|
||||||
|
// Smooth spring back / slide in (animation)
|
||||||
|
val animatedOffset by animateFloatAsState(
|
||||||
|
targetValue = offsetX,
|
||||||
|
animationSpec = if (pending) tween(200) else spring(dampingRatio = Spring.DampingRatioNoBouncy),
|
||||||
|
label = "swipeOffset"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Countdown: auto-delete 5 s after entering pending state
|
||||||
|
LaunchedEffect(pending) {
|
||||||
|
if (pending) {
|
||||||
|
countdown = 5
|
||||||
|
while (countdown > 0) {
|
||||||
|
delay(1000)
|
||||||
|
countdown--
|
||||||
|
}
|
||||||
|
if (pending) {
|
||||||
|
onDelete()
|
||||||
|
pending = false
|
||||||
|
offsetX = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.onSizeChanged { rowWidth = it.width }
|
||||||
|
) {
|
||||||
|
// Background layer (right-side icons: trash / undo + countdown)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.background(WaveLogYellow.copy(alpha = 0.25f)),
|
||||||
|
contentAlignment = Alignment.CenterEnd
|
||||||
|
) {
|
||||||
|
if (pending) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.padding(end = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "$countdown",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = WaveLogYellow
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.wavelog_delete_undo),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = WaveLogYellow,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(WaveLogYellow.copy(alpha = 0.15f))
|
||||||
|
.clickable {
|
||||||
|
pending = false
|
||||||
|
offsetX = 0f
|
||||||
|
}
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Trash icon (always visible while swiping, yellow)
|
||||||
|
Text(
|
||||||
|
text = "🗑",
|
||||||
|
fontSize = 20.sp,
|
||||||
|
modifier = Modifier.padding(end = 16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Content layer (follows finger): background sits inside the offset (moves with content)!
|
||||||
|
// Normally covers the trash; swiping moves content+background away -> yellow trash / undo countdown revealed
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.offset { IntOffset(animatedOffset.roundToInt(), 0) }
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
detectHorizontalDragGestures(
|
||||||
|
onDragStart = { if (pending) { pending = false } },
|
||||||
|
onHorizontalDrag = { change, dragAmount ->
|
||||||
|
change.consume()
|
||||||
|
offsetX = (offsetX + dragAmount).coerceIn(-rowWidth.toFloat(), 0f)
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
if (offsetX <= -threshold) {
|
||||||
|
pending = true
|
||||||
|
countdown = 5
|
||||||
|
} else {
|
||||||
|
offsetX = 0f
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDragCancel = { offsetX = 0f }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatLocalTime(utcMs: Long): String {
|
||||||
|
val cal = Calendar.getInstance(TimeZone.getDefault())
|
||||||
|
cal.timeInMillis = utcMs
|
||||||
|
return "%02d:%02d".format(cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pass session ID: satName-AOS timestamp (UTC yyyyMMdd-HHmm); falls back to Enter time without AOS */
|
||||||
|
private fun buildSessionId(satName: String, aosTimeMs: Long): String {
|
||||||
|
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||||
|
cal.timeInMillis = if (aosTimeMs > 0) aosTimeMs else System.currentTimeMillis()
|
||||||
|
val stamp = "%04d%02d%02d-%02d%02d".format(
|
||||||
|
cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH),
|
||||||
|
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)
|
||||||
|
)
|
||||||
|
return "$satName-$stamp"
|
||||||
|
}
|
||||||
@@ -61,6 +61,9 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
|||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.UploadOutcome
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||||
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
import com.rtbishop.look4sat.core.domain.utility.toDegrees
|
||||||
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
import com.rtbishop.look4sat.core.presentation.EmptyListCard
|
||||||
import com.rtbishop.look4sat.core.presentation.IconCard
|
import com.rtbishop.look4sat.core.presentation.IconCard
|
||||||
@@ -78,6 +81,8 @@ import kotlin.math.PI
|
|||||||
|
|
||||||
private enum class RadarPage(val title: String) {
|
private enum class RadarPage(val title: String) {
|
||||||
Transceivers("Transceivers"),
|
Transceivers("Transceivers"),
|
||||||
|
Log("Log"),
|
||||||
|
Calculator("Calculator"),
|
||||||
Sstv("SSTV")
|
Sstv("SSTV")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,9 +124,33 @@ fun RadarDestination(navigateUp: () -> Unit) {
|
|||||||
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
viewModel.onAction(RadarAction.SstvPermissionResult(granted))
|
||||||
viewModel.onAction(RadarAction.CwPermissionResult(granted))
|
viewModel.onAction(RadarAction.CwPermissionResult(granted))
|
||||||
}
|
}
|
||||||
RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = {
|
// WaveLog (4.5.2): auto-upload - retries the local queue every 10 min (when the switch is on)
|
||||||
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
val wavelogUploader = remember { container.provideWavelogUploader() }
|
||||||
})
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
delay(10 * 60 * 1000L)
|
||||||
|
val s = container.settingsRepo.otherSettings.value
|
||||||
|
if (s.wavelogAutoUpload && s.wavelogUrl.isNotBlank()) {
|
||||||
|
// Grid mismatch is skipped silently (left for manual-upload confirmation); other failures retry on the next tick
|
||||||
|
val outcome = wavelogUploader.uploadQueue()
|
||||||
|
if (outcome is UploadOutcome.NeedConfirm) {
|
||||||
|
// Skip this run; the user can upload and confirm manually in settings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RadarScreen(
|
||||||
|
uiState,
|
||||||
|
viewModel::onAction,
|
||||||
|
navigateUpAndClearMutual,
|
||||||
|
mutualData,
|
||||||
|
{ permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) },
|
||||||
|
wavelogQueue = container.wavelogQueue,
|
||||||
|
wavelogConfigured = container.settingsRepo.otherSettings.value.let {
|
||||||
|
it.wavelogUrl.isNotBlank() && it.wavelogApiKey.isNotBlank() && it.wavelogStationId.isNotBlank()
|
||||||
|
},
|
||||||
|
showToast = { msg -> container.provideShowToast()(msg) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -130,7 +159,10 @@ private fun RadarScreen(
|
|||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
navigateUp: () -> Unit,
|
navigateUp: () -> Unit,
|
||||||
mutualData: MutualPassData,
|
mutualData: MutualPassData,
|
||||||
requestMicPermission: () -> Unit
|
requestMicPermission: () -> Unit,
|
||||||
|
wavelogQueue: WavelogQueue,
|
||||||
|
wavelogConfigured: Boolean,
|
||||||
|
showToast: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
val upcomingPass = uiState.currentPass ?: getDefaultPass()
|
||||||
val addToCalendar: () -> Unit = {
|
val addToCalendar: () -> Unit = {
|
||||||
@@ -184,11 +216,11 @@ private fun RadarScreen(
|
|||||||
}
|
}
|
||||||
if (isVertical) {
|
if (isVertical) {
|
||||||
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
||||||
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
PagerCard(uiState, onAction, requestMicPermission, wavelogQueue, wavelogConfigured, showToast, Modifier.weight(1f))
|
||||||
} else {
|
} else {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
RadarCard(uiState, trackB, trackBPosition, Modifier.weight(1f))
|
||||||
PagerCard(uiState, onAction, requestMicPermission, Modifier.weight(1f))
|
PagerCard(uiState, onAction, requestMicPermission, wavelogQueue, wavelogConfigured, showToast, Modifier.weight(1f))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,18 +231,37 @@ private fun PagerCard(
|
|||||||
uiState: RadarState,
|
uiState: RadarState,
|
||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
requestMicPermission: () -> Unit,
|
requestMicPermission: () -> Unit,
|
||||||
|
wavelogQueue: WavelogQueue,
|
||||||
|
wavelogConfigured: Boolean,
|
||||||
|
showToast: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
val pages = RadarPage.entries
|
val hasCalculatorPage = remember(uiState.transceivers.transmitters) {
|
||||||
|
uiState.transceivers.transmitters.any(DopplerFrequencyCalculator::isNamedLinearTransponder)
|
||||||
|
}
|
||||||
|
val pages = remember(hasCalculatorPage) {
|
||||||
|
buildList {
|
||||||
|
add(RadarPage.Transceivers)
|
||||||
|
add(RadarPage.Log)
|
||||||
|
if (hasCalculatorPage) add(RadarPage.Calculator)
|
||||||
|
add(RadarPage.Sstv)
|
||||||
|
}
|
||||||
|
}
|
||||||
val pagerState = rememberPagerState(pageCount = { pages.size })
|
val pagerState = rememberPagerState(pageCount = { pages.size })
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
LaunchedEffect(pages.size) {
|
||||||
|
val lastPage = pages.lastIndex
|
||||||
|
if (pagerState.currentPage > lastPage) pagerState.scrollToPage(lastPage)
|
||||||
|
}
|
||||||
|
|
||||||
ElevatedCard(modifier = modifier) {
|
ElevatedCard(modifier = modifier) {
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
PrimaryTabRow(selectedTabIndex = pagerState.currentPage) {
|
val selectedTabIndex = pagerState.currentPage.coerceIn(0, pages.lastIndex)
|
||||||
|
PrimaryTabRow(selectedTabIndex = selectedTabIndex) {
|
||||||
pages.forEachIndexed { index, page ->
|
pages.forEachIndexed { index, page ->
|
||||||
Tab(
|
Tab(
|
||||||
selected = pagerState.currentPage == index,
|
selected = selectedTabIndex == index,
|
||||||
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
|
onClick = { coroutineScope.launch { pagerState.animateScrollToPage(index) } },
|
||||||
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
text = { Text(text = page.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }
|
||||||
)
|
)
|
||||||
@@ -222,11 +273,16 @@ private fun PagerCard(
|
|||||||
) { pageIndex ->
|
) { pageIndex ->
|
||||||
when (pages[pageIndex]) {
|
when (pages[pageIndex]) {
|
||||||
RadarPage.Transceivers -> TransceiversPage(
|
RadarPage.Transceivers -> TransceiversPage(
|
||||||
|
transceivers = uiState.transceivers.transmitters,
|
||||||
|
selectedUuid = uiState.transceivers.selectedUuid,
|
||||||
|
radioControl = uiState.radioControl,
|
||||||
|
onAction = onAction
|
||||||
|
)
|
||||||
|
RadarPage.Calculator -> CalculatorPage(
|
||||||
transceivers = uiState.transceivers.transmitters,
|
transceivers = uiState.transceivers.transmitters,
|
||||||
selectedUuid = uiState.transceivers.selectedUuid,
|
selectedUuid = uiState.transceivers.selectedUuid,
|
||||||
orbitalPos = uiState.orbitalPos,
|
orbitalPos = uiState.orbitalPos,
|
||||||
cw = uiState.cw,
|
cw = uiState.cw,
|
||||||
radioControl = uiState.radioControl,
|
|
||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
requestMicPermission = requestMicPermission
|
requestMicPermission = requestMicPermission
|
||||||
)
|
)
|
||||||
@@ -236,6 +292,16 @@ private fun PagerCard(
|
|||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
requestMicPermission = requestMicPermission
|
requestMicPermission = requestMicPermission
|
||||||
)
|
)
|
||||||
|
RadarPage.Log -> LogTab(
|
||||||
|
transceivers = uiState.transceivers.transmitters,
|
||||||
|
orbitalPos = uiState.orbitalPos,
|
||||||
|
satelliteName = uiState.currentPass?.name ?: "",
|
||||||
|
queue = wavelogQueue,
|
||||||
|
wavelogConfigured = wavelogConfigured,
|
||||||
|
showToast = showToast,
|
||||||
|
txBaseFrequencyHz = uiState.radioControl.txBaseFrequencyHz,
|
||||||
|
aosTimeMs = uiState.currentPass?.aosTime ?: 0L
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+343
-79
@@ -17,9 +17,13 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.feature.radar
|
package com.rtbishop.look4sat.feature.radar
|
||||||
|
|
||||||
|
import android.media.AudioFormat
|
||||||
|
import android.media.AudioRecord
|
||||||
|
import android.media.MediaRecorder
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.expandVertically
|
import androidx.compose.animation.expandVertically
|
||||||
import androidx.compose.animation.shrinkVertically
|
import androidx.compose.animation.shrinkVertically
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
@@ -39,9 +43,9 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ElevatedCard
|
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -50,28 +54,45 @@ import androidx.compose.material3.OutlinedButton
|
|||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.rotate
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
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.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
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 com.rtbishop.look4sat.core.domain.cw.CwFldigiDecoder
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPos
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
import com.rtbishop.look4sat.core.domain.utility.DopplerFrequencyCalculator
|
||||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
@@ -82,11 +103,8 @@ import kotlin.time.Duration.Companion.milliseconds
|
|||||||
fun TransceiversPage(
|
fun TransceiversPage(
|
||||||
transceivers: List<SatRadio>,
|
transceivers: List<SatRadio>,
|
||||||
selectedUuid: String?,
|
selectedUuid: String?,
|
||||||
orbitalPos: OrbitalPos?,
|
|
||||||
cw: CwSubState,
|
|
||||||
radioControl: RadioControlSubState,
|
radioControl: RadioControlSubState,
|
||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
requestMicPermission: () -> Unit = {},
|
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
if (transceivers.isEmpty()) {
|
if (transceivers.isEmpty()) {
|
||||||
@@ -109,11 +127,8 @@ fun TransceiversPage(
|
|||||||
TransceiverItem(
|
TransceiverItem(
|
||||||
radio = radio,
|
radio = radio,
|
||||||
isExpanded = isExpanded,
|
isExpanded = isExpanded,
|
||||||
orbitalPos = orbitalPos,
|
|
||||||
cw = cw,
|
|
||||||
radioControl = radioControl,
|
radioControl = radioControl,
|
||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
requestMicPermission = requestMicPermission,
|
|
||||||
onToggle = { onAction(RadarAction.SelectTransmitter(radio.uuid)) }
|
onToggle = { onAction(RadarAction.SelectTransmitter(radio.uuid)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -121,6 +136,87 @@ fun TransceiversPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CalculatorPage(
|
||||||
|
transceivers: List<SatRadio>,
|
||||||
|
selectedUuid: String?,
|
||||||
|
orbitalPos: OrbitalPos?,
|
||||||
|
cw: CwSubState,
|
||||||
|
onAction: (RadarAction) -> Unit,
|
||||||
|
requestMicPermission: () -> Unit = {},
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val calculatorTransceivers = remember(transceivers) {
|
||||||
|
transceivers.filter(DopplerFrequencyCalculator::isNamedLinearTransponder)
|
||||||
|
}
|
||||||
|
val selectedTransceiver = remember(calculatorTransceivers, selectedUuid) {
|
||||||
|
calculatorTransceivers.firstOrNull { it.uuid == selectedUuid }
|
||||||
|
?: calculatorTransceivers.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTransceiver == null) {
|
||||||
|
EmptyTransceiversContent(modifier)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
if (calculatorTransceivers.size > 1) {
|
||||||
|
item {
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
calculatorTransceivers.forEach { radio ->
|
||||||
|
FilterChip(
|
||||||
|
selected = radio.uuid == selectedTransceiver.uuid,
|
||||||
|
onClick = {
|
||||||
|
if (radio.uuid != selectedUuid) onAction(RadarAction.SelectTransmitter(radio.uuid))
|
||||||
|
},
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
text = transceiverTitle(radio),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
DopplerFrequencyCalculator(
|
||||||
|
transponder = selectedTransceiver,
|
||||||
|
orbitalPos = orbitalPos,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
HorizontalDivider(
|
||||||
|
thickness = 1.dp,
|
||||||
|
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
CwDecoderPanel(
|
||||||
|
cw = cw,
|
||||||
|
onAction = onAction,
|
||||||
|
requestMicPermission = requestMicPermission,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
|
private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
|
||||||
Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
|
Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
|
||||||
@@ -148,11 +244,8 @@ private fun EmptyTransceiversContent(modifier: Modifier = Modifier) {
|
|||||||
private fun TransceiverItem(
|
private fun TransceiverItem(
|
||||||
radio: SatRadio,
|
radio: SatRadio,
|
||||||
isExpanded: Boolean,
|
isExpanded: Boolean,
|
||||||
orbitalPos: OrbitalPos?,
|
|
||||||
cw: CwSubState,
|
|
||||||
radioControl: RadioControlSubState,
|
radioControl: RadioControlSubState,
|
||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
requestMicPermission: () -> Unit,
|
|
||||||
onToggle: () -> Unit
|
onToggle: () -> Unit
|
||||||
) {
|
) {
|
||||||
val bgColor = if (isExpanded) MaterialTheme.colorScheme.surfaceContainerHighest
|
val bgColor = if (isExpanded) MaterialTheme.colorScheme.surfaceContainerHighest
|
||||||
@@ -193,11 +286,8 @@ private fun TransceiverItem(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Title with mode
|
// Title with mode
|
||||||
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
|
|
||||||
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
|
|
||||||
val fullTitle = "$title ($mode)"
|
|
||||||
Text(
|
Text(
|
||||||
text = fullTitle,
|
text = transceiverTitle(radio),
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
@@ -245,11 +335,8 @@ private fun TransceiverItem(
|
|||||||
) {
|
) {
|
||||||
ExpandedRadioControl(
|
ExpandedRadioControl(
|
||||||
radio = radio,
|
radio = radio,
|
||||||
orbitalPos = orbitalPos,
|
|
||||||
cw = cw,
|
|
||||||
radioControl = radioControl,
|
radioControl = radioControl,
|
||||||
onAction = onAction,
|
onAction = onAction
|
||||||
requestMicPermission = requestMicPermission
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,11 +405,8 @@ private fun UnifiedFrequencyRow(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ExpandedRadioControl(
|
private fun ExpandedRadioControl(
|
||||||
radio: SatRadio,
|
radio: SatRadio,
|
||||||
orbitalPos: OrbitalPos?,
|
|
||||||
cw: CwSubState,
|
|
||||||
radioControl: RadioControlSubState,
|
radioControl: RadioControlSubState,
|
||||||
onAction: (RadarAction) -> Unit,
|
onAction: (RadarAction) -> Unit,
|
||||||
requestMicPermission: () -> Unit = {},
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -431,23 +515,6 @@ private fun ExpandedRadioControl(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Doppler frequency calculator (linear transponders only)
|
|
||||||
DopplerFrequencyCalculator(
|
|
||||||
transponder = radio,
|
|
||||||
orbitalPos = orbitalPos,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
|
|
||||||
// CW decoder panel (linear transponders only)
|
|
||||||
if (DopplerFrequencyCalculator.isLinearTransponder(radio)) {
|
|
||||||
CwDecoderPanel(
|
|
||||||
cw = cw,
|
|
||||||
onAction = onAction,
|
|
||||||
requestMicPermission = requestMicPermission,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Control buttons
|
// Control buttons
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
|
if (!radioControl.txPanel.isConnected && !radioControl.rxPanel.isConnected) {
|
||||||
@@ -565,7 +632,7 @@ private fun DopplerFrequencyCalculator(
|
|||||||
},
|
},
|
||||||
label = { Text(stringResource(R.string.radar_doppler_offset_hint)) },
|
label = { Text(stringResource(R.string.radar_doppler_offset_hint)) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -661,78 +728,111 @@ private fun CwDecoderPanel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(visible = cw.isExpanded) {
|
AnimatedVisibility(visible = cw.isExpanded) {
|
||||||
|
// fldigi engine: pure Compose panel (waterfall + decoded text).
|
||||||
|
val context = LocalContext.current
|
||||||
|
val decoder = remember { CwFldigiDecoder(initialWpm = 18) }
|
||||||
|
val waterfall = remember { CwPanelWaterfallState() }
|
||||||
|
var listening by remember { mutableStateOf(false) }
|
||||||
|
var captureJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val decodedText by decoder.decodedTextFlow.collectAsState()
|
||||||
|
val signalStrength by decoder.signalStrength.collectAsState()
|
||||||
|
|
||||||
|
fun startPanelCapture() {
|
||||||
|
if (!cw.hasPermission) {
|
||||||
|
requestMicPermission()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
captureJob?.cancel()
|
||||||
|
captureJob = scope.launch(Dispatchers.IO) {
|
||||||
|
val capture = CwPanelMicCapture()
|
||||||
|
capture.audioFlow().collect { chunk ->
|
||||||
|
if (!isActive) return@collect
|
||||||
|
decoder.processBuffer(chunk)
|
||||||
|
waterfall.pushSamples(chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listening = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopPanelCapture() {
|
||||||
|
captureJob?.cancel()
|
||||||
|
captureJob = null
|
||||||
|
listening = false
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose { stopPanelCapture() }
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(cw.hasPermission) {
|
||||||
|
if (cw.hasPermission && !listening) {
|
||||||
|
startPanelCapture()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
// Control buttons row
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
if (cw.status == CwStatus.Idle) {
|
if (!listening) {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = { startPanelCapture() },
|
||||||
if (!cw.hasPermission) {
|
|
||||||
requestMicPermission()
|
|
||||||
} else {
|
|
||||||
onAction(RadarAction.CwStartListening)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.radar_cw_start))
|
Text(stringResource(R.string.radar_cw_start))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Button(
|
Button(
|
||||||
onClick = { onAction(RadarAction.CwStopListening) },
|
onClick = { stopPanelCapture() },
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.radar_cw_stop))
|
Text(stringResource(R.string.radar_cw_stop))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { onAction(RadarAction.CwReset) },
|
onClick = { decoder.resetDecoder(); waterfall.reset() },
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.radar_cw_reset))
|
Text(stringResource(R.string.radar_cw_reset))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Signal strength indicator
|
Box(
|
||||||
if (cw.status == CwStatus.Listening && cw.signalStrength > 0f) {
|
|
||||||
val strengthPct = (cw.signalStrength * 100).toInt()
|
|
||||||
Text(
|
|
||||||
text = "Signal: $strengthPct%",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = if (cw.signalStrength > 0.5f) Color(0xFF4CAF50)
|
|
||||||
else if (cw.signalStrength > 0.2f) Color(0xFFFFC107)
|
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decoded text output
|
|
||||||
ElevatedCard(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(120.dp)
|
.height(90.dp)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
) {
|
) {
|
||||||
Column(
|
CwPanelWaterfallView(state = waterfall)
|
||||||
modifier = Modifier
|
}
|
||||||
.fillMaxSize()
|
|
||||||
.padding(8.dp)
|
Text(
|
||||||
|
text = decodedText.takeLast(120),
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Decoded:",
|
text = if (listening) "● ACTIVE" else "○ IDLE",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = if (listening) Color(0xFF4CAF50) else MaterialTheme.colorScheme.outline
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "LEVEL ${(signalStrength * 100).roundToInt()}%",
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = cw.decodedText.ifEmpty { "Waiting for CW signal..." },
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -754,5 +854,169 @@ private fun FrequencyText(frequency: Long?, modifier: Modifier = Modifier) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun transceiverTitle(radio: SatRadio): String {
|
||||||
|
val title = if (radio.isInverted) "INV: ${radio.info}" else radio.info
|
||||||
|
val mode = "${radio.downlinkMode ?: "--"}/${radio.uplinkMode ?: "--"}"
|
||||||
|
return "$title ($mode)"
|
||||||
|
}
|
||||||
|
|
||||||
private val FREQ_ADJUSTMENTS =
|
private val FREQ_ADJUSTMENTS =
|
||||||
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
|
listOf(-10_000L to "-10k", -1_000L to "-1k", -100L to "-100", 100L to "+100", 1_000L to "+1k", 10_000L to "+10k")
|
||||||
|
|
||||||
|
// --- CW panel components (fldigi engine, pure Compose) ---
|
||||||
|
|
||||||
|
/** Microphone capture at 8000 Hz mono PCM float (fldigi sample rate). */
|
||||||
|
private class CwPanelMicCapture {
|
||||||
|
private val sampleRate = 8000
|
||||||
|
private val channelConfig = AudioFormat.CHANNEL_IN_MONO
|
||||||
|
private val audioFormat = AudioFormat.ENCODING_PCM_FLOAT
|
||||||
|
|
||||||
|
fun audioFlow(): Flow<FloatArray> = flow {
|
||||||
|
val bufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
|
||||||
|
.coerceAtLeast(sampleRate / 5)
|
||||||
|
val recorder = AudioRecord(
|
||||||
|
MediaRecorder.AudioSource.MIC,
|
||||||
|
sampleRate,
|
||||||
|
channelConfig,
|
||||||
|
audioFormat,
|
||||||
|
bufferSize * 4
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
recorder.startRecording()
|
||||||
|
val chunkSize = sampleRate / 10
|
||||||
|
val buffer = FloatArray(chunkSize)
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
val read = recorder.read(buffer, 0, chunkSize, AudioRecord.READ_BLOCKING)
|
||||||
|
if (read > 0) {
|
||||||
|
emit(if (read == chunkSize) buffer.copyOf() else buffer.copyOfRange(0, read))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
recorder.stop()
|
||||||
|
recorder.release()
|
||||||
|
}
|
||||||
|
}.flowOn(Dispatchers.IO)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sliding spectrogram ring buffer for the CW waterfall. */
|
||||||
|
private class CwPanelWaterfallState {
|
||||||
|
private val columns = 120
|
||||||
|
private val rows = 48
|
||||||
|
private val data = FloatArray(columns * rows)
|
||||||
|
private val pending = ArrayList<Float>(4096)
|
||||||
|
private val fftWindow = 512
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun pushSamples(samples: FloatArray) {
|
||||||
|
pending.addAll(samples.toList())
|
||||||
|
while (pending.size >= fftWindow) {
|
||||||
|
val window = FloatArray(fftWindow) { pending[it] }
|
||||||
|
repeat(fftWindow) { pending.removeAt(0) }
|
||||||
|
val spectrum = computeSpectrum(window)
|
||||||
|
for (r in 0 until rows - 1) {
|
||||||
|
System.arraycopy(data, (r + 1) * columns, data, r * columns, columns)
|
||||||
|
}
|
||||||
|
for (c in 0 until columns) {
|
||||||
|
val bin = c * spectrum.size / columns
|
||||||
|
data[(rows - 1) * columns + c] = spectrum[bin.coerceAtMost(spectrum.size - 1)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun computeSpectrum(window: FloatArray): FloatArray {
|
||||||
|
val n = window.size
|
||||||
|
val re = FloatArray(n)
|
||||||
|
val im = FloatArray(n)
|
||||||
|
for (i in 0 until n) {
|
||||||
|
val w = 0.5 - 0.5 * kotlin.math.cos(2.0 * Math.PI * i / (n - 1))
|
||||||
|
re[i] = window[i] * w.toFloat()
|
||||||
|
}
|
||||||
|
var j = 0
|
||||||
|
for (i in 1 until n) {
|
||||||
|
var bit = n shr 1
|
||||||
|
while (j and bit != 0) { j = j xor bit; bit = bit shr 1 }
|
||||||
|
j = j xor bit
|
||||||
|
if (i < j) {
|
||||||
|
val tr = re[i]; re[i] = re[j]; re[j] = tr
|
||||||
|
val ti = im[i]; im[i] = im[j]; im[j] = ti
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var len = 2
|
||||||
|
while (len <= n) {
|
||||||
|
val ang = -2.0 * Math.PI / len
|
||||||
|
val wRe = kotlin.math.cos(ang).toFloat()
|
||||||
|
val wIm = kotlin.math.sin(ang).toFloat()
|
||||||
|
var i = 0
|
||||||
|
while (i < n) {
|
||||||
|
var curRe = 1f
|
||||||
|
var curIm = 0f
|
||||||
|
for (k in 0 until len / 2) {
|
||||||
|
val uRe = re[i + k]
|
||||||
|
val uIm = im[i + k]
|
||||||
|
val vRe = re[i + k + len / 2] * curRe - im[i + k + len / 2] * curIm
|
||||||
|
val vIm = re[i + k + len / 2] * curIm + im[i + k + len / 2] * curRe
|
||||||
|
re[i + k] = uRe + vRe
|
||||||
|
im[i + k] = uIm + vIm
|
||||||
|
re[i + k + len / 2] = uRe - vRe
|
||||||
|
im[i + k + len / 2] = uIm - vIm
|
||||||
|
val nRe = curRe * wRe - curIm * wIm
|
||||||
|
val nIm = curRe * wIm + curIm * wRe
|
||||||
|
curRe = nRe; curIm = nIm
|
||||||
|
}
|
||||||
|
i += len
|
||||||
|
}
|
||||||
|
len = len shl 1
|
||||||
|
}
|
||||||
|
val bins = 48
|
||||||
|
val out = FloatArray(bins)
|
||||||
|
for (k in 0 until bins) {
|
||||||
|
out[k] = kotlin.math.sqrt(re[k] * re[k] + im[k] * im[k]) / n
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun getColumn(c: Int): FloatArray {
|
||||||
|
val col = FloatArray(rows)
|
||||||
|
for (r in 0 until rows) {
|
||||||
|
col[r] = data[r * columns + c.coerceIn(0, columns - 1)]
|
||||||
|
}
|
||||||
|
return col
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun reset() {
|
||||||
|
data.fill(0f)
|
||||||
|
pending.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CwPanelWaterfallView(state: CwPanelWaterfallState) {
|
||||||
|
val columns = 120
|
||||||
|
val rows = 48
|
||||||
|
Canvas(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color(0xFF0D1B2A))
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
) {
|
||||||
|
val cellW = size.width / columns
|
||||||
|
val cellH = size.height / rows
|
||||||
|
for (c in 0 until columns) {
|
||||||
|
val col = state.getColumn(c)
|
||||||
|
for (r in 0 until rows) {
|
||||||
|
val v = col[r]
|
||||||
|
val intensity = (v * 4000f).coerceIn(0f, 255f)
|
||||||
|
if (intensity > 6f) {
|
||||||
|
val green = (intensity * 1.3f).coerceAtMost(255f)
|
||||||
|
drawRect(
|
||||||
|
color = Color(0f, green / 255f, 0f, 1f),
|
||||||
|
topLeft = Offset(c * cellW, r * cellH),
|
||||||
|
size = Size(cellW + 0.5f, cellH + 0.5f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
/*
|
||||||
|
* WavelogLogScreen.kt - the Log page (More menu, 4.5.2 fix).
|
||||||
|
*
|
||||||
|
* Shows locally stored logs as a table: time | freq | satellite | callsign | uploaded (✓).
|
||||||
|
* Full grid lines (header rule + row rules + column rules + outer border); rows reuse swipe-to-delete.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.feature.radar
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
||||||
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.core.presentation.formatFrequency
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
private val CheckGreen = Color(0xFF4CAF50)
|
||||||
|
private val GridLineColor = Color(0xFF3A3A3A)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun WavelogLogScreen(
|
||||||
|
queue: WavelogQueue,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
var refreshTick by remember { mutableIntStateOf(0) }
|
||||||
|
val entries = remember(refreshTick) { queue.all() }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
// Table outer border (left/right; top/bottom formed by header/row bottom rules)
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainer)
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(38.dp)
|
||||||
|
) {
|
||||||
|
HeaderCell(stringResource(id = R.string.wavelog_col_time), 72.dp)
|
||||||
|
GridVLine()
|
||||||
|
HeaderCell(stringResource(id = R.string.wavelog_col_freq), 64.dp)
|
||||||
|
GridVLine()
|
||||||
|
HeaderCell(stringResource(id = R.string.wavelog_col_sat), 0.dp, weight = 1.2f)
|
||||||
|
GridVLine()
|
||||||
|
HeaderCell(stringResource(id = R.string.wavelog_col_call), 0.dp, weight = 1f)
|
||||||
|
GridVLine()
|
||||||
|
HeaderCell(stringResource(id = R.string.wavelog_col_uploaded), 44.dp)
|
||||||
|
}
|
||||||
|
// Header rule
|
||||||
|
HorizontalDivider(thickness = 1.5.dp, color = GridLineColor)
|
||||||
|
// Space between header and data area
|
||||||
|
Box(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.wavelog_empty),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 12.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Grouped by pass session (sessionId = satName-AOS timestamp); empty sessionId goes to "Uncategorized" last
|
||||||
|
val groups = entries.groupBy { it.sessionId.ifBlank { UNGROUPED } }
|
||||||
|
.toSortedMap(compareByDescending<String> { id ->
|
||||||
|
entries.filter { it.sessionId.ifBlank { UNGROUPED } == id }.minOfOrNull { it.timeUtcMs } ?: 0L
|
||||||
|
}.thenByDescending { it })
|
||||||
|
groups.forEach { (sessionId, groupEntries) ->
|
||||||
|
// Group title (session name + local time)
|
||||||
|
val (satPart, timePart) = parseSession(sessionId)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = if (sessionId == UNGROUPED) stringResource(id = R.string.wavelog_ungrouped)
|
||||||
|
else "$satPart - ${formatSessionTime(timePart)}",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Rule between groups (md --- effect)
|
||||||
|
HorizontalDivider(thickness = 1.5.dp, color = GridLineColor)
|
||||||
|
groupEntries.forEach { entry ->
|
||||||
|
SwipeDeleteRow(
|
||||||
|
onDelete = {
|
||||||
|
queue.remove(entry.id)
|
||||||
|
refreshTick++
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(36.dp)
|
||||||
|
) {
|
||||||
|
Cell(formatLocalTime(entry.timeUtcMs), 72.dp)
|
||||||
|
GridVLine()
|
||||||
|
Cell(formatFrequency(entry.freqTxHz), 64.dp)
|
||||||
|
GridVLine()
|
||||||
|
Cell(entry.satName, 0.dp, weight = 1.2f)
|
||||||
|
GridVLine()
|
||||||
|
Cell(entry.call, 0.dp, weight = 1f)
|
||||||
|
GridVLine()
|
||||||
|
Box(modifier = Modifier.width(44.dp)) {
|
||||||
|
if (entry.uploaded) {
|
||||||
|
Text(
|
||||||
|
text = "✓",
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = CheckGreen
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Row divider
|
||||||
|
HorizontalDivider(thickness = 1.dp, color = GridLineColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Column rule */
|
||||||
|
@Composable
|
||||||
|
private fun GridVLine() {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(1.dp)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.background(GridLineColor)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RowScope.HeaderCell(text: String, width: Dp, weight: Float = 0f) {
|
||||||
|
val mod = if (weight > 0f) Modifier.weight(weight) else Modifier.width(width)
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = mod.padding(horizontal = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RowScope.Cell(text: String, width: Dp, weight: Float = 0f) {
|
||||||
|
val mod = if (weight > 0f) Modifier.weight(weight) else Modifier.width(width)
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
modifier = mod.padding(horizontal = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val UNGROUPED = "__ungrouped__"
|
||||||
|
|
||||||
|
/** Parse session ID: "ASRTU-1-20260804-2014" -> (sat name, "20260804-2014") */
|
||||||
|
private fun parseSession(sessionId: String): Pair<String, String> {
|
||||||
|
val parts = sessionId.split('-')
|
||||||
|
if (parts.size < 3) return sessionId to ""
|
||||||
|
val timePart = parts.takeLast(2).joinToString("-")
|
||||||
|
val satPart = parts.dropLast(2).joinToString("-")
|
||||||
|
return satPart to timePart
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Session time (UTC "20260804-2014") -> local "MM-dd HH:mm" */
|
||||||
|
private fun formatSessionTime(timePart: String): String {
|
||||||
|
if (timePart.length != 13 || timePart[8] != '-') return timePart
|
||||||
|
return try {
|
||||||
|
val year = timePart.substring(0, 4).toInt()
|
||||||
|
val month = timePart.substring(4, 6).toInt()
|
||||||
|
val day = timePart.substring(6, 8).toInt()
|
||||||
|
val hour = timePart.substring(9, 11).toInt()
|
||||||
|
val minute = timePart.substring(11, 13).toInt()
|
||||||
|
val cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||||
|
cal.set(year, month - 1, day, hour, minute, 0)
|
||||||
|
cal.timeInMillis = cal.timeInMillis + TimeZone.getDefault().getOffset(cal.timeInMillis)
|
||||||
|
"%02d-%02d %02d:%02d".format(
|
||||||
|
cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH),
|
||||||
|
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
timePart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatLocalTime(utcMs: Long): String {
|
||||||
|
val cal = Calendar.getInstance(TimeZone.getDefault())
|
||||||
|
cal.timeInMillis = utcMs
|
||||||
|
return "%02d-%02d %02d:%02d".format(
|
||||||
|
cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH),
|
||||||
|
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)
|
||||||
|
)
|
||||||
|
}
|
||||||
+722
-147
@@ -17,200 +17,775 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.feature.roaming
|
package com.rtbishop.look4sat.feature.roaming
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.location.Location
|
||||||
|
import android.location.LocationListener
|
||||||
|
import android.location.LocationManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.layout.systemBars
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
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.text.style.TextAlign
|
||||||
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.core.content.ContextCompat
|
||||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
private val InfoCyan = Color(0xFF0CCFE2)
|
import java.util.Locale
|
||||||
private val GridBlue = Color(0xFF12ACE0)
|
|
||||||
private val LabelBlue = Color(0xFF135678)
|
|
||||||
private val PositionRed = Color(0xFFFF0000)
|
|
||||||
private val DividerWhite = Color(0xFFF7F7F7)
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Roaming page - ported verbatim from QTH Locator 2.0 (com.us1pm.gridsquarelocator).
|
||||||
|
* UI: res/layout/main.xml (structure/size/colors copied item by item, no changes)
|
||||||
|
* Logic: MainActivity.showLocation() / checkEnabled() / live GPS listening
|
||||||
|
* Hard rule: UI and logic unchanged, pixel-aligned with the original screenshots.
|
||||||
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun RoamingScreen(viewModel: RoamingViewModel) {
|
fun RoamingScreen() {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val context = LocalContext.current
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
var state by remember { mutableStateOf(RoamingState()) }
|
||||||
InfoHeader(state = state, onGpsClick = viewModel::onGpsClick)
|
// Ported from onCreate: fixed date text + hour prefix (minutes appended on locate)
|
||||||
GridPanel(state = state)
|
val dateText = remember { SimpleDateFormat("dd MMM yyyy", Locale.getDefault()).format(Date()) }
|
||||||
|
var hourPrefix by remember { mutableStateOf("") }
|
||||||
|
// Ported from onCreate: initial checkEnabled() -> red dot + settings button, date hidden
|
||||||
|
var hasPermission by remember { mutableStateOf(false) }
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions()
|
||||||
|
) { }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
hourPrefix = SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date()).substring(0, 3)
|
||||||
|
val fine = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
val coarse = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
hasPermission = fine || coarse
|
||||||
|
if (!hasPermission) {
|
||||||
|
permissionLauncher.launch(
|
||||||
|
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Ported from onResume: green dot + progress + hint + date shown, red dot/button hidden
|
||||||
|
state = state.copy(gpsOn = true, gpsOff = false, showSettings = false, showProgress = true, showNotice = true, showDate = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Ported from onResume/onPause: GPS + network listeners, 10 s / 10 m
|
||||||
|
val locationManager = remember { context.getSystemService(Context.LOCATION_SERVICE) as LocationManager }
|
||||||
|
DisposableEffect(locationManager, hasPermission) {
|
||||||
|
if (hasPermission) {
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
val listener = object : LocationListener {
|
||||||
|
override fun onLocationChanged(location: Location) {
|
||||||
|
// Ported from showLocation start: accept only gps / network providers
|
||||||
|
if (location.provider != "gps" && location.provider != "network") return
|
||||||
|
state = roamingStateFromLocation(location.latitude, location.longitude, location.time, hourPrefix)
|
||||||
|
.copy(date = dateText)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
override fun onProviderDisabled(provider: String) {
|
||||||
private fun InfoHeader(state: RoamingState, onGpsClick: () -> Unit) {
|
// Ported from checkEnabled: red dot + button, date hidden
|
||||||
Column(
|
state = state.copy(gpsOn = false, gpsOff = true, showSettings = true, showProgress = false, showNotice = false, showDate = false)
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(InfoCyan)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
|
||||||
) {
|
|
||||||
// Top row: GPS status + big locator
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text = "GPS",
|
|
||||||
fontSize = 20.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = Color.Black
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(12.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(if (state.qthLocator != "----") Color(0xFF3BAF52) else Color.Gray)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
Text(
|
|
||||||
text = state.qthLocator,
|
|
||||||
fontSize = 30.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
color = Color.Black
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
|
||||||
// Lat / Lon rows with DMS + decimal
|
|
||||||
CoordinateRow(label = "Lat", value = state.latitude, isLat = true)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
CoordinateRow(label = "Lon", value = state.longitude, isLat = false)
|
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
|
||||||
CardButton(
|
|
||||||
onClick = onGpsClick,
|
|
||||||
text = "GPS 定位",
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
override fun onProviderEnabled(provider: String) {
|
||||||
private fun CoordinateRow(label: String, value: Double, isLat: Boolean) {
|
state = state.copy(gpsOn = true, gpsOff = false, showSettings = false, showProgress = true, showNotice = true, showDate = true)
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
fontSize = 16.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = Color.Black,
|
|
||||||
modifier = Modifier.width(44.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = formatDms(value, isLat),
|
|
||||||
fontSize = 15.sp,
|
|
||||||
color = Color.Black
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
Text(
|
|
||||||
text = "${formatDecimal(value)}°",
|
|
||||||
fontSize = 14.sp,
|
|
||||||
color = LabelBlue
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Deprecated("Deprecated in Java")
|
||||||
private fun GridPanel(state: RoamingState) {
|
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) = Unit
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
locationManager.requestLocationUpdates("gps", 10_000L, 10f, listener)
|
||||||
|
locationManager.requestLocationUpdates("network", 10_000L, 10f, listener)
|
||||||
|
} catch (_: SecurityException) {
|
||||||
|
}
|
||||||
|
onDispose { locationManager.removeUpdates(listener) }
|
||||||
|
} else {
|
||||||
|
onDispose { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ported from res/layout/main.xml: light page, blue info area, 43sp locator, 3x3 grid, bottom credit
|
||||||
|
// Per user request: inset from system bars top/bottom so content is not covered
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(GridBlue)
|
.background(MaterialTheme.colorScheme.background)
|
||||||
.padding(4.dp)
|
.windowInsetsPadding(WindowInsets.systemBars)
|
||||||
) {
|
) {
|
||||||
// 3x3 grid: row 0 = north
|
// relativeLayout7: top GPS status bar, 25dp high, holo_blue_bright
|
||||||
for (row in 0..2) {
|
Box(
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.weight(1f),
|
.padding(2.dp)
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
.height(25.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
) {
|
) {
|
||||||
for (col in 0..2) {
|
Row(modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
val index = row * 3 + col
|
// tvTitleGPS: "GPS" 14sp black, left 8dp
|
||||||
val isCenter = index == 4
|
|
||||||
GridCell(
|
|
||||||
label = state.gridSquares.getOrElse(index) { "----" },
|
|
||||||
isCenter = isCenter,
|
|
||||||
showMarker = isCenter && state.qthLocator != "----",
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (row < 2) Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun GridCell(
|
|
||||||
label: String,
|
|
||||||
isCenter: Boolean,
|
|
||||||
showMarker: Boolean,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
modifier = modifier
|
|
||||||
.aspectRatio(1f)
|
|
||||||
.background(if (isCenter) InfoCyan else GridBlue)
|
|
||||||
.border(1.dp, DividerWhite)
|
|
||||||
) {
|
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = "GPS",
|
||||||
fontSize = if (isCenter) 26.sp else 16.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = if (isCenter) FontWeight.Bold else FontWeight.Normal,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
color = LabelBlue,
|
modifier = Modifier.padding(start = 8.dp)
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
)
|
||||||
if (showMarker) {
|
// imGps: toRightOf tvTitleGPS + marginLeft 15dp (original main.xml)
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.width(15.dp))
|
||||||
|
// imGps: green dot (15dp, theme primary -> visible under night filter)
|
||||||
|
if (state.gpsOn) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(12.dp)
|
.size(15.dp)
|
||||||
.background(PositionRed)
|
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
||||||
.border(2.dp, DividerWhite)
|
)
|
||||||
|
}
|
||||||
|
// imGpsOff: red dot overlay (15dp, theme error)
|
||||||
|
if (state.gpsOff) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(15.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.error, CircleShape)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
// tvDate: 14sp black, centered
|
||||||
|
if (state.showDate) {
|
||||||
|
Text(text = state.date.ifEmpty { dateText }, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
// tvTime: 14sp black, right-aligned
|
||||||
|
Text(
|
||||||
|
text = state.time,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(end = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// btnLocationSettings: yellow button, centered
|
||||||
|
// btnLocationSettings: original #faf8f54a translucent yellow, 14sp black text, centered
|
||||||
|
if (state.showSettings) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.background(MaterialTheme.colorScheme.primary)
|
||||||
|
.clickable { context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)) }
|
||||||
|
.padding(horizontal = 5.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(text = "设置启用GPS", fontSize = 14.sp, color = MaterialTheme.colorScheme.onPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// linearLayout4: lat/lon area, holo_blue_bright
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 2.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
) {
|
||||||
|
// Lat row: tvLati 16sp black left 8dp | tvLat right-aligned 3dp
|
||||||
|
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(text = state.latLabel, fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp))
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text(text = state.latValue, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 3.dp))
|
||||||
|
}
|
||||||
|
// Lon row
|
||||||
|
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(text = state.lonLabel, fontSize = 16.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp))
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text(text = state.lonValue, fontSize = 14.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(end = 3.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// relativeLayout6: locator area, 43sp bold black centered
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(2.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = state.loc,
|
||||||
|
fontSize = 43.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
// progressBar: loading, centered
|
||||||
|
if (state.showProgress) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp).align(Alignment.Center),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// tvNotice: hint text, centered top
|
||||||
|
if (state.showNotice) {
|
||||||
|
Text(
|
||||||
|
text = "请稍等,加载可能需要几分钟的时间。",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.align(Alignment.TopCenter).padding(top = 2.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// relativeLayout: 3x3 grid, fills remaining space, bottom 15dp
|
||||||
|
// Box as RelativeLayout equivalent: left cell left / middle cell centered / right cell right (original main.xml)
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().weight(1f).padding(bottom = 15.dp)) {
|
||||||
|
// Row 1: relativeLayout5 (top row: lat +1)
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||||
|
GridCell(text = state.grids[0], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterStart))
|
||||||
|
GridCell(text = state.grids[1], modifier = Modifier.width(200.dp).fillMaxHeight().align(Alignment.Center))
|
||||||
|
GridCell(text = state.grids[2], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterEnd))
|
||||||
|
}
|
||||||
|
// Row 2: relativeLayout4, 205dp high (middle row: center)
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().height(205.dp)) {
|
||||||
|
GridCell(text = state.grids[3], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterStart))
|
||||||
|
// Center column: tv22 (100x80dp centerInParent) overlaid on linearLayout3 (200x200dp)
|
||||||
|
Box(modifier = Modifier.width(200.dp).fillMaxHeight().align(Alignment.Center)) {
|
||||||
|
// linearLayout3: 200x200dp, marginTop 2dp, holo_blue_bright
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.offset(y = 2.dp)
|
||||||
|
.size(width = 200.dp, height = 200.dp)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
) {
|
||||||
|
// iv: red dot 10x10dp, top|left, leftMargin/topMargin from lookup tables
|
||||||
|
Image(
|
||||||
|
painter = painterResource(R.drawable.roam_pnt),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(10.dp)
|
||||||
|
.offset(x = state.markerLeft.dp, y = state.markerTop.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// tv22: 100x80dp centerInParent, 30sp bold, textColorHighlight (white)
|
||||||
|
Text(
|
||||||
|
text = state.grids[4],
|
||||||
|
fontSize = 30.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(width = 100.dp, height = 80.dp)
|
||||||
|
.align(Alignment.Center)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
GridCell(text = state.grids[5], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterEnd))
|
||||||
|
}
|
||||||
|
// Row 3: relativeLayout3 (bottom row: lat -1)
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||||
|
GridCell(text = state.grids[6], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterStart))
|
||||||
|
GridCell(text = state.grids[7], modifier = Modifier.width(200.dp).fillMaxHeight().align(Alignment.Center))
|
||||||
|
GridCell(text = state.grids[8], modifier = Modifier.width(80.dp).fillMaxHeight().align(Alignment.CenterEnd))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// tv4: bottom credit, 10sp black (raised; bottom space kept clear of nav bar)
|
||||||
|
Text(
|
||||||
|
text = "制作:US1PM 汉化:BA7LCE",
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatDms(value: Double, isLat: Boolean): String {
|
// Surrounding cells: 60/200/60dp, #0BACF1, bold centered, 2dp margin (ported tv11/tv13/tv21/tv23/tv31/tv33)
|
||||||
val abs = kotlin.math.abs(value)
|
@Composable
|
||||||
val deg = abs.toInt()
|
private fun GridCell(text: String, modifier: Modifier) {
|
||||||
val minFull = (abs - deg) * 60
|
Box(
|
||||||
val min = minFull.toInt()
|
modifier = modifier
|
||||||
val sec = ((minFull - min) * 60).toInt()
|
.padding(2.dp)
|
||||||
val hemi = if (value >= 0) (if (isLat) "N" else "E") else (if (isLat) "S" else "W")
|
.background(MaterialTheme.colorScheme.surface),
|
||||||
return "$deg° $min' $sec\" $hemi"
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatDecimal(value: Double): String = String.format("%.5f", value)
|
/**
|
||||||
|
* Ported state from MainActivity.showLocation().
|
||||||
|
* Grid / 3x3 / red-dot lookup tables match the original line by line.
|
||||||
|
*/
|
||||||
|
data class RoamingState(
|
||||||
|
val date: String = "",
|
||||||
|
val time: String = "",
|
||||||
|
val latLabel: String = "纬度",
|
||||||
|
val latValue: String = "",
|
||||||
|
val lonLabel: String = "经度",
|
||||||
|
val lonValue: String = "",
|
||||||
|
val loc: String = "",
|
||||||
|
val grids: List<String> = List(9) { "" },
|
||||||
|
/** Red dot leftMargin (dp), looked up by the 3rd char pair of longitude */
|
||||||
|
val markerLeft: Int = 0,
|
||||||
|
/** Red dot topMargin (dp), looked up by the 3rd char pair of latitude (screen Y inverted) */
|
||||||
|
val markerTop: Int = 0,
|
||||||
|
val gpsOn: Boolean = false,
|
||||||
|
val gpsOff: Boolean = true,
|
||||||
|
val showSettings: Boolean = true,
|
||||||
|
val showProgress: Boolean = false,
|
||||||
|
val showNotice: Boolean = false,
|
||||||
|
val showDate: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ported full showLocation() computation: time / lat-lon DMS / 8-char grid / 3x3 / red-dot lookup.
|
||||||
|
*/
|
||||||
|
fun roamingStateFromLocation(lat: Double, lon: Double, fixTime: Long, hourPrefix: String): RoamingState {
|
||||||
|
// Ported: tvTime = cached hour prefix + locate time minutes
|
||||||
|
val minute = SimpleDateFormat("mm", Locale.getDefault()).format(Date(fixTime))
|
||||||
|
val time = hourPrefix + minute
|
||||||
|
// Direction chars
|
||||||
|
val ew = if (lon >= 0.0) "E" else "W"
|
||||||
|
val ns = if (lat >= 0.0) "N" else "S"
|
||||||
|
// Ported: latitude DMS (String.valueOf, no zero padding)
|
||||||
|
val latAbs = if (lat < 0.0) -lat else lat
|
||||||
|
val latDeg = latAbs.toInt()
|
||||||
|
val latMinFull = (latAbs - latDeg) * 60.0
|
||||||
|
val latMin = latMinFull.toInt()
|
||||||
|
val latSec = ((latMinFull - latMin) * 60.0).toInt()
|
||||||
|
val latLabel = "纬度 $latDeg° $latMin' $latSec\" $ns"
|
||||||
|
val latValue = "$lat° "
|
||||||
|
// Ported: longitude DMS
|
||||||
|
val lonAbs = if (lon < 0.0) -lon else lon
|
||||||
|
val lonDeg = lonAbs.toInt()
|
||||||
|
val lonMinFull = (lonAbs - lonDeg) * 60.0
|
||||||
|
val lonMin = lonMinFull.toInt()
|
||||||
|
val lonSec = ((lonMinFull - lonMin) * 60.0).toInt()
|
||||||
|
val lonLabel = "经度 $lonDeg° $lonMin' $lonSec\" $ew"
|
||||||
|
val lonValue = "$lon° "
|
||||||
|
// Ported: 8-char grid encoding (range lookup)
|
||||||
|
val (str, str2, str3, str4) = encodeLon(lon)
|
||||||
|
val (str5, str6, str38, str9) = encodeLat(lat)
|
||||||
|
val loc = str + str5 + str2 + str6 + str3 + str38 + str4 + str9
|
||||||
|
// Ported: 3x3 parseInt3 five-branch logic
|
||||||
|
val grids = buildGrids(str, str5, str2, str6, str3, str38)
|
||||||
|
// Ported: red-dot lookup (3rd char pair)
|
||||||
|
val markerLeft = lonMargin[str3.firstOrNull()] ?: 0
|
||||||
|
val markerTop = latMargin[str38.firstOrNull()] ?: 0
|
||||||
|
return RoamingState(
|
||||||
|
time = time,
|
||||||
|
latLabel = latLabel,
|
||||||
|
latValue = latValue,
|
||||||
|
lonLabel = lonLabel,
|
||||||
|
lonValue = lonValue,
|
||||||
|
loc = loc,
|
||||||
|
grids = grids,
|
||||||
|
markerLeft = markerLeft,
|
||||||
|
markerTop = markerTop,
|
||||||
|
gpsOn = true,
|
||||||
|
gpsOff = false,
|
||||||
|
showSettings = false,
|
||||||
|
showProgress = false,
|
||||||
|
showNotice = false,
|
||||||
|
showDate = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Red-dot leftMargin lookup (MainActivity lines 1286-1386): lon 3rd pair a..x -> -2..190 dp
|
||||||
|
private val lonMargin = mapOf(
|
||||||
|
'a' to -2, 'b' to 8, 'c' to 16, 'd' to 24, 'e' to 32, 'f' to 40, 'g' to 48, 'h' to 56,
|
||||||
|
'i' to 65, 'j' to 74, 'k' to 83, 'l' to 92, 'm' to 101, 'n' to 110, 'o' to 119, 'p' to 128,
|
||||||
|
'q' to 137, 'r' to 146, 's' to 155, 't' to 163, 'u' to 171, 'v' to 179, 'w' to 184, 'x' to 190
|
||||||
|
)
|
||||||
|
|
||||||
|
// Red-dot topMargin lookup (MainActivity lines 1315-1363): lat 3rd pair a..x -> 190..-2 dp (screen Y inverted)
|
||||||
|
private val latMargin = mapOf(
|
||||||
|
'a' to 190, 'b' to 182, 'c' to 174, 'd' to 169, 'e' to 163, 'f' to 155, 'g' to 146, 'h' to 137,
|
||||||
|
'i' to 128, 'j' to 119, 'k' to 110, 'l' to 101, 'm' to 92, 'n' to 83, 'o' to 74, 'p' to 65,
|
||||||
|
'q' to 56, 'r' to 48, 's' to 40, 't' to 32, 'u' to 24, 'v' to 16, 'w' to 8, 'x' to -2
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ported lines 311-611: longitude -> (20 deg zone letter, 2 deg digit, 2' letter, 30" digit)
|
||||||
|
private fun encodeLon(inputLon: Double): List<String> {
|
||||||
|
var longitude = inputLon
|
||||||
|
var d = 0.0
|
||||||
|
val str = when {
|
||||||
|
(longitude >= -180.0) && (longitude <= -160.0) -> { longitude += 180.0; "A" }
|
||||||
|
(longitude >= -160.0) && (longitude <= -140.0) -> { longitude += 160.0; "B" }
|
||||||
|
(longitude >= -140.0) && (longitude <= -120.0) -> { longitude += 140.0; "C" }
|
||||||
|
(longitude >= -120.0) && (longitude <= -100.0) -> { longitude += 120.0; "D" }
|
||||||
|
(longitude >= -100.0) && (longitude <= -80.0) -> { longitude += 100.0; "E" }
|
||||||
|
(longitude >= -80.0) && (longitude <= -60.0) -> { longitude += 80.0; "F" }
|
||||||
|
(longitude >= -60.0) && (longitude <= -40.0) -> { longitude += 60.0; "G" }
|
||||||
|
(longitude >= -40.0) && (longitude <= -20.0) -> { longitude += 40.0; "H" }
|
||||||
|
(longitude >= -20.0) && (longitude <= 0.0) -> { longitude += 20.0; "I" }
|
||||||
|
(longitude >= 0.0) && (longitude <= 20.0) -> "J"
|
||||||
|
(longitude >= 20.0) && (longitude <= 40.0) -> { longitude -= 20.0; "K" }
|
||||||
|
(longitude >= 40.0) && (longitude <= 60.0) -> { longitude -= 40.0; "L" }
|
||||||
|
(longitude >= 60.0) && (longitude <= 80.0) -> { longitude -= 60.0; "M" }
|
||||||
|
(longitude >= 80.0) && (longitude <= 100.0) -> { longitude -= 80.0; "N" }
|
||||||
|
(longitude >= 100.0) && (longitude <= 120.0) -> { longitude -= 100.0; "O" }
|
||||||
|
(longitude >= 120.0) && (longitude <= 140.0) -> { longitude -= 120.0; "P" }
|
||||||
|
(longitude >= 140.0) && (longitude <= 160.0) -> { longitude -= 140.0; "Q" }
|
||||||
|
(longitude >= 160.0) && (longitude <= 180.0) -> { longitude -= 160.0; "R" }
|
||||||
|
else -> { longitude = 0.0; " " }
|
||||||
|
}
|
||||||
|
val str2 = when {
|
||||||
|
((longitude >= 0.0) && (longitude <= 2.0)) || ((longitude >= -20.0) && (longitude <= -18.0)) -> { d = longitude * 60.0; "0" }
|
||||||
|
((longitude >= 2.0) && (longitude <= 4.0)) || ((longitude >= -18.0) && (longitude <= -16.0)) -> { d = (longitude - 2.0) * 60.0; "1" }
|
||||||
|
((longitude >= 4.0) && (longitude <= 6.0)) || ((longitude >= -16.0) && (longitude <= -14.0)) -> { d = (longitude - 4.0) * 60.0; "2" }
|
||||||
|
((longitude >= 6.0) && (longitude <= 8.0)) || ((longitude >= -14.0) && (longitude <= -12.0)) -> { d = (longitude - 6.0) * 60.0; "3" }
|
||||||
|
((longitude >= 8.0) && (longitude <= 10.0)) || ((longitude >= -12.0) && (longitude <= -10.0)) -> { d = (longitude - 8.0) * 60.0; "4" }
|
||||||
|
((longitude >= 10.0) && (longitude <= 12.0)) || ((longitude >= -10.0) && (longitude <= -8.0)) -> { d = (longitude - 10.0) * 60.0; "5" }
|
||||||
|
((longitude >= 12.0) && (longitude <= 14.0)) || ((longitude >= -8.0) && (longitude <= -6.0)) -> { d = (longitude - 12.0) * 60.0; "6" }
|
||||||
|
((longitude >= 14.0) && (longitude <= 16.0)) || ((longitude >= -6.0) && (longitude <= -4.0)) -> { d = (longitude - 14.0) * 60.0; "7" }
|
||||||
|
((longitude >= 16.0) && (longitude <= 18.0)) || ((longitude >= -4.0) && (longitude <= -2.0)) -> { d = (longitude - 16.0) * 60.0; "8" }
|
||||||
|
((longitude >= 18.0) && (longitude <= 20.0)) || ((longitude >= -2.0) && (longitude <= 0.0)) -> { d = (longitude - 18.0) * 60.0; "9" }
|
||||||
|
else -> { d = 0.0; " " }
|
||||||
|
}
|
||||||
|
val str3 = when {
|
||||||
|
((d >= 0.0) && (d <= 5.0)) || ((d >= -120.0) && (d <= -115.0)) -> { longitude = d * 60.0; "a" }
|
||||||
|
((d >= 5.0) && (d <= 10.0)) || ((d >= -115.0) && (d <= -110.0)) -> { longitude = (d - 5.0) * 60.0; "b" }
|
||||||
|
((d >= 10.0) && (d <= 15.0)) || ((d >= -110.0) && (d <= -105.0)) -> { longitude = (d - 10.0) * 60.0; "c" }
|
||||||
|
((d >= 15.0) && (d <= 20.0)) || ((d >= -105.0) && (d <= -100.0)) -> { longitude = (d - 15.0) * 60.0; "d" }
|
||||||
|
((d >= 20.0) && (d <= 25.0)) || ((d >= -100.0) && (d <= -95.0)) -> { longitude = (d - 20.0) * 60.0; "e" }
|
||||||
|
((d >= 25.0) && (d <= 30.0)) || ((d >= -95.0) && (d <= -90.0)) -> { longitude = (d - 25.0) * 60.0; "f" }
|
||||||
|
((d >= 30.0) && (d <= 35.0)) || ((d >= -90.0) && (d <= -85.0)) -> { longitude = (d - 30.0) * 60.0; "g" }
|
||||||
|
((d >= 35.0) && (d <= 40.0)) || ((d >= -85.0) && (d <= -80.0)) -> { longitude = (d - 35.0) * 60.0; "h" }
|
||||||
|
((d >= 40.0) && (d <= 45.0)) || ((d >= -80.0) && (d <= -75.0)) -> { longitude = (d - 40.0) * 60.0; "i" }
|
||||||
|
((d >= 45.0) && (d <= 50.0)) || ((d >= -75.0) && (d <= -70.0)) -> { longitude = (d - 45.0) * 60.0; "j" }
|
||||||
|
((d >= 50.0) && (d <= 55.0)) || ((d >= -70.0) && (d <= -65.0)) -> { longitude = (d - 50.0) * 60.0; "k" }
|
||||||
|
((d >= 55.0) && (d <= 60.0)) || ((d >= -65.0) && (d <= -60.0)) -> { longitude = (d - 55.0) * 60.0; "l" }
|
||||||
|
((d >= 60.0) && (d <= 65.0)) || ((d >= -60.0) && (d <= -55.0)) -> { longitude = (d - 60.0) * 60.0; "m" }
|
||||||
|
((d >= 65.0) && (d <= 70.0)) || ((d >= -55.0) && (d <= -50.0)) -> { longitude = (d - 65.0) * 60.0; "n" }
|
||||||
|
((d >= 70.0) && (d <= 75.0)) || ((d >= -50.0) && (d <= -45.0)) -> { longitude = (d - 70.0) * 60.0; "o" }
|
||||||
|
((d >= 75.0) && (d <= 80.0)) || ((d >= -45.0) && (d <= -40.0)) -> { longitude = (d - 75.0) * 60.0; "p" }
|
||||||
|
((d >= 80.0) && (d <= 85.0)) || ((d >= -40.0) && (d <= -35.0)) -> { longitude = (d - 80.0) * 60.0; "q" }
|
||||||
|
((d >= 85.0) && (d <= 90.0)) || ((d >= -35.0) && (d <= -30.0)) -> { longitude = (d - 85.0) * 60.0; "r" }
|
||||||
|
((d >= 90.0) && (d <= 95.0)) || ((d >= -30.0) && (d <= -25.0)) -> { longitude = (d - 90.0) * 60.0; "s" }
|
||||||
|
((d >= 95.0) && (d <= 100.0)) || ((d >= -25.0) && (d <= -20.0)) -> { longitude = (d - 95.0) * 60.0; "t" }
|
||||||
|
((d >= 100.0) && (d <= 105.0)) || ((d >= -20.0) && (d <= -15.0)) -> { longitude = (d - 100.0) * 60.0; "u" }
|
||||||
|
((d >= 105.0) && (d <= 110.0)) || ((d >= -15.0) && (d <= -10.0)) -> { longitude = (d - 105.0) * 60.0; "v" }
|
||||||
|
((d >= 110.0) && (d <= 115.0)) || ((d >= -10.0) && (d <= -5.0)) -> { longitude = (d - 110.0) * 60.0; "w" }
|
||||||
|
((d >= 115.0) && (d <= 120.0)) || ((d >= -5.0) && (d <= 0.0)) -> { longitude = (d - 115.0) * 60.0; "x" }
|
||||||
|
else -> { longitude = 0.0; " " }
|
||||||
|
}
|
||||||
|
val str4 = when {
|
||||||
|
((longitude >= 0.0) && (longitude <= 30.0)) || ((longitude >= -300.0) && (longitude <= -270.0)) -> "0"
|
||||||
|
((longitude >= 30.0) && (longitude <= 60.0)) || ((longitude >= -270.0) && (longitude <= -240.0)) -> "1"
|
||||||
|
((longitude >= 60.0) && (longitude <= 90.0)) || ((longitude >= -240.0) && (longitude <= -210.0)) -> "2"
|
||||||
|
((longitude >= 90.0) && (longitude <= 120.0)) || ((longitude >= -210.0) && (longitude <= -180.0)) -> "3"
|
||||||
|
((longitude >= 120.0) && (longitude <= 150.0)) || ((longitude >= -180.0) && (longitude <= -150.0)) -> "4"
|
||||||
|
((longitude >= 150.0) && (longitude <= 180.0)) || ((longitude >= -150.0) && (longitude <= -120.0)) -> "5"
|
||||||
|
((longitude >= 180.0) && (longitude <= 210.0)) || ((longitude >= -120.0) && (longitude <= -90.0)) -> "6"
|
||||||
|
((longitude >= 210.0) && (longitude <= 240.0)) || ((longitude >= -90.0) && (longitude <= -60.0)) -> "7"
|
||||||
|
((longitude >= 240.0) && (longitude <= 270.0)) || ((longitude >= -60.0) && (longitude <= -30.0)) -> "8"
|
||||||
|
((longitude >= 270.0) && (longitude <= 300.0)) || ((longitude >= -30.0) && (longitude <= 0.0)) -> "9"
|
||||||
|
else -> " "
|
||||||
|
}
|
||||||
|
return listOf(str, str2, str3, str4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ported lines 612-914: latitude -> (10 deg zone letter, 1 deg digit, 1' letter, 15" digit)
|
||||||
|
private fun encodeLat(inputLat: Double): List<String> {
|
||||||
|
var latitude = inputLat
|
||||||
|
var d2 = 0.0
|
||||||
|
var d3 = 0.0
|
||||||
|
val str5 = when {
|
||||||
|
(latitude >= -90.0) && (latitude <= -80.0) -> { d2 = latitude + 90.0; "A" }
|
||||||
|
(latitude >= -80.0) && (latitude <= -70.0) -> { d2 = latitude + 80.0; "B" }
|
||||||
|
(latitude >= -70.0) && (latitude <= -60.0) -> { d2 = latitude + 70.0; "C" }
|
||||||
|
(latitude >= -60.0) && (latitude <= -50.0) -> { d2 = latitude + 60.0; "D" }
|
||||||
|
(latitude >= -50.0) && (latitude <= -40.0) -> { d2 = latitude + 50.0; "E" }
|
||||||
|
(latitude >= -40.0) && (latitude <= -30.0) -> { d2 = latitude + 40.0; "F" }
|
||||||
|
(latitude >= -30.0) && (latitude <= -20.0) -> { d2 = latitude + 30.0; "G" }
|
||||||
|
(latitude >= -20.0) && (latitude <= -10.0) -> { d2 = latitude + 20.0; "H" }
|
||||||
|
(latitude >= -10.0) && (latitude <= 0.0) -> { d2 = latitude + 10.0; "I" }
|
||||||
|
(latitude >= 0.0) && (latitude <= 10.0) -> { d2 = latitude; "J" }
|
||||||
|
(latitude >= 10.0) && (latitude <= 20.0) -> { d2 = latitude - 10.0; "K" }
|
||||||
|
(latitude >= 20.0) && (latitude <= 30.0) -> { d2 = latitude - 20.0; "L" }
|
||||||
|
(latitude >= 30.0) && (latitude <= 40.0) -> { d2 = latitude - 30.0; "M" }
|
||||||
|
(latitude >= 40.0) && (latitude <= 50.0) -> { d2 = latitude - 40.0; "N" }
|
||||||
|
(latitude >= 50.0) && (latitude <= 60.0) -> { d2 = latitude - 50.0; "O" }
|
||||||
|
(latitude >= 60.0) && (latitude <= 70.0) -> { d2 = latitude - 60.0; "P" }
|
||||||
|
(latitude >= 70.0) && (latitude <= 80.0) -> { d2 = latitude - 70.0; "Q" }
|
||||||
|
(latitude >= 80.0) && (latitude <= 90.0) -> { d2 = latitude - 80.0; "R" }
|
||||||
|
else -> { d2 = 0.0; " " }
|
||||||
|
}
|
||||||
|
var str6 = " "
|
||||||
|
when {
|
||||||
|
((d2 >= 0.0) && (d2 <= 1.0)) || ((d2 >= -10.0) && (d2 <= -9.0)) -> { d3 = d2 * 60.0; str6 = "0" }
|
||||||
|
((d2 >= 1.0) && (d2 <= 2.0)) || ((d2 >= -9.0) && (d2 <= -8.0)) -> { d3 = (d2 - 1.0) * 60.0; str6 = "1" }
|
||||||
|
((d2 >= 2.0) && (d2 <= 3.0)) || ((d2 >= -8.0) && (d2 <= -7.0)) -> { d3 = (d2 - 2.0) * 60.0; str6 = "2" }
|
||||||
|
((d2 >= 3.0) && (d2 <= 4.0)) || ((d2 >= -7.0) && (d2 <= -6.0)) -> { d3 = (d2 - 3.0) * 60.0; str6 = "3" }
|
||||||
|
((d2 >= 4.0) && (d2 <= 5.0)) || ((d2 >= -6.0) && (d2 <= -5.0)) -> { d3 = (d2 - 4.0) * 60.0; str6 = "4" }
|
||||||
|
((d2 >= 5.0) && (d2 <= 6.0)) || ((d2 >= -5.0) && (d2 <= -4.0)) -> { d3 = (d2 - 5.0) * 60.0; str6 = "5" }
|
||||||
|
((d2 >= 6.0) && (d2 <= 7.0)) || ((d2 >= -4.0) && (d2 <= -3.0)) -> { d3 = (d2 - 6.0) * 60.0; str6 = "6" }
|
||||||
|
((d2 >= 7.0) && (d2 <= 8.0)) || ((d2 >= -3.0) && (d2 <= -2.0)) -> { d3 = (d2 - 7.0) * 60.0; str6 = "7" }
|
||||||
|
((d2 >= 8.0) && (d2 <= 9.0)) || ((d2 >= -2.0) && (d2 <= -1.0)) -> { d3 = (d2 - 8.0) * 60.0; str6 = "8" }
|
||||||
|
((d2 >= 9.0) && (d2 <= 10.0)) || ((d2 >= -1.0) && (d2 <= 0.0)) -> { d3 = (d2 - 9.0) * 60.0; str6 = "9" }
|
||||||
|
}
|
||||||
|
val str38 = when {
|
||||||
|
((d3 >= 0.0) && (d3 <= 2.5)) || ((d3 >= -60.0) && (d3 <= -57.5)) -> { d2 = d3 * 60.0; "a" }
|
||||||
|
((d3 >= 2.5) && (d3 <= 5.0)) || ((d3 >= -57.5) && (d3 <= -55.0)) -> { d2 = (d3 - 2.5) * 60.0; "b" }
|
||||||
|
((d3 >= 5.0) && (d3 <= 7.5)) || ((d3 >= -55.0) && (d3 <= -52.5)) -> { d2 = (d3 - 5.0) * 60.0; "c" }
|
||||||
|
((d3 >= 7.5) && (d3 <= 10.0)) || ((d3 >= -52.5) && (d3 <= -50.0)) -> { d2 = (d3 - 7.5) * 60.0; "d" }
|
||||||
|
((d3 >= 10.0) && (d3 <= 12.5)) || ((d3 >= -50.0) && (d3 <= -47.5)) -> { d2 = (d3 - 10.0) * 60.0; "e" }
|
||||||
|
((d3 >= 12.5) && (d3 <= 15.0)) || ((d3 >= -47.5) && (d3 <= -45.0)) -> { d2 = (d3 - 12.5) * 60.0; "f" }
|
||||||
|
((d3 >= 15.0) && (d3 <= 17.5)) || ((d3 >= -45.0) && (d3 <= -42.5)) -> { d2 = (d3 - 15.0) * 60.0; "g" }
|
||||||
|
((d3 >= 17.5) && (d3 <= 20.0)) || ((d3 >= -42.5) && (d3 <= -40.0)) -> { d2 = (d3 - 17.5) * 60.0; "h" }
|
||||||
|
((d3 >= 20.0) && (d3 <= 22.5)) || ((d3 >= -40.0) && (d3 <= -37.5)) -> { d2 = (d3 - 20.0) * 60.0; "i" }
|
||||||
|
((d3 >= 22.5) && (d3 <= 25.0)) || ((d3 >= -37.5) && (d3 <= -35.0)) -> { d2 = (d3 - 22.5) * 60.0; "j" }
|
||||||
|
((d3 >= 25.0) && (d3 <= 27.5)) || ((d3 >= -35.0) && (d3 <= -32.5)) -> { d2 = (d3 - 25.0) * 60.0; "k" }
|
||||||
|
((d3 >= 27.5) && (d3 <= 30.0)) || ((d3 >= -32.5) && (d3 <= -30.0)) -> { d2 = (d3 - 27.5) * 60.0; "l" }
|
||||||
|
((d3 >= 30.0) && (d3 <= 32.5)) || ((d3 >= -30.0) && (d3 <= -27.5)) -> { d2 = (d3 - 30.0) * 60.0; "m" }
|
||||||
|
((d3 >= 32.5) && (d3 <= 35.0)) || ((d3 >= -27.5) && (d3 <= -25.0)) -> { d2 = (d3 - 32.5) * 60.0; "n" }
|
||||||
|
((d3 >= 35.0) && (d3 <= 37.5)) || ((d3 >= -25.0) && (d3 <= -22.5)) -> { d2 = (d3 - 35.0) * 60.0; "o" }
|
||||||
|
((d3 >= 37.5) && (d3 <= 40.0)) || ((d3 >= -22.5) && (d3 <= -20.0)) -> { d2 = (d3 - 37.5) * 60.0; "p" }
|
||||||
|
((d3 >= 40.0) && (d3 <= 42.5)) || ((d3 >= -20.0) && (d3 <= -17.5)) -> { d2 = (d3 - 40.0) * 60.0; "q" }
|
||||||
|
((d3 >= 42.5) && (d3 <= 45.0)) || ((d3 >= -17.5) && (d3 <= -15.0)) -> { d2 = (d3 - 42.5) * 60.0; "r" }
|
||||||
|
((d3 >= 45.0) && (d3 <= 47.5)) || ((d3 >= -15.0) && (d3 <= -12.5)) -> { d2 = (d3 - 45.0) * 60.0; "s" }
|
||||||
|
((d3 >= 47.5) && (d3 <= 50.0)) || ((d3 >= -12.5) && (d3 <= -10.0)) -> { d2 = (d3 - 47.5) * 60.0; "t" }
|
||||||
|
((d3 >= 50.0) && (d3 <= 52.5)) || ((d3 >= -10.0) && (d3 <= -7.5)) -> { d2 = (d3 - 50.0) * 60.0; "u" }
|
||||||
|
((d3 >= 52.5) && (d3 <= 55.0)) || ((d3 >= -7.5) && (d3 <= -5.0)) -> { d2 = (d3 - 52.5) * 60.0; "v" }
|
||||||
|
((d3 >= 55.0) && (d3 <= 57.5)) || ((d3 >= -5.0) && (d3 <= -2.5)) -> { d2 = (d3 - 55.0) * 60.0; "w" }
|
||||||
|
((d3 >= 57.5) && (d3 <= 60.0)) || ((d3 >= -2.5) && (d3 <= 0.0)) -> { d2 = (d3 - 57.5) * 60.0; "x" }
|
||||||
|
else -> { d2 = 0.0; " " }
|
||||||
|
}
|
||||||
|
val str9 = when {
|
||||||
|
((d2 >= 0.0) && (d2 <= 15.0)) || ((d2 >= -150.0) && (d2 <= -135.0)) -> "0"
|
||||||
|
((d2 >= 15.0) && (d2 <= 30.0)) || ((d2 >= -135.0) && (d2 <= -120.0)) -> "1"
|
||||||
|
((d2 >= 30.0) && (d2 <= 45.0)) || ((d2 >= -120.0) && (d2 <= -105.0)) -> "2"
|
||||||
|
((d2 >= 45.0) && (d2 <= 60.0)) || ((d2 >= -105.0) && (d2 <= -90.0)) -> "3"
|
||||||
|
((d2 >= 60.0) && (d2 <= 75.0)) || ((d2 >= -90.0) && (d2 <= -75.0)) -> "4"
|
||||||
|
((d2 >= 75.0) && (d2 <= 90.0)) || ((d2 >= -75.0) && (d2 <= -60.0)) -> "5"
|
||||||
|
((d2 >= 90.0) && (d2 <= 105.0)) || ((d2 >= -60.0) && (d2 <= -45.0)) -> "6"
|
||||||
|
((d2 >= 105.0) && (d2 <= 120.0)) || ((d2 >= -45.0) && (d2 <= -30.0)) -> "7"
|
||||||
|
((d2 >= 120.0) && (d2 <= 135.0)) || ((d2 >= -30.0) && (d2 <= -15.0)) -> "8"
|
||||||
|
((d2 >= 135.0) && (d2 <= 150.0)) || ((d2 >= -15.0) && (d2 <= 0.0)) -> "9"
|
||||||
|
else -> " "
|
||||||
|
}
|
||||||
|
return listOf(str5, str6, str38, str9)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ported lines 917-1283: 3x3 parseInt3 five-branch (interior / west / east / north / south edges + corners).
|
||||||
|
* Returns 9 cell texts: [top-left, top-center, top-right, mid-left, center, mid-right, bottom-left, bottom-center, bottom-right].
|
||||||
|
*/
|
||||||
|
private fun buildGrids(str: String, str5: String, str2: String, str6: String, str3: String, str38: String): List<String> {
|
||||||
|
val parseInt = str2.toInt()
|
||||||
|
val parseInt2 = str6.toInt()
|
||||||
|
val str39 = str + str5
|
||||||
|
val str40 = str2 + str6
|
||||||
|
val parseInt3 = str40.toInt()
|
||||||
|
val str41 = str39 + str40
|
||||||
|
val g = MutableList(9) { "" }
|
||||||
|
fun fmt2(n: Int) = if (n <= 9) "0$n" else n.toString()
|
||||||
|
if ((parseInt > 0) && (parseInt < 9) && (parseInt2 > 0) && (parseInt2 < 9)) {
|
||||||
|
// Interior: digit pairs +-1
|
||||||
|
val i7 = parseInt3 + 1
|
||||||
|
val i8 = i7 - 10
|
||||||
|
val valueOf9 = fmt2(i8)
|
||||||
|
val str11 = i7.toString()
|
||||||
|
val valueOf18 = (i7 + 10).toString()
|
||||||
|
val i9 = parseInt3 - 10
|
||||||
|
val valueOf10 = fmt2(i9)
|
||||||
|
val valueOf19 = (parseInt3 + 10).toString()
|
||||||
|
val i10 = parseInt3 - 1
|
||||||
|
val i11 = i10 - 10
|
||||||
|
val valueOf11 = fmt2(i11)
|
||||||
|
val str14 = i10.toString()
|
||||||
|
val str15 = (i10 + 10).toString()
|
||||||
|
g[0] = str39 + valueOf9
|
||||||
|
g[1] = str39 + str11
|
||||||
|
g[2] = str39 + valueOf18
|
||||||
|
g[3] = str39 + valueOf10
|
||||||
|
g[4] = str41
|
||||||
|
g[5] = str39 + valueOf19
|
||||||
|
g[6] = str39 + valueOf11
|
||||||
|
g[7] = str39 + str14
|
||||||
|
g[8] = str39 + str15
|
||||||
|
} else if ((parseInt == 0) && (parseInt2 > 0) && (parseInt2 < 9)) {
|
||||||
|
// West edge: lon digit 0 -> 9, lon letter -1
|
||||||
|
val str43 = (str[0] - 1).toString() + str5
|
||||||
|
val str44 = "9" + (parseInt2 + 1)
|
||||||
|
val i12 = parseInt3 + 1
|
||||||
|
val valueOf7 = fmt2(i12)
|
||||||
|
val str12 = (i12 + 10).toString()
|
||||||
|
val sb4 = "9" + parseInt2
|
||||||
|
val valueOf20 = (parseInt3 + 10).toString()
|
||||||
|
val str46 = "9" + (parseInt2 - 1)
|
||||||
|
val i13 = parseInt3 - 1
|
||||||
|
val valueOf8 = fmt2(i13)
|
||||||
|
val str15 = (i13 + 10).toString()
|
||||||
|
g[0] = str43 + str44
|
||||||
|
g[1] = str39 + valueOf7
|
||||||
|
g[2] = str39 + str12
|
||||||
|
g[3] = str43 + sb4
|
||||||
|
g[4] = str41
|
||||||
|
g[5] = str39 + valueOf20
|
||||||
|
g[6] = str43 + str46
|
||||||
|
g[7] = str39 + valueOf8
|
||||||
|
g[8] = str39 + str15
|
||||||
|
} else if ((parseInt == 9) && (parseInt2 > 0) && (parseInt2 < 9)) {
|
||||||
|
// East edge: lon digit 9 -> 0, lon letter +1
|
||||||
|
val str47 = (str[0] + 1).toString() + str5
|
||||||
|
val i14 = parseInt3 + 1
|
||||||
|
val valueOf21 = (i14 - 10).toString()
|
||||||
|
val valueOf22 = i14.toString()
|
||||||
|
val str48 = "0" + (parseInt2 + 1)
|
||||||
|
val valueOf23 = (parseInt3 - 10).toString()
|
||||||
|
val str49 = "0" + parseInt2
|
||||||
|
val i15 = parseInt3 - 1
|
||||||
|
val valueOf24 = (i15 - 10).toString()
|
||||||
|
val valueOf25 = i15.toString()
|
||||||
|
val str15 = "0" + (parseInt2 - 1)
|
||||||
|
g[0] = str39 + valueOf21
|
||||||
|
g[1] = str39 + valueOf22
|
||||||
|
g[2] = str47 + str48
|
||||||
|
g[3] = str39 + valueOf23
|
||||||
|
g[4] = str41
|
||||||
|
g[5] = str47 + str49
|
||||||
|
g[6] = str39 + valueOf24
|
||||||
|
g[7] = str39 + valueOf25
|
||||||
|
g[8] = str47 + str15
|
||||||
|
} else if ((parseInt < 9) && (parseInt > 0) && (parseInt2 == 9)) {
|
||||||
|
// North edge: lat digit 9 -> 0, lat letter +1
|
||||||
|
val str50 = str + (str5[0] + 1).toString()
|
||||||
|
val i16 = parseInt3 - 9
|
||||||
|
val i17 = i16 - 10
|
||||||
|
val valueOf4 = fmt2(i17)
|
||||||
|
val valueOf26 = i16.toString()
|
||||||
|
val valueOf27 = (i16 + 10).toString()
|
||||||
|
val i18 = parseInt3 - 10
|
||||||
|
val valueOf5 = fmt2(i18)
|
||||||
|
val valueOf28 = (parseInt3 + 10).toString()
|
||||||
|
val i19 = parseInt3 - 1
|
||||||
|
val i20 = i19 - 10
|
||||||
|
val valueOf6 = fmt2(i20)
|
||||||
|
val str14 = i19.toString()
|
||||||
|
val str15 = (i19 + 10).toString()
|
||||||
|
g[0] = str39 + valueOf4
|
||||||
|
g[1] = str50 + valueOf26
|
||||||
|
g[2] = str39 + valueOf27
|
||||||
|
g[3] = str39 + valueOf5
|
||||||
|
g[4] = str41
|
||||||
|
g[5] = str39 + valueOf28
|
||||||
|
g[6] = str39 + valueOf6
|
||||||
|
g[7] = str39 + str14
|
||||||
|
g[8] = str39 + str15
|
||||||
|
} else if ((parseInt < 9) && (parseInt > 0) && (parseInt2 == 0)) {
|
||||||
|
// South edge: lat digit 0 -> 9, lat letter -1
|
||||||
|
val str52 = str + (str5[0] - 1).toString()
|
||||||
|
val i21 = parseInt3 + 1
|
||||||
|
val i22 = i21 - 10
|
||||||
|
val valueOf = fmt2(i22)
|
||||||
|
val valueOf29 = i21.toString()
|
||||||
|
val valueOf30 = (i21 + 10).toString()
|
||||||
|
val i23 = parseInt3 - 10
|
||||||
|
val valueOf2 = fmt2(i23)
|
||||||
|
val valueOf31 = (parseInt3 + 10).toString()
|
||||||
|
val i24 = parseInt3 - 1
|
||||||
|
val valueOf3 = fmt2(i24)
|
||||||
|
val str14 = (i24 + 10).toString()
|
||||||
|
val str15 = (i24 + 20).toString()
|
||||||
|
g[0] = str39 + valueOf
|
||||||
|
g[1] = str39 + valueOf29
|
||||||
|
g[2] = str39 + valueOf30
|
||||||
|
g[3] = str39 + valueOf2
|
||||||
|
g[4] = str41
|
||||||
|
g[5] = str39 + valueOf31
|
||||||
|
g[6] = str52 + valueOf3
|
||||||
|
g[7] = str52 + str14
|
||||||
|
g[8] = str52 + str15
|
||||||
|
} else if (parseInt3 == 0) {
|
||||||
|
// Corner 00: both digits -1 with carry
|
||||||
|
val v32 = (str[0] - 1).toString()
|
||||||
|
val v33 = (str5[0] - 1).toString()
|
||||||
|
g[0] = v32 + str5 + "91"
|
||||||
|
g[1] = str39 + "01"
|
||||||
|
g[2] = str39 + "11"
|
||||||
|
g[3] = v32 + str5 + "90"
|
||||||
|
g[4] = str41
|
||||||
|
g[5] = v32 + str5 + "10"
|
||||||
|
g[6] = v32 + v33 + "99"
|
||||||
|
g[7] = str + v33 + "09"
|
||||||
|
g[8] = str + v33 + "19"
|
||||||
|
} else if (parseInt3 == 9) {
|
||||||
|
// Corner 09: lon -1, lat +1
|
||||||
|
val v34 = (str[0] - 1).toString()
|
||||||
|
val v35 = (str5[0] + 1).toString()
|
||||||
|
g[0] = v34 + v35 + "90"
|
||||||
|
g[1] = str + v35 + "00"
|
||||||
|
g[2] = str + v35 + "10"
|
||||||
|
g[3] = v34 + str5 + "99"
|
||||||
|
g[4] = str41
|
||||||
|
g[5] = v34 + str5 + "19"
|
||||||
|
g[6] = v34 + str5 + "98"
|
||||||
|
g[7] = v34 + str5 + "08"
|
||||||
|
g[8] = v34 + str5 + "18"
|
||||||
|
} else if (parseInt3 == 99) {
|
||||||
|
// Corner 99: both digits +1 with carry
|
||||||
|
val v36 = (str[0] + 1).toString()
|
||||||
|
val v37 = (str5[0] + 1).toString()
|
||||||
|
g[0] = str + v37 + "80"
|
||||||
|
g[1] = str + v37 + "90"
|
||||||
|
g[2] = v36 + v37 + "00"
|
||||||
|
g[3] = str39 + "89"
|
||||||
|
g[4] = str41
|
||||||
|
g[5] = v36 + str5 + "09"
|
||||||
|
g[6] = str39 + "88"
|
||||||
|
g[7] = str39 + "98"
|
||||||
|
g[8] = v36 + str5 + "08"
|
||||||
|
} else if (parseInt3 == 90) {
|
||||||
|
// Corner 90: lon +1, lat -1
|
||||||
|
val v38 = (str[0] + 1).toString()
|
||||||
|
val v39 = (str5[0] - 1).toString()
|
||||||
|
g[0] = str39 + "81"
|
||||||
|
g[1] = str39 + "91"
|
||||||
|
g[2] = v38 + str5 + "01"
|
||||||
|
g[3] = str39 + "80"
|
||||||
|
g[4] = str41
|
||||||
|
g[5] = v38 + str5 + "00"
|
||||||
|
g[6] = str + v39 + "89"
|
||||||
|
g[7] = str + v39 + "99"
|
||||||
|
g[8] = v38 + v39 + "09"
|
||||||
|
}
|
||||||
|
return g
|
||||||
|
}
|
||||||
-83
@@ -1,83 +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.roaming
|
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.qthNeighbors
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.qthToSquare
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
data class RoamingState(
|
|
||||||
val latitude: Double = 0.0,
|
|
||||||
val longitude: Double = 0.0,
|
|
||||||
val altitude: Double = 0.0,
|
|
||||||
val qthLocator: String = "----",
|
|
||||||
val gridSquares: List<String> = List(9) { "----" },
|
|
||||||
val isUpdating: Boolean = false,
|
|
||||||
val messageResId: Int = 0
|
|
||||||
)
|
|
||||||
|
|
||||||
class RoamingViewModel(private val settingsRepo: ISettingsRepo) : ViewModel() {
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(RoamingState())
|
|
||||||
val uiState: StateFlow<RoamingState> = _uiState
|
|
||||||
|
|
||||||
init {
|
|
||||||
viewModelScope.launch {
|
|
||||||
settingsRepo.stationPosition.collect { geoPos ->
|
|
||||||
_uiState.update { it.copy(isUpdating = false, messageResId = 0).withPosition(geoPos) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onGpsClick() {
|
|
||||||
val success = settingsRepo.setStationPosition()
|
|
||||||
if (success) {
|
|
||||||
_uiState.update { it.copy(isUpdating = true) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun RoamingState.withPosition(pos: GeoPos): RoamingState {
|
|
||||||
val locator = positionToQth(pos.latitude, pos.longitude) ?: return copy(qthLocator = "----")
|
|
||||||
val square = qthToSquare(locator)
|
|
||||||
val squares = if (square != "----") qthNeighbors(square) else List(9) { "----" }
|
|
||||||
return copy(
|
|
||||||
latitude = pos.latitude,
|
|
||||||
longitude = pos.longitude,
|
|
||||||
altitude = pos.altitude,
|
|
||||||
qthLocator = locator,
|
|
||||||
gridSquares = squares
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun factory(container: IMainContainer) = viewModelFactory {
|
|
||||||
initializer { RoamingViewModel(container.settingsRepo) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 176 B |
Binary file not shown.
|
After Width: | Height: | Size: 172 B |
Binary file not shown.
|
After Width: | Height: | Size: 375 B |
+124
@@ -0,0 +1,124 @@
|
|||||||
|
/*
|
||||||
|
* 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.roaming
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class RoamingStateTest {
|
||||||
|
|
||||||
|
// User-verified coordinates (QTH Locator 2.0 screenshot): 22.314066 / 108.706575
|
||||||
|
private fun stateOf(lat: Double, lon: Double) =
|
||||||
|
roamingStateFromLocation(lat, lon, System.currentTimeMillis(), "21:")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `user coordinates produce OL42ih45`() {
|
||||||
|
val state = stateOf(22.314066, 108.706575)
|
||||||
|
assertEquals("OL42ih45", state.loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `red marker from ih pair matches reference lookup`() {
|
||||||
|
val state = stateOf(22.314066, 108.706575)
|
||||||
|
// Original lookup: lon 3rd pair 'i' -> leftMargin 65dp, lat 3rd pair 'h' -> topMargin 137dp
|
||||||
|
assertEquals(65, state.markerLeft)
|
||||||
|
assertEquals(137, state.markerTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `grid nine from OL42 matches reference`() {
|
||||||
|
val state = stateOf(22.314066, 108.706575)
|
||||||
|
val expected = listOf(
|
||||||
|
"OL33", "OL43", "OL53",
|
||||||
|
"OL32", "OL42", "OL52",
|
||||||
|
"OL31", "OL41", "OL51"
|
||||||
|
)
|
||||||
|
assertEquals(expected, state.grids)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `latitude dms formatting matches reference`() {
|
||||||
|
val state = stateOf(22.314066, 108.706575)
|
||||||
|
assertEquals("纬度 22° 18' 50\" N", state.latLabel)
|
||||||
|
assertEquals("22.314066° ", state.latValue)
|
||||||
|
assertEquals("经度 108° 42' 23\" E", state.lonLabel)
|
||||||
|
assertEquals("108.706575° ", state.lonValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `time is hour prefix plus fix minutes`() {
|
||||||
|
// fixTime = 2026-08-03 21:12:13 UTC+8 -> minutes 12
|
||||||
|
val fix = 1785820333000L // 2026-08-03 21:12:13 +0800
|
||||||
|
val state = roamingStateFromLocation(22.314066, 108.706575, fix, "21:")
|
||||||
|
assertEquals("21:12", state.time)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `west edge borrows lon letter`() {
|
||||||
|
// lon 0.5 lat 22.314066: digit pair 02, west edge parseInt==0
|
||||||
|
val state = stateOf(22.314066, 0.5)
|
||||||
|
assertEquals(8, state.loc.length)
|
||||||
|
// Left column lon 0->9, lon letter J-1=I: IL92
|
||||||
|
assertEquals("IL92", state.grids[3])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `north edge carries lat letter`() {
|
||||||
|
// lat 29.5 lon 108.706575: digit pair 49? -> lat digit 9 -> north edge
|
||||||
|
val state = stateOf(29.5, 108.706575)
|
||||||
|
assertEquals(8, state.loc.length)
|
||||||
|
// Top row lat +1 -> 0, lat letter +1 (M)
|
||||||
|
assertEquals("OM40", state.grids[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `east edge carries lon letter`() {
|
||||||
|
// lon 119.5 lat 22.314066: lon digit 9 -> east edge
|
||||||
|
val state = stateOf(22.314066, 119.5)
|
||||||
|
assertEquals(8, state.loc.length)
|
||||||
|
// Top-right lon 9->0, lon letter O+1=P, lat 2+1=3: PL03
|
||||||
|
assertEquals("PL03", state.grids[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `south edge borrows lat letter`() {
|
||||||
|
// lat 0.5 lon 108.706575: lat digit 0 -> south edge
|
||||||
|
val state = stateOf(0.5, 108.706575)
|
||||||
|
assertEquals(8, state.loc.length)
|
||||||
|
// Bottom-center lat 0->9, lat letter J-1=I: OI49
|
||||||
|
assertEquals("OI49", state.grids[7])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `boundary values wrap like reference`() {
|
||||||
|
// (90, 180) -> RR99xx99 (original range-lookup behavior)
|
||||||
|
val state = stateOf(90.0, 180.0)
|
||||||
|
assertEquals("RR99xx99", state.loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `gps state after fix matches showLocation`() {
|
||||||
|
val state = stateOf(22.314066, 108.706575)
|
||||||
|
assertEquals(true, state.gpsOn)
|
||||||
|
assertEquals(false, state.gpsOff)
|
||||||
|
assertEquals(false, state.showSettings)
|
||||||
|
assertEquals(false, state.showProgress)
|
||||||
|
assertEquals(false, state.showNotice)
|
||||||
|
assertEquals(true, state.showDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,3 +5,7 @@ plugins {
|
|||||||
android {
|
android {
|
||||||
namespace = "com.rtbishop.look4sat.feature.settings"
|
namespace = "com.rtbishop.look4sat.feature.settings"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core:data"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
package com.rtbishop.look4sat.feature.settings
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.rtbishop.look4sat.core.data.aprs.AprsConfig
|
||||||
|
import com.rtbishop.look4sat.core.domain.aprs.AprsPacket
|
||||||
|
import com.rtbishop.look4sat.core.data.aprs.AprsStore
|
||||||
|
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||||
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
|
||||||
|
/** APRS card: sits between the satellite-data and data-output containers; settings live in a gear dialog to save space */
|
||||||
|
@Composable
|
||||||
|
fun AprsCard() {
|
||||||
|
val context = LocalContext.current
|
||||||
|
var config by remember { mutableStateOf(AprsStore.loadConfig(context)) }
|
||||||
|
var showDialog by remember { mutableStateOf(false) }
|
||||||
|
var lastReport by remember { mutableStateOf(AprsStore.loadLastReport(context)) }
|
||||||
|
var reportPing by remember { mutableStateOf(0) }
|
||||||
|
val notifPermissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { }
|
||||||
|
fun requestNotifPermissionIfNeeded() {
|
||||||
|
if (Build.VERSION.SDK_INT >= 33 &&
|
||||||
|
context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) !=
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
notifPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
// Title row: title + gear key (dialog, space-saving)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.prefs_aprs_title),
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
IconButton(onClick = { showDialog = true }) {
|
||||||
|
Text(
|
||||||
|
text = "\u2699", // ⚙
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontSize = 18.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Enable switch
|
||||||
|
AprsSwitchRow(R.string.prefs_aprs_enable, config.enabled) { enabled ->
|
||||||
|
config = config.copy(enabled = enabled)
|
||||||
|
AprsStore.saveConfig(context, config)
|
||||||
|
if (enabled) requestNotifPermissionIfNeeded()
|
||||||
|
val intent = Intent()
|
||||||
|
.setClassName(context.packageName, AprsStore.SERVICE_CLASS)
|
||||||
|
.setAction(if (enabled) AprsStore.ACTION_START else AprsStore.ACTION_STOP)
|
||||||
|
if (enabled) context.startForegroundService(intent) else context.startService(intent)
|
||||||
|
}
|
||||||
|
// Compact info row (not crowded)
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
id = R.string.prefs_aprs_summary,
|
||||||
|
config.server, config.port,
|
||||||
|
if (config.callsign.isBlank()) "-" else config.callsign
|
||||||
|
),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
// Last-report status row
|
||||||
|
val lr = lastReport
|
||||||
|
if (lr.time > 0L) {
|
||||||
|
val timeStr = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault())
|
||||||
|
.format(java.util.Date(lr.time))
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
id = R.string.prefs_aprs_last_report,
|
||||||
|
timeStr,
|
||||||
|
if (lr.ok) stringResource(R.string.prefs_aprs_last_ok)
|
||||||
|
else stringResource(R.string.prefs_aprs_last_fail, lr.detail)
|
||||||
|
),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = if (lr.ok) MaterialTheme.colorScheme.primary
|
||||||
|
else MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Manual report button
|
||||||
|
CardButton(
|
||||||
|
onClick = {
|
||||||
|
val intent = Intent()
|
||||||
|
.setClassName(context.packageName, AprsStore.SERVICE_CLASS)
|
||||||
|
.setAction(AprsStore.ACTION_REPORT_NOW)
|
||||||
|
context.startForegroundService(intent)
|
||||||
|
reportPing++
|
||||||
|
},
|
||||||
|
text = stringResource(id = R.string.prefs_aprs_report_now),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Refresh the status row after reporting (wait for the result to persist)
|
||||||
|
androidx.compose.runtime.LaunchedEffect(reportPing) {
|
||||||
|
if (reportPing > 0) {
|
||||||
|
kotlinx.coroutines.delay(6000)
|
||||||
|
lastReport = AprsStore.loadLastReport(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showDialog) {
|
||||||
|
AprsSettingsDialog(
|
||||||
|
config = config,
|
||||||
|
onDismiss = { showDialog = false },
|
||||||
|
onSave = { newConfig ->
|
||||||
|
config = newConfig
|
||||||
|
AprsStore.saveConfig(context, newConfig)
|
||||||
|
// Apply new config when enabled (restart the service)
|
||||||
|
if (newConfig.enabled) {
|
||||||
|
context.startService(
|
||||||
|
Intent().setClassName(context.packageName, AprsStore.SERVICE_CLASS)
|
||||||
|
.setAction(AprsStore.ACTION_STOP)
|
||||||
|
)
|
||||||
|
context.startForegroundService(
|
||||||
|
Intent().setClassName(context.packageName, AprsStore.SERVICE_CLASS)
|
||||||
|
.setAction(AprsStore.ACTION_START)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
showDialog = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** APRS settings dialog (plenty of room, full config) */
|
||||||
|
@Composable
|
||||||
|
private fun AprsSettingsDialog(
|
||||||
|
config: AprsConfig,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSave: (AprsConfig) -> Unit
|
||||||
|
) {
|
||||||
|
var server by remember { mutableStateOf(config.server) }
|
||||||
|
var port by remember { mutableStateOf(config.port.toString()) }
|
||||||
|
var callsign by remember { mutableStateOf(config.callsign) }
|
||||||
|
var ssid by remember { mutableStateOf(config.ssid) }
|
||||||
|
var passcode by remember { mutableStateOf(config.passcode) }
|
||||||
|
var interval by remember { mutableStateOf(config.intervalMin.toString()) }
|
||||||
|
var status by remember { mutableStateOf(config.statusText) }
|
||||||
|
var symbolTable by remember { mutableStateOf(config.symbolTable) }
|
||||||
|
var symbolCode by remember { mutableStateOf(config.symbolCode) }
|
||||||
|
val textStyle = LocalTextStyle.current.copy(fontSize = 14.sp)
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(id = R.string.prefs_aprs_settings_title)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = server,
|
||||||
|
onValueChange = { server = it },
|
||||||
|
label = { Text(stringResource(id = R.string.prefs_aprs_server)) },
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = textStyle
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = port,
|
||||||
|
onValueChange = { port = it },
|
||||||
|
label = { Text(stringResource(id = R.string.prefs_aprs_port)) },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
textStyle = textStyle
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = callsign,
|
||||||
|
onValueChange = { callsign = it },
|
||||||
|
label = { Text(stringResource(id = R.string.prefs_aprs_callsign)) },
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = textStyle
|
||||||
|
)
|
||||||
|
// Passcode compute button (user request: let the user see the computed result)
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val call = callsign.trim().uppercase()
|
||||||
|
if (call.isNotBlank()) {
|
||||||
|
passcode = AprsPacket.passcode(call).toString()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = callsign.trim().isNotBlank()
|
||||||
|
) { Text(stringResource(id = R.string.prefs_aprs_calc_passcode)) }
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = ssid,
|
||||||
|
onValueChange = { ssid = it },
|
||||||
|
label = { Text(stringResource(id = R.string.prefs_aprs_ssid)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
textStyle = textStyle
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = passcode,
|
||||||
|
onValueChange = { passcode = it },
|
||||||
|
label = { Text(stringResource(id = R.string.prefs_aprs_passcode)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
textStyle = textStyle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = interval,
|
||||||
|
onValueChange = { interval = it },
|
||||||
|
label = { Text(stringResource(id = R.string.prefs_aprs_interval)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
textStyle = textStyle
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = status,
|
||||||
|
onValueChange = { status = it },
|
||||||
|
label = { Text(stringResource(id = R.string.prefs_aprs_status)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
textStyle = textStyle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = symbolTable,
|
||||||
|
onValueChange = { symbolTable = it },
|
||||||
|
label = { Text(stringResource(id = R.string.prefs_aprs_symbol_table)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
textStyle = textStyle
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = symbolCode,
|
||||||
|
onValueChange = { symbolCode = it },
|
||||||
|
label = { Text(stringResource(id = R.string.prefs_aprs_symbol_code)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
textStyle = textStyle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.prefs_aprs_passcode_hint),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
onSave(
|
||||||
|
config.copy(
|
||||||
|
server = server.ifBlank { "euro.aprs2.net" },
|
||||||
|
port = port.toIntOrNull() ?: 14580,
|
||||||
|
callsign = callsign.trim().uppercase(),
|
||||||
|
ssid = ssid.trim(),
|
||||||
|
passcode = passcode.trim(),
|
||||||
|
intervalMin = interval.toIntOrNull() ?: 5,
|
||||||
|
statusText = status,
|
||||||
|
symbolTable = symbolTable.ifBlank { "/" },
|
||||||
|
symbolCode = symbolCode.ifBlank { ">" }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}) { Text(stringResource(id = R.string.prefs_aprs_save)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(id = R.string.prefs_aprs_cancel)) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switch row local to this file (same as SettingsScreen's private SwitchRow) */
|
||||||
|
@Composable
|
||||||
|
private fun AprsSwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(id = labelResId))
|
||||||
|
Switch(checked = checked, onCheckedChange = onCheckedChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
@@ -30,6 +30,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
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
|
||||||
@@ -41,11 +43,13 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
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 com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
import com.rtbishop.look4sat.core.presentation.CardButton
|
import com.rtbishop.look4sat.core.presentation.CardButton
|
||||||
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
import com.rtbishop.look4sat.core.presentation.LocalSpacing
|
||||||
@@ -203,6 +207,17 @@ fun DataSourcesDialog(
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
enabled = isEnabledCustomTle.value,
|
enabled = isEnabledCustomTle.value,
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(
|
||||||
|
onClick = { urlTle.value = Sources.defaultTleUrl },
|
||||||
|
enabled = isEnabledCustomTle.value
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_reset),
|
||||||
|
contentDescription = stringResource(id = R.string.prefs_data_reset_url)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
Row(
|
Row(
|
||||||
@@ -232,6 +247,17 @@ fun DataSourcesDialog(
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
enabled = isEnabledCustomTransceivers.value,
|
enabled = isEnabledCustomTransceivers.value,
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(
|
||||||
|
onClick = { urlTransceivers.value = Sources.defaultTransceiversUrl },
|
||||||
|
enabled = isEnabledCustomTransceivers.value
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_reset),
|
||||||
|
contentDescription = stringResource(id = R.string.prefs_data_reset_url)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
|
|||||||
+510
-19
@@ -22,39 +22,72 @@ import android.os.Build
|
|||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import android.content.Context
|
||||||
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.material3.AlertDialog
|
||||||
import androidx.compose.material3.ElevatedCard
|
import androidx.compose.material3.ElevatedCard
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
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.LocalTextStyle
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
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.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
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
|
||||||
@@ -72,10 +105,13 @@ import com.rtbishop.look4sat.core.presentation.R
|
|||||||
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
import com.rtbishop.look4sat.core.presentation.ScreenColumn
|
||||||
import com.rtbishop.look4sat.core.presentation.TopBar
|
import com.rtbishop.look4sat.core.presentation.TopBar
|
||||||
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
import com.rtbishop.look4sat.core.presentation.infiniteMarquee
|
||||||
|
import com.rtbishop.look4sat.core.presentation.defaultScreenOrder
|
||||||
|
import com.rtbishop.look4sat.core.presentation.defaultSubMenuOrder
|
||||||
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
import com.rtbishop.look4sat.core.presentation.isVerticalLayout
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsDestination() {
|
fun SettingsDestination() {
|
||||||
@@ -83,11 +119,72 @@ fun SettingsDestination() {
|
|||||||
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
|
val viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.factory(container))
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
SettingsScreen(uiState, viewModel::onAction)
|
SettingsScreen(uiState, viewModel::onAction, container.provideLotwSatellitesRepo())
|
||||||
|
|
||||||
|
// WaveLog grid mismatch confirmation dialog (4.5.2)
|
||||||
|
val gridConfirm = viewModel.gridConfirm
|
||||||
|
if (gridConfirm != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { viewModel.resolveGridConfirm(false) },
|
||||||
|
title = { Text(text = stringResource(id = R.string.wavelog_title)) },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
id = R.string.wavelog_grid_mismatch,
|
||||||
|
gridConfirm.userGrid.take(4),
|
||||||
|
gridConfirm.stationGrid.take(4)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { viewModel.resolveGridConfirm(true) }) {
|
||||||
|
Text(text = stringResource(id = R.string.wavelog_ignore))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { viewModel.resolveGridConfirm(false) }) {
|
||||||
|
Text(text = stringResource(id = R.string.wavelog_cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaveLog error detail dialog (one-tap copy)
|
||||||
|
val wavelogError = viewModel.wavelogError
|
||||||
|
if (wavelogError != null) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { viewModel.wavelogError = null },
|
||||||
|
title = { Text(text = stringResource(id = R.string.wavelog_title)) },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = wavelogError,
|
||||||
|
fontSize = 13.sp
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||||
|
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("WaveLog error", wavelogError))
|
||||||
|
viewModel.wavelogError = null
|
||||||
|
}) {
|
||||||
|
Text(text = stringResource(id = R.string.wavelog_copy))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { viewModel.wavelogError = null }) {
|
||||||
|
Text(text = stringResource(id = R.string.btn_cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) -> Unit) {
|
private fun SettingsScreen(
|
||||||
|
uiState: SettingsState,
|
||||||
|
onAction: (SettingsAction) -> Unit,
|
||||||
|
lotwRepo: com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
|
||||||
|
) {
|
||||||
val dialogs = rememberDialogVisibility()
|
val dialogs = rememberDialogVisibility()
|
||||||
val pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) }
|
val pendingCustomSourcesGrant = remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
val pendingCustomSourcesDeny = remember { mutableStateOf<(() -> Unit)?>(null) }
|
val pendingCustomSourcesDeny = remember { mutableStateOf<(() -> Unit)?>(null) }
|
||||||
@@ -291,6 +388,9 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
showDataSourcesDialog = { dialogs.dataSources = true }
|
showDataSourcesDialog = { dialogs.dataSources = true }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
|
AprsCard()
|
||||||
|
}
|
||||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||||
OutputCard(
|
OutputCard(
|
||||||
onNetworkClick = permissions.launchNetwork,
|
onNetworkClick = permissions.launchNetwork,
|
||||||
@@ -299,6 +399,18 @@ private fun SettingsScreen(uiState: SettingsState, onAction: (SettingsAction) ->
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
item { OtherCard(uiState.otherSettings, onAction) }
|
item { OtherCard(uiState.otherSettings, onAction) }
|
||||||
|
item { WavelogCard(uiState.otherSettings, onAction, lotwRepo) }
|
||||||
|
item {
|
||||||
|
UiSettingsCard(
|
||||||
|
hiddenScreens = uiState.otherSettings.hiddenScreens,
|
||||||
|
screenOrder = uiState.otherSettings.screenOrder,
|
||||||
|
subMenuOrder = uiState.otherSettings.subMenuOrder,
|
||||||
|
onToggle = { name -> onAction(SettingsAction.ToggleScreen(name)) },
|
||||||
|
onReorder = { order -> onAction(SettingsAction.ReorderScreens(order)) },
|
||||||
|
onResetOrder = { onAction(SettingsAction.ResetMenuOrder) },
|
||||||
|
onUpdateMenu = { main, sub -> onAction(SettingsAction.UpdateMenuOrder(main, sub)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
item { CardCredits() }
|
item { CardCredits() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,11 +449,11 @@ private fun LocationCard(
|
|||||||
Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp))
|
Text(text = formatUpdateTime(updateTime = settings.stationPos.timestamp))
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(text = "Lat: ${settings.stationPos.latitude}°")
|
Text(text = stringResource(R.string.loc_lat_text, settings.stationPos.latitude))
|
||||||
Text(text = "Lon: ${settings.stationPos.longitude}°")
|
Text(text = stringResource(R.string.loc_lon_text, settings.stationPos.longitude))
|
||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
|
||||||
Text(text = "Qth: ${settings.stationPos.qthLocator}")
|
Text(text = stringResource(R.string.loc_qth_text, settings.stationPos.qthLocator))
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(1.dp))
|
Spacer(modifier = Modifier.height(1.dp))
|
||||||
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
|
Row(horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||||
@@ -486,11 +598,12 @@ private fun OtherCardPreview() = MainTheme {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
|
private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Unit) {
|
||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth()
|
||||||
.fillMaxWidth()
|
) {
|
||||||
.height(268.dp)
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) {
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.prefs_other_title),
|
text = stringResource(id = R.string.prefs_other_title),
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
@@ -514,8 +627,389 @@ private fun OtherCard(settings: OtherSettings, onAction: (SettingsAction) -> Uni
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI settings card: controls which pages appear in the bottom menu bar.
|
||||||
|
* Order is fixed (Satellites/Passes/Radar/Mutual/Roaming/Map/Settings); turning one off makes the rest close up;
|
||||||
|
* the Settings page is always shown (so the settings entry can't be lost).
|
||||||
|
*/
|
||||||
|
/** WaveLog log server settings card (4.5.2): matches the user's screenshot (dark card / label left, input right / button row) */
|
||||||
@Composable
|
@Composable
|
||||||
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
|
private fun WavelogCard(
|
||||||
|
settings: OtherSettings,
|
||||||
|
onAction: (SettingsAction) -> Unit,
|
||||||
|
lotwRepo: com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
|
||||||
|
) {
|
||||||
|
val qrzContext = LocalContext.current
|
||||||
|
var showQrzDialog by remember { mutableStateOf(false) }
|
||||||
|
var qrzCookie by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
qrzContext.getSharedPreferences("qrz_cookie", android.content.Context.MODE_PRIVATE)
|
||||||
|
.getString("cookie", "") ?: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var qrzTestResult by remember { mutableStateOf<String?>(null) }
|
||||||
|
var qrzTesting by remember { mutableStateOf(false) }
|
||||||
|
val qrzScope = rememberCoroutineScope()
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.prefs_wavelog_title),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
IconButton(onClick = { showQrzDialog = true }) {
|
||||||
|
Text("\u2699")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedTextField(
|
||||||
|
value = settings.wavelogUrl,
|
||||||
|
onValueChange = {
|
||||||
|
onAction(SettingsAction.UpdateWavelogSettings(it, settings.wavelogApiKey, settings.wavelogStationId, settings.wavelogAutoUpload))
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(id = R.string.prefs_wavelog_url_hint)) },
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = LocalTextStyle.current.copy(fontSize = 14.sp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = settings.wavelogApiKey,
|
||||||
|
onValueChange = {
|
||||||
|
onAction(SettingsAction.UpdateWavelogSettings(settings.wavelogUrl, it, settings.wavelogStationId, settings.wavelogAutoUpload))
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(id = R.string.prefs_wavelog_key_hint)) },
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = LocalTextStyle.current.copy(fontSize = 14.sp),
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = settings.wavelogStationId,
|
||||||
|
onValueChange = {
|
||||||
|
onAction(SettingsAction.UpdateWavelogSettings(settings.wavelogUrl, settings.wavelogApiKey, it, settings.wavelogAutoUpload))
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(id = R.string.prefs_wavelog_station_hint)) },
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = LocalTextStyle.current.copy(fontSize = 14.sp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
SwitchRow(R.string.prefs_wavelog_auto, settings.wavelogAutoUpload) {
|
||||||
|
onAction(SettingsAction.UpdateWavelogSettings(settings.wavelogUrl, settings.wavelogApiKey, settings.wavelogStationId, it))
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
CardButton(
|
||||||
|
onClick = { onAction(SettingsAction.TestWavelogConnection) },
|
||||||
|
text = stringResource(id = R.string.prefs_wavelog_test),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
CardButton(
|
||||||
|
onClick = { onAction(SettingsAction.UpdateLotwSatellites) },
|
||||||
|
text = stringResource(id = R.string.prefs_wavelog_update_sats),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
CardButton(
|
||||||
|
onClick = { onAction(SettingsAction.UploadWavelogQueue) },
|
||||||
|
text = stringResource(id = R.string.prefs_wavelog_upload),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showQrzDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showQrzDialog = false },
|
||||||
|
title = { Text(text = stringResource(id = R.string.prefs_wavelog_qrz_title)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.prefs_wavelog_qrz_hint),
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = qrzCookie,
|
||||||
|
onValueChange = { qrzCookie = it },
|
||||||
|
label = { Text(stringResource(id = R.string.prefs_wavelog_qrz_label)) },
|
||||||
|
minLines = 3,
|
||||||
|
maxLines = 8,
|
||||||
|
textStyle = LocalTextStyle.current.copy(fontSize = 12.sp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
if (qrzTestResult != null) {
|
||||||
|
Text(
|
||||||
|
text = qrzTestResult!!,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
qrzTesting = true
|
||||||
|
qrzTestResult = null
|
||||||
|
qrzScope.launch {
|
||||||
|
val header = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.parseCookies(qrzCookie)
|
||||||
|
if (header.isBlank()) {
|
||||||
|
qrzTestResult = "Cookie 为空, 请先粘贴"
|
||||||
|
qrzTesting = false
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val own = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.fetchOwnCallsign(header)
|
||||||
|
if (own == null) {
|
||||||
|
qrzTestResult = "Cookie 无效或已过期(无法识别登录呼号)"
|
||||||
|
} else {
|
||||||
|
val grid = com.rtbishop.look4sat.core.domain.qrz.QrzGridClient.lookupGrid(own, header)
|
||||||
|
qrzTestResult = if (grid != null) {
|
||||||
|
"登录呼号: $own 网格: $grid ✅"
|
||||||
|
} else {
|
||||||
|
"登录呼号: $own 网格: 未填写"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
qrzTesting = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !qrzTesting
|
||||||
|
) {
|
||||||
|
Text(text = if (qrzTesting) "测试中…" else "测试查询")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
qrzContext.getSharedPreferences("qrz_cookie", android.content.Context.MODE_PRIVATE)
|
||||||
|
.edit().putString("cookie", qrzCookie.trim()).apply()
|
||||||
|
showQrzDialog = false
|
||||||
|
}) { Text(text = stringResource(id = R.string.btn_accept)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showQrzDialog = false }) {
|
||||||
|
Text(text = stringResource(id = R.string.wavelog_cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UiSettingsCard(
|
||||||
|
hiddenScreens: List<String>,
|
||||||
|
screenOrder: List<String>,
|
||||||
|
subMenuOrder: List<String>,
|
||||||
|
onToggle: (String) -> Unit,
|
||||||
|
onReorder: (List<String>) -> Unit,
|
||||||
|
onResetOrder: () -> Unit,
|
||||||
|
onUpdateMenu: (List<String>, List<String>) -> Unit
|
||||||
|
) {
|
||||||
|
val screens = listOf(
|
||||||
|
R.string.nav_sat to "Satellites",
|
||||||
|
R.string.nav_pass to "Passes",
|
||||||
|
R.string.nav_radar to "Radar",
|
||||||
|
R.string.nav_mutual to "Mutual",
|
||||||
|
R.string.nav_roaming to "Roaming",
|
||||||
|
R.string.nav_cw to "CwDecode",
|
||||||
|
R.string.nav_log to "WavelogLog",
|
||||||
|
R.string.nav_amsat to "AMSAT",
|
||||||
|
R.string.nav_map to "Map",
|
||||||
|
R.string.nav_prefs to "Settings"
|
||||||
|
) // name 必须与 Screen.screenId 一致 (R8 安全)
|
||||||
|
// Main menu items: exclude sub-menu items; Settings pinned last
|
||||||
|
val mainItems = remember(screens, screenOrder, subMenuOrder) {
|
||||||
|
val sub = subMenuOrder.ifEmpty { defaultSubMenuOrder }
|
||||||
|
screens.map { it.second }
|
||||||
|
.filter { it !in sub }
|
||||||
|
.sortedBy { name ->
|
||||||
|
screenOrder.indexOf(name).let { idx ->
|
||||||
|
if (idx != -1) idx else defaultScreenOrder.indexOf(name).let {
|
||||||
|
if (it != -1) it else Int.MAX_VALUE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sortedWith(compareBy { if (it == "Settings") 1 else 0 })
|
||||||
|
}
|
||||||
|
// More-menu items
|
||||||
|
val subItems = remember(screens, subMenuOrder) {
|
||||||
|
val sub = subMenuOrder.ifEmpty { defaultSubMenuOrder }
|
||||||
|
sub.filter { s -> screens.any { (_, name) -> name == s } }
|
||||||
|
}
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.prefs_ui_title),
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
screens.forEach { (labelRes, name) ->
|
||||||
|
if (name == "Settings") {
|
||||||
|
// Settings always shown; its switch is disabled
|
||||||
|
SwitchRow(labelRes, true, null)
|
||||||
|
} else {
|
||||||
|
SwitchRow(labelRes, name !in hiddenScreens) {
|
||||||
|
onToggle(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.prefs_ui_order_title),
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
// Main menu area (bottom bar, max 5 items, Settings locked last)
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.prefs_ui_main_title),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
DragOrderList(
|
||||||
|
items = mainItems,
|
||||||
|
labels = screens,
|
||||||
|
locked = setOf("Settings"),
|
||||||
|
moveLabel = stringResource(id = R.string.prefs_ui_move_out),
|
||||||
|
moveEnabled = { name -> name != "Settings" },
|
||||||
|
onMove = { name ->
|
||||||
|
onUpdateMenu(
|
||||||
|
mainItems.filter { it != name },
|
||||||
|
(subMenuOrder.ifEmpty { defaultSubMenuOrder } + name).distinct()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onReorder = { main -> onUpdateMenu(main, subMenuOrder) }
|
||||||
|
)
|
||||||
|
// More-menu area (pages behind "More")
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.prefs_ui_sub_title),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
DragOrderList(
|
||||||
|
items = subItems,
|
||||||
|
labels = screens,
|
||||||
|
moveLabel = stringResource(id = R.string.prefs_ui_move_back),
|
||||||
|
// Buttons always visible; when the main menu hits 5, the last non-Settings item moves to More (swap)
|
||||||
|
moveEnabled = { true },
|
||||||
|
onMove = { name ->
|
||||||
|
val mainWithoutSettings = mainItems.filter { it != "Settings" }
|
||||||
|
val needSwap = mainWithoutSettings.size >= 5
|
||||||
|
val evicted = if (needSwap) mainWithoutSettings.last() else null
|
||||||
|
val newMain = if (needSwap) {
|
||||||
|
(mainWithoutSettings.dropLast(1) + name).distinct()
|
||||||
|
} else {
|
||||||
|
(mainWithoutSettings + name).distinct()
|
||||||
|
}
|
||||||
|
val newSub = (subItems.filter { it != name } +
|
||||||
|
if (evicted != null) listOf(evicted) else emptyList()).distinct()
|
||||||
|
onUpdateMenu(newMain, newSub)
|
||||||
|
},
|
||||||
|
onReorder = { sub -> onUpdateMenu(screenOrder, sub) }
|
||||||
|
)
|
||||||
|
TextButton(onClick = onResetOrder) {
|
||||||
|
Text(text = stringResource(id = R.string.prefs_ui_order_reset))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page-order drag list: handle on each row's right; drag up/down to reorder live; persists on release */
|
||||||
|
@Composable
|
||||||
|
private fun DragOrderList(
|
||||||
|
items: List<String>,
|
||||||
|
labels: List<Pair<Int, String>>,
|
||||||
|
locked: Set<String> = emptySet(),
|
||||||
|
moveLabel: String? = null,
|
||||||
|
moveEnabled: (String) -> Boolean = { true },
|
||||||
|
onMove: ((String) -> Unit)? = null,
|
||||||
|
onReorder: (List<String>) -> Unit
|
||||||
|
) {
|
||||||
|
val itemHeight = 48.dp
|
||||||
|
val itemHeightPx = with(LocalDensity.current) { itemHeight.toPx() }
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val localItems = remember(items) { mutableStateListOf<String>().apply { addAll(items) } }
|
||||||
|
var draggingIndex by remember { mutableIntStateOf(-1) }
|
||||||
|
var dragOffset by remember { mutableStateOf(0f) }
|
||||||
|
|
||||||
|
// Fixed height: nested inside LazyVerticalGrid it needs a bounded height or infinite constraints crash
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(itemHeight * localItems.size)
|
||||||
|
) {
|
||||||
|
itemsIndexed(localItems, key = { _, name -> name }) { index, name ->
|
||||||
|
val isDragging = draggingIndex == index
|
||||||
|
val isLocked = name in locked
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(itemHeight)
|
||||||
|
.zIndex(if (isDragging) 1f else 0f)
|
||||||
|
.graphicsLayer { translationY = if (isDragging) dragOffset else 0f }
|
||||||
|
.animateItem()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = labels.first { it.second == name }.first),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
if (moveLabel != null && onMove != null && moveEnabled(name)) {
|
||||||
|
TextButton(onClick = { onMove(name) }, contentPadding = PaddingValues(horizontal = 4.dp)) {
|
||||||
|
Text(moveLabel, fontSize = 11.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isLocked) {
|
||||||
|
// Locked row (Settings): no handle, pinned last
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.prefs_ui_order_locked),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.padding(20.dp)
|
||||||
|
.pointerInput(name) {
|
||||||
|
detectDragGestures(
|
||||||
|
onDragStart = { draggingIndex = localItems.indexOf(name) },
|
||||||
|
onDrag = { change, dragAmount ->
|
||||||
|
change.consume()
|
||||||
|
dragOffset += dragAmount.y
|
||||||
|
val currentIndex = draggingIndex
|
||||||
|
val target = (currentIndex + (dragOffset / itemHeightPx).roundToInt())
|
||||||
|
.coerceIn(0, localItems.size - 1)
|
||||||
|
if (target != currentIndex) {
|
||||||
|
localItems.add(target, localItems.removeAt(currentIndex))
|
||||||
|
dragOffset += (currentIndex - target) * itemHeightPx
|
||||||
|
draggingIndex = target
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
onReorder(localItems.toList())
|
||||||
|
draggingIndex = -1
|
||||||
|
dragOffset = 0f
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
draggingIndex = -1
|
||||||
|
dragOffset = 0f
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
// Dot handle (touch area padding 20dp centered -> 8dp dot)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.onSurfaceVariant, CircleShape)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SwitchRow(labelResId: Int, checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?) {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
@@ -558,15 +1052,11 @@ private fun CardCreditsPreview() = MainTheme { CardCredits() }
|
|||||||
@Composable
|
@Composable
|
||||||
private fun CardCredits(modifier: Modifier = Modifier) {
|
private fun CardCredits(modifier: Modifier = Modifier) {
|
||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = modifier
|
modifier = modifier.fillMaxWidth()
|
||||||
.fillMaxWidth()
|
|
||||||
.height(268.dp)
|
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
|
||||||
.fillMaxHeight()
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.prefs_outro_title),
|
text = stringResource(id = R.string.prefs_outro_title),
|
||||||
@@ -575,6 +1065,8 @@ private fun CardCredits(modifier: Modifier = Modifier) {
|
|||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.prefs_outro_thanks)
|
text = stringResource(id = R.string.prefs_outro_thanks)
|
||||||
)
|
)
|
||||||
|
// Keep roughly two lines of spacing between the thanks list and the license block (user request)
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(id = R.string.prefs_outro_license),
|
text = stringResource(id = R.string.prefs_outro_license),
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
@@ -590,7 +1082,7 @@ private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version:
|
|||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.height(48.dp)
|
.heightIn(min = 48.dp)
|
||||||
.clickable { onClick() }) {
|
.clickable { onClick() }) {
|
||||||
Spacer(Modifier)
|
Spacer(Modifier)
|
||||||
Icon(
|
Icon(
|
||||||
@@ -605,7 +1097,6 @@ private fun TopCard(onClick: () -> Unit, modifier: Modifier = Modifier, version:
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(end = 6.dp)
|
.padding(end = 6.dp)
|
||||||
.infiniteMarquee()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
@@ -64,6 +64,26 @@ 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
|
||||||
|
// UI settings: toggle a bottom-nav page's hidden state (Screen simpleName)
|
||||||
|
data class ToggleScreen(val screenName: String) : SettingsAction
|
||||||
|
// UI settings: update page order
|
||||||
|
data class ReorderScreens(val order: List<String>) : SettingsAction
|
||||||
|
// UI settings: reset to default order
|
||||||
|
data object ResetScreenOrder : SettingsAction
|
||||||
|
// UI settings: update main + More menu order (4.5.1 foldable menu)
|
||||||
|
data class UpdateMenuOrder(val mainOrder: List<String>, val subOrder: List<String>) : SettingsAction
|
||||||
|
// UI settings: reset main + More menu order
|
||||||
|
data object ResetMenuOrder : SettingsAction
|
||||||
|
// WaveLog logging (4.5.2): update server config
|
||||||
|
data class UpdateWavelogSettings(
|
||||||
|
val url: String, val apiKey: String, val stationId: String, val autoUpload: Boolean
|
||||||
|
) : SettingsAction
|
||||||
|
// WaveLog: test connection
|
||||||
|
data object TestWavelogConnection : SettingsAction
|
||||||
|
// WaveLog: upload queue manually
|
||||||
|
data object UploadWavelogQueue : SettingsAction
|
||||||
|
// WaveLog: update LoTW satellite list
|
||||||
|
data object UpdateLotwSatellites : SettingsAction
|
||||||
|
|
||||||
// Remote control
|
// Remote control
|
||||||
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
data class UpdateRC(val settings: RCSettings) : SettingsAction
|
||||||
|
|||||||
+118
-4
@@ -25,16 +25,24 @@ import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
import com.rtbishop.look4sat.core.domain.usecase.IShowToast
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.UploadOutcome
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.WaveLogApi
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.WavelogResult
|
||||||
|
import com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
|
||||||
import com.rtbishop.look4sat.core.presentation.R
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
|
||||||
class SettingsViewModel(
|
class SettingsViewModel(
|
||||||
private val databaseRepo: IDatabaseRepo,
|
private val databaseRepo: IDatabaseRepo,
|
||||||
private val settingsRepo: ISettingsRepo,
|
private val settingsRepo: ISettingsRepo,
|
||||||
private val showToast: IShowToast
|
private val showToast: IShowToast,
|
||||||
|
private val lotwRepo: com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val defaultPosSettings = PositionSettings(false, settingsRepo.stationPosition.value, 0)
|
private val defaultPosSettings = PositionSettings(false, settingsRepo.stationPosition.value, 0)
|
||||||
@@ -121,10 +129,35 @@ 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.ToggleScreen -> settingsRepo.updateOtherSettings { current ->
|
||||||
|
val hidden = current.hiddenScreens.toMutableList()
|
||||||
|
if (action.screenName in hidden) hidden.remove(action.screenName) else hidden.add(action.screenName)
|
||||||
|
current.copy(hiddenScreens = hidden)
|
||||||
|
}
|
||||||
|
is SettingsAction.ReorderScreens -> settingsRepo.updateOtherSettings { it.copy(screenOrder = action.order) }
|
||||||
|
SettingsAction.ResetScreenOrder -> settingsRepo.updateOtherSettings { it.copy(screenOrder = emptyList()) }
|
||||||
|
is SettingsAction.UpdateMenuOrder -> settingsRepo.updateOtherSettings {
|
||||||
|
it.copy(screenOrder = action.mainOrder, subMenuOrder = action.subOrder)
|
||||||
|
}
|
||||||
|
SettingsAction.ResetMenuOrder -> settingsRepo.updateOtherSettings {
|
||||||
|
it.copy(screenOrder = emptyList(), subMenuOrder = emptyList())
|
||||||
|
}
|
||||||
// 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)
|
||||||
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
|
is SettingsAction.UpdateDataSources -> settingsRepo.updateDataSourcesSettings(action.settings)
|
||||||
|
// WaveLog logging (4.5.2)
|
||||||
|
is SettingsAction.UpdateWavelogSettings -> settingsRepo.updateOtherSettings {
|
||||||
|
it.copy(
|
||||||
|
wavelogUrl = action.url,
|
||||||
|
wavelogApiKey = action.apiKey,
|
||||||
|
wavelogStationId = action.stationId,
|
||||||
|
wavelogAutoUpload = action.autoUpload
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SettingsAction.TestWavelogConnection -> testWavelogConnection()
|
||||||
|
SettingsAction.UploadWavelogQueue -> uploadWavelogQueue()
|
||||||
|
SettingsAction.UpdateLotwSatellites -> updateLotwSatellites()
|
||||||
// System
|
// System
|
||||||
is SettingsAction.ShowToast -> showToast(action.message)
|
is SettingsAction.ShowToast -> showToast(action.message)
|
||||||
}
|
}
|
||||||
@@ -133,7 +166,16 @@ class SettingsViewModel(
|
|||||||
// region Position helpers — consolidated from 3 near-identical functions
|
// region Position helpers — consolidated from 3 near-identical functions
|
||||||
|
|
||||||
private fun setGpsPosition() {
|
private fun setGpsPosition() {
|
||||||
updatePosition(R.string.prefs_loc_gps_error) { settingsRepo.setStationPosition() }
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(positionSettings = it.positionSettings.copy(isUpdating = true)) }
|
||||||
|
val success = settingsRepo.setStationPosition()
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(positionSettings = it.positionSettings.copy(
|
||||||
|
isUpdating = false,
|
||||||
|
messageResId = if (success) R.string.prefs_loc_success else R.string.prefs_loc_gps_error
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setGeoPosition(latitude: Double, longitude: Double) {
|
private fun setGeoPosition(latitude: Double, longitude: Double) {
|
||||||
@@ -181,6 +223,7 @@ class SettingsViewModel(
|
|||||||
it.copy(dataSettings = it.dataSettings.copy(isUpdating = false))
|
it.copy(dataSettings = it.dataSettings.copy(isUpdating = false))
|
||||||
}
|
}
|
||||||
println(exception)
|
println(exception)
|
||||||
|
showToast(R.string.prefs_data_update_failed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +240,76 @@ class SettingsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region WaveLog(4.5.2)
|
||||||
|
|
||||||
|
// Shared uploader injected by SettingsScreen (container-level singleton)
|
||||||
|
var pendingUploader: WavelogUploader? = null
|
||||||
|
|
||||||
|
// Grid-mismatch confirmation state (UI observes it and shows an AlertDialog)
|
||||||
|
var gridConfirm by mutableStateOf<GridConfirmData?>(null)
|
||||||
|
|
||||||
|
// WaveLog error-detail dialog (UI observes it; one-tap copy)
|
||||||
|
var wavelogError by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
data class GridConfirmData(val stationGrid: String, val userGrid: String)
|
||||||
|
|
||||||
|
private fun testWavelogConnection() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val s = settingsRepo.otherSettings.value
|
||||||
|
val result = WaveLogApi.testToken(s.wavelogUrl, s.wavelogApiKey, s.wavelogStationId)
|
||||||
|
when (result) {
|
||||||
|
is WavelogResult.Success -> showToast("WaveLog: ${result.message}")
|
||||||
|
is WavelogResult.Failure -> wavelogError = result.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateLotwSatellites() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val r = lotwRepo.refresh()) {
|
||||||
|
is com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo.RefreshResult.Ok ->
|
||||||
|
showToast("LoTW 卫星列表已更新: ${r.count} 个")
|
||||||
|
is com.rtbishop.look4sat.core.domain.wavelog.ILotwSatellitesRepo.RefreshResult.Error ->
|
||||||
|
wavelogError = "LoTW 卫星列表更新失败: ${r.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun uploadWavelogQueue() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = pendingUploader?.uploadQueue()
|
||||||
|
when (result) {
|
||||||
|
is UploadOutcome.Done -> {
|
||||||
|
if (result.failedCount > 0 && result.firstError.isNotBlank()) {
|
||||||
|
wavelogError = result.firstError
|
||||||
|
} else {
|
||||||
|
showToast("WaveLog: ${result.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is UploadOutcome.NeedConfirm -> {
|
||||||
|
gridConfirm = GridConfirmData(result.stationGrid, result.userGrid)
|
||||||
|
}
|
||||||
|
null -> showToast("WaveLog: 上传失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grid-confirm result: ignore and upload / cancel */
|
||||||
|
fun resolveGridConfirm(ignoreAndUpload: Boolean) {
|
||||||
|
val confirm = gridConfirm ?: return
|
||||||
|
gridConfirm = null
|
||||||
|
if (!ignoreAndUpload) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = pendingUploader?.uploadQueue(force = true)
|
||||||
|
when (result) {
|
||||||
|
is UploadOutcome.Done -> showToast("WaveLog: ${result.message}")
|
||||||
|
else -> showToast("WaveLog: 上传失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -206,8 +319,9 @@ class SettingsViewModel(
|
|||||||
SettingsViewModel(
|
SettingsViewModel(
|
||||||
databaseRepo = container.databaseRepo,
|
databaseRepo = container.databaseRepo,
|
||||||
settingsRepo = container.settingsRepo,
|
settingsRepo = container.settingsRepo,
|
||||||
showToast = container.provideShowToast()
|
showToast = container.provideShowToast(),
|
||||||
)
|
lotwRepo = container.provideLotwSatellitesRepo()
|
||||||
|
).apply { pendingUploader = container.provideWavelogUploader() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import com.android.build.api.dsl.LibraryExtension
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.convention.featurePlugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.rtbishop.look4sat.feature.status"
|
||||||
|
compileOptions {
|
||||||
|
encoding = "UTF-8"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":core:domain"))
|
||||||
|
implementation(project(":core:presentation"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
package com.rtbishop.look4sat.feature.status
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.ui.graphics.ColorFilter
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.rotate
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatDay
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatSlot
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
|
import com.rtbishop.look4sat.core.presentation.R
|
||||||
|
import com.rtbishop.look4sat.feature.status.R as StatusR
|
||||||
|
import java.util.Calendar
|
||||||
|
|
||||||
|
// ========== Official status colors (amsat.org/status originals) ==========
|
||||||
|
val ActiveBlue = Color(0xFF648FFF)
|
||||||
|
val TlmOrange = Color(0xFFFFB000)
|
||||||
|
val NotHeardPink = Color(0xFFDC267F)
|
||||||
|
val ConflictDeepOrange = Color(0xFFFE6100)
|
||||||
|
val NoReportGray = Color(0xFFC0C0C0)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SatStatusScreen(container: IMainContainer) {
|
||||||
|
val viewModel: SatStatusViewModel = viewModel(
|
||||||
|
factory = viewModelFactory {
|
||||||
|
initializer { SatStatusViewModel(container) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
var selectedDay by remember { mutableStateOf<Pair<SatStatus, SatDay>?>(null) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = 8.dp)
|
||||||
|
) {
|
||||||
|
// Top: legend + refresh
|
||||||
|
StatusHeader(
|
||||||
|
fetchedAtUtcMs = uiState.fetchedAtUtcMs,
|
||||||
|
isRefreshing = uiState.isRefreshing,
|
||||||
|
onRefresh = { viewModel.refresh() }
|
||||||
|
)
|
||||||
|
LegendRow()
|
||||||
|
|
||||||
|
when {
|
||||||
|
uiState.isLoading -> {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uiState.error != null && uiState.statuses.isEmpty() -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.amsat_load_failed),
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
TextButton(onClick = { viewModel.refresh() }) {
|
||||||
|
Text(text = stringResource(id = R.string.amsat_retry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Header
|
||||||
|
HeaderRow(statuses = uiState.statuses)
|
||||||
|
HorizontalDivider(thickness = 1.dp)
|
||||||
|
// Row
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(bottom = 16.dp)
|
||||||
|
) {
|
||||||
|
items(uiState.statuses, key = { it.name }) { status ->
|
||||||
|
StatusRow(
|
||||||
|
status = status,
|
||||||
|
onClickDay = { day -> selectedDay = status to day }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedDay?.let { (status, day) ->
|
||||||
|
ReportDialog(
|
||||||
|
statusName = status.name,
|
||||||
|
day = day,
|
||||||
|
reports = uiState.reports,
|
||||||
|
onDismiss = { selectedDay = null }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top: update time + refresh button (spinner while loading) */
|
||||||
|
@Composable
|
||||||
|
private fun StatusHeader(
|
||||||
|
fetchedAtUtcMs: Long,
|
||||||
|
isRefreshing: Boolean,
|
||||||
|
onRefresh: () -> Unit
|
||||||
|
) {
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "refresh")
|
||||||
|
val angle by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 360f,
|
||||||
|
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Restart),
|
||||||
|
label = "angle"
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (fetchedAtUtcMs > 0) stringResource(id = R.string.amsat_updated) + " " + formatFetchedAt(fetchedAtUtcMs) else stringResource(id = R.string.amsat_title),
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(34.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.clickable(onClick = onRefresh),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (isRefreshing) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||||
|
} else {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = StatusR.drawable.ic_refresh),
|
||||||
|
contentDescription = stringResource(id = R.string.amsat_refresh),
|
||||||
|
modifier = Modifier
|
||||||
|
.size(18.dp)
|
||||||
|
.rotate(angle),
|
||||||
|
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legend (official four colors) */
|
||||||
|
@Composable
|
||||||
|
private fun LegendRow() {
|
||||||
|
val legend = listOf(
|
||||||
|
stringResource(id = R.string.amsat_active) to ActiveBlue,
|
||||||
|
stringResource(id = R.string.amsat_tlm) to TlmOrange,
|
||||||
|
stringResource(id = R.string.amsat_not_heard) to NotHeardPink,
|
||||||
|
stringResource(id = R.string.amsat_conflict) to ConflictDeepOrange
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
legend.forEach { (label, color) ->
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(10.dp)
|
||||||
|
.clip(RoundedCornerShape(2.dp))
|
||||||
|
.background(color)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(3.dp))
|
||||||
|
Text(text = label, fontSize = 10.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Header: satellite name + 6 day dates */
|
||||||
|
@Composable
|
||||||
|
private fun HeaderRow(statuses: List<SatStatus>) {
|
||||||
|
val dates = statuses.firstOrNull()?.days?.map { it.dateLabel } ?: emptyList()
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.amsat_name),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.weight(2f).padding(start = 4.dp)
|
||||||
|
)
|
||||||
|
dates.forEach { date ->
|
||||||
|
Text(
|
||||||
|
text = date,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.weight(0.8f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Satellite row: name + 6 day color blocks (official colors + report counts) */
|
||||||
|
@Composable
|
||||||
|
private fun StatusRow(status: SatStatus, onClickDay: (SatDay) -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 3.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = status.name,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(2f).padding(start = 4.dp)
|
||||||
|
)
|
||||||
|
status.days.forEach { day ->
|
||||||
|
val slot = day.slots.firstOrNull { it.statusColor != NoReportGray.value.toInt().toLong() } ?: day.slots.first()
|
||||||
|
DayCell(
|
||||||
|
slot = slot,
|
||||||
|
modifier = Modifier.weight(0.8f).padding(horizontal = 1.dp),
|
||||||
|
onClick = { onClickDay(day) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Day block: newest reported status among the day's 12 slots; gray when none */
|
||||||
|
@Composable
|
||||||
|
private fun DayCell(slot: SatSlot, modifier: Modifier, onClick: () -> Unit) {
|
||||||
|
val color = Color(slot.statusColor)
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.height(24.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(color)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
if (slot.count > 0) {
|
||||||
|
Text(
|
||||||
|
text = slot.count.toString(),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Report detail dialog (3 levels: callsign/date/time/grid) */
|
||||||
|
@Composable
|
||||||
|
private fun ReportDialog(
|
||||||
|
statusName: String,
|
||||||
|
day: SatDay,
|
||||||
|
reports: Map<String, SatReport>,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
val dayReports = day.slots.flatMap { it.reportIds }
|
||||||
|
.mapNotNull { reports[it] }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(text = "$statusName · ${day.dateLabel}") },
|
||||||
|
text = {
|
||||||
|
if (dayReports.isEmpty()) {
|
||||||
|
Text(stringResource(id = R.string.amsat_no_reports))
|
||||||
|
} else {
|
||||||
|
LazyColumn(modifier = Modifier.height(320.dp)) {
|
||||||
|
items(dayReports) { report ->
|
||||||
|
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(statusColorOf(report.statusText))
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = report.statusText,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "${report.call} ${report.dateUtc} ${report.timeUtc}" +
|
||||||
|
if (report.grid.isNotBlank() && report.grid != "-") " ${report.grid}" else "",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HorizontalDivider(thickness = 0.5.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(id = R.string.amsat_close)) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun statusColorOf(statusText: String): Color = when {
|
||||||
|
statusText.contains("Heard", ignoreCase = true) && !statusText.contains("Not", ignoreCase = true) -> ActiveBlue
|
||||||
|
statusText.contains("Telemetry", ignoreCase = true) || statusText.contains("Beacon", ignoreCase = true) -> TlmOrange
|
||||||
|
statusText.contains("Not Heard", ignoreCase = true) -> NotHeardPink
|
||||||
|
else -> ConflictDeepOrange
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatFetchedAt(utcMs: Long): String {
|
||||||
|
val cal = Calendar.getInstance()
|
||||||
|
cal.timeInMillis = utcMs
|
||||||
|
return "%02d-%02d %02d:%02d".format(
|
||||||
|
cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH),
|
||||||
|
cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)
|
||||||
|
)
|
||||||
|
}
|
||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
package com.rtbishop.look4sat.feature.status
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatReport
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class SatStatusUiState(
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val isRefreshing: Boolean = false,
|
||||||
|
val statuses: List<SatStatus> = emptyList(),
|
||||||
|
val reports: Map<String, SatReport> = emptyMap(),
|
||||||
|
val fetchedAtUtcMs: Long = 0L,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
class SatStatusViewModel(
|
||||||
|
private val container: IMainContainer
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow(SatStatusUiState(isLoading = true))
|
||||||
|
val uiState: StateFlow<SatStatusUiState> = _uiState
|
||||||
|
|
||||||
|
init {
|
||||||
|
fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fetch() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||||
|
try {
|
||||||
|
val page = container.amSatRepo.fetchStatus()
|
||||||
|
if (page != null && page.statuses.isNotEmpty()) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = false,
|
||||||
|
isRefreshing = false,
|
||||||
|
statuses = page.statuses,
|
||||||
|
reports = page.reports,
|
||||||
|
fetchedAtUtcMs = page.fetchedAtUtcMs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(isLoading = false, isRefreshing = false, error = "fetch_failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(isLoading = false, isRefreshing = false, error = exception.message ?: "fetch_failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
if (_uiState.value.isRefreshing) return
|
||||||
|
_uiState.update { it.copy(isRefreshing = true, error = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val page = container.amSatRepo.fetchStatus()
|
||||||
|
if (page != null && page.statuses.isNotEmpty()) {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isRefreshing = false,
|
||||||
|
statuses = page.statuses,
|
||||||
|
reports = page.reports,
|
||||||
|
fetchedAtUtcMs = page.fetchedAtUtcMs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_uiState.update { it.copy(isRefreshing = false, error = "fetch_failed") }
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
_uiState.update { it.copy(isRefreshing = false, error = exception.message ?: "fetch_failed") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Standard Material refresh icon (24dp viewport), centered geometry -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z" />
|
||||||
|
</vector>
|
||||||
+3
-4
@@ -1,6 +1,5 @@
|
|||||||
# Configure your build environment: http://www.gradle.org/docs/current/userguide/build_environment.html
|
# Configure your build environment: http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||||
org.gradle.caching=true
|
org.gradle.caching=true
|
||||||
# NOTE: lowered from -Xmx6g for the 2GB build server; heavy release builds run on GitHub Actions
|
org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||||
org.gradle.jvmargs=-Xmx768m -XX:MaxMetaspaceSize=256m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
org.gradle.parallel=true
|
||||||
org.gradle.parallel=false
|
org.gradle.workers.max=4
|
||||||
org.gradle.workers.max=1
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
[versions]
|
[versions]
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionCode = "446"
|
appVersionCode = "459"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
appVersionName = "4.4.6"
|
appVersionName = "4.5.6"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
compileSdk = "37"
|
compileSdk = "37"
|
||||||
#noinspection UnusedVersionCatalogEntry
|
#noinspection UnusedVersionCatalogEntry
|
||||||
|
|||||||
+3
-1
@@ -21,11 +21,13 @@ include(
|
|||||||
":core:presentation"
|
":core:presentation"
|
||||||
)
|
)
|
||||||
include(
|
include(
|
||||||
|
":feature:cw",
|
||||||
":feature:map",
|
":feature:map",
|
||||||
":feature:mutual",
|
":feature:mutual",
|
||||||
":feature:passes",
|
":feature:passes",
|
||||||
":feature:radar",
|
":feature:radar",
|
||||||
":feature:roaming",
|
":feature:roaming",
|
||||||
":feature:satellites",
|
":feature:satellites",
|
||||||
":feature:settings"
|
":feature:settings",
|
||||||
|
":feature:status"
|
||||||
)
|
)
|
||||||
Reference in new issue
Block a user