Compare commits

...
14 Commits
Author SHA1 Message Date
mckero 213425379c merge: merge atsunatsu/main (mutual radar overlay arrows)
Merge upstream commit 2298d8ee 'feat: add mutual radar overlay arrows'. Clean auto-merge: upstream changed RadarView.kt arrow drawing, our sweep optimization stayed intact. No conflicts.

First v4.4.5 was built from c3444aed; this release rebuilds from the merged tree.
2026-08-03 10:04:09 +00:00
mckero c3444aed24 chore: bump version to 4.4.5
Next release: versionCode 444 -> 445, versionName 4.4.4 -> 4.4.5, following the upstream scheme without suffix. Includes the station panel layout fix, 5-decimal coordinates, and credits additions already merged on main.
2026-08-03 09:43:10 +00:00
mckero 368bd199e5 fix: station panel layout wraps QTH to its own line
With 5-decimal coordinates the Lat/Lon/Qth row overflowed, pushing the QTH value to a wrapped line. Split into two rows: Lat + Lon on the first line, Qth on the second.

Also append BA7OPF (pass matching feature) and BG7NTA to the credits list in en/zh/tr string resources.
2026-08-03 09:33:40 +00:00
atsunatsu 2298d8eea4 feat: add mutual radar overlay arrows 2026-08-03 17:27:48 +08:00
mckero 7bb0bbbb46 build: load release signing config from local keystore.properties
Conditionally applies the BG7NTA signingConfig when keystore.properties exists (gitignored). CI signs via apksigner with GitHub Secrets, so this only affects local builds.
2026-08-03 09:25:53 +00:00
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
16 changed files with 209 additions and 103 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
+26
View File
@@ -1,3 +1,29 @@
import java.util.Properties
plugins { plugins {
alias(libs.plugins.convention.applicationPlugin) alias(libs.plugins.convention.applicationPlugin)
} }
// Load signing config from keystore.properties (gitignored, never commit credentials)
val keystoreProperties = Properties().apply {
val propsFile = rootProject.file("keystore.properties")
if (propsFile.exists()) propsFile.inputStream().use { load(it) }
}
android {
signingConfigs {
if (keystoreProperties["storeFile"] != null) {
create("release") {
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
}
}
buildTypes {
release {
signingConfig = signingConfigs.findByName("release")
}
}
}
+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>
@@ -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" }
}
}
} }
@@ -199,7 +199,9 @@
\n• Dave Moten (predict4java) \n• Dave Moten (predict4java)
\n• Alexandru Csete (Gpredict) \n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak) \n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS)</string> \n• Libre Space Foundation (SatNOGS)
\n• BA7OPF (pass matching feature)
\n• BG7NTA</string>
<string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string> <string name="prefs_outro_license">Bu uygulama herhangi bir garanti sunmaz</string>
</resources> </resources>
@@ -175,7 +175,9 @@
\n• Alexandru Csete (Gpredict) \n• Alexandru Csete (Gpredict)
\n• Dr T.S. Kelso (Celestrak) \n• Dr T.S. Kelso (Celestrak)
\n• Libre Space Foundation (SatNOGS) \n• Libre Space Foundation (SatNOGS)
\n• xdsopl 和 Robot36 贡献者!</string> \n• xdsopl 和 Robot36 贡献者!
\n• BA7OPF(过境匹配功能)
\n• BG7NTA</string>
<string name="prefs_outro_license">该应用程序不提供任何保修.</string> <string name="prefs_outro_license">该应用程序不提供任何保修.</string>
</resources> </resources>
@@ -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>
@@ -211,6 +211,8 @@
\n* Dr T.S. Kelso (Celestrak) \n* Dr T.S. Kelso (Celestrak)
\n* Libre Space Foundation (SatNOGS) \n* Libre Space Foundation (SatNOGS)
\n* xdsopl and Robot36 contributors! \n* xdsopl and Robot36 contributors!
\n* BA7OPF (pass matching feature)
\n* BG7NTA
</string> </string>
<string name="prefs_outro_license">The app comes with no warranty</string> <string name="prefs_outro_license">The app comes with no warranty</string>
@@ -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)
@@ -112,9 +119,10 @@ fun RadarViewCompose(
var cachedSweepColor by remember { mutableStateOf(Color.Unspecified) } var cachedSweepColor by remember { mutableStateOf(Color.Unspecified) }
var trackPath by remember { mutableStateOf(Path()) } var trackPath by remember { mutableStateOf(Path()) }
var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) } var trackEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
// Station-B overlay track (dashed) // Station-B overlay track (dashed, with the same directional arrow stamp as station A)
var cachedTrackBRef by remember { mutableStateOf<List<OrbitalPos>?>(null) } var cachedTrackBRef by remember { mutableStateOf<List<OrbitalPos>?>(null) }
var trackBPath by remember { mutableStateOf(Path()) } var trackBPath by remember { mutableStateOf(Path()) }
var trackBEffect by remember { mutableStateOf(PathEffect.cornerPathEffect(0f)) }
// ShaderBrush is cached to avoid allocating a new GPU shader object every frame // ShaderBrush is cached to avoid allocating a new GPU shader object every frame
var cachedSweepBrush by remember { mutableStateOf<ShaderBrush?>(null) } var cachedSweepBrush by remember { mutableStateOf<ShaderBrush?>(null) }
@@ -124,7 +132,10 @@ fun RadarViewCompose(
if (radius != cachedRadius || items !== cachedItemsRef || trackB !== cachedTrackBRef) { if (radius != cachedRadius || items !== cachedItemsRef || trackB !== cachedTrackBRef) {
trackPath = createTrackPath(items, radius) trackPath = createTrackPath(items, radius)
trackEffect = createTrackEffect(trackPath) trackEffect = createTrackEffect(trackPath)
trackBPath = trackB?.let { createTrackPath(it, radius) } ?: Path() val newTrackBPath = trackB?.let { createTrackPath(it, radius) } ?: Path()
trackBPath = newTrackBPath
trackBEffect = if (!newTrackBPath.isEmpty) createTrackEffect(newTrackBPath)
else PathEffect.cornerPathEffect(0f)
cachedSweepBrush = makeSweepBrush(center, primaryColor) cachedSweepBrush = makeSweepBrush(center, primaryColor)
cachedRadius = radius cachedRadius = radius
cachedItemsRef = items cachedItemsRef = items
@@ -141,13 +152,14 @@ fun RadarViewCompose(
drawElevationLabels(radius, primaryColor, measurer) drawElevationLabels(radius, primaryColor, measurer)
translate(center.x, center.y) { translate(center.x, center.y) {
drawTrack(trackPath, trackEffect, aimColor, primaryColor) drawTrack(trackPath, trackEffect, aimColor, primaryColor)
// Station-B overlay: full dashed track + live position dot // Station-B overlay: full dashed track + directional arrows + live position dot
// (same display mode as the local station: track line + pulsing dot) // (same display mode as the local station: track line/arrow + pulsing dot)
if (trackB != null && trackB.isNotEmpty() && !trackBPath.isEmpty) { if (trackB != null && trackB.isNotEmpty() && !trackBPath.isEmpty) {
drawPath( drawPath(
trackBPath, trackBColor, trackBPath, trackBColor,
style = Stroke(STROKE_WIDTH, pathEffect = PathEffect.dashPathEffect(floatArrayOf(18f, 12f))) style = Stroke(STROKE_WIDTH, pathEffect = PathEffect.dashPathEffect(floatArrayOf(18f, 12f)))
) )
drawPath(trackBPath, trackBColor, style = Stroke(pathEffect = trackBEffect))
} }
trackBPosition?.let { posB -> trackBPosition?.let { posB ->
if (posB.elevation > 0) { if (posB.elevation > 0) {
@@ -181,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)
} }
@@ -342,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 }
@@ -339,6 +339,8 @@ private fun LocationCard(
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = "Lat: ${settings.stationPos.latitude}°") Text(text = "Lat: ${settings.stationPos.latitude}°")
Text(text = "Lon: ${settings.stationPos.longitude}°") Text(text = "Lon: ${settings.stationPos.longitude}°")
}
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
Text(text = "Qth: ${settings.stationPos.qthLocator}") Text(text = "Qth: ${settings.stationPos.qthLocator}")
} }
Spacer(modifier = Modifier.height(1.dp)) Spacer(modifier = Modifier.height(1.dp))
+7 -2
View File
@@ -1,8 +1,8 @@
[versions] [versions]
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionCode = "443" appVersionCode = "445"
#noinspection UnusedVersionCatalogEntry #noinspection UnusedVersionCatalogEntry
appVersionName = "4.4.3" appVersionName = "4.4.5"
#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"