Compare commits

..
Author SHA1 Message Date
mckero deec5581ae feat: rebrand to Look4Sat Pro, bump version to 4.4.4
App display name changed from Look4Sat to Look4Sat Pro (fork identity). Version follows upstream scheme without suffix: 4.4.3 -> 4.4.4, versionCode 443 -> 444.
2026-08-03 09:20:41 +00:00
mckero 1de5632428 feat: increase station coordinate precision to 5 decimals
Station lat/lon was stored rounded to 4 decimals (~11 m). Bump to 5 decimals (~1.1 m), slightly better than GPS hardware accuracy without showing noise.

- SettingsRepo.setStationPosition: round(4) -> round(5)
- QthConverter.qthToPosition: round(4) -> round(6) so 10-char locators fully roundtrip
- Update QthConverterTest expected values to 6-decimal precision
2026-08-03 09:14:13 +00:00
mckero 2dfd76825b fix: fork under own applicationId com.rtbishop.look4sat.bg7nta
The official app signs com.rtbishop.look4sat with its own certificate. A fork sharing that applicationId cannot be installed over the official build (signature mismatch) and users saw overwrite/install failures.

- Add applicationId version catalog entry; namespace stays com.rtbishop.look4sat so source imports are untouched, applicationId becomes com.rtbishop.look4sat.bg7nta.
- Update PROPERTY_SATELLITE_DATA_OPTIMIZED meta-data to the fork id.
- Document the fork-applicationId requirement in the version catalog.
2026-08-03 08:38:21 +00:00
mckero 06fc8bc2fb ci: set GH_TOKEN for release creation step
gh CLI in Actions requires GH_TOKEN to authenticate. Use the built-in github.token.
2026-08-03 08:15:20 +00:00
mckero 75322390b8 ci: allow specifying release tag on manual dispatch
workflow_dispatch defaults TAG_NAME to the branch name (main), which breaks gh release create. Accept an optional tag_name input and fall back to github.ref_name for tag-triggered runs.
2026-08-03 08:12:20 +00:00
mckero d1312f1f09 ci: add manual workflow_dispatch trigger
Allow triggering the release build from the Actions tab in case tag events are not picked up.
2026-08-03 08:06:19 +00:00
mckero 75acbbd633 ci: simplify release workflow for fork builds
Remove Google Play upload (needs SERVICE_ACCOUNT_JSON we don't have) and AAB signing. Use built-in GITHUB_TOKEN instead of RELEASE_TOKEN secret. Pin actions to stable versions (checkout@v4, setup-java@v4, setup-gradle@v4). Builds assembleRelease, signs APK via apksigner with KEY_STORE secrets, creates GitHub release with APK. Triggered by v** tags.
2026-08-03 08:03:16 +00:00
mckero a37fb8f773 feat(domain): support 6/8/10-char Maidenhead grid square conversion
Port the 8-char (4-pair) Maidenhead grid algorithm from the
"QTH定位器 2.0" app (com.us1pm.gridsquarelocator) into QthConverter
so locator precision matches common grid tools instead of being
truncated to 6 chars.

Previously positionToQth() emitted only 6-char locators and
qthToPosition() discarded everything past the 6th character via
take(6), losing the finer 30" x 15" resolution carried by 8-char
grid squares.

What changed:
- positionToQth(lat, lon, precision = 8) now emits 8-char locators
  by default; precision = 6 / 10 available for backwards
  compatibility and maximum resolution (1.25" x 0.625").
- qthToPosition() parses 6/8/10-char locators and returns the center
  of the finest encoded cell (30" x 15" for 8-char, 1.25" x 0.625"
  for 10-char) instead of the 6-char cell center.
- Locator validation regex tightened: 6/8/10 chars accepted,
  4-char strings like "JN58" are now rejected as invalid.
- Boundary clamping added so lat = 90 / lon = 180 no longer overflow
  the A-R / 0-9 / a-x alphabet (previously produced invalid chars).

Also fixed a pre-existing compile error in RadarView.kt: a delegated
property was assigned after declaration ("by" on an already declared
val). Converted the sweep angle to an if/else expression.

Verification:
- QthConverterTest extended to 5 test cases covering 6/8/10-char
  roundtrips, invalid input, boundary coordinates and roundtrip
  stability.
- Cross-checked against a Python reference model of the decompiled
  APK algorithm: 20k random roundtrips at 8 and 10 chars, 0 failures.
- :core:domain:test green; :app:compileDebugKotlin passes.
2026-08-03 07:25:31 +00:00
mckero 9d0d971500 refine: AOS binary-search refinement, getRadios signature cleanup
Refine AOS crossing to ~500ms via binary search instead of linear stepping; simplify getRadios to take satPos; tidy radar view/viewmodel.
2026-08-03 06:47:49 +00:00
atsunatsu 1813e1bfa4 fix: refine mutual pass matching and radar overlay 2026-08-03 13:04:42 +08:00
19 changed files with 475 additions and 258 deletions

No files matched your search

+14 -38
View File
@@ -4,10 +4,15 @@ on:
push: push:
tags: tags:
- v** - v**
workflow_dispatch:
inputs:
tag_name:
description: 'Release tag name (e.g. v4.4.3)'
required: false
default: ''
env: env:
TAG_NAME: ${{ github.ref_name }} TAG_NAME: ${{ github.event.inputs.tag_name || github.ref_name }}
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
jobs: jobs:
release: release:
@@ -16,19 +21,19 @@ jobs:
contents: write contents: write
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@v7 uses: actions/checkout@v4
- name: Setup Java - name: Setup Java
uses: actions/setup-java@v5 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '21' java-version: '21'
- name: Setup Gradle - name: Setup Gradle
uses: gradle/actions/setup-gradle@v6 uses: gradle/actions/setup-gradle@v4
- name: Assemble Artifacts - name: Assemble APK
run: ./gradlew assembleRelease bundleRelease run: ./gradlew assembleRelease
- name: Sign APK - name: Sign APK
run: | run: |
@@ -44,38 +49,9 @@ jobs:
"$APK" "$APK"
rm keystore.jks rm keystore.jks
- name: Sign Bundle
run: |
echo "${{ secrets.KEY_STORE }}" | base64 -d > keystore.jks
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
jarsigner -verbose -sigalg SHA256withRSA -digestalg SHA-256 \
-keystore keystore.jks \
-storepass ${{ secrets.KEY_STORE_PASSWORD }} \
-keypass ${{ secrets.KEY_PASSWORD }} \
"$AAB" ${{ secrets.KEY_ALIAS }}
rm keystore.jks
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.4'
- name: Deploy Bundle to Google Play
run: |
gem install multi_json
gem install fastlane --no-document
AAB=$(find app/build/outputs/bundle/release -name "*.aab" | head -1)
echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
fastlane supply \
--aab "$AAB" \
--json_key service_account.json \
--package_name com.rtbishop.look4sat \
--track production \
--skip_upload_images true \
--skip_upload_screenshots true \
rm service_account.json
- name: Create Release - name: Create Release
env:
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 app/build/outputs/apk/release/look4sat.apk
+1 -1
View File
@@ -43,7 +43,7 @@
<meta-data <meta-data
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED" android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
android:value="com.rtbishop.look4sat" /> android:value="com.rtbishop.look4sat.bg7nta" />
</application> </application>
</manifest> </manifest>
@@ -72,6 +72,7 @@ import androidx.navigation3.runtime.rememberNavBackStack
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
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.presentation.DeeplinkResolver import com.rtbishop.look4sat.core.presentation.DeeplinkResolver
import com.rtbishop.look4sat.core.presentation.ElevationThresholds import com.rtbishop.look4sat.core.presentation.ElevationThresholds
import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds import com.rtbishop.look4sat.core.presentation.LocalElevationThresholds
@@ -200,6 +201,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
} }
entry<Screen.Passes> { entry<Screen.Passes> {
PassesDestination { catNum, aosTime -> PassesDestination { catNum, aosTime ->
container.setMutualPassData(MutualPassData())
container.satelliteRepo.selectPass(catNum, aosTime) container.satelliteRepo.selectPass(catNum, aosTime)
backStack.add(Screen.Radar) backStack.add(Screen.Radar)
// navigateToRadar() // navigateToRadar()
@@ -216,7 +218,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
viewModel = mutualViewModel, viewModel = mutualViewModel,
navigateUp = navigateBack, navigateUp = navigateBack,
navigateToRadar = { catNum, aosTime, pass -> navigateToRadar = { catNum, aosTime, pass ->
pass?.let { container.setMutualPassData(it) } container.setMutualPassData(pass ?: MutualPassData())
container.satelliteRepo.selectPass(catNum, aosTime) container.satelliteRepo.selectPass(catNum, aosTime)
backStack.add(Screen.Radar) backStack.add(Screen.Radar)
} }
@@ -245,6 +247,7 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
.clickable { .clickable {
val pass = trackingState.currentPass val pass = trackingState.currentPass
if (pass != null) { if (pass != null) {
container.setMutualPassData(MutualPassData())
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime) container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
backStack.add(Screen.Radar) backStack.add(Screen.Radar)
} }
@@ -57,7 +57,7 @@ internal fun Project.setupAndroidApp() {
namespace = libs.versions.packageName.get() namespace = libs.versions.packageName.get()
compileSdk = libs.versions.compileSdk.get().toInt() compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig { defaultConfig {
applicationId = libs.versions.packageName.get() applicationId = libs.versions.applicationId.get()
minSdk = libs.versions.minSdk.get().toInt() minSdk = libs.versions.minSdk.get().toInt()
versionCode = libs.versions.appVersionCode.get().toInt() versionCode = libs.versions.appVersionCode.get().toInt()
versionName = libs.versions.appVersionName.get() versionName = libs.versions.appVersionName.get()
@@ -95,14 +95,8 @@ class SatelliteRepo(
} }
} }
override suspend fun getRadios( override suspend fun getRadios(satPos: OrbitalPos, radios: List<SatRadio>): List<SatRadio> {
sat: OrbitalObject,
pos: GeoPos,
radios: List<SatRadio>,
time: Long
): List<SatRadio> {
return withContext(dispatcher) { return withContext(dispatcher) {
val satPos = sat.getPosition(pos, time)
radios.map { transmitter -> radios.map { transmitter ->
transmitter.copy( transmitter.copy(
downlinkLow = transmitter.downlinkLow?.let { satPos.getDownlinkFreq(it) }, downlinkLow = transmitter.downlinkLow?.let { satPos.getDownlinkFreq(it) },
@@ -242,13 +236,16 @@ class SatelliteRepo(
if (elevation > maxElevation) maxElevation = elevation if (elevation > maxElevation) maxElevation = elevation
} while (elevation < 0.0) } while (elevation < 0.0)
// refine AOS to ~500ms precision // refine AOS to ~500ms precision via binary search.
calendarTimeMillis -= 60L * 1000L // Elevation is monotonic across the horizon crossing, so binary search
do { // finds the crossing in ~8 SGP4 calls instead of up to 120 linear steps.
calendarTimeMillis += 500L var aosLo = calendarTimeMillis - 60L * 1000L // elevation < 0 (below horizon)
elevation = sat.getElevation(pos, calendarTimeMillis) var aosHi = calendarTimeMillis // elevation >= 0 (above horizon)
if (elevation > maxElevation) maxElevation = elevation while (aosHi - aosLo > 500L) {
} while (elevation < 0.0) val mid = (aosLo + aosHi) / 2
if (sat.getElevation(pos, mid) < 0.0) aosLo = mid else aosHi = mid
}
calendarTimeMillis = aosHi
// Get full position for AOS data (azimuth, altitude) // Get full position for AOS data (azimuth, altitude)
val aosPos = sat.getFullPosition(pos, calendarTimeMillis) val aosPos = sat.getFullPosition(pos, calendarTimeMillis)
@@ -262,13 +259,14 @@ class SatelliteRepo(
if (elevation > maxElevation) maxElevation = elevation if (elevation > maxElevation) maxElevation = elevation
} while (elevation > 0.0) } while (elevation > 0.0)
// refine LOS to ~500ms precision // refine LOS to ~500ms precision via binary search (same monotonic argument)
calendarTimeMillis -= 30L * 1000L var losLo = calendarTimeMillis - 30L * 1000L // elevation > 0 (above horizon)
do { var losHi = calendarTimeMillis // elevation <= 0 (below horizon)
calendarTimeMillis += 500L while (losHi - losLo > 500L) {
elevation = sat.getElevation(pos, calendarTimeMillis) val mid = (losLo + losHi) / 2
if (elevation > maxElevation) maxElevation = elevation if (sat.getElevation(pos, mid) > 0.0) losLo = mid else losHi = mid
} while (elevation > 0.0) }
calendarTimeMillis = losHi
// Get full position for LOS data (azimuth, altitude) // Get full position for LOS data (azimuth, altitude)
val losPos = sat.getFullPosition(pos, calendarTimeMillis) val losPos = sat.getFullPosition(pos, calendarTimeMillis)
@@ -213,8 +213,8 @@ class SettingsRepo(
} }
private fun setStationPosition(latitude: Double, longitude: Double, altitude: Double, locator: String) { private fun setStationPosition(latitude: Double, longitude: Double, altitude: Double, locator: String) {
val newLat = latitude.round(4) val newLat = latitude.round(5)
val newLon = longitude.round(4) val newLon = longitude.round(5)
val newAlt = altitude.round(1) val newAlt = altitude.round(1)
val timestamp = System.currentTimeMillis() val timestamp = System.currentTimeMillis()
println("Received new Position($newLat, $newLon, $newAlt) & Locator $locator") println("Received new Position($newLat, $newLon, $newAlt) & Locator $locator")
@@ -61,7 +61,7 @@ interface ISatelliteRepo {
suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos> suspend fun getTrack(sat: OrbitalObject, pos: GeoPos, start: Long, end: Long): List<OrbitalPos>
/** Get Doppler-shifted radio frequencies for a satellite at the given time. */ /** Get Doppler-shifted radio frequencies for a satellite at the given time. */
suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): List<SatRadio> suspend fun getRadios(satPos: OrbitalPos, radios: List<SatRadio>): List<SatRadio>
/** Fetch radio transceivers for a satellite by its catalog number. */ /** Fetch radio transceivers for a satellite by its catalog number. */
suspend fun getRadiosWithId(id: Int): List<SatRadio> suspend fun getRadiosWithId(id: Int): List<SatRadio>
@@ -19,31 +19,78 @@ package com.rtbishop.look4sat.core.domain.utility
import com.rtbishop.look4sat.core.domain.predict.GeoPos import com.rtbishop.look4sat.core.domain.predict.GeoPos
/**
* Converts a Maidenhead locator (QTH grid square) to a GeoPos.
* Supports 6-char (3 pair), 8-char (4 pair) and 10-char (5 pair) locators.
* The returned position is the center of the finest cell encoded by the locator:
* - 6 char: 5' lon x 2.5' lat cell center
* - 8 char: 30" lon x 15" lat cell center
* - 10 char: 1.25" lon x 0.625" lat cell center
*/
fun qthToPosition(locator: String): GeoPos? { fun qthToPosition(locator: String): GeoPos? {
val trimmedQth = locator.take(6) val trimmedQth = locator.trim().uppercase()
if (!isValidLocator(trimmedQth)) return null if (!isValidLocator(trimmedQth)) return null
val lonFirst = (trimmedQth[0].uppercaseChar().code - 65) * 20 val lonFirst = (trimmedQth[0].code - 65) * 20
val latFirst = (trimmedQth[1].uppercaseChar().code - 65) * 10 val latFirst = (trimmedQth[1].code - 65) * 10
val lonSecond = trimmedQth[2].toString().toInt() * 2 val lonSecond = trimmedQth[2].toString().toInt() * 2
val latSecond = trimmedQth[3].toString().toInt() val latSecond = trimmedQth[3].toString().toInt()
val lonThird = (((trimmedQth[4].lowercaseChar().code - 97) / 12.0) + (1.0 / 24.0)) - 180 val lonThird = (trimmedQth[4].lowercaseChar().code - 97) / 12.0
val latThird = (((trimmedQth[5].lowercaseChar().code - 97) / 24.0) + (1.0 / 48.0)) - 90 val latThird = (trimmedQth[5].lowercaseChar().code - 97) / 24.0
val longitude = (lonFirst + lonSecond + lonThird).round(4) var longitude = lonFirst + lonSecond + lonThird - 180
val latitude = (latFirst + latSecond + latThird).round(4) var latitude = latFirst + latSecond + latThird - 90
return GeoPos(latitude, longitude) // 8-char extension: 4th pair, digits, 30" lon x 15" lat cells
if (trimmedQth.length >= 8) {
longitude += trimmedQth[6].toString().toInt() / 120.0
latitude += trimmedQth[7].toString().toInt() / 240.0
}
// 10-char extension: 5th pair, letters, 1.25" lon x 0.625" lat cells
if (trimmedQth.length >= 10) {
longitude += (trimmedQth[8].lowercaseChar().code - 97) / 2880.0
latitude += (trimmedQth[9].lowercaseChar().code - 97) / 5760.0
}
// Offset to the center of the finest encoded cell
when (trimmedQth.length) {
8 -> {
longitude += 1.0 / 240.0
latitude += 1.0 / 480.0
}
10 -> {
longitude += 1.0 / 5760.0
latitude += 1.0 / 11520.0
}
else -> {
longitude += 1.0 / 24.0
latitude += 1.0 / 48.0
}
}
return GeoPos(latitude.round(6), longitude.round(6))
} }
fun positionToQth(latitude: Double, longitude: Double): String? { /**
* Converts a GeoPos to a Maidenhead locator (QTH grid square).
* Default precision is 8 characters (4 pairs) giving 30" lon x 15" lat resolution,
* matching common 8-char grid square tools. Pass precision = 6 for the classic
* 5' x 2.5' resolution, or precision = 10 for the finest 1.25" x 0.625" resolution.
*/
fun positionToQth(latitude: Double, longitude: Double, precision: Int = 8): String? {
if (!isValidPosition(latitude, longitude)) return null if (!isValidPosition(latitude, longitude)) return null
val newLongitude = if (longitude > 180.0) longitude else longitude + 180 val newLongitude = longitude + 180
val newLatitude = latitude + 90 val newLatitude = latitude + 90
val lonFirst = (65 + (newLongitude / 20)).toInt().toChar() val lonFirst = (65 + (newLongitude / 20).toInt().coerceIn(0, 17)).toChar()
val latFirst = (65 + (newLatitude / 10)).toInt().toChar() val latFirst = (65 + (newLatitude / 10).toInt().coerceIn(0, 17)).toChar()
val lonSecond = ((newLongitude / 2) % 10).toInt() val lonSecond = ((newLongitude % 20) / 2).toInt()
val latSecond = (newLatitude % 10).toInt() val latSecond = (newLatitude % 10).toInt()
val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().lowercaseChar() val lonThird = (65 + (newLongitude % 2) * 12).toInt().toChar().lowercaseChar()
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().lowercaseChar() val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().lowercaseChar()
return "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird" val qth = "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
if (precision < 8) return qth
val lonFourth = ((newLongitude % (1.0 / 12.0)) * 120).toInt()
val latFourth = ((newLatitude % (1.0 / 24.0)) * 240).toInt()
val qth8 = "$qth$lonFourth$latFourth"
if (precision < 10) return qth8
val lonFifth = (65 + (newLongitude % (1.0 / 120.0)) * 2880).toInt().toChar().lowercaseChar()
val latFifth = (65 + (newLatitude % (1.0 / 240.0)) * 5760).toInt().toChar().lowercaseChar()
return "$qth8$lonFifth$latFifth"
} }
private fun isValidPosition(lat: Double, lon: Double): Boolean { private fun isValidPosition(lat: Double, lon: Double): Boolean {
@@ -51,5 +98,5 @@ private fun isValidPosition(lat: Double, lon: Double): Boolean {
} }
private fun isValidLocator(locator: String): Boolean { private fun isValidLocator(locator: String): Boolean {
return locator.matches("[a-xA-X][a-xA-X]\\d\\d[a-xA-X][a-xA-X]".toRegex()) return locator.matches("[a-xA-X]{2}\\d{2}[a-xA-X]{2}(?:\\d{2}(?:[a-xA-X]{2})?)?".toRegex())
} }
@@ -26,21 +26,36 @@ class QthConverterTest {
@Test @Test
fun `Given valid QTH returns correct POS`() { fun `Given valid QTH returns correct POS`() {
var result = qthToPosition("io91VL39FX") var result = qthToPosition("io91VL39FX")
assert(result?.latitude == 51.4792 && result.longitude == -0.2083) assert(result?.latitude == 51.499913 && result.longitude == -0.22309)
result = qthToPosition("gf15vc") result = qthToPosition("gf15vc")
assert(result?.latitude == -34.8958 && result.longitude == -56.2083) assert(result?.latitude == -34.895833 && result.longitude == -56.208333)
// 8-char locators: finer 30" x 15" cell center
result = qthToPosition("io91vl47")
assert(result?.latitude == 51.489583 && result.longitude == -0.2125)
result = qthToPosition("jn58td25")
assert(result?.latitude == 48.147917 && result.longitude == 11.604167)
} }
@Test @Test
fun `Given invalid QTH returns null`() { fun `Given invalid QTH returns null`() {
assert(qthToPosition("ZZ00zz") == null) assert(qthToPosition("ZZ00zz") == null)
assert(qthToPosition("JN58") == null) assert(qthToPosition("JN58") == null)
assert(qthToPosition("io9") == null)
assert(qthToPosition("IO91VL7") == null)
assert(qthToPosition("IO91VL4X") == null)
} }
@Test @Test
fun `Given valid POS returns correct QTH`() { fun `Given valid POS returns correct QTH`() {
assert(positionToQth(51.4878, -0.2146) == "IO91vl") // default precision is 8 chars
assert(positionToQth(48.1466, 11.6083) == "JN58td") assert(positionToQth(51.4878, -0.2146) == "IO91vl47")
assert(positionToQth(48.1466, 11.6083) == "JN58td25")
// 6-char precision still available for backwards compatibility
assert(positionToQth(51.4878, -0.2146, 6) == "IO91vl")
assert(positionToQth(48.1466, 11.6083, 6) == "JN58td")
// 10-char precision
assert(positionToQth(51.4878, -0.2146, 10) == "IO91vl47fb")
assert(positionToQth(48.1466, 11.6083, 10) == "JN58td25xe")
} }
@Test @Test
@@ -48,4 +63,23 @@ class QthConverterTest {
assert(positionToQth(91.0542, -170.1142) == null) assert(positionToQth(91.0542, -170.1142) == null)
assert(positionToQth(89.0542, -240.1142) == null) assert(positionToQth(89.0542, -240.1142) == null)
} }
@Test
fun `Given boundary POS stays in valid grid`() {
// antipodal / edge cases must not overflow the A-R / 0-9 / a-x alphabet
assert(positionToQth(-90.0, -180.0, 8) == "AA00aa00")
assert(positionToQth(90.0, 180.0, 8) == "RR00aa00")
assert(positionToQth(0.0, 0.0, 8) == "JJ00aa00")
// roundtrip stability: 8-char roundtrip is stable across a sample of positions
val positions = listOf(
Pair(51.4878, -0.2146), Pair(48.1466, 11.6083), Pair(-33.8688, 151.2093),
Pair(39.9042, 116.4074), Pair(35.6895, 139.6917), Pair(41.714, -72.727)
)
positions.forEach { (lat, lon) ->
val qth = positionToQth(lat, lon, 8)
val pos = qthToPosition(qth!!)
val qth2 = positionToQth(pos!!.latitude, pos.longitude, 8)
assert(qth == qth2) { "Roundtrip failed for ($lat, $lon): $qth -> $qth2" }
}
}
} }
@@ -36,7 +36,7 @@ sealed class Screen(val iconResId: Int, val titleResId: Int) : NavKey {
data object Map : Screen(R.drawable.ic_map, R.string.nav_map) data object Map : Screen(R.drawable.ic_map, R.string.nav_map)
@Serializable @Serializable
data object Mutual : Screen(R.drawable.ic_radar, R.string.nav_mutual) data object Mutual : Screen(R.drawable.ic_match, R.string.nav_mutual)
@Serializable @Serializable
data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs) data object Settings : Screen(R.drawable.ic_settings, R.string.nav_prefs)
@@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="96"
android:viewportHeight="96">
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="6"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M 8 76 C 8 44, 21 24, 39 24 C 57 24, 70 44, 70 76" />
<path
android:fillColor="@android:color/transparent"
android:strokeColor="@android:color/white"
android:strokeWidth="6"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M 30 76 C 30 54, 44 42, 62 42 C 80 42, 94 54, 94 76" />
</vector>
@@ -9,7 +9,7 @@
<string name="nav_sat">卫星</string> <string name="nav_sat">卫星</string>
<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_map">地图</string> <string name="nav_map">地图</string>
<string name="nav_prefs">设置</string> <string name="nav_prefs">设置</string>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Commmon --> <!-- Commmon -->
<string name="app_name" translatable="false">Look4Sat</string> <string name="app_name" translatable="false">Look4Sat Pro</string>
<string name="btn_accept">Accept</string> <string name="btn_accept">Accept</string>
<string name="btn_cancel">Cancel</string> <string name="btn_cancel">Cancel</string>
<string name="empty_list_message">No data to show</string> <string name="empty_list_message">No data to show</string>
@@ -10,7 +10,7 @@
<string name="nav_sat">Satellites</string> <string name="nav_sat">Satellites</string>
<string name="nav_pass">Passes</string> <string name="nav_pass">Passes</string>
<string name="nav_radar">Radar</string> <string name="nav_radar">Radar</string>
<string name="nav_mutual">Mutual</string> <string name="nav_mutual">Match</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>
@@ -88,7 +88,7 @@ fun MutualScreen(
}, },
topInfo = { topInfo = {
Text( Text(
text = "对台过境", text = "过境匹配",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
@@ -96,7 +96,7 @@ fun MutualScreen(
bottomInfo = { bottomInfo = {
if (state.mutualPasses.isNotEmpty()) { if (state.mutualPasses.isNotEmpty()) {
Text( Text(
text = "找到 ${state.mutualPasses.size} 个过境", text = "找到 ${state.mutualPasses.size} 个匹配",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -183,153 +183,91 @@ private fun MutualContent(
// Input form // Input form
item { item {
ElevatedCard(modifier = Modifier.fillMaxWidth()) { Column(
Column( modifier = Modifier.fillMaxWidth(),
modifier = Modifier verticalArrangement = Arrangement.spacedBy(6.dp)
.fillMaxWidth() ) {
.padding(12.dp), MatchIntroCard()
verticalArrangement = Arrangement.spacedBy(8.dp)
) { if (isVertical) {
Text( StationInputCard(
text = "你的位置", title = "你的位置",
style = MaterialTheme.typography.titleSmall, titleColor = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium lat = state.stationALat,
lon = state.stationALon,
grid = state.stationAGrid,
minElev = state.stationAMinElev,
latPlaceholder = "39.9042",
lonPlaceholder = "116.4074",
gridPlaceholder = "ON79uj",
onLatChange = onStationALat,
onLonChange = onStationALon,
onGridChange = onStationAGrid,
onMinElevChange = onStationAMinElev,
currentPositionAction = onUseCurrentPosition
) )
StationInputCard(
title = "友台位置",
titleColor = MaterialTheme.colorScheme.tertiary,
lat = state.stationBLat,
lon = state.stationBLon,
grid = state.stationBGrid,
minElev = state.stationBMinElev,
latPlaceholder = "34.0522",
lonPlaceholder = "-118.2437",
gridPlaceholder = "PM01tv",
onLatChange = onStationBLat,
onLonChange = onStationBLon,
onGridChange = onStationBGrid,
onMinElevChange = onStationBMinElev
)
} else {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
OutlinedTextField( StationInputCard(
value = state.stationALat, title = "你的位置",
onValueChange = onStationALat, titleColor = MaterialTheme.colorScheme.primary,
label = { Text("纬度") }, lat = state.stationALat,
placeholder = { Text("39.9042") }, lon = state.stationALon,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), grid = state.stationAGrid,
singleLine = true, minElev = state.stationAMinElev,
latPlaceholder = "39.9042",
lonPlaceholder = "116.4074",
gridPlaceholder = "ON79uj",
onLatChange = onStationALat,
onLonChange = onStationALon,
onGridChange = onStationAGrid,
onMinElevChange = onStationAMinElev,
currentPositionAction = onUseCurrentPosition,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
OutlinedTextField( StationInputCard(
value = state.stationALon, title = "友台位置",
onValueChange = onStationALon, titleColor = MaterialTheme.colorScheme.tertiary,
label = { Text("经度") }, lat = state.stationBLat,
placeholder = { Text("116.4074") }, lon = state.stationBLon,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), grid = state.stationBGrid,
singleLine = true, minElev = state.stationBMinElev,
latPlaceholder = "34.0522",
lonPlaceholder = "-118.2437",
gridPlaceholder = "PM01tv",
onLatChange = onStationBLat,
onLonChange = onStationBLon,
onGridChange = onStationBGrid,
onMinElevChange = onStationBMinElev,
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
} }
OutlinedTextField(
value = state.stationAGrid,
onValueChange = onStationAGrid,
label = { Text("网格(4/6/8位,填此可省略经纬度)") },
placeholder = { Text("ON79uj") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
OutlinedButton(
onClick = onUseCurrentPosition,
modifier = Modifier.align(Alignment.End)
) {
Text("当前精确位置", style = MaterialTheme.typography.bodySmall)
}
Text(
text = "最小仰角:${state.stationAMinElev.toInt()}°",
style = MaterialTheme.typography.bodySmall
)
Slider(
value = state.stationAMinElev.toFloat(),
onValueChange = { onStationAMinElev(it.toDouble()) },
valueRange = 0f..90f,
steps = 17
)
HorizontalDivider()
Text(
text = "友台位置",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = state.stationBLat,
onValueChange = onStationBLat,
label = { Text("纬度") },
placeholder = { Text("34.0522") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = state.stationBLon,
onValueChange = onStationBLon,
label = { Text("经度") },
placeholder = { Text("-118.2437") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
)
}
OutlinedTextField(
value = state.stationBGrid,
onValueChange = onStationBGrid,
label = { Text("网格(4/6/8位,填此可省略经纬度)") },
placeholder = { Text("PM01tv") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "最小仰角:${state.stationBMinElev.toInt()}°",
style = MaterialTheme.typography.bodySmall
)
Slider(
value = state.stationBMinElev.toFloat(),
onValueChange = { onStationBMinElev(it.toDouble()) },
valueRange = 0f..90f,
steps = 17
)
HorizontalDivider()
Text(
text = "时间范围",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
listOf(6, 12, 24, 48, 72).forEach { hours ->
FilterChip(
selected = state.hoursAhead == hours,
onClick = { onHoursAhead(hours) },
label = { Text("${hours}h") }
)
}
}
Button(
onClick = onQuery,
enabled = !state.isCalculating,
modifier = Modifier.fillMaxWidth()
) {
if (state.isCalculating) {
CircularProgressIndicator(
modifier = Modifier
.height(18.dp)
.width(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
Spacer(Modifier.width(8.dp))
}
Text(if (state.isCalculating) "计算中..." else "查询过境")
}
} }
MatchSearchCard(
hoursAhead = state.hoursAhead,
isCalculating = state.isCalculating,
onHoursAhead = onHoursAhead,
onQuery = onQuery
)
} }
} }
@@ -365,6 +303,169 @@ private fun MutualContent(
} }
} }
@Composable
private fun MatchIntroCard(modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = "位置匹配",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
)
Text(
text = "输入双方位置,查找共同可见的卫星过境窗口",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun StationInputCard(
title: String,
titleColor: androidx.compose.ui.graphics.Color,
lat: String,
lon: String,
grid: String,
minElev: Double,
latPlaceholder: String,
lonPlaceholder: String,
gridPlaceholder: String,
onLatChange: (String) -> Unit,
onLonChange: (String) -> Unit,
onGridChange: (String) -> Unit,
onMinElevChange: (Double) -> Unit,
modifier: Modifier = Modifier,
currentPositionAction: (() -> Unit)? = null
) {
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
color = titleColor
)
if (currentPositionAction != null) {
OutlinedButton(onClick = currentPositionAction) {
Text("填入当前位置", style = MaterialTheme.typography.bodySmall)
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = lat,
onValueChange = onLatChange,
label = { Text("纬度") },
placeholder = { Text(latPlaceholder) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
)
OutlinedTextField(
value = lon,
onValueChange = onLonChange,
label = { Text("经度") },
placeholder = { Text(lonPlaceholder) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
singleLine = true,
modifier = Modifier.weight(1f)
)
}
OutlinedTextField(
value = grid,
onValueChange = onGridChange,
label = { Text("网格") },
placeholder = { Text(gridPlaceholder) },
supportingText = { Text("支持 4/6/8 位 Maidenhead 网格;也可直接输入经纬度") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Text(
text = "最小仰角:${minElev.toInt()}°",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Slider(
value = minElev.toFloat(),
onValueChange = { onMinElevChange(it.toDouble()) },
valueRange = 0f..90f,
steps = 17
)
}
}
}
@Composable
private fun MatchSearchCard(
hoursAhead: Int,
isCalculating: Boolean,
onHoursAhead: (Int) -> Unit,
onQuery: () -> Unit,
modifier: Modifier = Modifier
) {
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
text = "时间范围",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
listOf(6, 12, 24, 48, 72).forEach { hours ->
FilterChip(
selected = hoursAhead == hours,
onClick = { onHoursAhead(hours) },
label = { Text("${hours}h") }
)
}
}
Button(
onClick = onQuery,
enabled = !isCalculating,
modifier = Modifier.fillMaxWidth()
) {
if (isCalculating) {
CircularProgressIndicator(
modifier = Modifier
.height(18.dp)
.width(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
Spacer(Modifier.width(8.dp))
}
Text(if (isCalculating) "计算中..." else "查询匹配")
}
}
}
}
@Composable @Composable
private fun MutualPassCard( private fun MutualPassCard(
pass: MutualPass, pass: MutualPass,
@@ -380,7 +481,6 @@ private fun MutualPassCard(
var dragProgress by remember(pass) { mutableFloatStateOf(0.5f) } var dragProgress by remember(pass) { mutableFloatStateOf(0.5f) }
// Filter to only show the portion where both stations are above their minElev // Filter to only show the portion where both stations are above their minElev
// (satlover.de style — only the usable common window)
val visibleSamples = remember(pass, minElevA, minElevB) { val visibleSamples = remember(pass, minElevA, minElevB) {
pass.elevationSamples.filter { (_, elev) -> pass.elevationSamples.filter { (_, elev) ->
elev.first >= minElevA && elev.second >= minElevB elev.first >= minElevA && elev.second >= minElevB
@@ -447,7 +547,7 @@ private fun MutualPassCard(
// Dual-station radar track (polar plot) // Dual-station radar track (polar plot)
if (visibleTracks.isNotEmpty()) { if (visibleTracks.isNotEmpty()) {
Text( Text(
text = "双方轨迹(雷达图)", text = "双方轨迹",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
@@ -352,20 +352,23 @@ class MutualViewModel(
tSample += sampleInterval tSample += sampleInterval
} }
// The pass list already filters by maxElev > minElevation, so we trust // Search uses the 0° horizon as the pass boundary, but the user sliders are
// the pass is valid. The mutual pass just needs both stations' curves. // a final visibility filter. Both stations must exceed their own threshold;
results.add( // otherwise the result would expand to an empty filtered chart.
MutualPass( if (maxElevA > minElevADeg && maxElevB > minElevBDeg) {
catNum = pass.catNum, results.add(
name = pass.orbitalObject.data.name, MutualPass(
startTime = refinedAos, catNum = pass.catNum,
endTime = refinedLos, name = pass.orbitalObject.data.name,
maxElevationA = (maxElevA * 10).roundToInt() / 10.0, startTime = refinedAos,
maxElevationB = (maxElevB * 10).roundToInt() / 10.0, endTime = refinedLos,
elevationSamples = samples, maxElevationA = (maxElevA * 10).roundToInt() / 10.0,
trackSamples = tracks maxElevationB = (maxElevB * 10).roundToInt() / 10.0,
elevationSamples = samples,
trackSamples = tracks
)
) )
) }
} }
return results return results
} }
@@ -413,10 +416,12 @@ class MutualViewModel(
var tSample = refinedAos var tSample = refinedAos
while (tSample <= refinedLos) { while (tSample <= refinedLos) {
// Use getElevation for elevation, matching the main pass predictor; use
// getFullPosition only for azimuth needed by the radar plot.
val elevA = elevationDeg(sat, posA, tSample)
val elevB = elevationDeg(sat, posB, tSample)
val fullA = sat.getFullPosition(posA, tSample) val fullA = sat.getFullPosition(posA, tSample)
val fullB = sat.getFullPosition(posB, tSample) val fullB = sat.getFullPosition(posB, tSample)
val elevA = fullA.elevation * 180.0 / PI
val elevB = fullB.elevation * 180.0 / PI
if (elevA > maxElevA) maxElevA = elevA if (elevA > maxElevA) maxElevA = elevA
if (elevB > maxElevB) maxElevB = elevB if (elevB > maxElevB) maxElevB = elevB
samples.add(tSample to (elevA to elevB)) samples.add(tSample to (elevA to elevB))
@@ -73,6 +73,7 @@ import com.rtbishop.look4sat.core.presentation.getDefaultPass
import com.rtbishop.look4sat.core.presentation.isVerticalLayout import com.rtbishop.look4sat.core.presentation.isVerticalLayout
import com.rtbishop.look4sat.core.presentation.layoutPadding import com.rtbishop.look4sat.core.presentation.layoutPadding
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
import kotlin.math.PI import kotlin.math.PI
private enum class RadarPage(val title: String) { private enum class RadarPage(val title: String) {
@@ -87,6 +88,23 @@ fun RadarDestination(navigateUp: () -> Unit) {
val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container)) val viewModel: RadarViewModel = viewModel(factory = RadarViewModel.factory(container))
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val mutualData by container.mutualPassData.collectAsStateWithLifecycle() val mutualData by container.mutualPassData.collectAsStateWithLifecycle()
val navigateUpAndClearMutual = {
if (container.mutualPassData.value.endTime > 0L) {
container.setMutualPassData(MutualPassData())
}
navigateUp()
}
LaunchedEffect(mutualData.endTime) {
if (mutualData.endTime <= 0L) return@LaunchedEffect
while (true) {
val remainingMs = mutualData.endTime - System.currentTimeMillis()
if (remainingMs <= 0L) {
navigateUpAndClearMutual()
return@LaunchedEffect
}
delay(remainingMs.coerceAtMost(1000L))
}
}
// Sync actual permission state on every recomposition so it survives screen re-entry // Sync actual permission state on every recomposition so it survives screen re-entry
val hasPermission = ContextCompat.checkSelfPermission( val hasPermission = ContextCompat.checkSelfPermission(
context, Manifest.permission.RECORD_AUDIO context, Manifest.permission.RECORD_AUDIO
@@ -101,7 +119,7 @@ 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, navigateUp, mutualData, requestMicPermission = { RadarScreen(uiState, viewModel::onAction, navigateUpAndClearMutual, mutualData, requestMicPermission = {
permissionLauncher.launch(Manifest.permission.RECORD_AUDIO) permissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
}) })
} }
@@ -94,14 +94,21 @@ fun RadarViewCompose(
animationSpec = infiniteRepeatable(tween(1000)), animationSpec = infiniteRepeatable(tween(1000)),
label = "animScale" label = "animScale"
) )
// Drive the sweep from the animation framework to eliminate state mutation inside the draw block // Drive the sweep from the animation framework to eliminate state mutation inside the draw block.
val sweepTransition = rememberInfiniteTransition(label = "sweep") // Only create the infinite transition when the sweep is enabled — otherwise the canvas would
val sweepDegrees by sweepTransition.animateFloat( // be invalidated every frame for an effect that isn't visible, wasting GPU and battery.
initialValue = 0f, val sweepDegrees = if (shouldShowSweep) {
targetValue = 360f, val sweepTransition = rememberInfiniteTransition(label = "sweep")
animationSpec = infiniteRepeatable(tween(SWEEP_DURATION_MS, easing = LinearEasing)), val animatedSweep by sweepTransition.animateFloat(
label = "sweepDegrees" initialValue = 0f,
) targetValue = 360f,
animationSpec = infiniteRepeatable(tween(SWEEP_DURATION_MS, easing = LinearEasing)),
label = "sweepDegrees"
)
animatedSweep
} else {
0f
}
val measurer = rememberTextMeasurer() val measurer = rememberTextMeasurer()
val sunPainter = painterResource(R.drawable.ic_sun) val sunPainter = painterResource(R.drawable.ic_sun)
val moonPainter = painterResource(R.drawable.ic_moon) val moonPainter = painterResource(R.drawable.ic_moon)
@@ -139,6 +139,8 @@ class RadarViewModel(
val passes = satelliteRepo.passes.value val passes = satelliteRepo.passes.value
val (catNum, aosTime) = satelliteRepo.selectedPass.value val (catNum, aosTime) = satelliteRepo.selectedPass.value
return passes.find { it.catNum == catNum && it.aosTime == aosTime } return passes.find { it.catNum == catNum && it.aosTime == aosTime }
?: passes.find { it.catNum == catNum && aosTime in it.aosTime..it.losTime }
?: passes.find { it.catNum == catNum }
?: passes.firstOrNull() ?: passes.firstOrNull()
} }
@@ -179,7 +181,7 @@ class RadarViewModel(
orbitalPos = pos, sunPosition = cachedSunPos, moonPosition = cachedMoonPos orbitalPos = pos, sunPosition = cachedSunPos, moonPosition = cachedMoonPos
) )
} }
processRadios(allRadios, pass.orbitalObject, timeNow) processRadios(allRadios, pos)
sendPassData(pos) sendPassData(pos)
} }
@@ -340,8 +342,8 @@ class RadarViewModel(
} }
} }
private suspend fun processRadios(radios: List<SatRadio>, orbitalObject: OrbitalObject, time: Long) { private suspend fun processRadios(radios: List<SatRadio>, satPos: OrbitalPos) {
val transmitters = satelliteRepo.getRadios(orbitalObject, stationPos, radios, time) val transmitters = satelliteRepo.getRadios(satPos, radios)
_uiState.update { state -> _uiState.update { state ->
val freq = if (state.transceivers.selectedUuid != null) { val freq = if (state.transceivers.selectedUuid != null) {
val selectedRadio = transmitters.firstOrNull { it.uuid == state.transceivers.selectedUuid } val selectedRadio = transmitters.firstOrNull { it.uuid == state.transceivers.selectedUuid }
+7 -2
View File
@@ -1,8 +1,8 @@
[versions] [versions]
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionCode = "443" appVersionCode = "444"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.3" appVersionName = "4.4.4"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
compileSdk = "37" compileSdk = "37"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
@@ -11,6 +11,11 @@ minSdk = "24"
jdkVersion = "21" jdkVersion = "21"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
packageName = "com.rtbishop.look4sat" packageName = "com.rtbishop.look4sat"
# Fork application ID: official app uses com.rtbishop.look4sat with a different
# signing cert. Installing a fork with the same applicationId would conflict
# with the official build (signature mismatch / cannot overwrite), so the fork
# ships under its own applicationId suffix.
applicationId = "com.rtbishop.look4sat.bg7nta"
android-gradle-plugin = "9.3.1" android-gradle-plugin = "9.3.1"