mirror of
https://github.com/atsunatsu/Look4Sat.git
synced 2026-10-02 19:17:21 +00:00
Compare commits
248
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
800494b978 | ||
|
|
f1091acbbd | ||
|
|
99bf3d34a9 | ||
|
|
1dd185971f | ||
|
|
fe85703e56 | ||
|
|
caddba5b81 | ||
|
|
52b733a450 | ||
|
|
474762cbd3 | ||
|
|
8fe88fbc93 | ||
|
|
7728b5c38f | ||
|
|
a769f7dea7 | ||
|
|
3be2e4b9e8 | ||
|
|
5957f30af2 | ||
|
|
d8d0825f54 | ||
|
|
bc5a8e4bba | ||
|
|
851bc25d73 | ||
|
|
238511aa78 | ||
|
|
e606afb0f3 | ||
|
|
e19f098aa5 | ||
|
|
797b1c19f7 | ||
|
|
8e549cd43c | ||
|
|
d33629f015 | ||
|
|
7081ffaa60 | ||
|
|
3b5b5fe627 | ||
|
|
ebe32ed153 | ||
|
|
091d3e867b | ||
|
|
26e50ddab5 | ||
|
|
5f90eebab4 | ||
|
|
70ada0d608 | ||
|
|
317f6d877f | ||
|
|
865fa9c6f2 | ||
|
|
a082abb72c | ||
|
|
a2e41a0c1a | ||
|
|
e1daf70f11 | ||
|
|
ca9f93c1ed | ||
|
|
42bd55e180 | ||
|
|
7401e1451b | ||
|
|
fa51fb2b44 | ||
|
|
75a251eb98 | ||
|
|
91b5269740 | ||
|
|
522cac25e8 | ||
|
|
732412deb2 | ||
|
|
30000a913e | ||
|
|
e7754a4c9e | ||
|
|
cf1e2b99b4 | ||
|
|
b295500d14 | ||
|
|
0163c7b217 | ||
|
|
85a4b9cfa3 | ||
|
|
c4c4dd682d | ||
|
|
62ad995aeb | ||
|
|
c8fdd2e93e | ||
|
|
dc013f920d | ||
|
|
f36a426855 | ||
|
|
4da59bdd17 | ||
|
|
b8138c948b | ||
|
|
0e28231c4a | ||
|
|
d6bdf5ecb6 | ||
|
|
6962a4bfa1 | ||
|
|
94a1a4f7b7 | ||
|
|
dbf2229774 | ||
|
|
f2fc1b7b28 | ||
|
|
d600836e19 | ||
|
|
94bffe80b0 | ||
|
|
4260fbb096 | ||
|
|
3d5b55faa3 | ||
|
|
466f2f6088 | ||
|
|
14bd984327 | ||
|
|
d5c100903b | ||
|
|
1c6388f3bc | ||
|
|
3e0874e6a8 | ||
|
|
6beaeb3e1a | ||
|
|
91f0cb1c56 | ||
|
|
3b72f8d68a | ||
|
|
3bc2983d26 | ||
|
|
0ec4c5dc6c | ||
|
|
8e8a3849df | ||
|
|
cd65e6c077 | ||
|
|
a3d370b1fa | ||
|
|
bf492e3f8b | ||
|
|
3591960d55 | ||
|
|
3f7a087938 | ||
|
|
4b82332133 | ||
|
|
dcd16a0196 | ||
|
|
9e88609efc | ||
|
|
322becdda8 | ||
|
|
7f93a10556 | ||
|
|
3cd5b49f98 | ||
|
|
4dbe896124 | ||
|
|
b47bd78ecd | ||
|
|
c89e79e28f | ||
|
|
b40a79e3c6 | ||
|
|
6c23696e22 | ||
|
|
f997d95cd4 | ||
|
|
e0a1d279ce | ||
|
|
059c816075 | ||
|
|
60dfa5d600 | ||
|
|
5f1eb239ed | ||
|
|
01ca06b43f | ||
|
|
22363c2549 | ||
|
|
45fe755470 | ||
|
|
0ce177cfbf | ||
|
|
f3fdf61784 | ||
|
|
fc028f852c | ||
|
|
7a865fffd7 | ||
|
|
89220a0b4b | ||
|
|
03d0055ce2 | ||
|
|
afb1e434e8 | ||
|
|
1bed7849a3 | ||
|
|
06f00e1601 | ||
|
|
462c3de155 | ||
|
|
4020381b03 | ||
|
|
b8389f63fe | ||
|
|
e64beee44f | ||
|
|
d2dffc039c | ||
|
|
d7d4a752ed | ||
|
|
6a3ab54226 | ||
|
|
3b3579696b | ||
|
|
826f44829b | ||
|
|
b979524b37 | ||
|
|
9e748f5534 | ||
|
|
25b982ac59 | ||
|
|
6a6399b606 | ||
|
|
c1cc992abd | ||
|
|
212dced3c4 | ||
|
|
7ef555e377 | ||
|
|
2773145e0c | ||
|
|
d8d3d81648 | ||
|
|
25a41def35 | ||
|
|
d9dc0a1735 | ||
|
|
692e917893 | ||
|
|
6371bb114d | ||
|
|
2d76aeda23 | ||
|
|
ecc24fd836 | ||
|
|
689f08881c | ||
|
|
d467200a45 | ||
|
|
6277e36a96 | ||
|
|
08a5c8c460 | ||
|
|
1ffb5ed9e8 | ||
|
|
cc684b6e3a | ||
|
|
6cb5b062d3 | ||
|
|
9c12b1eccb | ||
|
|
d56392f3c6 | ||
|
|
14b98cc7b1 | ||
|
|
200d2274de | ||
|
|
3d950f90ad | ||
|
|
1cc0bc770c | ||
|
|
84ba25c163 | ||
|
|
5bf4546ee3 | ||
|
|
030a6eecc9 | ||
|
|
42174da0be | ||
|
|
06421f8cfc | ||
|
|
9ea5b533c1 | ||
|
|
b6a9331ba3 | ||
|
|
390fd7717d | ||
|
|
ef0a34723b | ||
|
|
36d091a7e1 | ||
|
|
20143cf096 | ||
|
|
a64f9c80b9 | ||
|
|
db0320f521 | ||
|
|
adc9ac008b | ||
|
|
4a33d1b19a | ||
|
|
cce9209b3d | ||
|
|
237e4cb27a | ||
|
|
cd3eded77c | ||
|
|
7d7f8f1eda | ||
|
|
11b46c6710 | ||
|
|
da1facac64 | ||
|
|
cb1f341ed9 | ||
|
|
c2e496c406 | ||
|
|
7afce7ee68 | ||
|
|
86048ee1fd | ||
|
|
42a6ffa4c9 | ||
|
|
cf1d8e6539 | ||
|
|
ab8b072f1f | ||
|
|
1d4036e3d0 | ||
|
|
ab22244f86 | ||
|
|
3b0874d707 | ||
|
|
2c5b1479f1 | ||
|
|
39d4498a54 | ||
|
|
9ed2aebc9d | ||
|
|
72d5e13acf | ||
|
|
736dc23aae | ||
|
|
052ba79c94 | ||
|
|
cb648caf65 | ||
|
|
05170541ed | ||
|
|
e75d2dc410 | ||
|
|
d55a07bc26 | ||
|
|
c372386a77 | ||
|
|
de6ade025c | ||
|
|
2eb6d6cd5b | ||
|
|
c09f718aae | ||
|
|
a9fe0b74e9 | ||
|
|
2c9a5b376f | ||
|
|
422e480b2f | ||
|
|
efd6931abb | ||
|
|
c7ca77a943 | ||
|
|
be6473e692 | ||
|
|
75d162301b | ||
|
|
07c09ec3e9 | ||
|
|
513d50e56a | ||
|
|
366edd8bbb | ||
|
|
13df0dfcec | ||
|
|
eb1a5d51a2 | ||
|
|
4e3547649f | ||
|
|
7abbeb6eb3 | ||
|
|
5c73d5ed7a | ||
|
|
a663ef7f1b | ||
|
|
1f1e1fb144 | ||
|
|
5166daa297 | ||
|
|
604c2ec59d | ||
|
|
1904a6b3e6 | ||
|
|
7f390117b8 | ||
|
|
346803fe6e | ||
|
|
47b87a1604 | ||
|
|
935fe6d99f | ||
|
|
69af1c38bb | ||
|
|
5c35f2418d | ||
|
|
2aa9858660 | ||
|
|
bc6e065804 | ||
|
|
7924a6f69e | ||
|
|
9a2a715d1f | ||
|
|
ee32c7a1eb | ||
|
|
1d54ac4718 | ||
|
|
433ce85561 | ||
|
|
cebe3b7ec4 | ||
|
|
d8f60cf270 | ||
|
|
4a3332fdb5 | ||
|
|
9e7fdbfe71 | ||
|
|
463df58eb9 | ||
|
|
58b8801c0c | ||
|
|
2581ba92c0 | ||
|
|
e5131ebb72 | ||
|
|
f9accdd517 | ||
|
|
ee3c1e7058 | ||
|
|
a08a28df39 | ||
|
|
759a6de2c6 | ||
|
|
ba5a984c16 | ||
|
|
083b8a2847 | ||
|
|
e8ebfb1ef3 | ||
|
|
12eed1d9a9 | ||
|
|
b3859fbece | ||
|
|
f40a67e1b7 | ||
|
|
16dce5df2a | ||
|
|
80a58bed61 | ||
|
|
a8afc63bba | ||
|
|
dbf9a2a65f | ||
|
|
ddecaa6017 | ||
|
|
418a05e343 |
No files matched your search
@@ -4,15 +4,10 @@ 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.event.inputs.tag_name || github.ref_name }}
|
TAG_NAME: ${{ github.ref_name }}
|
||||||
|
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
@@ -21,40 +16,66 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
- name: Setup Java
|
- name: Setup Java
|
||||||
uses: actions/setup-java@v4
|
uses: actions/setup-java@v5
|
||||||
with:
|
with:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
java-version: '21'
|
java-version: '21'
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v4
|
uses: gradle/actions/setup-gradle@v6
|
||||||
|
|
||||||
- name: Assemble APK
|
- name: Assemble Artifacts
|
||||||
run: ./gradlew assembleRelease
|
run: ./gradlew assembleRelease bundleRelease
|
||||||
|
|
||||||
- name: Sign APK
|
- name: Sign APK
|
||||||
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 "$SIGNED_APK" \
|
--out app/build/outputs/apk/release/look4sat.apk \
|
||||||
"$APK"
|
"$APK"
|
||||||
rm keystore.jks
|
rm keystore.jks
|
||||||
echo "SIGNED_APK=$SIGNED_APK" >> "$GITHUB_ENV"
|
|
||||||
|
- 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 "$SIGNED_APK"
|
gh release upload $TAG_NAME app/build/outputs/apk/release/look4sat.apk
|
||||||
+4
-1
@@ -18,6 +18,7 @@ out/
|
|||||||
# Gradle files
|
# Gradle files
|
||||||
.gradle/
|
.gradle/
|
||||||
build/
|
build/
|
||||||
|
.cxx/
|
||||||
|
|
||||||
# Local configuration file (sdk path, etc)
|
# Local configuration file (sdk path, etc)
|
||||||
local.properties
|
local.properties
|
||||||
@@ -39,7 +40,8 @@ captures/
|
|||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
# Keystore files
|
# Keystore files
|
||||||
*.jks
|
# Uncomment the following line if you do not want to check your keystore files in.
|
||||||
|
#*.jks
|
||||||
/*.properties
|
/*.properties
|
||||||
/keystore.properties
|
/keystore.properties
|
||||||
/app/keystore.jks
|
/app/keystore.jks
|
||||||
@@ -66,3 +68,4 @@ fastlane/readme.md
|
|||||||
/app/release/output-metadata.json
|
/app/release/output-metadata.json
|
||||||
/app/release/
|
/app/release/
|
||||||
/.kotlin/sessions/
|
/.kotlin/sessions/
|
||||||
|
dist/
|
||||||
@@ -1,45 +1,42 @@
|
|||||||
# Look4Sat: Satellite tracker
|
# Look4Sat-BA7OPF
|
||||||
|
|
||||||
[](https://github.com/rt-bishop/Look4Sat/actions/workflows/release.yml)
|
[](https://github.com/atsunatsu/Look4Sat/releases)
|
||||||
|
|
||||||
[<img src="https://play.google.com/intl/en_gb/badges/static/images/badges/en_badge_web_generic.png" alt="Get it on Google Play" height="80">](https://play.google.com/store/apps/details?id=com.rtbishop.look4sat)
|
**BA7OPF 定制版** — 基于 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的业余无线电卫星追踪器,增加了线性卫星频率计算器等功能。
|
||||||
[<img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" alt="Get it on F-Droid" height="80">](https://f-droid.org/packages/com.rtbishop.look4sat/)
|
|
||||||
|
|
||||||
### Radio satellite tracker and pass predictor for Android, inspired by Gpredict
|
## 本仓库特色功能
|
||||||
|
|
||||||
<p float="left">
|
- **网格地图模式(Grid Mode)** — 地图页内置 VUCC 网格覆盖图:已通联网格绿色填充、漫游网格蓝色 45° 斜纹(疏朗半透明样式)、当前所在方格淡黄加粗框;点击已通联网格弹出该网格内通联明细弹窗;进入网格模式默认 VUCC 视图
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="192"/>
|
- **奖状边界模式(Award Boundaries)** — 在网格地图上叠加 DXCC / WAPC / WAJA / WAZ / WAS 国界·省界·分区边界,已确认区域绿色填充
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="192"/>
|
- **LoTW 网格同步** — 直连 ARRL Logbook(账号密码验证)下载确认报告,worked / roamed 网格自动更新(兼容任意字段顺序的 ADIF 解析);同步失败按凭据错误/限流/超时分类提示
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" width="192"/>
|
- **双站过境匹配(Mutual Pass)** — 输入友台经纬度/网格,筛选双方同时可见的卫星过境,在地图上同时显示双方仰角曲线和地面轨迹,支持一键跳转雷达页查看详情
|
||||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" width="192">
|
- **线性卫星转发器频率计算器** — 在雷达页的 Calculator 标签页中,支持 TX/RX 双向多普勒频率计算,以及下行频率偏移(offset)输入,方便操作带偏移的线性卫星
|
||||||
</p>
|
- **CW 解码器** — 集成 Morse Expert 解码引擎,支持瀑布图、实时解码文本(arm64 + armeabi-v7a 双架构)
|
||||||
|
- **AMSAT 状态页** — 实时查看卫星的业余无线电转发器状态(开启/关闭/待机),支持 72 小时历史记录回放,启动时预取缓存
|
||||||
|
- **应用内更新检查** — 设置页一键检查 GitHub 最新 Release,展示版本说明并直接下载安装
|
||||||
|
- **自定义 TLE 数据源** — 可添加自定义 TLE/Celestrak CSV 数据源,长按拖拽排序,数据源启停开关与 HTTP 状态码显示
|
||||||
|
- **中文界面优化** — 翻译修正、UI 布局调整,系统语言自动切换
|
||||||
|
|
||||||
### Track satellite passes with ease!
|
## 上游仓库
|
||||||
|
|
||||||
Thanks to [Celestrak](https://celestrak.com/) and [SatNOGS](https://satnogs.org/) you have access to over 9000 active satellites.\
|
本仓库是 [rt-bishop/Look4Sat](https://github.com/rt-bishop/Look4Sat) 的分支,上游仓库的原始功能包括:
|
||||||
You can search the entire database by NORAD Catalog Number or the satellite's name.
|
|
||||||
|
|
||||||
Orbital positions and passes are calculated relative to your location.\
|
- 基于 Celestrak / SatNOGS 数据的 9000+ 活跃卫星追踪
|
||||||
To get reliable data make sure to set the station position via the app Settings.
|
- SGP4/SDP4 轨道预测,10 天过境预报
|
||||||
|
- 极坐标雷达图、地面轨迹图
|
||||||
|
- SSTV 图像解码
|
||||||
|
- 无广告、无跟踪、完全离线
|
||||||
|
|
||||||
The application is built using Kotlin, Coroutines, Jetpack Compose and Navigation.\
|
## 许可证
|
||||||
It is now and always will be completely ad-free and open-source.
|
|
||||||
|
|
||||||
## Main features:
|
GNU General Public License v3.0。详见 [LICENSE](LICENSE)。
|
||||||
|
|
||||||
* Predicting satellite positions and passes for up to 10 days
|
|
||||||
* Showing the list of currently active and upcoming satellite passes
|
|
||||||
* Showing the active pass progress, polar trajectory and transceivers info
|
|
||||||
* Showing the satellite positional data, footprint and ground track on the map
|
|
||||||
* Custom TLE satellite data import is available via Three Line Element .txt files
|
|
||||||
* Offline first: calculations are made offline. Weekly TLE data update is recommended.
|
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
<a href="https://www.star-history.com/?repos=rt-bishop%2FLook4Sat&type=timeline&legend=top-left">
|
<a href="https://star-history.dera.page/#atsunatsu/Look4Sat&type=timeline&legend=top-left">
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&theme=dark&legend=top-left" />
|
||||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&legend=top-left" />
|
||||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=rt-bishop/Look4Sat&type=timeline&legend=top-left" />
|
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=atsunatsu/Look4Sat&type=timeline&legend=top-left" />
|
||||||
</picture>
|
</picture>
|
||||||
</a>
|
</a>
|
||||||
+21
-16
@@ -1,36 +1,41 @@
|
|||||||
|
import java.io.FileInputStream
|
||||||
import java.util.Properties
|
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)
|
// Release signing credentials live in local.properties (gitignored) or
|
||||||
val keystoreProperties = Properties().apply {
|
// environment variables — never hardcode passwords in VCS.
|
||||||
val propsFile = rootProject.file("keystore.properties")
|
val releaseProps = Properties().apply {
|
||||||
if (propsFile.exists()) propsFile.inputStream().use { load(it) }
|
val propsFile = rootProject.file("local.properties")
|
||||||
|
if (propsFile.exists()) FileInputStream(propsFile).use { load(it) }
|
||||||
}
|
}
|
||||||
|
fun releaseCred(name: String): String =
|
||||||
|
releaseProps.getProperty(name) ?: System.getenv(name) ?: ""
|
||||||
|
|
||||||
android {
|
android {
|
||||||
// CW 解码 native 库仅 armeabi-v7a(照搬 Morse Expert 1.15): 全 ABI 打包会在
|
namespace = libs.versions.packageName.get()
|
||||||
// arm64 设备 loadLibrary 失败, 强制 32 位兼容(用户设备为 32 位软件)
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
|
applicationId = "cn.ba7opf.look4sat"
|
||||||
|
// CW decoder ships for both ABIs (see feature/cw jniLibs); the APK must
|
||||||
|
// not be pinned to 32-bit only or 64-bit devices fall back to ARM32.
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("armeabi-v7a")
|
abiFilters.add("armeabi-v7a")
|
||||||
|
abiFilters.add("arm64-v8a")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
if (keystoreProperties["storeFile"] != null) {
|
create("release") {
|
||||||
create("release") {
|
storeFile = file(System.getProperty("user.home") + "/my-release-key.jks")
|
||||||
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
|
storePassword = releaseCred("RELEASE_STORE_PASSWORD")
|
||||||
storePassword = keystoreProperties["storePassword"] as String
|
keyAlias = releaseCred("RELEASE_KEY_ALIAS").ifEmpty { "look4sat" }
|
||||||
keyAlias = keystoreProperties["keyAlias"] as String
|
keyPassword = releaseCred("RELEASE_KEY_PASSWORD")
|
||||||
keyPassword = keystoreProperties["keyPassword"] as String
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
signingConfig = signingConfigs.findByName("release")
|
signingConfig = signingConfigs.getByName("release")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
<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.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".MainApplication"
|
android:name=".MainApplication"
|
||||||
@@ -43,7 +44,17 @@
|
|||||||
|
|
||||||
<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.bg7nta" />
|
android:value="com.rtbishop.look4sat" />
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -49,7 +49,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
observeNightFilterState()
|
observeNightFilterState()
|
||||||
setContent {
|
setContent {
|
||||||
MainTheme(isDarkTheme = true) { MainScreen() }
|
MainTheme(isDarkTheme = true) { NavRoot() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,21 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat
|
package com.rtbishop.look4sat
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.app.Application.ActivityLifecycleCallbacks
|
||||||
|
import android.os.Bundle
|
||||||
import com.rtbishop.look4sat.core.data.injection.MainContainer
|
import com.rtbishop.look4sat.core.data.injection.MainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
import com.rtbishop.look4sat.core.domain.repository.IContainerProvider
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWSyncMode
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.toConfirmedRecord
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.applyLoTWGridResult
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.lotwCursorApi
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.lotwSyncToday
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.resolveLoTWSyncMode
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.shouldAutoSyncLoTW
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
@@ -35,12 +46,39 @@ class MainApplication : Application(), IContainerProvider {
|
|||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
container = MainContainer(this)
|
container = MainContainer(this)
|
||||||
|
clearAmSatCacheOnAppBackground()
|
||||||
// trigger automatic update every 48 hours
|
// trigger automatic update every 48 hours
|
||||||
container.appScope.launch { checkAutoUpdate() }
|
container.appScope.launch { checkAutoUpdate() }
|
||||||
|
// automatic LoTW grid sync on every app start (gated, see checkLoTWAutoSync)
|
||||||
|
container.appScope.launch { checkLoTWAutoSync() }
|
||||||
// load satellite data on every app start
|
// load satellite data on every app start
|
||||||
container.appScope.launch { container.satelliteRepo.initRepository() }
|
container.appScope.launch { container.satelliteRepo.initRepository() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun clearAmSatCacheOnAppBackground() {
|
||||||
|
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
|
||||||
|
private var startedActivityCount = 0
|
||||||
|
|
||||||
|
override fun onActivityStarted(activity: Activity) {
|
||||||
|
if (startedActivityCount == 0) {
|
||||||
|
container.appScope.launch { container.amSatRepo.prefetchStatus() }
|
||||||
|
}
|
||||||
|
startedActivityCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityStopped(activity: Activity) {
|
||||||
|
startedActivityCount = (startedActivityCount - 1).coerceAtLeast(0)
|
||||||
|
if (startedActivityCount == 0) container.amSatRepo.clearStatusCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit
|
||||||
|
override fun onActivityResumed(activity: Activity) = Unit
|
||||||
|
override fun onActivityPaused(activity: Activity) = Unit
|
||||||
|
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
|
||||||
|
override fun onActivityDestroyed(activity: Activity) = Unit
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun checkAutoUpdate(timeNow: Long = System.currentTimeMillis()) {
|
private suspend fun checkAutoUpdate(timeNow: Long = System.currentTimeMillis()) {
|
||||||
if (container.settingsRepo.otherSettings.value.stateOfAutoUpdate) {
|
if (container.settingsRepo.otherSettings.value.stateOfAutoUpdate) {
|
||||||
val timeDelta = timeNow - container.settingsRepo.databaseState.value.updateTimestamp
|
val timeDelta = timeNow - container.settingsRepo.databaseState.value.updateTimestamp
|
||||||
@@ -51,4 +89,42 @@ class MainApplication : Application(), IContainerProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatic LoTW grid sync, mirroring [checkAutoUpdate]: checked on every
|
||||||
|
* app start, but only pulls when due — LoTW credentials configured, the
|
||||||
|
* LoTW auto-sync toggle on, at least one prior (manual) sync, and not
|
||||||
|
* already synced today (ARRL limits report pulls, ~once a day per account).
|
||||||
|
* Incremental when the callsign is unchanged; a callsign change falls back
|
||||||
|
* to a full replace so no stale grids from another account linger.
|
||||||
|
* Failures are silent and simply retried on the next app start — the manual
|
||||||
|
* sync button still surfaces the explicit cause to the user.
|
||||||
|
*/
|
||||||
|
private suspend fun checkLoTWAutoSync(timeNow: Long = System.currentTimeMillis()) {
|
||||||
|
val settingsRepo = container.settingsRepo
|
||||||
|
val lotwSettings = settingsRepo.lotwSettings.value
|
||||||
|
if (!shouldAutoSyncLoTW(
|
||||||
|
isConfigured = lotwSettings.isConfigured,
|
||||||
|
autoLotwSyncEnabled = settingsRepo.otherSettings.value.stateOfAutoLotwSync,
|
||||||
|
lastSyncDate = settingsRepo.getLastLotwSyncDate(),
|
||||||
|
today = lotwSyncToday(timeNow)
|
||||||
|
)
|
||||||
|
) return
|
||||||
|
val callsign = lotwSettings.callsign.trim().uppercase()
|
||||||
|
val mode = resolveLoTWSyncMode(settingsRepo.getLastLotwSyncCallsign(), callsign, requested = null)
|
||||||
|
val since = if (mode == LoTWSyncMode.Incremental) lotwCursorApi(settingsRepo.getLastLotwSyncDate()) else ""
|
||||||
|
println("Started periodic LoTW grid sync (${mode.name.lowercase()})")
|
||||||
|
val result = container.lotwRepo.fetchConfirmedGridQsos(callsign, lotwSettings.password, since)
|
||||||
|
if (result is LoTWResult.Success) {
|
||||||
|
val count = applyLoTWGridResult(settingsRepo, result, mode, callsign, timeNow)
|
||||||
|
// Feed the logbook so confirmations appear and local uploads get matched.
|
||||||
|
runCatching {
|
||||||
|
val confirmed = result.qsos.values.flatten().map { it.toConfirmedRecord(callsign) }
|
||||||
|
container.qsoRepository.mergeLoTW(confirmed)
|
||||||
|
}
|
||||||
|
println("Periodic LoTW grid sync finished: $count worked grids")
|
||||||
|
} else {
|
||||||
|
println("Periodic LoTW grid sync skipped (${result::class.simpleName})")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -17,23 +17,14 @@
|
|||||||
*/
|
*/
|
||||||
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.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
|
||||||
@@ -51,7 +42,7 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||||
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
|
||||||
@@ -60,7 +51,6 @@ 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.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -90,94 +80,124 @@ 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.map.MapFilterViewModel
|
||||||
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.satellites.SatellitesDestination
|
import com.rtbishop.look4sat.feature.satellites.SatellitesDestination
|
||||||
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
import com.rtbishop.look4sat.feature.settings.SettingsDestination
|
||||||
|
import com.rtbishop.look4sat.feature.status.SatStatusDestination
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NavRoot(deeplink: String? = null) {
|
fun NavRoot(deeplink: String? = null) {
|
||||||
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
val rootBackStack = rememberNavBackStack(Screen.Passes)
|
||||||
val deeplinkResolver = DeeplinkResolver()
|
val deeplinkResolver = DeeplinkResolver()
|
||||||
LaunchedEffect(deeplink) {
|
LaunchedEffect(deeplink) {
|
||||||
deeplink?.let {
|
deeplink?.let { rootBackStack.add(deeplinkResolver.resolve(it)) }
|
||||||
val destination = deeplinkResolver.resolve(it) // rootBackStack.clear()
|
|
||||||
rootBackStack.add(destination)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
val navigateBack: () -> Unit = { rootBackStack.removeLastOrNull() }
|
||||||
val slideInTransition = slideInHorizontally(initialOffsetX = { it }) togetherWith scaleOut(targetScale = 0.9f)
|
val navigateToRadar: () -> Unit = { rootBackStack.add(RadarDestination) }
|
||||||
val slideOutTransition = scaleIn(initialScale = 0.9f) togetherWith slideOutHorizontally(targetOffsetX = { it })
|
// Incoming screen slides in from the right, outgoing drifts left at 1/3 speed (API35+ style)
|
||||||
NavDisplay(
|
val pushTransition = slideInHorizontally(tween(300)) { it } togetherWith
|
||||||
modifier = Modifier.fillMaxSize(),
|
slideOutHorizontally(tween(300)) { -it / 3 }
|
||||||
backStack = rootBackStack,
|
// Reverse: outgoing slides out to the right, incoming drifts in from the left
|
||||||
onBack = navigateBack,
|
val popTransition = slideInHorizontally(tween(300)) { -it / 3 } togetherWith
|
||||||
transitionSpec = { slideInTransition },
|
slideOutHorizontally(tween(300)) { it }
|
||||||
popTransitionSpec = { slideOutTransition },
|
// Elevation color thresholds must be provided at the root level so that BOTH
|
||||||
predictivePopTransitionSpec = { slideOutTransition },
|
// the tab content (MainScreen) and the root-level RadarDestination see the
|
||||||
entryDecorators = listOf(
|
// user's custom thresholds. RadarDestination is a sibling entry of MainScreen
|
||||||
rememberSaveableStateHolderNavEntryDecorator(), // Required for saving Compose state per entry
|
// in this NavDisplay, so a provider inside MainScreen never reaches it.
|
||||||
rememberViewModelStoreNavEntryDecorator() // Required for ViewModel scoping per entry
|
val context = LocalContext.current
|
||||||
),
|
val container = (context.applicationContext as IContainerProvider).getMainContainer()
|
||||||
entryProvider = entryProvider {
|
val otherSettings by container.settingsRepo.otherSettings.collectAsStateWithLifecycle()
|
||||||
entry<Screen.Passes> { MainScreen(navigateToRadar = { rootBackStack.add(RadarDestination) }) }
|
CompositionLocalProvider(
|
||||||
entry<RadarDestination> {
|
LocalElevationThresholds provides ElevationThresholds(
|
||||||
Scaffold { innerPadding ->
|
low = otherSettings.lowElevation,
|
||||||
RadarDestination(navigateUp = navigateBack)
|
high = otherSettings.highElevation
|
||||||
innerPadding.calculateTopPadding()
|
)
|
||||||
|
) {
|
||||||
|
NavDisplay(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
backStack = rootBackStack,
|
||||||
|
onBack = navigateBack,
|
||||||
|
transitionSpec = { pushTransition },
|
||||||
|
popTransitionSpec = { popTransition },
|
||||||
|
predictivePopTransitionSpec = { popTransition },
|
||||||
|
entryDecorators = listOf(
|
||||||
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
|
),
|
||||||
|
entryProvider = entryProvider {
|
||||||
|
entry<Screen.Passes> {
|
||||||
|
MainScreen(
|
||||||
|
navigateToRadar = navigateToRadar
|
||||||
|
)
|
||||||
|
}
|
||||||
|
entry<RadarDestination> {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
RadarDestination(navigateUp = navigateBack)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
fun MainScreen(
|
||||||
|
navigateToRadar: () -> Unit = {}
|
||||||
|
) {
|
||||||
val backStack = rememberNavBackStack(Screen.Passes)
|
val backStack = rememberNavBackStack(Screen.Passes)
|
||||||
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))
|
||||||
|
// Map sits at position 4 from the left, matching the upstream nav order
|
||||||
|
// (Satellites, Passes, AMSAT/Status, Map, ...), with the fork's Mutual tab
|
||||||
|
// appended before Settings.
|
||||||
|
val navItems = listOf(Screen.Satellites, Screen.Passes, Screen.AMSAT, Screen.Map, Screen.Mutual, 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 设置: 按 screenOrder 排序(空 = 默认顺序), 再按 hiddenScreens 过滤(设置页固定保留)
|
|
||||||
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 ->
|
|
||||||
// 未知(新页面如 CwDecode 不在旧持久化顺序里): 用默认顺序位置(漫游↔地图), 再兜底最后
|
|
||||||
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 折叠菜单: 主菜单(底部栏 5 槽) + 更多菜单(溢出页面)
|
|
||||||
// 老用户迁移: 已持久化的 subMenuOrder 不含新页面 WavelogLog → 追加到子菜单尾部
|
|
||||||
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) }
|
|
||||||
// 更多菜单打开时拦截返回: 先关菜单
|
|
||||||
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,
|
||||||
factory = MutualViewModel.factory(container)
|
factory = MutualViewModel.factory(container)
|
||||||
)
|
)
|
||||||
|
// Activity-scoped so the map's award filter survives page switches;
|
||||||
|
// resets to VUCC only on cold start (fresh process).
|
||||||
|
val mapFilterViewModel: MapFilterViewModel = viewModel(
|
||||||
|
viewModelStoreOwner = context as ViewModelStoreOwner,
|
||||||
|
factory = MapFilterViewModel.factory()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Map grid-QSO dialog "Match" button: pre-fill the match page for that
|
||||||
|
// grid and run the query BEFORE navigating, so the page's first frame
|
||||||
|
// already contains the results (time-range card at the top, pass curves
|
||||||
|
// right below — no scroll flicker). Navigation happens once the query
|
||||||
|
// finishes (see the collector below).
|
||||||
|
val mutualState by mutualViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val matchCalculating = mutualState.pendingNavigation && mutualState.isCalculating
|
||||||
|
|
||||||
|
LaunchedEffect(mutualViewModel) {
|
||||||
|
mutualViewModel.uiState.collect { state ->
|
||||||
|
if (state.pendingNavigation && !state.isCalculating) {
|
||||||
|
mutualViewModel.consumePendingNavigation()
|
||||||
|
// Push Mutual on top of the Map entry instead of replacing the
|
||||||
|
// stack (bottom-nav style): the system back gesture then pops
|
||||||
|
// back to the map page, which is the page the user came from.
|
||||||
|
if (backStack.lastOrNull() !is Screen.Mutual) {
|
||||||
|
backStack.add(Screen.Mutual)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalElevationThresholds provides ElevationThresholds(
|
LocalElevationThresholds provides ElevationThresholds(
|
||||||
@@ -187,16 +207,13 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
) {
|
) {
|
||||||
NavigationSuiteScaffold(
|
NavigationSuiteScaffold(
|
||||||
navigationSuiteItems = {
|
navigationSuiteItems = {
|
||||||
mainNavItems.forEach { screen ->
|
navItems.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.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.AMSAT -> screen is Screen.AMSAT
|
||||||
|
is Screen.Mutual -> screen is Screen.Mutual
|
||||||
is Screen.Settings -> screen is Screen.Settings
|
is Screen.Settings -> screen is Screen.Settings
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
@@ -206,26 +223,11 @@ 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)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// 更多菜单按钮(固定第 6 槽, 子菜单非空才显示)
|
|
||||||
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
|
||||||
@@ -236,137 +238,112 @@ fun MainScreen(navigateToRadar: () -> Unit = {}) {
|
|||||||
else -> NavigationSuiteType.ShortNavigationBarMedium
|
else -> NavigationSuiteType.ShortNavigationBarMedium
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
Box {
|
Column {
|
||||||
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 },
|
predictivePopTransitionSpec = { fadeTransition },
|
||||||
predictivePopTransitionSpec = { fadeTransition },
|
entryDecorators = listOf(
|
||||||
entryDecorators = listOf(
|
// Required for saving Compose state per entry
|
||||||
// Required for saving Compose state per entry
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
rememberSaveableStateHolderNavEntryDecorator(),
|
// Required for ViewModel scoping per entry
|
||||||
// Required for ViewModel scoping per entry
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
rememberViewModelStoreNavEntryDecorator()
|
),
|
||||||
),
|
entryProvider = entryProvider {
|
||||||
entryProvider = entryProvider {
|
entry<Screen.Satellites> {
|
||||||
entry<Screen.Satellites> {
|
SatellitesDestination(navigateUp = navigateBack)
|
||||||
SatellitesDestination(navigateUp = navigateBack)
|
}
|
||||||
}
|
entry<Screen.Passes> {
|
||||||
entry<Screen.Passes> {
|
PassesDestination(
|
||||||
PassesDestination { catNum, aosTime ->
|
navigateToRadar = { catNum, aosTime ->
|
||||||
container.setMutualPassData(MutualPassData())
|
container.setMutualPassData(MutualPassData())
|
||||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
backStack.add(Screen.Radar)
|
navigateToRadar()
|
||||||
// navigateToRadar()
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
entry<Screen.Radar> {
|
|
||||||
RadarDestination(navigateUp = navigateBack)
|
|
||||||
}
|
|
||||||
entry<Screen.Map> {
|
|
||||||
MapDestination()
|
|
||||||
}
|
|
||||||
entry<Screen.Mutual> {
|
|
||||||
MutualScreen(
|
|
||||||
viewModel = mutualViewModel,
|
|
||||||
navigateUp = navigateBack,
|
|
||||||
navigateToRadar = { catNum, aosTime, pass ->
|
|
||||||
container.setMutualPassData(pass ?: MutualPassData())
|
|
||||||
container.satelliteRepo.selectPass(catNum, aosTime)
|
|
||||||
backStack.add(Screen.Radar)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
entry<Screen.Roaming> {
|
|
||||||
RoamingScreen()
|
|
||||||
}
|
|
||||||
entry<Screen.CwDecode> {
|
|
||||||
CwDecodeScreen()
|
|
||||||
}
|
|
||||||
entry<Screen.AmSat> {
|
|
||||||
SatStatusScreen(container = container)
|
|
||||||
}
|
|
||||||
entry<Screen.WavelogLog> {
|
|
||||||
WavelogLogScreen(queue = container.wavelogQueue)
|
|
||||||
}
|
|
||||||
entry<Screen.Settings> {
|
|
||||||
SettingsDestination()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
entry<Screen.Map> {
|
||||||
// Radio tracking status banner
|
MapDestination(
|
||||||
if (trackingState.isActive) {
|
mapFilterViewModel = mapFilterViewModel,
|
||||||
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
matchCalculating = matchCalculating,
|
||||||
val alpha by infiniteTransition.animateFloat(
|
onMatchGrid = { grid ->
|
||||||
initialValue = 1f, targetValue = 0.4f,
|
// Grid-QSO dialog "Match" button: pre-fill
|
||||||
animationSpec = infiniteRepeatable(
|
// the match page for that grid and start the
|
||||||
animation = tween(1000, easing = LinearEasing),
|
// query. Navigation to the match page is
|
||||||
repeatMode = RepeatMode.Reverse
|
// handled by the uiState collector above,
|
||||||
), label = "pulseAlpha"
|
// once the query finishes.
|
||||||
)
|
mutualViewModel.prefillMatchFromGrid(grid)
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
|
||||||
.clickable {
|
|
||||||
val pass = trackingState.currentPass
|
|
||||||
if (pass != null) {
|
|
||||||
container.setMutualPassData(MutualPassData())
|
|
||||||
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
|
||||||
backStack.add(Screen.Radar)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(8.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
}
|
||||||
Text(
|
entry<Screen.Mutual> {
|
||||||
text = stringResource(com.rtbishop.look4sat.core.presentation.R.string.tracking_status, trackingState.currentPass?.name ?: ""),
|
MutualScreen(
|
||||||
fontSize = 13.sp,
|
viewModel = mutualViewModel,
|
||||||
fontWeight = FontWeight.Medium,
|
navigateToRadar = { catNum, aosTime, pass ->
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
container.setMutualPassData(pass ?: MutualPassData())
|
||||||
modifier = Modifier.weight(1f)
|
container.satelliteRepo.selectPass(catNum, aosTime)
|
||||||
)
|
navigateToRadar()
|
||||||
val txOk = if (trackingState.txConnected) "TX" else ""
|
}
|
||||||
val rxOk = if (trackingState.rxConnected) "RX" else ""
|
|
||||||
Text(
|
|
||||||
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
entry<Screen.AMSAT> { SatStatusDestination() }
|
||||||
|
entry<Screen.Settings> {
|
||||||
|
SettingsDestination()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
// 更多菜单弹出面板(覆盖在内容上, 底部栏上方; spring 弹跳)
|
// Radio tracking status banner
|
||||||
AnimatedVisibility(
|
if (trackingState.isActive) {
|
||||||
visible = moreExpanded,
|
val infiniteTransition = rememberInfiniteTransition(label = "trackingPulse")
|
||||||
modifier = Modifier.fillMaxSize(),
|
val alpha by infiniteTransition.animateFloat(
|
||||||
enter = expandVertically(
|
initialValue = 1f, targetValue = 0.4f,
|
||||||
animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy)
|
animationSpec = infiniteRepeatable(
|
||||||
) + fadeIn(),
|
animation = tween(1000, easing = LinearEasing),
|
||||||
exit = shrinkVertically() + fadeOut()
|
repeatMode = RepeatMode.Reverse
|
||||||
) {
|
), label = "pulseAlpha"
|
||||||
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)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||||
|
.clickable {
|
||||||
|
val pass = trackingState.currentPass
|
||||||
|
if (pass != null) {
|
||||||
|
container.setMutualPassData(MutualPassData())
|
||||||
|
container.satelliteRepo.selectPass(pass.catNum, pass.aosTime)
|
||||||
|
navigateToRadar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0xFF4CAF50).copy(alpha = alpha))
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Tracking: ${trackingState.currentPass?.name ?: ""}",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
val txOk = if (trackingState.txConnected) "TX" else ""
|
||||||
|
val rxOk = if (trackingState.rxConnected) "RX" else ""
|
||||||
|
Text(
|
||||||
|
text = listOf(txOk, rxOk).filter { it.isNotBlank() }.joinToString("/"),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
/*
|
|
||||||
* MoreMenuPopup.kt — 底部导航「更多」二级菜单弹出面板(4.5.1)。
|
|
||||||
*
|
|
||||||
* 覆盖在内容区上(底部栏上方, 右对齐), 竖排菜单项(图标+文本+箭头),
|
|
||||||
* 当前页高亮; 点击遮罩关闭, 点击项跳转。弹出/收起动画由调用处
|
|
||||||
* (MainScreen 的 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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<cache-path name="updates" path="." />
|
||||||
|
</paths>
|
||||||
-2
@@ -31,12 +31,10 @@ 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"))
|
||||||
implementation(project(":feature:radar"))
|
implementation(project(":feature:radar"))
|
||||||
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(project(":feature:status"))
|
||||||
|
|||||||
+3
@@ -35,6 +35,9 @@ internal class CoreDataPlugin : Plugin<Project> {
|
|||||||
ksp(libs.androidx.room.compiler)
|
ksp(libs.androidx.room.compiler)
|
||||||
implementation(libs.kotlin.coroutines)
|
implementation(libs.kotlin.coroutines)
|
||||||
implementation(libs.other.okhttp)
|
implementation(libs.other.okhttp)
|
||||||
|
// android.jar's org.json is a stub ("not mocked") in unit tests;
|
||||||
|
// provide a real implementation so JSON parsing is testable.
|
||||||
|
testImplementation(libs.test.json)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -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.applicationId.get()
|
applicationId = libs.versions.packageName.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()
|
||||||
|
|||||||
Binary file not shown.
@@ -45,9 +45,22 @@ interface Look4SatDao {
|
|||||||
@Query("DELETE FROM entries")
|
@Query("DELETE FROM entries")
|
||||||
suspend fun deleteEntries()
|
suspend fun deleteEntries()
|
||||||
|
|
||||||
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes)")
|
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes) AND isAlive = 1")
|
||||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||||
|
|
||||||
|
/** Like [getIdsWithModes] but only matches transponder records with an uplink,
|
||||||
|
* excluding downlink-only beacons/telemetry that share the same mode label
|
||||||
|
* (e.g. CW beacons, FM voice-synthesis beacons) from FM/Linear filter fallback. */
|
||||||
|
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes) AND uplinkLow IS NOT NULL AND isAlive = 1")
|
||||||
|
suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int>
|
||||||
|
|
||||||
|
/** Like [getIdsWithModes] but only matches records whose service class is
|
||||||
|
* "Amateur", so the SSTV filter never matches weather birds (TIROS),
|
||||||
|
* launcher debris or other non-amateur transmitters that happen to carry
|
||||||
|
* an SSTV-labelled downlink. */
|
||||||
|
@Query("SELECT catnum FROM radios WHERE downlinkMode IN (:modes) AND service = 'Amateur' AND isAlive = 1")
|
||||||
|
suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int>
|
||||||
|
|
||||||
@Query("SELECT COUNT(*) FROM radios")
|
@Query("SELECT COUNT(*) FROM radios")
|
||||||
suspend fun getRadiosTotal(): Int
|
suspend fun getRadiosTotal(): Int
|
||||||
|
|
||||||
@@ -60,4 +73,7 @@ interface Look4SatDao {
|
|||||||
|
|
||||||
@Query("DELETE FROM radios")
|
@Query("DELETE FROM radios")
|
||||||
suspend fun deleteRadios()
|
suspend fun deleteRadios()
|
||||||
|
|
||||||
|
@Query("DELETE FROM radios WHERE isCustom = 0")
|
||||||
|
suspend fun deleteManagedRadios()
|
||||||
}
|
}
|
||||||
@@ -19,19 +19,29 @@ package com.rtbishop.look4sat.core.data.database
|
|||||||
|
|
||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
|
import com.rtbishop.look4sat.core.data.database.entity.SatEntry
|
||||||
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
|
import com.rtbishop.look4sat.core.data.database.entity.SatRadio
|
||||||
|
|
||||||
@Database(entities = [SatEntry::class, SatRadio::class], version = 1, exportSchema = false)
|
@Database(entities = [SatEntry::class, SatRadio::class], version = 3, exportSchema = false)
|
||||||
abstract class Look4SatDb : RoomDatabase() {
|
abstract class Look4SatDb : RoomDatabase() {
|
||||||
abstract fun look4SatDao(): Look4SatDao
|
abstract fun look4SatDao(): Look4SatDao
|
||||||
}
|
}
|
||||||
|
|
||||||
//val MIGRATION_1_2 = object : Migration(1, 2) {
|
/** Adds the mean motion derivative, needed to tell decayed satellites apart, and marks the
|
||||||
// override fun migrate(database: SupportSQLiteDatabase) {
|
* transceivers that were imported from a file, so that remote updates leave them alone. */
|
||||||
// database.execSQL("CREATE TABLE entries_backup (name TEXT NOT NULL, epoch REAL NOT NULL, meanmo REAL NOT NULL, eccn REAL NOT NULL, incl REAL NOT NULL, raan REAL NOT NULL, argper REAL NOT NULL, meanan REAL NOT NULL, catnum INTEGER NOT NULL, bstar REAL NOT NULL, xincl REAL NOT NULL, xnodeo REAL NOT NULL, omegao REAL NOT NULL, xmo REAL NOT NULL, xno REAL NOT NULL, orbitalPeriod REAL NOT NULL, isDeepSpace INTEGER NOT NULL, comment TEXT, PRIMARY KEY(catnum))")
|
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||||
// database.execSQL("INSERT INTO entries_backup (name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, orbitalPeriod, isDeepSpace, comment) SELECT name, epoch, meanmo, eccn, incl, raan, argper, meanan, catnum, bstar, xincl, xnodeo, omegao, xmo, xno, 1440 / meanmo, 1440 / meanmo >= 225.0, comment FROM entries")
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
// database.execSQL("DROP TABLE entries")
|
db.execSQL("ALTER TABLE entries ADD COLUMN ndot REAL NOT NULL DEFAULT 0.0")
|
||||||
// database.execSQL("ALTER TABLE entries_backup RENAME TO entries")
|
db.execSQL("ALTER TABLE radios ADD COLUMN isCustom INTEGER NOT NULL DEFAULT 0")
|
||||||
// }
|
}
|
||||||
//}
|
}
|
||||||
|
|
||||||
|
/** Adds the transceiver service class ("Amateur" etc.), used by the SSTV virtual
|
||||||
|
* filter to tell amateur satellites apart from debris/weather/launcher stages. */
|
||||||
|
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE radios ADD COLUMN service TEXT")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.database
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
|
import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface QsoDao {
|
||||||
|
@Query("SELECT * FROM qso_records ORDER BY startUtcMillis DESC, id DESC")
|
||||||
|
fun observeAll(): Flow<List<QsoEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM qso_records WHERE id = :id LIMIT 1")
|
||||||
|
suspend fun find(id: Long): QsoEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM qso_records ORDER BY startUtcMillis DESC, id DESC")
|
||||||
|
suspend fun getAll(): List<QsoEntity>
|
||||||
|
|
||||||
|
@Query("SELECT dedupeKey FROM qso_records")
|
||||||
|
suspend fun getDedupeKeys(): List<String>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun save(record: QsoEntity): Long
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
||||||
|
suspend fun importRecords(records: List<QsoEntity>): List<Long>
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
suspend fun saveBatch(records: List<QsoEntity>) {
|
||||||
|
records.forEach { save(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("DELETE FROM qso_records WHERE id = :id")
|
||||||
|
suspend fun delete(id: Long)
|
||||||
|
|
||||||
|
@Query("UPDATE qso_records SET lotwUploaded = 1 WHERE id IN (:ids)")
|
||||||
|
suspend fun markUploaded(ids: List<Long>)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.database
|
||||||
|
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Separate database for the logbook (QSO records, LoTW confirmations and
|
||||||
|
* upload receipts). Kept apart from [Look4SatDb] so existing installations
|
||||||
|
* never need a migration of the satellite database.
|
||||||
|
*/
|
||||||
|
@Database(entities = [QsoEntity::class], version = 2, exportSchema = false)
|
||||||
|
abstract class QsoDatabase : RoomDatabase() {
|
||||||
|
abstract fun qsoDao(): QsoDao
|
||||||
|
}
|
||||||
|
|
||||||
|
/** v1 → v2: track whether a QSO was uploaded to LoTW (distinct from confirmed). */
|
||||||
|
val MIGRATION_QSO_1_2 = object : Migration(1, 2) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("ALTER TABLE qso_records ADD COLUMN lotwUploaded INTEGER NOT NULL DEFAULT 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.database.entity
|
||||||
|
|
||||||
|
import androidx.room.ColumnInfo
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.Index
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(
|
||||||
|
tableName = "qso_records",
|
||||||
|
indices = [Index(value = ["dedupeKey"])]
|
||||||
|
)
|
||||||
|
data class QsoEntity(
|
||||||
|
@PrimaryKey(autoGenerate = true) val id: Long = 0L,
|
||||||
|
val startUtcMillis: Long,
|
||||||
|
val endUtcMillis: Long?,
|
||||||
|
val theirCallsign: String,
|
||||||
|
val myCallsign: String,
|
||||||
|
val theirGrid: String,
|
||||||
|
val myGrid: String,
|
||||||
|
val sentReport: String,
|
||||||
|
val receivedReport: String,
|
||||||
|
val txFrequencyHz: Long?,
|
||||||
|
val rxFrequencyHz: Long?,
|
||||||
|
val band: String,
|
||||||
|
val rxBand: String,
|
||||||
|
@ColumnInfo(defaultValue = "'MFSK'") val mode: String,
|
||||||
|
@ColumnInfo(defaultValue = "'FT4'") val submode: String,
|
||||||
|
val satelliteName: String,
|
||||||
|
val transponderName: String,
|
||||||
|
val satelliteMode: String,
|
||||||
|
val passAosUtcMillis: Long?,
|
||||||
|
val automatic: Boolean,
|
||||||
|
val status: String,
|
||||||
|
@ColumnInfo(defaultValue = "''") val dedupeKey: String,
|
||||||
|
@ColumnInfo(defaultValue = "''") val propagationMode: String = "",
|
||||||
|
@ColumnInfo(defaultValue = "0") val lotwConfirmed: Boolean = false,
|
||||||
|
@ColumnInfo(defaultValue = "0") val lotwUploaded: Boolean = false,
|
||||||
|
@ColumnInfo(defaultValue = "0") val lotwReceived: Boolean = false,
|
||||||
|
@ColumnInfo(defaultValue = "''") val lotwQslDate: String = "",
|
||||||
|
@ColumnInfo(defaultValue = "''") val vuccGrids: String = "",
|
||||||
|
val dxcc: Int? = null,
|
||||||
|
@ColumnInfo(defaultValue = "''") val country: String = "",
|
||||||
|
val cqZone: Int? = null,
|
||||||
|
@ColumnInfo(defaultValue = "''") val region: String = "",
|
||||||
|
@ColumnInfo(defaultValue = "''") val comment: String = ""
|
||||||
|
)
|
||||||
@@ -30,5 +30,6 @@ data class SatEntry(
|
|||||||
val argper: Double,
|
val argper: Double,
|
||||||
val meanan: Double,
|
val meanan: Double,
|
||||||
val catnum: Int,
|
val catnum: Int,
|
||||||
val bstar: Double
|
val bstar: Double,
|
||||||
|
val ndot: Double = 0.0
|
||||||
)
|
)
|
||||||
@@ -32,5 +32,11 @@ data class SatRadio(
|
|||||||
val uplinkHigh: Long?,
|
val uplinkHigh: Long?,
|
||||||
val uplinkMode: String?,
|
val uplinkMode: String?,
|
||||||
val isInverted: Boolean,
|
val isInverted: Boolean,
|
||||||
val catnum: Int?
|
val catnum: Int?,
|
||||||
|
/** Service class from the transceiver source ("Amateur"/"Unknown" etc.).
|
||||||
|
* Used to tell amateur satellites apart from debris / weather birds
|
||||||
|
* (TIROS) / launcher stages (Ariane 6 R/B) in the SSTV virtual filter. */
|
||||||
|
val service: String? = null,
|
||||||
|
/** Set for manually imported transceivers, which remote updates must not replace. */
|
||||||
|
val isCustom: Boolean = false
|
||||||
)
|
)
|
||||||
+69
-28
@@ -17,8 +17,10 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.data.framework
|
package com.rtbishop.look4sat.core.data.framework
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
@@ -31,18 +33,30 @@ class NetworkReporter(
|
|||||||
private val rotatorServer: String,
|
private val rotatorServer: String,
|
||||||
private val rotatorPort: Int,
|
private val rotatorPort: Int,
|
||||||
private val frequencyServer: String,
|
private val frequencyServer: String,
|
||||||
private val frequencyPort: Int
|
private val frequencyPort: Int,
|
||||||
|
private val frequencyOffsetHz: Long = 0L
|
||||||
) : IReporter {
|
) : IReporter {
|
||||||
|
|
||||||
private val writeMutex = Mutex()
|
private val writeMutex = Mutex()
|
||||||
|
private val connectionMutex = Mutex()
|
||||||
|
private val frequencyCommands = Channel<String>(Channel.CONFLATED)
|
||||||
|
|
||||||
private var rotatorSocket: SocketChannel? = null
|
private var rotatorSocket: SocketChannel? = null
|
||||||
private var rotatorConnected = false
|
private var rotatorConnected = false
|
||||||
private var rotatorConnecting = false
|
|
||||||
|
|
||||||
private var frequencySocket: SocketChannel? = null
|
private var frequencySocket: SocketChannel? = null
|
||||||
private var frequencyConnected = false
|
private var frequencyConnected = false
|
||||||
private var frequencyConnecting = false
|
|
||||||
|
init {
|
||||||
|
// Keep only the latest frequency command to avoid stale backlog and effective lag.
|
||||||
|
reporterScope.launch {
|
||||||
|
for (command in frequencyCommands) {
|
||||||
|
ensureFrequencyConnected()
|
||||||
|
if (!frequencyConnected) continue
|
||||||
|
write(frequencySocket, command) { resetFrequencyConnection() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
|
override fun reportRotation(format: String, azimuth: Double, elevation: Double) {
|
||||||
reporterScope.launch {
|
reporterScope.launch {
|
||||||
@@ -53,51 +67,48 @@ class NetworkReporter(
|
|||||||
.replace($$"$AZ", azimuth.toString())
|
.replace($$"$AZ", azimuth.toString())
|
||||||
.replace($$"$EL", el.toString())
|
.replace($$"$EL", el.toString())
|
||||||
.unescapeControlChars()
|
.unescapeControlChars()
|
||||||
write(rotatorSocket, command) { rotatorConnected = false }
|
write(rotatorSocket, command) { resetRotatorConnection() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun reportFrequency(format: String, frequency: Long) {
|
override fun reportFrequency(format: String, frequency: Long) {
|
||||||
reporterScope.launch {
|
val clampedOffset = frequencyOffsetHz.coerceIn(
|
||||||
ensureFrequencyConnected()
|
Constants.FREQ_OFFSET_MIN_HZ,
|
||||||
if (!frequencyConnected) return@launch
|
Constants.FREQ_OFFSET_MAX_HZ
|
||||||
val command = format
|
)
|
||||||
.replace($$"$FREQ", frequency.toString())
|
val correctedFreq = frequency.coerceAtLeast(0L).safeAdd(clampedOffset).coerceAtLeast(0L)
|
||||||
.unescapeControlChars()
|
val command = format
|
||||||
write(frequencySocket, command) { frequencyConnected = false }
|
.replace($$"$FREQ", correctedFreq.toString())
|
||||||
}
|
.unescapeControlChars()
|
||||||
|
frequencyCommands.trySend(command)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureRotatorConnected() {
|
private suspend fun ensureRotatorConnected() {
|
||||||
if (rotatorConnected || rotatorConnecting || rotatorServer.isBlank()) return
|
connectionMutex.withLock {
|
||||||
reporterScope.launch {
|
if (rotatorConnected || rotatorServer.isBlank()) return
|
||||||
try {
|
try {
|
||||||
rotatorConnecting = true
|
resetRotatorConnection()
|
||||||
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
rotatorSocket = SocketChannel.open(InetSocketAddress(rotatorServer, rotatorPort))
|
||||||
rotatorConnected = true
|
rotatorConnected = true
|
||||||
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
|
println("NetworkReporter: Rotator connected to $rotatorServer:$rotatorPort")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("NetworkReporter rotator connect error: ${e.message}")
|
println("NetworkReporter rotator connect error: ${e.message}")
|
||||||
rotatorConnected = false
|
resetRotatorConnection()
|
||||||
} finally {
|
|
||||||
rotatorConnecting = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensureFrequencyConnected() {
|
private suspend fun ensureFrequencyConnected() {
|
||||||
if (frequencyConnected || frequencyConnecting || frequencyServer.isBlank()) return
|
connectionMutex.withLock {
|
||||||
reporterScope.launch {
|
if (frequencyConnected || frequencyServer.isBlank()) return
|
||||||
try {
|
try {
|
||||||
frequencyConnecting = true
|
resetFrequencyConnection()
|
||||||
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
frequencySocket = SocketChannel.open(InetSocketAddress(frequencyServer, frequencyPort))
|
||||||
frequencyConnected = true
|
frequencyConnected = true
|
||||||
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
|
println("NetworkReporter: Frequency connected to $frequencyServer:$frequencyPort")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("NetworkReporter frequency connect error: ${e.message}")
|
println("NetworkReporter frequency connect error: ${e.message}")
|
||||||
frequencyConnected = false
|
resetFrequencyConnection()
|
||||||
} finally {
|
|
||||||
frequencyConnecting = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,7 +117,9 @@ class NetworkReporter(
|
|||||||
try {
|
try {
|
||||||
writeMutex.withLock {
|
writeMutex.withLock {
|
||||||
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
|
val buffer = ByteBuffer.wrap("$command\n".toByteArray())
|
||||||
socket?.write(buffer)
|
while (buffer.hasRemaining()) {
|
||||||
|
socket?.write(buffer)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
println("NetworkReporter write error: ${e.message}")
|
println("NetworkReporter write error: ${e.message}")
|
||||||
@@ -114,6 +127,34 @@ class NetworkReporter(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun resetRotatorConnection() {
|
||||||
|
rotatorConnected = false
|
||||||
|
closeQuietly(rotatorSocket)
|
||||||
|
rotatorSocket = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetFrequencyConnection() {
|
||||||
|
frequencyConnected = false
|
||||||
|
closeQuietly(frequencySocket)
|
||||||
|
frequencySocket = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeQuietly(socket: SocketChannel?) {
|
||||||
|
try {
|
||||||
|
socket?.close()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Long.safeAdd(delta: Long): Long {
|
||||||
|
return when {
|
||||||
|
delta > 0 && this > Long.MAX_VALUE - delta -> Long.MAX_VALUE
|
||||||
|
delta < 0 && this < Long.MIN_VALUE - delta -> Long.MIN_VALUE
|
||||||
|
else -> this + delta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun String.unescapeControlChars(): String =
|
private fun String.unescapeControlChars(): String =
|
||||||
replace("\\r", "\r").replace("\\n", "\n").replace("\\t", "\t")
|
replace("""\r""", 13.toChar().toString())
|
||||||
|
.replace("""\n""", 10.toChar().toString())
|
||||||
|
.replace("""\t""", 9.toChar().toString())
|
||||||
}
|
}
|
||||||
+44
-26
@@ -24,36 +24,46 @@ import android.hardware.display.DisplayManager
|
|||||||
import android.location.LocationManager
|
import android.location.LocationManager
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
import com.rtbishop.look4sat.core.data.database.Look4SatDb
|
||||||
|
import com.rtbishop.look4sat.core.data.database.MIGRATION_1_2
|
||||||
|
import com.rtbishop.look4sat.core.data.database.MIGRATION_2_3
|
||||||
|
import com.rtbishop.look4sat.core.data.database.MIGRATION_QSO_1_2
|
||||||
|
import com.rtbishop.look4sat.core.data.database.QsoDatabase
|
||||||
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
import com.rtbishop.look4sat.core.data.framework.BluetoothReporter
|
||||||
import com.rtbishop.look4sat.core.data.framework.Ft817Controller
|
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.lotw.LoTWUploadRepository
|
||||||
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
|
import com.rtbishop.look4sat.core.data.repository.AmSatRepository
|
||||||
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
import com.rtbishop.look4sat.core.data.repository.DatabaseRepo
|
||||||
|
import com.rtbishop.look4sat.core.data.repository.QsoRepository
|
||||||
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
|
||||||
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
|
import com.rtbishop.look4sat.core.data.repository.SensorsRepo
|
||||||
import com.rtbishop.look4sat.core.data.repository.SettingsRepo
|
import com.rtbishop.look4sat.core.data.repository.SettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.data.repository.UpdateRepository
|
||||||
|
import com.rtbishop.look4sat.core.data.repository.LoTWRepository
|
||||||
|
import com.rtbishop.look4sat.core.data.repository.WavelogRepository
|
||||||
import com.rtbishop.look4sat.core.data.source.LocalSource
|
import com.rtbishop.look4sat.core.data.source.LocalSource
|
||||||
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
import com.rtbishop.look4sat.core.data.source.RemoteSource
|
||||||
import com.rtbishop.look4sat.core.data.usecase.AddToCalendar
|
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.logbook.IQsoRepository
|
||||||
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.ILoTWUploadRepository
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
import com.rtbishop.look4sat.core.domain.repository.IMainContainer
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
import com.rtbishop.look4sat.core.domain.repository.IRadioController
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
import com.rtbishop.look4sat.core.domain.repository.IRadioTrackingService
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
import com.rtbishop.look4sat.core.domain.repository.IReporter
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISatelliteRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
|
||||||
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
import com.rtbishop.look4sat.core.domain.repository.MutualPassData
|
||||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
@@ -70,18 +80,29 @@ 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 java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
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 remoteSource = 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(remoteSource)
|
override val amSatRepo by lazy { AmSatRepository(remoteSource, appScope) }
|
||||||
|
override val updateRepo by lazy { UpdateRepository(remoteSource) }
|
||||||
|
override val wavelogRepo: IWavelogRepository by lazy { WavelogRepository() }
|
||||||
|
override val lotwRepo: ILoTWRepository by lazy { LoTWRepository() }
|
||||||
|
override val qsoRepository: IQsoRepository by lazy {
|
||||||
|
val database = Room.databaseBuilder(context, QsoDatabase::class.java, "Look4SatQsoDB")
|
||||||
|
.addMigrations(MIGRATION_QSO_1_2)
|
||||||
|
.build()
|
||||||
|
QsoRepository(database.qsoDao(), Dispatchers.IO)
|
||||||
|
}
|
||||||
|
override val lotwUploadRepository: ILoTWUploadRepository by lazy { LoTWUploadRepository(context) }
|
||||||
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)
|
||||||
@@ -102,16 +123,6 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
|
|
||||||
override fun provideSaveImage(): ISaveImage = SaveImage(context)
|
override fun provideSaveImage(): ISaveImage = SaveImage(context)
|
||||||
|
|
||||||
// WaveLog 日志(4.5.2): 本地队列 + 上传器(共享实例)
|
|
||||||
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)
|
|
||||||
|
|
||||||
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
|
||||||
@@ -130,7 +141,8 @@ class MainContainer(private val context: Context) : IMainContainer {
|
|||||||
rc.rotatorAddress,
|
rc.rotatorAddress,
|
||||||
rc.rotatorPort.toIntOrNull() ?: 0,
|
rc.rotatorPort.toIntOrNull() ?: 0,
|
||||||
rc.frequencyAddress,
|
rc.frequencyAddress,
|
||||||
rc.frequencyPort.toIntOrNull() ?: 0
|
rc.frequencyPort.toIntOrNull() ?: 0,
|
||||||
|
rc.frequencyOffsetHz
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,24 +177,30 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun provideLocalSource(): ILocalSource {
|
private fun provideLocalSource(): ILocalSource {
|
||||||
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
|
val builder = Room.databaseBuilder(context, Look4SatDb::class.java, "Look4SatDBv400")
|
||||||
val database = builder.fallbackToDestructiveMigration(false).build()
|
val database = builder
|
||||||
|
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
|
||||||
|
.fallbackToDestructiveMigration(false)
|
||||||
|
.build()
|
||||||
return LocalSource(database.look4SatDao())
|
return LocalSource(database.look4SatDao())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun provideRemoteSource(): IRemoteSource {
|
private fun provideRemoteSource(): IRemoteSource {
|
||||||
return RemoteSource(
|
// amsat.org live pages are slow (7-10s+ from some networks); the
|
||||||
Dispatchers.IO, context.contentResolver,
|
// OkHttp defaults (10s read timeout) made every AMSAT Live update
|
||||||
OkHttpClient.Builder()
|
// time out, silently keeping stale FM/Linear lists forever.
|
||||||
.connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
val client = OkHttpClient.Builder()
|
||||||
.readTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
|
.connectTimeout(20, TimeUnit.SECONDS)
|
||||||
.writeTimeout(20, java.util.concurrent.TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.build()
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
)
|
.callTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
return RemoteSource(Dispatchers.IO, context.contentResolver, client)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun provideSatelliteRepo(): ISatelliteRepo {
|
private fun provideSatelliteRepo(): ISatelliteRepo {
|
||||||
@@ -198,6 +216,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(context, manager, appPreferences, appVersionName)
|
return SettingsRepo(manager, appPreferences, appVersionName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.lotw
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.displayMode
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWRegionField
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWRegionOption
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWStation
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWStationMeta
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWZonePair
|
||||||
|
import org.w3c.dom.Element
|
||||||
|
import org.xml.sax.InputSource
|
||||||
|
import org.xml.sax.SAXException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.StringReader
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.zip.GZIPInputStream
|
||||||
|
import javax.xml.parsers.DocumentBuilderFactory
|
||||||
|
|
||||||
|
/** Bundled ARRL configuration 11.34 from https://lotw.arrl.org/lotw/config.tq6. */
|
||||||
|
internal class LoTWConfig(input: InputStream) {
|
||||||
|
private val root = input.use { source -> GZIPInputStream(source).use { gzip ->
|
||||||
|
val xml = gzip.bufferedReader(Charsets.UTF_8).readText()
|
||||||
|
require(!xml.contains("<!DOCTYPE", true) && !xml.contains("<!ENTITY", true))
|
||||||
|
// Android's DOM provider does not support Xerces-specific setFeature flags.
|
||||||
|
DocumentBuilderFactory.newInstance().apply { isExpandEntityReferences = false }.newDocumentBuilder().apply {
|
||||||
|
setEntityResolver { _, _ -> throw SAXException("External entities are not permitted") }
|
||||||
|
}.parse(InputSource(StringReader(xml))).documentElement
|
||||||
|
} }
|
||||||
|
private val config = root.elements("tqslconfig").single()
|
||||||
|
val version = config.getAttribute("majorversion") + "." + config.getAttribute("minorversion")
|
||||||
|
private val spec = config.elements("sigspec").single { it.getAttribute("version") == "2.0" }
|
||||||
|
val stationOrder = spec.elements("tSTATION").single().childElements().map { it.tagName }
|
||||||
|
val contactOrder = spec.elements("tCONTACT").single().childElements().map { it.tagName }
|
||||||
|
private val primaryRegionFields = setOf("US_STATE", "CA_PROVINCE", "RU_OBLAST", "CN_PROVINCE", "AU_STATE", "JA_PREFECTURE", "FI_KUNTA")
|
||||||
|
private val secondaryRegionFields = setOf("US_COUNTY", "JA_CITY_GUN_KU")
|
||||||
|
private val bands = config.elements("bands").single().elements("band")
|
||||||
|
private val satellites = config.elements("satellite").associateBy { it.getAttribute("name").uppercase(Locale.US) }
|
||||||
|
private val modes = config.elements("modes").single().elements("mode").map { it.textContent }.toSet()
|
||||||
|
|
||||||
|
fun mode(record: QsoRecord): String {
|
||||||
|
val display = record.displayMode
|
||||||
|
if (display in modes) return display
|
||||||
|
val mapped = config.elements("adifmode").firstOrNull { it.textContent.equals(display, true) }?.getAttribute("mode")
|
||||||
|
return mapped ?: fail(LoTWProblem.MODE, display)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun band(explicit: String, frequency: Long?, required: Boolean): String {
|
||||||
|
if (frequency != null && frequency <= 0) fail(LoTWProblem.BAND)
|
||||||
|
val frequencyBand = frequency?.let { hz -> bands.firstOrNull { band ->
|
||||||
|
val unit = if (band.getAttribute("spectrum") == "HF") 1_000.0 else 1_000_000.0
|
||||||
|
val mhz = hz / unit
|
||||||
|
mhz >= band.getAttribute("low").toDouble() && mhz <= band.getAttribute("high").toDouble()
|
||||||
|
}?.textContent }
|
||||||
|
val value = explicit.trim().uppercase(Locale.US).ifBlank { frequencyBand.orEmpty() }
|
||||||
|
if (value.isBlank() && !required && frequency == null) return ""
|
||||||
|
if (bands.none { it.textContent == value } || (frequency != null && frequencyBand != value)) fail(LoTWProblem.BAND, value)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun satellite(name: String, date: String): String {
|
||||||
|
val normalized = name.trim().uppercase(Locale.US)
|
||||||
|
// Look4Sat satellite names come from SatNOGS (e.g. "SO-50 (SaudiOSCAR 50)");
|
||||||
|
// the bundled config only knows the official LoTW name ("SO-50"). Exact match
|
||||||
|
// first, then accept any satellite whose official name is a substring of the
|
||||||
|
// Look4Sat name (covers SO-50, AO-91, IO-86, CAS-7B …). Failing that the
|
||||||
|
// contact is unavailable for upload.
|
||||||
|
val sat = satellites[normalized]
|
||||||
|
?: satellites.entries.firstOrNull { (official, _) ->
|
||||||
|
official.length > 1 && normalized.contains(official)
|
||||||
|
}?.value
|
||||||
|
?: fail(LoTWProblem.SATELLITE, normalized)
|
||||||
|
val first = sat.getAttribute("startDate")
|
||||||
|
val last = sat.getAttribute("endDate")
|
||||||
|
if ((first.isNotBlank() && date < first) || (last.isNotBlank() && date > last)) fail(LoTWProblem.SATELLITE, normalized)
|
||||||
|
return sat.getAttribute("name").uppercase(Locale.US)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun propagation(value: String): String {
|
||||||
|
val normalized = value.trim().uppercase(Locale.US)
|
||||||
|
if (normalized.isNotBlank() && config.elements("propmode").none { it.getAttribute("name") == normalized || it.textContent == normalized }) {
|
||||||
|
fail(LoTWProblem.INVALID_CONTACT, normalized)
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Region-field metadata and the national zonemap for one DXCC entity. */
|
||||||
|
fun stationMeta(dxcc: Int): LoTWStationMeta {
|
||||||
|
val pageFields = config.elements("page").filter { it.getAttribute("dependency") == dxcc.toString() }
|
||||||
|
.flatMap { it.elements("pageField") }.map { it.textContent }
|
||||||
|
val primary = pageFields.firstOrNull { it in primaryRegionFields }
|
||||||
|
val regionField = primary?.let { id ->
|
||||||
|
val field = config.elements("field").single { it.getAttribute("Id") == id }
|
||||||
|
val dependency = if (field.getAttribute("dependsOn") == "DXCC") dxcc.toString() else null
|
||||||
|
val options = field.elements("enums").filter {
|
||||||
|
it.getAttribute("dependency").isBlank() || it.getAttribute("dependency") == dependency
|
||||||
|
}.flatMap { it.elements("enum") }.map { enum ->
|
||||||
|
LoTWRegionOption(
|
||||||
|
code = enum.getAttribute("value").uppercase(Locale.US),
|
||||||
|
name = enum.textContent.trim().ifBlank { enum.getAttribute("value") },
|
||||||
|
zones = parseZonemap(enum.getAttribute("zonemap"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LoTWRegionField(id = id, label = field.getAttribute("label"), options = options)
|
||||||
|
}
|
||||||
|
val entity = config.elements("dxcc").flatMap { it.elements("entity") }
|
||||||
|
.firstOrNull { it.getAttribute("arrlId") == dxcc.toString() }
|
||||||
|
val countryZones = entity?.getAttribute("zonemap")?.let(::parseZonemap).orEmpty()
|
||||||
|
return LoTWStationMeta(regionField, countryZones)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseZonemap(zonemap: String): List<LoTWZonePair> = zonemap.split(',').mapNotNull { pair ->
|
||||||
|
val parts = pair.split(':')
|
||||||
|
if (parts.size != 2) return@mapNotNull null
|
||||||
|
val itu = parts[0].trim().toIntOrNull() ?: return@mapNotNull null
|
||||||
|
val cq = parts[1].trim().toIntOrNull() ?: return@mapNotNull null
|
||||||
|
LoTWZonePair(itu, cq)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stationFields(station: LoTWStation, dxcc: Int): Map<String, String> {
|
||||||
|
fun normalized(value: String) = value.trim().uppercase(Locale.US)
|
||||||
|
val grids = station.grid.split(',').map(::normalized).filter(String::isNotBlank).distinct()
|
||||||
|
if (grids.size !in 1..4 || grids.any { !it.matches(Regex("[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?")) }) {
|
||||||
|
fail(LoTWProblem.STATION_GRID)
|
||||||
|
}
|
||||||
|
// The primary grid goes into GRIDSQUARE (the field the config's tSTATION
|
||||||
|
// order signs); the full multi-grid set goes into MY_VUCC_GRIDS, an extra
|
||||||
|
// station field that TQSL stores but does not sign (same precedent as IOTA).
|
||||||
|
val fields = linkedMapOf("GRIDSQUARE" to grids.first())
|
||||||
|
if (grids.size > 1) fields["MY_VUCC_GRIDS"] = grids.joinToString(",")
|
||||||
|
fun zone(name: String, value: String, maximum: Int) {
|
||||||
|
if (value.isBlank()) return
|
||||||
|
val number = value.trim().toIntOrNull()?.takeIf { it in 1..maximum } ?: fail(LoTWProblem.STATION_ZONE)
|
||||||
|
fields[name] = number.toString()
|
||||||
|
}
|
||||||
|
zone("CQZ", station.cqZone, 40)
|
||||||
|
zone("ITUZ", station.ituZone, 90)
|
||||||
|
if (station.iota.isNotBlank()) {
|
||||||
|
val iota = normalized(station.iota)
|
||||||
|
if (!iota.matches(Regex("(AF|AN|AS|EU|NA|OC|SA)-[0-9]{3}"))) fail(LoTWProblem.STATION_IOTA)
|
||||||
|
fields["IOTA"] = iota
|
||||||
|
}
|
||||||
|
val pageFields = config.elements("page").filter { it.getAttribute("dependency") == dxcc.toString() }
|
||||||
|
.flatMap { it.elements("pageField") }.map { it.textContent }
|
||||||
|
val primary = pageFields.firstOrNull { it in primaryRegionFields }
|
||||||
|
val secondary = pageFields.firstOrNull { it in secondaryRegionFields }
|
||||||
|
fun region(name: String?, value: String) {
|
||||||
|
if (value.isBlank()) return
|
||||||
|
if (name == null) fail(LoTWProblem.STATION_REGION)
|
||||||
|
val field = config.elements("field").single { it.getAttribute("Id") == name }
|
||||||
|
val dependency = when (field.getAttribute("dependsOn")) {
|
||||||
|
"DXCC" -> dxcc.toString()
|
||||||
|
else -> fields[field.getAttribute("dependsOn")]
|
||||||
|
}
|
||||||
|
val options = field.elements("enums").filter {
|
||||||
|
it.getAttribute("dependency").isBlank() || it.getAttribute("dependency") == dependency
|
||||||
|
}.flatMap { it.elements("enum") }.map { it.getAttribute("value").uppercase(Locale.US) }
|
||||||
|
val code = normalized(value)
|
||||||
|
if (code !in options) fail(LoTWProblem.STATION_REGION, name)
|
||||||
|
fields[name] = code
|
||||||
|
}
|
||||||
|
region(primary, station.region)
|
||||||
|
region(secondary, station.county)
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Element.elements(tag: String): List<Element> = getElementsByTagName(tag).let { nodes ->
|
||||||
|
(0 until nodes.length).map { nodes.item(it) as Element }
|
||||||
|
}
|
||||||
|
private fun Element.childElements(): List<Element> = (0 until childNodes.length).mapNotNull { childNodes.item(it) as? Element }
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.lotw
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.PrivateKey
|
||||||
|
import java.security.Signature
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
internal data class LoTWKeyMaterial(val key: PrivateKey, val certificate: X509Certificate, val info: LoTWCertificate) {
|
||||||
|
companion object {
|
||||||
|
fun read(bytes: ByteArray, password: CharArray, now: Long): LoTWKeyMaterial {
|
||||||
|
if (bytes.isEmpty() || bytes.size > MAX_CERTIFICATE_BYTES) fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||||
|
val key: PrivateKey
|
||||||
|
val cert: X509Certificate
|
||||||
|
val store = try {
|
||||||
|
KeyStore.getInstance("PKCS12").apply { bytes.inputStream().use { load(it, password) } }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Modern TQSL / OpenSSL 3 exports use PBES2+AES-CBC which Android's legacy
|
||||||
|
// Bouncy Castle parser cannot read. Fall back to our own PBES2 reader; if
|
||||||
|
// that fails too, report the real reason (format vs password).
|
||||||
|
if (isPbes2(bytes)) {
|
||||||
|
try {
|
||||||
|
val parsed = Pkcs12Reader.read(bytes, password)
|
||||||
|
parsed.first to parsed.second
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Classify by exception type so the user sees the right message:
|
||||||
|
// - BadPadding (BAD_DECRYPT) -> wrong password
|
||||||
|
// - IllegalStateException (error()) -> unsupported algorithm,
|
||||||
|
// message is the algorithm name
|
||||||
|
// - IllegalArgumentException (require()) -> structurally invalid file
|
||||||
|
fail(
|
||||||
|
when {
|
||||||
|
e is javax.crypto.BadPaddingException -> LoTWProblem.CERTIFICATE_PASSWORD
|
||||||
|
e is IllegalStateException && password.isNotEmpty() -> LoTWProblem.CERTIFICATE_FORMAT
|
||||||
|
else -> LoTWProblem.CERTIFICATE_INVALID
|
||||||
|
},
|
||||||
|
if (e is IllegalStateException) (e.message ?: "") else ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fail(LoTWProblem.CERTIFICATE_PASSWORD)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (store is Pair<*, *>) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
key = store.first as PrivateKey
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
cert = store.second as X509Certificate
|
||||||
|
} else {
|
||||||
|
val ks = store as KeyStore
|
||||||
|
val aliases = Collections.list(ks.aliases()).filter { ks.isKeyEntry(it) }
|
||||||
|
if (aliases.size != 1) fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||||
|
val alias = aliases.single()
|
||||||
|
key = try { ks.getKey(alias, password) as? PrivateKey }
|
||||||
|
catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_PASSWORD) }
|
||||||
|
?: fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||||
|
cert = ks.getCertificate(alias) as? X509Certificate ?: fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||||
|
}
|
||||||
|
if (key.algorithm != "RSA" || cert.publicKey.algorithm != "RSA") fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||||
|
try { cert.checkValidity(Date(now)) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_EXPIRED) }
|
||||||
|
val info = try { metadata(cert) } catch (_: Exception) { fail(LoTWProblem.CERTIFICATE_INVALID) }
|
||||||
|
// Use the platform's crypto implementation, including PKCS#1 padding. No custom cryptography.
|
||||||
|
val challenge = "Look4Sat LoTW certificate key check".toByteArray(Charsets.US_ASCII)
|
||||||
|
val signed = Signature.getInstance("SHA1withRSA").run { initSign(key); update(challenge); sign() }
|
||||||
|
val valid = Signature.getInstance("SHA1withRSA").run { initVerify(cert); update(challenge); verify(signed) }
|
||||||
|
if (!valid) fail(LoTWProblem.CERTIFICATE_INVALID)
|
||||||
|
return LoTWKeyMaterial(key, cert, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the PKCS12 uses PBES2 (OID 1.2.840.113549.1.5.13), the default
|
||||||
|
* algorithm of OpenSSL 3 / modern TQSL. Android's legacy BC parser can't read it. */
|
||||||
|
private fun isPbes2(bytes: ByteArray): Boolean {
|
||||||
|
val oid = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x05, 0x0d)
|
||||||
|
if (bytes.size < oid.size) return false
|
||||||
|
outer@ for (i in 0..bytes.size - oid.size) {
|
||||||
|
for (j in oid.indices) if (bytes[i + j] != oid[j]) continue@outer
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun metadata(cert: X509Certificate): LoTWCertificate {
|
||||||
|
val oid = byteArrayOf(0x2b, 0x06, 0x01, 0x04, 0x01, 0xe0.toByte(), 0x3c, 0x01, 0x01)
|
||||||
|
val subject = DerValue.read(cert.subjectX500Principal.encoded).single()
|
||||||
|
require(subject.tag == 0x30)
|
||||||
|
val calls = subject.children().flatMap { it.children() }.mapNotNull { attribute ->
|
||||||
|
val pair = attribute.children()
|
||||||
|
if (pair.size == 2 && pair[0].tag == 6 && pair[0].data.contentEquals(oid)) pair[1].text() else null
|
||||||
|
}
|
||||||
|
val call = calls.single().trim().uppercase(Locale.US)
|
||||||
|
require(call.matches(Regex("[A-Z0-9]+(/[A-Z0-9]+)*")) && call.any(Char::isDigit) && call.any(Char::isLetter))
|
||||||
|
val first = extension(cert, "2")
|
||||||
|
val last = extension(cert, "3").takeUnless { it == "0000-00-00" }.orEmpty()
|
||||||
|
require(validDate(first) && (last.isBlank() || validDate(last)) && (last.isBlank() || last >= first))
|
||||||
|
val dxcc = extension(cert, "4").toInt()
|
||||||
|
require(dxcc > 0)
|
||||||
|
return LoTWCertificate(call, dxcc, cert.serialNumber.toString(16), utc(cert.notAfter.time, "yyyy-MM-dd"), first, last)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extension(cert: X509Certificate, suffix: String): String {
|
||||||
|
val bytes = cert.getExtensionValue("1.3.6.1.4.1.12348.1.$suffix") ?: return ""
|
||||||
|
val outer = DerValue.read(bytes).single()
|
||||||
|
require(outer.tag == 4)
|
||||||
|
// TrustedQSL uses raw ASCII extension values. Also accept DER string-wrapped values.
|
||||||
|
return if (outer.data.all { it.toInt() in 32..126 || it.toInt() == 0 }) {
|
||||||
|
outer.data.toString(Charsets.US_ASCII).trim('\u0000', ' ')
|
||||||
|
} else DerValue.read(outer.data).single().text().trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal const val MAX_CERTIFICATE_BYTES = 1024 * 1024
|
||||||
|
internal fun fail(problem: LoTWProblem, detail: String = ""): Nothing = throw LoTWOperationException(problem, detail)
|
||||||
|
internal fun utc(millis: Long, pattern: String): String = SimpleDateFormat(pattern, Locale.US).apply {
|
||||||
|
timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
}.format(Date(millis))
|
||||||
|
internal fun validDate(value: String): Boolean = value.matches(Regex("[0-9]{4}-[0-9]{2}-[0-9]{2}")) && runCatching {
|
||||||
|
SimpleDateFormat("yyyy-MM-dd", Locale.US).apply { isLenient = false }.parse(value) != null
|
||||||
|
}.getOrDefault(false)
|
||||||
|
|
||||||
|
/** Only bounded DER metadata decoding; cryptographic verification is delegated to JCA. */
|
||||||
|
internal data class DerValue(val tag: Int, val data: ByteArray) {
|
||||||
|
fun children(): List<DerValue> = read(data)
|
||||||
|
fun text(): String = when (tag) {
|
||||||
|
12, 19, 20, 22, 23, 24 -> data.toString(Charsets.UTF_8)
|
||||||
|
30 -> data.toString(Charsets.UTF_16BE)
|
||||||
|
else -> error("Not an ASN.1 string")
|
||||||
|
}
|
||||||
|
companion object {
|
||||||
|
fun read(bytes: ByteArray): List<DerValue> {
|
||||||
|
val result = mutableListOf<DerValue>()
|
||||||
|
var offset = 0
|
||||||
|
while (offset < bytes.size) {
|
||||||
|
require(bytes.size - offset >= 2)
|
||||||
|
val tag = bytes[offset++].toInt() and 255
|
||||||
|
var length = bytes[offset++].toInt() and 255
|
||||||
|
if (length and 128 != 0) {
|
||||||
|
val size = length and 127
|
||||||
|
require(size in 1..3 && size <= bytes.size - offset)
|
||||||
|
length = 0
|
||||||
|
repeat(size) { length = (length shl 8) or (bytes[offset++].toInt() and 255) }
|
||||||
|
}
|
||||||
|
require(length <= bytes.size - offset)
|
||||||
|
result += DerValue(tag, bytes.copyOfRange(offset, offset + length))
|
||||||
|
offset += length
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.lotw
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.isSatellite
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.Signature
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.zip.GZIPOutputStream
|
||||||
|
import kotlin.io.encoding.Base64
|
||||||
|
|
||||||
|
internal data class LoTWContact(val record: QsoRecord, val fields: Map<String, String>, val signData: String, val fingerprint: String)
|
||||||
|
|
||||||
|
internal class LoTWSigner(private val config: LoTWConfig) {
|
||||||
|
fun contact(record: QsoRecord, key: LoTWKeyMaterial, station: Map<String, String>, now: Long): LoTWContact {
|
||||||
|
val call = record.theirCallsign.trim().uppercase(Locale.US)
|
||||||
|
if (record.status != QsoStatus.COMPLETE || !call.matches(Regex("[A-Z0-9]+(/[A-Z0-9]+)*")) ||
|
||||||
|
!call.any(Char::isLetter) || !call.any(Char::isDigit)) fail(LoTWProblem.INVALID_CONTACT, call)
|
||||||
|
if (!record.myCallsign.trim().equals(key.info.callsign, true)) fail(LoTWProblem.CALLSIGN_MISMATCH, call)
|
||||||
|
val date = utc(record.startUtcMillis, "yyyy-MM-dd")
|
||||||
|
if (date < key.info.firstQsoDate || (key.info.lastQsoDate.isNotBlank() && date > key.info.lastQsoDate) || record.startUtcMillis > now) {
|
||||||
|
fail(LoTWProblem.QSO_DATE, call)
|
||||||
|
}
|
||||||
|
fun mhz(hz: Long?): String = hz?.let { BigDecimal.valueOf(it, 6).stripTrailingZeros().toPlainString() }.orEmpty()
|
||||||
|
val fields = linkedMapOf(
|
||||||
|
"BAND" to config.band(record.band, record.txFrequencyHz, true),
|
||||||
|
"BAND_RX" to config.band(record.rxBand, record.rxFrequencyHz, false),
|
||||||
|
"CALL" to call,
|
||||||
|
"FREQ" to mhz(record.txFrequencyHz),
|
||||||
|
"FREQ_RX" to mhz(record.rxFrequencyHz),
|
||||||
|
"MODE" to config.mode(record),
|
||||||
|
"PROP_MODE" to if (record.isSatellite) "SAT" else config.propagation(record.propagationMode),
|
||||||
|
"QSO_DATE" to date,
|
||||||
|
"QSO_TIME" to utc(record.startUtcMillis, "HH:mm:ss'Z'"),
|
||||||
|
"SAT_NAME" to if (record.isSatellite) config.satellite(record.satelliteName, date) else ""
|
||||||
|
).filterValues { it.isNotBlank() }
|
||||||
|
val signData = (config.stationOrder.map { station[it].orEmpty() } + config.contactOrder.map { fields[it].orEmpty() }).joinToString("")
|
||||||
|
// Fingerprint identifies the CONTACT only (call, date/time, band, mode,
|
||||||
|
// satellite, frequency). Station fields (grid, zones, county, IOTA) are
|
||||||
|
// deliberately excluded: changing the station location must not change
|
||||||
|
// the fingerprint, otherwise previously-uploaded contacts would lose
|
||||||
|
// their ledger entry and be re-uploaded (and rejected as duplicates).
|
||||||
|
val identity = field("CALL", key.info.callsign) + field("DXCC", key.info.dxcc.toString()) +
|
||||||
|
fields.entries.joinToString("") { field(it.key, it.value) }
|
||||||
|
val hash = MessageDigest.getInstance("SHA-256").digest(identity.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
|
||||||
|
return LoTWContact(record, fields, signData, hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sign(contacts: List<LoTWContact>, key: LoTWKeyMaterial, station: Map<String, String>): ByteArray {
|
||||||
|
val text = buildString {
|
||||||
|
append(field("TQSL_IDENT", "Look4Sat LoTW Config: V${config.version} AllowDupes: false"))
|
||||||
|
append(field("REC_TYPE", "tCERT")); append(field("CERT_UID", "1"))
|
||||||
|
append(field("CERTIFICATE", Base64.Default.encode(key.certificate.encoded).chunked(64).joinToString("\n")))
|
||||||
|
append("<eor>\n")
|
||||||
|
append(field("REC_TYPE", "tSTATION")); append(field("STATION_UID", "1")); append(field("CERT_UID", "1"))
|
||||||
|
append(field("CALL", key.info.callsign)); append(field("DXCC", key.info.dxcc.toString()))
|
||||||
|
station.forEach { (name, value) -> append(field(name, value)) }
|
||||||
|
append("<eor>\n")
|
||||||
|
contacts.forEach { contact ->
|
||||||
|
append(field("REC_TYPE", "tCONTACT")); append(field("STATION_UID", "1"))
|
||||||
|
contact.fields.forEach { (name, value) -> append(field(name, value)) }
|
||||||
|
// SHA-1 is required by the LoTW V2.0 wire format, not chosen for general-purpose signing.
|
||||||
|
val signature = Signature.getInstance("SHA1withRSA").run {
|
||||||
|
initSign(key.key); update(contact.signData.toByteArray(Charsets.UTF_8)); sign()
|
||||||
|
}
|
||||||
|
val encoded = Base64.Default.encode(signature).chunked(64).joinToString("\n")
|
||||||
|
append("<SIGN_LOTW_V2.0:${encoded.length}:6>$encoded\n")
|
||||||
|
append(field("SIGNDATA", contact.signData)); append("<eor>\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ByteArrayOutputStream().also { output ->
|
||||||
|
GZIPOutputStream(output).use { it.write(text.toByteArray(Charsets.UTF_8)) }
|
||||||
|
}.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun field(name: String, value: String): String = "<$name:${value.toByteArray(Charsets.UTF_8).size}>$value\n"
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.lotw
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.util.AtomicFile
|
||||||
|
import java.io.File
|
||||||
|
import java.security.KeyStore
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
|
||||||
|
internal interface LoTWStorage {
|
||||||
|
fun read(name: String): ByteArray?
|
||||||
|
fun write(name: String, data: ByteArray)
|
||||||
|
fun delete(name: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** P12 backups and their saved passwords are encrypted at rest and excluded from Android/cloud backup. */
|
||||||
|
internal class AndroidLoTWStorage(context: Context) : LoTWStorage {
|
||||||
|
private val directory = File(context.noBackupFilesDir, "lotw")
|
||||||
|
private val store by lazy { KeyStore.getInstance("AndroidKeyStore").apply { load(null) } }
|
||||||
|
private fun file(name: String): AtomicFile {
|
||||||
|
require(name.matches(Regex("[a-z]+")))
|
||||||
|
check(directory.isDirectory || directory.mkdirs())
|
||||||
|
return AtomicFile(File(directory, name))
|
||||||
|
}
|
||||||
|
private fun key(): SecretKey = (store.getKey(ALIAS, null) as? SecretKey) ?: KeyGenerator.getInstance(
|
||||||
|
KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"
|
||||||
|
).run {
|
||||||
|
init(KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setKeySize(256).build())
|
||||||
|
generateKey()
|
||||||
|
}
|
||||||
|
@Synchronized override fun read(name: String): ByteArray? {
|
||||||
|
val file = file(name)
|
||||||
|
if (!file.baseFile.exists() && !File(file.baseFile.path + ".bak").exists()) return null
|
||||||
|
val bytes = file.readFully()
|
||||||
|
require(bytes.size in 29..(16 * 1024 * 1024) && bytes[0] == 1.toByte())
|
||||||
|
return Cipher.getInstance("AES/GCM/NoPadding").run {
|
||||||
|
init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, bytes.copyOfRange(1, 13)))
|
||||||
|
updateAAD(name.toByteArray(Charsets.UTF_8))
|
||||||
|
doFinal(bytes, 13, bytes.size - 13)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Synchronized override fun write(name: String, data: ByteArray) {
|
||||||
|
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
|
||||||
|
init(Cipher.ENCRYPT_MODE, key())
|
||||||
|
updateAAD(name.toByteArray(Charsets.UTF_8))
|
||||||
|
}
|
||||||
|
val bytes = byteArrayOf(1) + cipher.iv + cipher.doFinal(data)
|
||||||
|
val file = file(name)
|
||||||
|
val stream = file.startWrite()
|
||||||
|
try { stream.write(bytes); file.finishWrite(stream) }
|
||||||
|
catch (error: Exception) { file.failWrite(stream); throw error }
|
||||||
|
}
|
||||||
|
@Synchronized override fun delete(name: String) = file(name).delete()
|
||||||
|
private companion object { const val ALIAS = "look4sat.lotw.storage.v1" }
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.lotw
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ILoTWUploadRepository
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWCertificate
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWOperationException
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWProblem
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWStation
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWStationMeta
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadAudit
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadPreview
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWUploadResult
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.Callback
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.MultipartBody
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.DataInputStream
|
||||||
|
import java.io.DataOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.CharBuffer
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
|
private fun readBoundedBody(response: Response, limit: Int): String = response.body.charStream().use { reader ->
|
||||||
|
val result = StringBuilder()
|
||||||
|
val buffer = CharArray(8192)
|
||||||
|
while (true) {
|
||||||
|
val count = reader.read(buffer)
|
||||||
|
if (count < 0) break
|
||||||
|
require(result.length <= limit - count) { "Response exceeds size limit" }
|
||||||
|
result.append(buffer, 0, count)
|
||||||
|
}
|
||||||
|
result.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoTWUploadRepository internal constructor(
|
||||||
|
private val storage: LoTWStorage,
|
||||||
|
private val config: () -> LoTWConfig,
|
||||||
|
client: OkHttpClient = OkHttpClient(),
|
||||||
|
private val now: () -> Long = System::currentTimeMillis
|
||||||
|
) : ILoTWUploadRepository {
|
||||||
|
constructor(context: Context) : this(AndroidLoTWStorage(context), { LoTWConfig(context.assets.open("lotw/config.tq6")) })
|
||||||
|
private val client = client.newBuilder().connectTimeout(30, TimeUnit.SECONDS).readTimeout(120, TimeUnit.SECONDS)
|
||||||
|
.callTimeout(180, TimeUnit.SECONDS).retryOnConnectionFailure(false).followRedirects(false).followSslRedirects(false).build()
|
||||||
|
private val mutex = Mutex()
|
||||||
|
@Volatile private var pending: Pending? = null
|
||||||
|
private data class Pending(val preview: LoTWUploadPreview, val data: ByteArray, val hashes: List<String>, val created: Long)
|
||||||
|
private data class SigningContext(val key: LoTWKeyMaterial, val signer: LoTWSigner, val location: Map<String, String>)
|
||||||
|
|
||||||
|
override suspend fun certificate(): LoTWCertificate? = withContext(Dispatchers.IO) {
|
||||||
|
mutex.withLock {
|
||||||
|
val bytes = storage.read("certificate") ?: return@withLock null
|
||||||
|
try {
|
||||||
|
readBundle(bytes).let { bundle ->
|
||||||
|
bundle.p12.fill(0)
|
||||||
|
bundle.password?.fill(0)
|
||||||
|
bundle.info.copy(passwordSaved = bundle.password != null)
|
||||||
|
}
|
||||||
|
} finally { bytes.fill(0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun importCertificate(data: ByteArray, password: CharArray): LoTWCertificate = withContext(Dispatchers.IO) {
|
||||||
|
mutex.withLock {
|
||||||
|
val passwordBytes = encodePassword(password)
|
||||||
|
try {
|
||||||
|
if (passwordBytes.size > MAX_CERTIFICATE_PASSWORD_BYTES) fail(LoTWProblem.CERTIFICATE_PASSWORD)
|
||||||
|
val key = LoTWKeyMaterial.read(data, password, now())
|
||||||
|
val bytes = encodeBundle(key.info, data, passwordBytes)
|
||||||
|
try { storage.write("certificate", bytes) } finally { bytes.fill(0) }
|
||||||
|
discardPreview()
|
||||||
|
key.info.copy(passwordSaved = true)
|
||||||
|
} finally {
|
||||||
|
passwordBytes.fill(0)
|
||||||
|
password.fill('\u0000')
|
||||||
|
data.fill(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate = withContext(Dispatchers.IO) {
|
||||||
|
mutex.withLock {
|
||||||
|
val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING)
|
||||||
|
val bundle = try { readBundle(stored) } finally { stored.fill(0) }
|
||||||
|
val passwordBytes = encodePassword(password)
|
||||||
|
try {
|
||||||
|
if (passwordBytes.size > MAX_CERTIFICATE_PASSWORD_BYTES) fail(LoTWProblem.CERTIFICATE_PASSWORD)
|
||||||
|
val key = LoTWKeyMaterial.read(bundle.p12, password, now())
|
||||||
|
val bytes = encodeBundle(key.info, bundle.p12, passwordBytes)
|
||||||
|
try { storage.write("certificate", bytes) } finally { bytes.fill(0) }
|
||||||
|
discardPreview()
|
||||||
|
key.info.copy(passwordSaved = true)
|
||||||
|
} finally {
|
||||||
|
bundle.p12.fill(0)
|
||||||
|
bundle.password?.fill(0)
|
||||||
|
passwordBytes.fill(0)
|
||||||
|
password.fill('\u0000')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun station(): LoTWStation? = withContext(Dispatchers.IO) {
|
||||||
|
mutex.withLock { readStation() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun saveStation(station: LoTWStation): LoTWStation = withContext(Dispatchers.IO) {
|
||||||
|
mutex.withLock {
|
||||||
|
val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING)
|
||||||
|
val bundle = try { readBundle(stored) } finally { stored.fill(0) }
|
||||||
|
val normalized = station.normalized()
|
||||||
|
try {
|
||||||
|
config().stationFields(normalized, bundle.info.dxcc)
|
||||||
|
} catch (e: LoTWOperationException) {
|
||||||
|
// A region saved under a different certificate no longer applies to
|
||||||
|
// this DXCC entity — drop it instead of failing the whole save.
|
||||||
|
if (e.reason != LoTWProblem.STATION_REGION) throw e
|
||||||
|
val cleaned = normalized.copy(region = "", county = "")
|
||||||
|
config().stationFields(cleaned, bundle.info.dxcc)
|
||||||
|
writeStation(cleaned)
|
||||||
|
discardPreview()
|
||||||
|
return@withLock cleaned
|
||||||
|
} finally {
|
||||||
|
bundle.p12.fill(0)
|
||||||
|
bundle.password?.fill(0)
|
||||||
|
}
|
||||||
|
writeStation(normalized)
|
||||||
|
discardPreview()
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeCertificate() = withContext(Dispatchers.IO) {
|
||||||
|
mutex.withLock { discardPreview(); storage.delete("certificate") }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun stationMeta(dxcc: Int): LoTWStationMeta = withContext(Dispatchers.IO) {
|
||||||
|
config().stationMeta(dxcc)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun audit(records: List<QsoRecord>): LoTWUploadAudit = withContext(Dispatchers.IO) {
|
||||||
|
mutex.withLock {
|
||||||
|
val signing = signingContext()
|
||||||
|
val ledger = ledger()
|
||||||
|
val unique = hashSetOf<String>()
|
||||||
|
var pendingCount = 0
|
||||||
|
var uploaded = 0
|
||||||
|
var unknown = 0
|
||||||
|
var unavailable = 0
|
||||||
|
records.sortedBy { it.startUtcMillis }.forEach { record ->
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
when {
|
||||||
|
record.status != QsoStatus.COMPLETE -> unavailable++
|
||||||
|
record.lotwReceived || record.lotwUploaded -> uploaded++
|
||||||
|
else -> {
|
||||||
|
val contact = try {
|
||||||
|
signing.signer.contact(record, signing.key, signing.location, now())
|
||||||
|
} catch (_: LoTWOperationException) {
|
||||||
|
unavailable++
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (contact != null) when {
|
||||||
|
!unique.add(contact.fingerprint) -> unavailable++
|
||||||
|
ledger[contact.fingerprint] == "accepted" -> uploaded++
|
||||||
|
ledger[contact.fingerprint] == "unknown" -> unknown++
|
||||||
|
else -> pendingCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LoTWUploadAudit(records.size, pendingCount, uploaded, unknown, unavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun prepare(records: List<QsoRecord>, resubmit: Boolean): LoTWUploadPreview = withContext(Dispatchers.IO) {
|
||||||
|
mutex.withLock {
|
||||||
|
discardPreview()
|
||||||
|
if (records.size > 10_000) fail(LoTWProblem.TOO_MANY_CONTACTS)
|
||||||
|
val signing = signingContext()
|
||||||
|
val ledger = ledger()
|
||||||
|
var skipped = 0
|
||||||
|
var unknown = 0
|
||||||
|
var unavailable = 0
|
||||||
|
val unique = hashSetOf<String>()
|
||||||
|
val contacts = records.sortedBy { it.startUtcMillis }.mapNotNull { record ->
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
if (record.status != QsoStatus.COMPLETE || ((record.lotwReceived || record.lotwUploaded) && !resubmit)) { skipped++; return@mapNotNull null }
|
||||||
|
val contact = try {
|
||||||
|
signing.signer.contact(record, signing.key, signing.location, now())
|
||||||
|
} catch (_: LoTWOperationException) {
|
||||||
|
// One un-signable record must not abort the whole batch:
|
||||||
|
// skip it, count it, and let the rest upload.
|
||||||
|
unavailable++
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
val previous = ledger[contact.fingerprint]
|
||||||
|
when {
|
||||||
|
(previous == "accepted" && !resubmit) || !unique.add(contact.fingerprint) -> { skipped++; null }
|
||||||
|
previous == "unknown" && !resubmit -> { skipped++; unknown++; null }
|
||||||
|
else -> contact
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val preview = LoTWUploadPreview(
|
||||||
|
UUID.randomUUID().toString(), signing.key.info.callsign, signing.key.info.dxcc, signing.location.getValue("GRIDSQUARE"),
|
||||||
|
contacts.size, skipped,
|
||||||
|
contacts.firstOrNull()?.record?.let { utc(it.startUtcMillis, "yyyy-MM-dd HH:mm:ss") }.orEmpty(),
|
||||||
|
contacts.lastOrNull()?.record?.let { utc(it.startUtcMillis, "yyyy-MM-dd HH:mm:ss") }.orEmpty(),
|
||||||
|
contacts.map { "${utc(it.record.startUtcMillis, "MM-dd HH:mm")} ${it.record.theirCallsign} ${it.fields["MODE"]} ${it.fields["SAT_NAME"].orEmpty()}" },
|
||||||
|
unknown,
|
||||||
|
unavailable,
|
||||||
|
contacts.map { it.record.id }
|
||||||
|
)
|
||||||
|
if (contacts.isNotEmpty()) pending = Pending(
|
||||||
|
preview,
|
||||||
|
signing.signer.sign(contacts, signing.key, signing.location),
|
||||||
|
contacts.map { it.fingerprint },
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
preview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun upload(previewId: String): LoTWUploadResult = withContext(Dispatchers.IO) {
|
||||||
|
mutex.withLock {
|
||||||
|
val payload = pending?.takeIf { it.preview.id == previewId && now() - it.created in 0..900_000 }
|
||||||
|
?: return@withLock LoTWUploadResult.ExpiredPreview
|
||||||
|
pending = null // A preview is a one-shot authorization, including across failures.
|
||||||
|
val ledger = ledger()
|
||||||
|
payload.hashes.forEach { ledger[it] = "unknown" }
|
||||||
|
saveLedger(ledger) // Persist BEFORE the POST, so process death/cancellation cannot cause an automatic retry.
|
||||||
|
try {
|
||||||
|
val result = post(payload.data, payload.preview.count)
|
||||||
|
when (result) {
|
||||||
|
is LoTWUploadResult.Accepted -> payload.hashes.forEach { ledger[it] = "accepted" }
|
||||||
|
is LoTWUploadResult.Rejected -> payload.hashes.forEach { ledger.remove(it) }
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
saveLedger(ledger)
|
||||||
|
result
|
||||||
|
} finally { payload.data.fill(0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun discardPreview() { pending?.data?.fill(0); pending = null }
|
||||||
|
|
||||||
|
private fun signingContext(): SigningContext {
|
||||||
|
val stored = storage.read("certificate") ?: fail(LoTWProblem.CERTIFICATE_MISSING)
|
||||||
|
val bundle = try { readBundle(stored) } finally { stored.fill(0) }
|
||||||
|
val passwordBytes = bundle.password ?: run {
|
||||||
|
bundle.p12.fill(0)
|
||||||
|
fail(LoTWProblem.CERTIFICATE_PASSWORD)
|
||||||
|
}
|
||||||
|
val password = decodePassword(passwordBytes)
|
||||||
|
return try {
|
||||||
|
val key = LoTWKeyMaterial.read(bundle.p12, password, now())
|
||||||
|
val tqslConfig = config()
|
||||||
|
val signer = LoTWSigner(tqslConfig)
|
||||||
|
val station = readStation() ?: fail(LoTWProblem.STATION_GRID)
|
||||||
|
SigningContext(key, signer, tqslConfig.stationFields(station, key.info.dxcc))
|
||||||
|
} finally {
|
||||||
|
bundle.p12.fill(0)
|
||||||
|
passwordBytes.fill(0)
|
||||||
|
password.fill('\u0000')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readStation(): LoTWStation? = storage.read("station")?.inputStream()?.let { stream ->
|
||||||
|
DataInputStream(stream).use { input ->
|
||||||
|
require(input.readInt() == 1)
|
||||||
|
LoTWStation(input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeStation(station: LoTWStation) {
|
||||||
|
val bytes = ByteArrayOutputStream().also { stream -> DataOutputStream(stream).use { output ->
|
||||||
|
output.writeInt(1)
|
||||||
|
listOf(station.grid, station.cqZone, station.ituZone, station.region, station.county, station.iota).forEach(output::writeUTF)
|
||||||
|
} }.toByteArray()
|
||||||
|
try { storage.write("station", bytes) } finally { bytes.fill(0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun post(data: ByteArray, count: Int): LoTWUploadResult = suspendCancellableCoroutine { continuation ->
|
||||||
|
val body = MultipartBody.Builder().setType(MultipartBody.FORM)
|
||||||
|
.addFormDataPart("upfile", "look4sat-${UUID.randomUUID()}.tq8", data.toRequestBody("application/octet-stream".toMediaType())).build()
|
||||||
|
val call = client.newCall(Request.Builder().url("https://lotw.arrl.org/lotw/upload").post(body).build())
|
||||||
|
continuation.invokeOnCancellation { call.cancel() }
|
||||||
|
call.enqueue(object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
if (continuation.isActive) continuation.resume(LoTWUploadResult.Unknown)
|
||||||
|
}
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
val result = try {
|
||||||
|
response.use {
|
||||||
|
if (!it.isSuccessful) LoTWUploadResult.Unknown else parseLoTWUploadResponse(readBoundedBody(it, 256 * 1024), count)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { LoTWUploadResult.Unknown }
|
||||||
|
if (continuation.isActive) continuation.resume(result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ledger(): MutableMap<String, String> = storage.read("receipts")?.inputStream()?.let { stream ->
|
||||||
|
DataInputStream(stream).use { input ->
|
||||||
|
require(input.readInt() == 1)
|
||||||
|
val size = input.readInt().also { require(it in 0..200_000) }
|
||||||
|
val entries = linkedMapOf<String, String>()
|
||||||
|
repeat(size) { entries[input.readUTF()] = input.readUTF() }
|
||||||
|
entries
|
||||||
|
}
|
||||||
|
} ?: linkedMapOf()
|
||||||
|
|
||||||
|
private fun saveLedger(entries: Map<String, String>) {
|
||||||
|
val bytes = ByteArrayOutputStream().also { stream -> DataOutputStream(stream).use { output ->
|
||||||
|
output.writeInt(1); output.writeInt(entries.size)
|
||||||
|
entries.forEach { (hash, status) -> output.writeUTF(hash); output.writeUTF(status) }
|
||||||
|
} }.toByteArray()
|
||||||
|
storage.write("receipts", bytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun parseLoTWUploadResponse(body: String, count: Int): LoTWUploadResult {
|
||||||
|
val markers = Regex("<!--\\s*\\.UPL\\.\\s*(accepted|rejected)\\s*-->", RegexOption.IGNORE_CASE)
|
||||||
|
.findAll(body).map { it.groupValues[1].lowercase() }.toList()
|
||||||
|
return when (markers.singleOrNull()) {
|
||||||
|
"accepted" -> LoTWUploadResult.Accepted(count)
|
||||||
|
"rejected" -> {
|
||||||
|
val message = Regex("<!--\\s*\\.UPLMESSAGE\\.\\s*(.*?)-->", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
|
||||||
|
.find(body)?.groupValues?.get(1).orEmpty().replace(Regex("<[^>]*>"), " ")
|
||||||
|
.replace(Regex("[\\p{Cc}\\s]+"), " ").trim().take(500)
|
||||||
|
LoTWUploadResult.Rejected(message)
|
||||||
|
}
|
||||||
|
else -> LoTWUploadResult.Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class StoredCertificate(val info: LoTWCertificate, val p12: ByteArray, val password: ByteArray?)
|
||||||
|
|
||||||
|
private fun encodeBundle(info: LoTWCertificate, p12: ByteArray, password: ByteArray): ByteArray = ByteArrayOutputStream().also { stream ->
|
||||||
|
DataOutputStream(stream).use { output ->
|
||||||
|
output.writeInt(2)
|
||||||
|
output.writeUTF(info.callsign); output.writeInt(info.dxcc); output.writeUTF(info.serial)
|
||||||
|
output.writeUTF(info.expires); output.writeUTF(info.firstQsoDate); output.writeUTF(info.lastQsoDate)
|
||||||
|
output.writeInt(password.size); output.write(password)
|
||||||
|
output.writeInt(p12.size); output.write(p12)
|
||||||
|
}
|
||||||
|
}.toByteArray()
|
||||||
|
|
||||||
|
private fun readBundle(bytes: ByteArray): StoredCertificate = DataInputStream(bytes.inputStream()).use { input ->
|
||||||
|
val version = input.readInt().also { require(it in 1..2) }
|
||||||
|
val info = LoTWCertificate(input.readUTF(), input.readInt(), input.readUTF(), input.readUTF(), input.readUTF(), input.readUTF())
|
||||||
|
val password = if (version >= 2) {
|
||||||
|
val size = input.readInt().also { require(it in 0..MAX_CERTIFICATE_PASSWORD_BYTES) }
|
||||||
|
ByteArray(size).also { input.readFully(it) }
|
||||||
|
} else null
|
||||||
|
val p12Size = input.readInt().also { require(it in 1..MAX_CERTIFICATE_BYTES) }
|
||||||
|
StoredCertificate(info, ByteArray(p12Size).also { input.readFully(it) }, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodePassword(password: CharArray): ByteArray {
|
||||||
|
val buffer = Charsets.UTF_8.encode(CharBuffer.wrap(password))
|
||||||
|
return ByteArray(buffer.remaining()).also(buffer::get)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodePassword(password: ByteArray): CharArray {
|
||||||
|
val buffer = Charsets.UTF_8.decode(ByteBuffer.wrap(password))
|
||||||
|
return CharArray(buffer.remaining()).also(buffer::get)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LoTWStation.normalized() = LoTWStation(
|
||||||
|
grid.trim().uppercase(Locale.US),
|
||||||
|
cqZone.trim(),
|
||||||
|
ituZone.trim(),
|
||||||
|
region.trim().uppercase(Locale.US),
|
||||||
|
county.trim().uppercase(Locale.US),
|
||||||
|
iota.trim().uppercase(Locale.US)
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val MAX_CERTIFICATE_PASSWORD_BYTES = 16 * 1024
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
/*
|
||||||
|
* 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.data.lotw
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.security.KeyFactory
|
||||||
|
import java.security.PrivateKey
|
||||||
|
import java.security.cert.CertificateFactory
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.security.spec.PKCS8EncodedKeySpec
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.SecretKeyFactory
|
||||||
|
import javax.crypto.spec.IvParameterSpec
|
||||||
|
import javax.crypto.spec.PBEKeySpec
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal PKCS#12 reader for the PBES2/AES-CBC format that OpenSSL 3 and modern
|
||||||
|
* TQSL produce by default. Android's legacy bundled Bouncy Castle PKCS12 parser
|
||||||
|
* cannot handle PBES2, so we parse the DER structure ourselves and decrypt with
|
||||||
|
* the platform JCE (PBKDF2WithHmacSHA1/256 + AES/CBC), which ships on Android.
|
||||||
|
*
|
||||||
|
* Handles both layouts:
|
||||||
|
* - OpenSSL `-certpbe NONE`: plaintext certificate bags + a PBES2-shrouded key
|
||||||
|
* inside a plaintext `data` ContentInfo.
|
||||||
|
* - Modern TQSL: the certificate SafeContents wrapped in an `encryptedData`
|
||||||
|
* ContentInfo (PBES2), plus the PBES2-shrouded key in a plaintext `data`
|
||||||
|
* ContentInfo.
|
||||||
|
*/
|
||||||
|
internal object Pkcs12Reader {
|
||||||
|
|
||||||
|
private val OID_DATA = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x07, 0x01)
|
||||||
|
private val OID_ENCRYPTED_DATA = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x07, 0x06)
|
||||||
|
private val OID_PKCS8_SHROUDED_KEY_BAG = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x0c, 0x0a, 0x01, 0x02)
|
||||||
|
private val OID_CERT_BAG = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x0c, 0x0a, 0x01, 0x03)
|
||||||
|
private val OID_X509_CERT = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x09, 0x16, 0x01)
|
||||||
|
private val OID_PBES2 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x05, 0x0d)
|
||||||
|
private val OID_PBKDF2 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x01, 0x05, 0x0c)
|
||||||
|
private val OID_HMAC_SHA1 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x07)
|
||||||
|
private val OID_HMAC_SHA256 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x09)
|
||||||
|
private val OID_HMAC_SHA384 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x0a)
|
||||||
|
private val OID_HMAC_SHA512 = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x02, 0x0b)
|
||||||
|
private val OID_AES_256_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x2a)
|
||||||
|
private val OID_AES_192_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x16)
|
||||||
|
private val OID_AES_128_CBC = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x02)
|
||||||
|
private val OID_DES_EDE3_CBC = byteArrayOf(0x2a, 0x86.toByte(), 0x48, 0x86.toByte(), 0xf7.toByte(), 0x0d, 0x03, 0x07)
|
||||||
|
|
||||||
|
fun read(bytes: ByteArray, password: CharArray): Pair<PrivateKey, X509Certificate> {
|
||||||
|
val pfx = DerReader.read(bytes)
|
||||||
|
require(pfx.tag == 0x30) { "not a PFX" }
|
||||||
|
val pfxChildren = DerReader.children(pfx.content)
|
||||||
|
require(pfxChildren.size >= 2) { "PFX too small" }
|
||||||
|
// authSafe ContentInfo
|
||||||
|
val authSafeCi = DerReader.children(pfxChildren[1].content)
|
||||||
|
require(authSafeCi.size >= 2) { "authSafe malformed" }
|
||||||
|
// content: [0] EXPLICIT OCTET STRING -> AuthenticatedSafe
|
||||||
|
val explicit = DerReader.children(authSafeCi[1].content).first()
|
||||||
|
val octet = DerReader.read(explicit.content)
|
||||||
|
// octet.content is the AuthenticatedSafe body: a sequence of ContentInfo.
|
||||||
|
var privateKey: PrivateKey? = null
|
||||||
|
val certs = mutableListOf<X509Certificate>()
|
||||||
|
for (info in DerReader.children(octet.content)) {
|
||||||
|
val parts = DerReader.children(info.content)
|
||||||
|
if (parts.isEmpty()) continue
|
||||||
|
when {
|
||||||
|
parts[0].content.contentEquals(OID_DATA) ->
|
||||||
|
parseSafeContentsContent(parts, password, certs) { privateKey = it }
|
||||||
|
parts[0].content.contentEquals(OID_ENCRYPTED_DATA) ->
|
||||||
|
parseEncryptedData(parts, password, certs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val key = privateKey ?: error("no private key bag found")
|
||||||
|
require(certs.isNotEmpty()) { "no certificate bag found" }
|
||||||
|
return key to certs[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A plaintext ContentInfo: [0] EXPLICIT OCTET STRING -> full SafeContents DER. */
|
||||||
|
private fun parseSafeContentsContent(
|
||||||
|
parts: List<Der>,
|
||||||
|
password: CharArray,
|
||||||
|
certs: MutableList<X509Certificate>,
|
||||||
|
onKey: (PrivateKey) -> Unit
|
||||||
|
) {
|
||||||
|
val explicit = DerReader.children(parts[1].content).first()
|
||||||
|
parseSafeBags(explicit.content, password, certs, onKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** EncryptedData ContentInfo: version, EncryptedContentInfo{ oid, PBES2 alg, [0] IMPLICIT OCTET }. */
|
||||||
|
private fun parseEncryptedData(
|
||||||
|
parts: List<Der>,
|
||||||
|
password: CharArray,
|
||||||
|
certs: MutableList<X509Certificate>
|
||||||
|
) {
|
||||||
|
// parts[0] = encryptedData OID; parts[1] = [0] EXPLICIT EncryptedData SEQ
|
||||||
|
val explicit = DerReader.children(parts[1].content).first()
|
||||||
|
val eciParts = DerReader.children(explicit.content)
|
||||||
|
// eciParts = [version INT, EncryptedContentInfo SEQ]
|
||||||
|
require(eciParts.size >= 2) { "EncryptedData malformed" }
|
||||||
|
val eci = DerReader.children(eciParts[1].content)
|
||||||
|
// eci = [contentType OID, contentEncryptionAlgorithm, [0] IMPLICIT OCTET STRING]
|
||||||
|
require(eci.size >= 3) { "EncryptedContentInfo malformed" }
|
||||||
|
val alg = eci[1]
|
||||||
|
val ciphertext = eci[2].content // [0] IMPLICIT OCTET STRING -> raw bytes
|
||||||
|
val safeContents = decryptPbes2(alg, ciphertext, password)
|
||||||
|
// The decrypted SafeContents holds certificate bags.
|
||||||
|
parseSafeBags(safeContents, password, certs) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decrypt a PBES2-encrypted blob given its AlgorithmIdentifier. */
|
||||||
|
private fun decryptPbes2(algorithm: Der, encrypted: ByteArray, password: CharArray): ByteArray {
|
||||||
|
val algParts = DerReader.children(algorithm.content)
|
||||||
|
require(algParts.size >= 2 && algParts[0].content.contentEquals(OID_PBES2)) { "not PBES2" }
|
||||||
|
// PBES2-params ::= SEQUENCE { kdf AlgorithmIdentifier, enc AlgorithmIdentifier }
|
||||||
|
val pbes2 = DerReader.children(algParts[1].content)
|
||||||
|
val kdf = DerReader.children(pbes2[0].content)
|
||||||
|
// KDF must be PBKDF2 (OpenSSL 3 also supports scrypt — not available on the
|
||||||
|
// platform JCE, so report it by name instead of a generic failure).
|
||||||
|
if (kdf.isEmpty() || !kdf[0].content.contentEquals(OID_PBKDF2)) {
|
||||||
|
error(oidName(kdf.firstOrNull()?.content))
|
||||||
|
}
|
||||||
|
val kdfParams = DerReader.children(kdf[1].content)
|
||||||
|
val salt = kdfParams[0].content
|
||||||
|
val iterations = readInt(kdfParams[1].content)
|
||||||
|
require(iterations in 1..10_000_000) { "implausible PBKDF2 iteration count" }
|
||||||
|
// Optional PBKDF2-params elements: keyLength (INTEGER) and/or prf (SEQUENCE),
|
||||||
|
// in either order. Distinguish by DER tag — mistaking prf for keyLength yields
|
||||||
|
// an absurd key size and a PBKDF2 that runs for hours.
|
||||||
|
var keyBits = -1 // -1 = not written; defaulted below from the cipher
|
||||||
|
var prfName = "PBKDF2WithHmacSHA1"
|
||||||
|
for (i in 2 until kdfParams.size) {
|
||||||
|
val param = kdfParams[i]
|
||||||
|
when (param.tag) {
|
||||||
|
0x02 -> keyBits = readInt(param.content) * 8
|
||||||
|
0x30 -> {
|
||||||
|
val prf = DerReader.children(param.content)
|
||||||
|
prfName = when {
|
||||||
|
prf.isEmpty() || prf[0].content.contentEquals(OID_HMAC_SHA1) -> "PBKDF2WithHmacSHA1"
|
||||||
|
prf[0].content.contentEquals(OID_HMAC_SHA256) -> "PBKDF2WithHmacSHA256"
|
||||||
|
prf[0].content.contentEquals(OID_HMAC_SHA384) -> "PBKDF2WithHmacSHA384"
|
||||||
|
prf[0].content.contentEquals(OID_HMAC_SHA512) -> "PBKDF2WithHmacSHA512"
|
||||||
|
else -> error(oidName(prf[0].content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val enc = DerReader.children(pbes2[1].content)
|
||||||
|
val encOid = enc[0].content
|
||||||
|
val iv = enc[1].content
|
||||||
|
val (cipherName, aesKeyBits) = when {
|
||||||
|
encOid.contentEquals(OID_AES_256_CBC) -> "AES/CBC/PKCS5Padding" to 256
|
||||||
|
encOid.contentEquals(OID_AES_192_CBC) -> "AES/CBC/PKCS5Padding" to 192
|
||||||
|
encOid.contentEquals(OID_AES_128_CBC) -> "AES/CBC/PKCS5Padding" to 128
|
||||||
|
encOid.contentEquals(OID_DES_EDE3_CBC) -> "DESede/CBC/PKCS5Padding" to 192
|
||||||
|
else -> error(oidName(encOid))
|
||||||
|
}
|
||||||
|
val finalKeyBits = if (keyBits > 0) keyBits else aesKeyBits
|
||||||
|
require(finalKeyBits in 128..512) { "implausible PBKDF2 key size" }
|
||||||
|
|
||||||
|
val spec = PBEKeySpec(password, salt, iterations, finalKeyBits)
|
||||||
|
val secretKey = try {
|
||||||
|
SecretKeyFactory.getInstance(prfName).generateSecret(spec)
|
||||||
|
} catch (e: java.security.NoSuchAlgorithmException) {
|
||||||
|
// e.g. PBKDF2WithHmacSHA512 needs API 26+; surface the real reason.
|
||||||
|
error(prfName.replace("PBKDF2WithHmac", "PBKDF2-HMAC-") + " (needs Android 8.0+)")
|
||||||
|
}
|
||||||
|
val cipher = Cipher.getInstance(cipherName)
|
||||||
|
cipher.init(
|
||||||
|
Cipher.DECRYPT_MODE,
|
||||||
|
if (cipherName == "AES/CBC/PKCS5Padding") SecretKeySpec(secretKey.encoded, "AES")
|
||||||
|
else SecretKeySpec(secretKey.encoded, "DESede"),
|
||||||
|
IvParameterSpec(iv)
|
||||||
|
)
|
||||||
|
return cipher.doFinal(encrypted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human-readable name for a known algorithm OID. */
|
||||||
|
private fun oidName(oid: ByteArray?): String = when {
|
||||||
|
oid == null -> "unknown algorithm"
|
||||||
|
oid.contentEquals(OID_PBKDF2) -> "PBKDF2"
|
||||||
|
oid.contentEquals(OID_HMAC_SHA1) -> "PBKDF2-HMAC-SHA1"
|
||||||
|
oid.contentEquals(OID_HMAC_SHA256) -> "PBKDF2-HMAC-SHA256"
|
||||||
|
oid.contentEquals(OID_HMAC_SHA384) -> "PBKDF2-HMAC-SHA384"
|
||||||
|
oid.contentEquals(OID_HMAC_SHA512) -> "PBKDF2-HMAC-SHA512"
|
||||||
|
oid.contentEquals(OID_AES_256_CBC) -> "AES-256-CBC"
|
||||||
|
oid.contentEquals(OID_AES_192_CBC) -> "AES-192-CBC"
|
||||||
|
oid.contentEquals(OID_AES_128_CBC) -> "AES-128-CBC"
|
||||||
|
oid.contentEquals(OID_DES_EDE3_CBC) -> "DES-EDE3-CBC"
|
||||||
|
else -> "unknown algorithm"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSafeBags(
|
||||||
|
safeContentsDer: ByteArray,
|
||||||
|
password: CharArray,
|
||||||
|
certs: MutableList<X509Certificate>,
|
||||||
|
onKey: (PrivateKey) -> Unit
|
||||||
|
) {
|
||||||
|
val safeContents = DerReader.read(safeContentsDer)
|
||||||
|
require(safeContents.tag == 0x30) { "SafeContents malformed" }
|
||||||
|
for (bag in DerReader.children(safeContents.content)) {
|
||||||
|
val bagParts = DerReader.children(bag.content)
|
||||||
|
if (bagParts.size < 2) continue
|
||||||
|
val bagOid = bagParts[0].content
|
||||||
|
val bagValue = DerReader.children(bagParts[1].content).first()
|
||||||
|
when {
|
||||||
|
bagOid.contentEquals(OID_PKCS8_SHROUDED_KEY_BAG) ->
|
||||||
|
onKey(decryptShroudedKeyBag(bagValue, password))
|
||||||
|
bagOid.contentEquals(OID_CERT_BAG) -> parseCertBag(bagValue, certs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decryptShroudedKeyBag(bagValue: Der, password: CharArray): PrivateKey {
|
||||||
|
// bagValue = the SafeBag value SEQ (EncryptedPrivateKeyInfo): children are
|
||||||
|
// [AlgorithmIdentifier, encryptedData OCTET STRING].
|
||||||
|
val parts = DerReader.children(bagValue.content)
|
||||||
|
require(parts.size == 2) { "EncryptedPrivateKeyInfo malformed" }
|
||||||
|
val pkcs8 = decryptPbes2(parts[0], parts[1].content, password)
|
||||||
|
return KeyFactory.getInstance("RSA").generatePrivate(PKCS8EncodedKeySpec(pkcs8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseCertBag(bagValue: Der, certs: MutableList<X509Certificate>) {
|
||||||
|
// bagValue = the SafeBag value SEQ (CertBag): children are
|
||||||
|
// [certType OID, [0] EXPLICIT OCTET STRING (full X.509 DER)].
|
||||||
|
val parts = DerReader.children(bagValue.content)
|
||||||
|
if (parts.size < 2) return
|
||||||
|
if (!parts[0].content.contentEquals(OID_X509_CERT)) return
|
||||||
|
// [0] EXPLICIT -> OCTET STRING -> full X.509 certificate DER.
|
||||||
|
val explicit = DerReader.children(parts[1].content).first()
|
||||||
|
val certDer = explicit.content
|
||||||
|
val factory = CertificateFactory.getInstance("X.509")
|
||||||
|
certs.add(factory.generateCertificate(ByteArrayInputStream(certDer)) as X509Certificate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readInt(bytes: ByteArray): Int {
|
||||||
|
var value = 0
|
||||||
|
var start = 0
|
||||||
|
if (bytes.size > 1 && bytes[0].toInt() == 0x00) start = 1 // strip leading zero for positive
|
||||||
|
for (i in start until bytes.size) value = (value shl 8) or (bytes[i].toInt() and 0xff)
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal DER element parser. */
|
||||||
|
private data class Der(val tag: Int, val content: ByteArray)
|
||||||
|
|
||||||
|
private object DerReader {
|
||||||
|
fun read(der: ByteArray, offset: Int = 0): Der {
|
||||||
|
require(offset < der.size) { "DER truncated" }
|
||||||
|
val tag = der[offset].toInt() and 0xff
|
||||||
|
var i = offset + 1
|
||||||
|
var len = der[i].toInt() and 0xff
|
||||||
|
i++
|
||||||
|
if (len and 0x80 != 0) {
|
||||||
|
val numBytes = len and 0x7f
|
||||||
|
len = 0
|
||||||
|
repeat(numBytes) {
|
||||||
|
len = (len shl 8) or (der[i].toInt() and 0xff)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require(i + len <= der.size) { "DER length overflow" }
|
||||||
|
return Der(tag, der.copyOfRange(i, i + len))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun children(content: ByteArray): List<Der> {
|
||||||
|
val out = mutableListOf<Der>()
|
||||||
|
var off = 0
|
||||||
|
while (off < content.size) {
|
||||||
|
val d = read(content, off)
|
||||||
|
out.add(d)
|
||||||
|
val step = headerSize(content, off) + d.content.size
|
||||||
|
if (step <= 0) break // defensive: never spin on malformed input
|
||||||
|
off += step
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun headerSize(der: ByteArray, offset: Int): Int {
|
||||||
|
var i = offset + 1
|
||||||
|
var len = der[i].toInt() and 0xff
|
||||||
|
i++
|
||||||
|
if (len and 0x80 != 0) i += len and 0x7f
|
||||||
|
return i - offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+276
-9
@@ -1,21 +1,288 @@
|
|||||||
package com.rtbishop.look4sat.core.data.repository
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.data.source.AmSatParser
|
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
|
||||||
|
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.model.SatStatusPage
|
||||||
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
import com.rtbishop.look4sat.core.domain.repository.IAmSatRepository
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
/** AMSAT 状态仓库: 抓 HTML → 解析 → SatStatusPage */
|
/** One report from the AMSAT API (data layer model). */
|
||||||
class AmSatRepository(private val remoteSource: IRemoteSource) : IAmSatRepository {
|
private data class ApiReport(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val callsign: String,
|
||||||
|
val report: String,
|
||||||
|
val gridSquare: String,
|
||||||
|
val reportedTimeUtcSec: Long
|
||||||
|
)
|
||||||
|
|
||||||
override suspend fun fetchStatus(): SatStatusPage? {
|
/** AMSAT status repository using RemoteSource (Clean Architecture: data layer handles HTTP). */
|
||||||
val html = remoteSource.getStatusHtml() ?: return null
|
class AmSatRepository(
|
||||||
if (html.isBlank()) return null
|
private val remoteSource: IRemoteSource,
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
) : IAmSatRepository {
|
||||||
|
|
||||||
|
private val statusCacheMutex = Mutex()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var statusCache: SatStatusPage? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var statusFetchInFlight: Deferred<SatStatusPage?>? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var cacheGeneration = 0
|
||||||
|
|
||||||
|
private val isoUtcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||||
|
timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCachedStatus(): SatStatusPage? = statusCache
|
||||||
|
|
||||||
|
override suspend fun fetchStatus(forceRefresh: Boolean): SatStatusPage? {
|
||||||
|
if (!forceRefresh) statusCache?.let { return it }
|
||||||
|
|
||||||
|
var cachedPage: SatStatusPage? = null
|
||||||
|
val inFlightFetch = statusCacheMutex.withLock {
|
||||||
|
if (!forceRefresh) {
|
||||||
|
val cached = statusCache
|
||||||
|
if (cached != null) {
|
||||||
|
cachedPage = cached
|
||||||
|
return@withLock null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusFetchInFlight ?: scope.async(Dispatchers.IO) {
|
||||||
|
fetchStatusFromRemote(cacheGeneration)
|
||||||
|
}.also { statusFetchInFlight = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedPage?.let { return it }
|
||||||
|
val fetch = inFlightFetch ?: return null
|
||||||
return try {
|
return try {
|
||||||
AmSatParser.parse(html, System.currentTimeMillis())
|
fetch.await()
|
||||||
} catch (exception: Exception) {
|
} finally {
|
||||||
println("AmSatRepository parse exception: $exception")
|
statusCacheMutex.withLock {
|
||||||
|
if (statusFetchInFlight === fetch && fetch.isCompleted) statusFetchInFlight = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchStatusFromRemote(generation: Int): SatStatusPage? {
|
||||||
|
val nowSec = System.currentTimeMillis() / 1000
|
||||||
|
val catalogJson = remoteSource.getAmSatCatalog() ?: return null
|
||||||
|
// 72h = 3 days; API hard cap is limit=500 regardless of what we send.
|
||||||
|
// 500 records across ~100 catalog satellites ≈ ~1-5 reports/satellite/day — enough for 3 days.
|
||||||
|
// Upgrade path: paginate or request AMSAT to raise the cap if catalog grows beyond ~200 sats.
|
||||||
|
val reportsJson = remoteSource.getAmSatReports(hours = 72, limit = 500) ?: return null
|
||||||
|
|
||||||
|
val names = parseCatalog(catalogJson)
|
||||||
|
val reports = parseReports(reportsJson)
|
||||||
|
|
||||||
|
if (names.isEmpty() && reports.isEmpty()) return null
|
||||||
|
|
||||||
|
val statuses = buildStatuses(names, reports, nowSec)
|
||||||
|
val reportMap = reports.associate { it.id to toSatReport(it) }
|
||||||
|
return SatStatusPage(System.currentTimeMillis(), statuses, reportMap).also { page ->
|
||||||
|
if (generation == cacheGeneration) statusCache = page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun prefetchStatus() {
|
||||||
|
fetchStatus(forceRefresh = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clearStatusCache() {
|
||||||
|
cacheGeneration += 1
|
||||||
|
statusCache = null
|
||||||
|
statusFetchInFlight = null
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult = withContext(Dispatchers.IO) {
|
||||||
|
val payload = JSONObject().apply {
|
||||||
|
put("name", submission.name)
|
||||||
|
put("report", submission.report)
|
||||||
|
put("callsign", submission.callsign)
|
||||||
|
put("reported_at", isoUtcFormat.format(Date(submission.reportedAtUtcMillis)))
|
||||||
|
if (submission.gridSquare.isNotBlank()) put("grid_square", submission.gridSquare)
|
||||||
|
}
|
||||||
|
val response = remoteSource.submitAmSatReport(payload.toString())
|
||||||
|
?: return@withContext AmSatReportSubmitResult(success = false, message = "Network request failed")
|
||||||
|
val (code, body) = response
|
||||||
|
val errorMessage = parseSubmitError(body)
|
||||||
|
return@withContext if (code in 200..299 && errorMessage.isBlank()) {
|
||||||
|
AmSatReportSubmitResult(success = true, reportId = parseSubmitReportId(body))
|
||||||
|
} else {
|
||||||
|
AmSatReportSubmitResult(
|
||||||
|
success = false,
|
||||||
|
message = errorMessage.ifBlank { "HTTP $code" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSubmitError(json: String): String {
|
||||||
|
return try {
|
||||||
|
JSONObject(json).optJSONObject("error")?.optString("message").orEmpty()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSubmitReportId(json: String): String? {
|
||||||
|
return try {
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
obj.optJSONObject("data")?.optString("id")?.takeIf { it.isNotBlank() }
|
||||||
|
?: obj.optString("id").takeIf { it.isNotBlank() }
|
||||||
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parse catalog JSON to list of satellite names */
|
||||||
|
private fun parseCatalog(json: String): List<String> {
|
||||||
|
return try {
|
||||||
|
val arr = JSONObject(json).getJSONArray("data")
|
||||||
|
(0 until arr.length()).map { arr.getJSONObject(it).getString("name") }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse reports JSON to list of ApiReport domain objects */
|
||||||
|
private fun parseReports(json: String): List<ApiReport> {
|
||||||
|
return try {
|
||||||
|
val arr = JSONObject(json).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 (_: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse ISO 8601 UTC timestamp to epoch seconds (e.g., "2026-08-05T07:30:00Z") */
|
||||||
|
private fun parseIsoUtcSec(iso: String): Long {
|
||||||
|
return try {
|
||||||
|
(isoUtcFormat.parse(iso)?.time ?: 0L) / 1000
|
||||||
|
} catch (_: Exception) {
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build one SatStatus (3 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 utc = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||||
|
val labels = (0 until 3).map { d ->
|
||||||
|
utc.timeInMillis = (nowSec - d * 86400L) * 1000
|
||||||
|
formatDayLabel(utc)
|
||||||
|
}
|
||||||
|
return names.map { name ->
|
||||||
|
val slots = (0 until 36).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 = NO_REPORT_GRAY, 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 3).map { d ->
|
||||||
|
// 当天最近连续相同状态报告数:从最新一条往回数,遇状态不同即停。
|
||||||
|
// 空时段(无报告)不打断;按状态文本严格比较,不跨天。
|
||||||
|
val dayStart = nowSec - (d + 1) * 86400L
|
||||||
|
val dayEnd = nowSec - d * 86400L
|
||||||
|
val dayReports = byName[name].orEmpty()
|
||||||
|
.filter { it.reportedTimeUtcSec in dayStart until dayEnd }
|
||||||
|
.sortedByDescending { it.reportedTimeUtcSec }
|
||||||
|
val streakCount = if (dayReports.isEmpty()) 0 else {
|
||||||
|
val newestStatus = dayReports.first().report
|
||||||
|
dayReports.takeWhile { it.report == newestStatus }.size
|
||||||
|
}
|
||||||
|
SatDay(
|
||||||
|
dateLabel = labels[d],
|
||||||
|
slots = slots.subList(d * 12, (d + 1) * 12),
|
||||||
|
streakCount = streakCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SatStatus(name = name, days = days)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDayLabel(calendar: Calendar): String {
|
||||||
|
return if (Locale.getDefault().language == Locale.CHINESE.language) {
|
||||||
|
"${calendar.get(Calendar.MONTH) + 1}月${calendar.get(Calendar.DAY_OF_MONTH)}日"
|
||||||
|
} else {
|
||||||
|
val monthAbbr = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||||
|
"${monthAbbr[calendar.get(Calendar.MONTH)]} ${calendar.get(Calendar.DAY_OF_MONTH)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map status text to color value (for UI rendering). */
|
||||||
|
private fun statusColorOf(report: String): Long = when (report.lowercase()) {
|
||||||
|
"heard", "crew active" -> ACTIVE_BLUE
|
||||||
|
"telemetry only" -> TLM_ORANGE
|
||||||
|
"not heard" -> NOT_HEARD_PINK
|
||||||
|
else -> CONFLICT_DEEP_ORANGE
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// AMSAT official status colors (from amsat.org/status)
|
||||||
|
private const val ACTIVE_BLUE = 0xFF648FFF
|
||||||
|
private const val TLM_ORANGE = 0xFFFFB000
|
||||||
|
private const val NOT_HEARD_PINK = 0xFFDC267F
|
||||||
|
private const val CONFLICT_DEEP_ORANGE = 0xFFFE6100
|
||||||
|
private const val NO_REPORT_GRAY = 0xFFC0C0C0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+51
-33
@@ -23,6 +23,7 @@ import com.rtbishop.look4sat.core.domain.repository.IDatabaseRepo
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||||
import com.rtbishop.look4sat.core.domain.source.Sources
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -58,7 +59,7 @@ class DatabaseRepo(
|
|||||||
var importedCount = 0
|
var importedCount = 0
|
||||||
remoteSource.getFileStream(uri)?.let { stream ->
|
remoteSource.getFileStream(uri)?.let { stream ->
|
||||||
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
val transceivers = dataParser.parseJSONStream(unwrapIfZipped(uri, stream))
|
||||||
localSource.insertRadios(transceivers)
|
localSource.insertRadios(transceivers, isCustom = true)
|
||||||
importedCount = transceivers.size
|
importedCount = transceivers.size
|
||||||
}
|
}
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
@@ -66,41 +67,55 @@ class DatabaseRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
override suspend fun updateFromRemote() = withContext(dispatcher) {
|
||||||
val dataSourcesSettings = settingsRepo.dataSourcesSettings.value
|
val settings = settingsRepo.dataSourcesSettings.value
|
||||||
val tleUrls = buildMap {
|
// Keep the raw URL as the status key (matches what the settings UI
|
||||||
putAll(Sources.satelliteDataUrls)
|
// displays) while requesting with the normalized URL.
|
||||||
// 开关开且 URL 非空 -> All 用自定义 URL;否则用默认 URL(在线更新默认源)
|
val tleUrls = settings.satelliteUrls
|
||||||
put("All", if (dataSourcesSettings.useCustomTLE && dataSourcesSettings.tleUrl.isNotBlank())
|
.filterIndexed { i, url -> url.isNotBlank() && settings.isSatelliteEnabled(i) }
|
||||||
dataSourcesSettings.tleUrl else Sources.defaultTleUrl)
|
.map { it to normalizeUrl(it) }
|
||||||
}.filterValues { it.isNotBlank() }
|
.distinctBy { it.second }
|
||||||
val radioUrls = buildMap {
|
val radioUrls = settings.transceiversUrls
|
||||||
putAll(Sources.transceiversDataUrls)
|
.filterIndexed { i, url -> url.isNotBlank() && settings.isTransceiverEnabled(i) }
|
||||||
put("SatNOGS", if (dataSourcesSettings.useCustomTransceivers && dataSourcesSettings.transceiversUrl.isNotBlank())
|
.map { it to normalizeUrl(it) }
|
||||||
dataSourcesSettings.transceiversUrl else Sources.defaultTransceiversUrl)
|
.distinctBy { it.second }
|
||||||
}.filterValues { it.isNotBlank() }
|
val builtinTypesByUrl = Sources.satelliteDataUrls
|
||||||
// launch all network requests concurrently
|
.filterValues { it.isNotBlank() }
|
||||||
val tleJobs = tleUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
.mapValues { normalizeUrl(it.value) }
|
||||||
val radioJobs = radioUrls.values.map { url -> async { url to remoteSource.getNetworkStream(url) } }
|
.entries
|
||||||
// 统计成功源数: 0 成功视为更新失败(不刷时间戳, 抛异常让 UI 提示)
|
.associate { (type, url) -> url to type }
|
||||||
|
val importedTypeIds = mutableMapOf<String, MutableList<Int>>()
|
||||||
|
// launch all network requests concurrently, keeping the raw url as key for status reporting
|
||||||
|
val tleJobs = tleUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } }
|
||||||
|
val radioJobs = radioUrls.map { (raw, norm) -> async { raw to remoteSource.getNetworkStream(norm) } }
|
||||||
val tleResults = tleJobs.awaitAll()
|
val tleResults = tleJobs.awaitAll()
|
||||||
val radioResults = radioJobs.awaitAll()
|
val radioResults = radioJobs.awaitAll()
|
||||||
val successCount = tleResults.count { it.second != null } + radioResults.count { it.second != null }
|
// report the HTTP status code of every source (200, 404, ...)
|
||||||
if (successCount == 0) {
|
settingsRepo.updateDataSourcesStatus(
|
||||||
throw java.io.IOException("All data sources failed to download")
|
(tleResults + radioResults).associate { (url, result) -> url to result.code }
|
||||||
|
)
|
||||||
|
// parse fetched data concurrently and associate known built-in URLs with existing type filters.
|
||||||
|
val importedEntries = tleResults.flatMap { (rawUrl, result) ->
|
||||||
|
val normUrl = normalizeUrl(rawUrl)
|
||||||
|
val entries = result.stream?.let { parseSatelliteStream(normUrl, unwrapIfZipped(normUrl, it)) }.orEmpty()
|
||||||
|
val type = builtinTypesByUrl[normUrl] ?: customSourceType
|
||||||
|
importedTypeIds.getOrPut(type) { mutableListOf() }.addAll(entries.map { it.catnum })
|
||||||
|
entries
|
||||||
|
}.distinctBy { it.catnum }
|
||||||
|
importedTypeIds.forEach { (type, ids) -> settingsRepo.setSatelliteTypeIds(type, ids.distinct()) }
|
||||||
|
val importedRadios = radioResults.flatMap { (rawUrl, result) ->
|
||||||
|
val normUrl = normalizeUrl(rawUrl)
|
||||||
|
result.stream?.let { dataParser.parseJSONStream(unwrapIfZipped(normUrl, it)) }.orEmpty()
|
||||||
|
}.filter { it.uuid.isNotBlank() }.distinctBy { it.uuid }
|
||||||
|
// insert parsed data into the database.
|
||||||
|
// Transceivers are a full snapshot: sources publish active entries only, so a retired
|
||||||
|
// transceiver simply disappears from the feed and has to be dropped locally as well.
|
||||||
|
// Manually imported ones (isCustom) are kept: no source can refresh them, so nothing
|
||||||
|
// would bring them back if they were replaced here.
|
||||||
|
if (importedRadios.isNotEmpty()) {
|
||||||
|
localSource.deleteManagedRadios()
|
||||||
|
localSource.insertRadios(importedRadios)
|
||||||
}
|
}
|
||||||
// parse fetched data concurrently and associate with types
|
if (importedEntries.isNotEmpty()) localSource.insertEntries(importedEntries)
|
||||||
val importedEntries = tleResults.flatMap { (url, stream) ->
|
|
||||||
val type = tleUrls.entries.find { it.value == url }?.key ?: customSourceType
|
|
||||||
stream?.let { parseSatelliteStream(url, unwrapIfZipped(url, it)) }.orEmpty().also { entries ->
|
|
||||||
settingsRepo.setSatelliteTypeIds(type, entries.map { it.catnum })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val importedRadios = radioResults.flatMap { (url, stream) ->
|
|
||||||
stream?.let { dataParser.parseJSONStream(unwrapIfZipped(url, it)) }.orEmpty()
|
|
||||||
}
|
|
||||||
// insert parsed data into the database
|
|
||||||
localSource.insertEntries(importedEntries)
|
|
||||||
localSource.insertRadios(importedRadios)
|
|
||||||
setUpdateSuccessful(System.currentTimeMillis())
|
setUpdateSuccessful(System.currentTimeMillis())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +125,9 @@ class DatabaseRepo(
|
|||||||
setUpdateSuccessful(0L)
|
setUpdateSuccessful(0L)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun normalizeUrl(url: String): String =
|
||||||
|
if (url.startsWith("http", ignoreCase = true)) url else "https://$url"
|
||||||
|
|
||||||
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
|
private suspend fun parseSatelliteStream(url: String, stream: InputStream): List<OrbitalData> {
|
||||||
val bufferedStream = stream.buffered()
|
val bufferedStream = stream.buffered()
|
||||||
return when {
|
return when {
|
||||||
|
|||||||
@@ -0,0 +1,522 @@
|
|||||||
|
/*
|
||||||
|
* 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.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWPhase
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWProgress
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ensureActive
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlin.coroutines.coroutineContext
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.io.IOException
|
||||||
|
import javax.net.ssl.SSLException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches confirmed gridsquares directly from ARRL LoTW via the official report endpoint:
|
||||||
|
* GET https://lotw.arrl.org/lotwuser/lotwreport.adi?login=<call>&password=<pwd>
|
||||||
|
* &qso_query=1&qso_qsl=yes&qso_qsldetail=yes&qso_mydetail=yes&qso_qslsince=<date>
|
||||||
|
*
|
||||||
|
* Only QSL_RCVD=Y records are returned by LoTW for qso_qsl=yes, so every grid in the
|
||||||
|
* report is a *confirmed* grid (green on the map). GRIDSQUARE may be a 4- or 6-char
|
||||||
|
* value; VUCC_GRIDS ("EN52en,EN53fa") also yields 4-char fields. All values are
|
||||||
|
* truncated/expanded to the 4-char form used by the map overlay.
|
||||||
|
*
|
||||||
|
* ARRL rate-limits the report endpoint: one download in progress per user id, and
|
||||||
|
* frequent full-report pulls get refused. Failures are reported with an explicit
|
||||||
|
* cause (see LoTWResult) so the UI can tell the user what to do next.
|
||||||
|
*/
|
||||||
|
class LoTWRepository : ILoTWRepository {
|
||||||
|
|
||||||
|
override suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
fetchReportBody(callsign, password, since = "", onProgress = {}).fold(
|
||||||
|
onSuccess = { body ->
|
||||||
|
parseBoth(body)?.let { (grids, _, roamed) ->
|
||||||
|
LoTWResult.Success(grids, emptyMap(), roamed)
|
||||||
|
} ?: LoTWResult.RateLimited
|
||||||
|
},
|
||||||
|
onFailure = { toResult(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun fetchConfirmedGridQsos(
|
||||||
|
callsign: String,
|
||||||
|
password: String,
|
||||||
|
since: String,
|
||||||
|
onProgress: (LoTWProgress) -> Unit
|
||||||
|
): LoTWResult = withContext(Dispatchers.IO) {
|
||||||
|
fetchReportBody(callsign, password, since, onProgress).fold(
|
||||||
|
onSuccess = { body ->
|
||||||
|
parseBoth(body)?.let { (grids, qsos, roamed) ->
|
||||||
|
LoTWResult.Success(grids, qsos, roamed)
|
||||||
|
} ?: LoTWResult.RateLimited
|
||||||
|
},
|
||||||
|
onFailure = { toResult(it) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun toResult(e: Throwable): LoTWResult = when (e) {
|
||||||
|
is CredentialsException -> LoTWResult.BadCredentials
|
||||||
|
is RateLimitException -> LoTWResult.RateLimited
|
||||||
|
is TimeoutException -> LoTWResult.Timeout
|
||||||
|
else -> LoTWResult.NetworkError(e.message ?: e.javaClass.simpleName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single report fetch feeding the grid set, the per-QSO detail and the roamed set. */
|
||||||
|
private fun parseBoth(
|
||||||
|
body: String
|
||||||
|
): Triple<Set<String>, Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>, Set<String>>? {
|
||||||
|
val grids = parseConfirmedGrids(body) ?: return null
|
||||||
|
val qsos = parseConfirmedGridQsos(body) ?: return null
|
||||||
|
val roamed = parseRoamedGrids(body) ?: return null
|
||||||
|
return Triple(grids, qsos, roamed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchReportBody(
|
||||||
|
callsign: String,
|
||||||
|
password: String,
|
||||||
|
since: String,
|
||||||
|
onProgress: (LoTWProgress) -> Unit
|
||||||
|
): Result<String> {
|
||||||
|
val call = callsign.trim().uppercase()
|
||||||
|
val pwd = password.trim()
|
||||||
|
if (call.isBlank() || pwd.isBlank()) return Result.failure(IOException("empty credentials"))
|
||||||
|
// Empty since -> full report. qso_qslsince with an early date forces a
|
||||||
|
// FULL confirmed-QSL report; without it LoTW applies a "system supplied
|
||||||
|
// default" since-date and only returns confirmations newer than the
|
||||||
|
// account's last query — subsequent syncs would be incremental/empty.
|
||||||
|
val effectiveSince = since.ifBlank { "2000-01-01" }
|
||||||
|
val query = buildString {
|
||||||
|
append("login=").append(URLEncoder.encode(call, "UTF-8"))
|
||||||
|
append("&password=").append(URLEncoder.encode(pwd, "UTF-8"))
|
||||||
|
append("&qso_query=1&qso_qsl=yes&qso_qsldetail=yes&qso_mydetail=yes")
|
||||||
|
append("&qso_qslsince=").append(URLEncoder.encode(effectiveSince, "UTF-8"))
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
val connection = URL("$BASE_URL?$query").openConnection() as HttpURLConnection
|
||||||
|
// ARRL can be slow to accept connections from mobile networks
|
||||||
|
// (long TLS handshakes across the Pacific, occasional server-side
|
||||||
|
// queueing — a busy server took 36.5 s to accept a connection in
|
||||||
|
// testing, so 30 s connect would have killed it).
|
||||||
|
//
|
||||||
|
// NOTE: readTimeout is NOT a total-download cap. It is the longest
|
||||||
|
// wait for the NEXT chunk of data (a stall timeout). A full report
|
||||||
|
// for a huge account (tens of thousands of QSLs ≈ tens of MB at
|
||||||
|
// ARRL's ~10 KB/s stream rate) legitimately takes 30+ minutes;
|
||||||
|
// as long as chunks keep arriving it must never be cut off.
|
||||||
|
// 600 s tolerates long server-side pauses while it generates the
|
||||||
|
// report; a truly dead link (no data at all) still fails within it.
|
||||||
|
connection.connectTimeout = 60_000
|
||||||
|
connection.readTimeout = 600_000
|
||||||
|
connection.requestMethod = "GET"
|
||||||
|
connection.setRequestProperty("Accept-Encoding", "gzip")
|
||||||
|
connection.instanceFollowRedirects = true
|
||||||
|
onProgress(LoTWProgress(LoTWPhase.Connecting))
|
||||||
|
val code = connection.responseCode
|
||||||
|
if (code !in 200..299) {
|
||||||
|
connection.disconnect()
|
||||||
|
// Observed in the wild (CQRLOG #2422): LoTW answers a throttled
|
||||||
|
// report pull with HTTP 503 "Page request limit".
|
||||||
|
return if (code == 503 || code == 429) Result.failure(RateLimitException())
|
||||||
|
else Result.failure(IOException("HTTP $code"))
|
||||||
|
}
|
||||||
|
val stream = connection.inputStream
|
||||||
|
val isGzip = "gzip".equals(connection.contentEncoding, ignoreCase = true)
|
||||||
|
val body = readBodyWithProgress(stream, isGzip, onProgress)
|
||||||
|
connection.disconnect()
|
||||||
|
when {
|
||||||
|
// Login failure: HTTP 200 + HTML login page with the error text.
|
||||||
|
body.contains("incorrect", ignoreCase = true) &&
|
||||||
|
body.contains("assword", ignoreCase = true) -> Result.failure(CredentialsException())
|
||||||
|
// Any other non-ADIF body: rate-limit refusal / server error page.
|
||||||
|
!body.contains("<eoh>", ignoreCase = true) -> Result.failure(RateLimitException())
|
||||||
|
else -> Result.success(body)
|
||||||
|
}
|
||||||
|
} catch (e: java.util.concurrent.CancellationException) {
|
||||||
|
// Cancellation must propagate — it is not a network failure.
|
||||||
|
throw e
|
||||||
|
} catch (e: SocketTimeoutException) {
|
||||||
|
Result.failure(TimeoutException(e.message ?: "timed out"))
|
||||||
|
} catch (e: SSLException) {
|
||||||
|
Result.failure(IOException("TLS: ${e.message ?: "handshake failed"}"))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("LoTWRepository fetch failure: $e")
|
||||||
|
Result.failure(IOException(e.message ?: e.javaClass.simpleName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streams the report body into a string while reporting download progress.
|
||||||
|
* Once the header's record count (<APP_LoTW_NUMREC>) is seen, the total
|
||||||
|
* body size is estimated as NUMREC * AVG_RECORD_BYTES so the UI can show a
|
||||||
|
* meaningful progress bar and remaining-time estimate.
|
||||||
|
*/
|
||||||
|
private suspend fun readBodyWithProgress(
|
||||||
|
stream: java.io.InputStream,
|
||||||
|
isGzip: Boolean,
|
||||||
|
onProgress: (LoTWProgress) -> Unit
|
||||||
|
): String {
|
||||||
|
val input = if (isGzip) java.util.zip.GZIPInputStream(stream) else stream
|
||||||
|
val reader = java.io.InputStreamReader(input, Charsets.UTF_8)
|
||||||
|
val sb = StringBuilder()
|
||||||
|
val buf = CharArray(8192)
|
||||||
|
var bytesRead = 0L
|
||||||
|
var expected = 0L
|
||||||
|
var qsoCount = 0L
|
||||||
|
var speed = 0L
|
||||||
|
var headerSeen = false
|
||||||
|
var lastSampleMs = System.currentTimeMillis()
|
||||||
|
var lastSampleBytes = 0L
|
||||||
|
var lastEmitMs = 0L
|
||||||
|
while (true) {
|
||||||
|
// Cooperative cancellation: lets the user abort a full sync that
|
||||||
|
// was started by mistake. Throws CancellationException once the
|
||||||
|
// job is cancelled; the blocking read below is fast while chunks
|
||||||
|
// keep arriving, so the abort lands on the next chunk boundary.
|
||||||
|
coroutineContext.ensureActive()
|
||||||
|
val n = reader.read(buf)
|
||||||
|
if (n <= 0) break
|
||||||
|
sb.append(buf, 0, n)
|
||||||
|
bytesRead += n
|
||||||
|
// The record count sits in the report header, long before the data.
|
||||||
|
if (!headerSeen) {
|
||||||
|
NUMREC_REGEX.find(sb)?.let { m ->
|
||||||
|
qsoCount = m.groupValues[1].toLongOrNull() ?: 0L
|
||||||
|
expected = qsoCount * AVG_RECORD_BYTES
|
||||||
|
headerSeen = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sample speed every 500ms, EMA-smoothed.
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastSampleMs >= 500) {
|
||||||
|
val elapsedS = (now - lastSampleMs) / 1000.0
|
||||||
|
if (elapsedS > 0) {
|
||||||
|
val inst = ((bytesRead - lastSampleBytes) / elapsedS).toLong()
|
||||||
|
speed = if (speed == 0L) inst else (speed * 7 + inst * 3) / 10
|
||||||
|
lastSampleMs = now
|
||||||
|
lastSampleBytes = bytesRead
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Throttle callbacks to ~4/s so StateFlow updates stay cheap.
|
||||||
|
if (now - lastEmitMs >= 250) {
|
||||||
|
onProgress(LoTWProgress(LoTWPhase.Downloading, bytesRead, expected, speed, qsoCount))
|
||||||
|
lastEmitMs = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onProgress(LoTWProgress(LoTWPhase.Downloading, bytesRead, expected, speed, qsoCount))
|
||||||
|
return sb.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class CredentialsException : Exception("bad callsign/password")
|
||||||
|
internal class RateLimitException : Exception("report refused (rate limit / server error)")
|
||||||
|
internal class TimeoutException(message: String) : Exception(message)
|
||||||
|
|
||||||
|
internal fun parseConfirmedGridQsos(
|
||||||
|
body: String
|
||||||
|
): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>? {
|
||||||
|
if (!body.contains("<eoh>", ignoreCase = true)) return null
|
||||||
|
// One ADIF record = fields up to <EOR>. We buffer the fields we care
|
||||||
|
// about, then emit a GridQso on <EOR> when the record is a satellite
|
||||||
|
// QSO (PROP_MODE=SAT) carrying a grid.
|
||||||
|
val result = mutableMapOf<String, MutableList<com.rtbishop.look4sat.core.domain.model.GridQso>>()
|
||||||
|
var propMode: String? = null
|
||||||
|
var call = ""
|
||||||
|
var qsoDate = ""
|
||||||
|
var timeOn = ""
|
||||||
|
var satName = ""
|
||||||
|
var mode = ""
|
||||||
|
var bandUp = ""
|
||||||
|
var bandDown = ""
|
||||||
|
var dxcc: Int? = null
|
||||||
|
var country: String? = null
|
||||||
|
var cqz: Int? = null
|
||||||
|
var state: String? = null
|
||||||
|
// Own-station (台址) fields: MY_GRIDSQUARE + MY_VUCC_GRIDS give the
|
||||||
|
// full grid set of the station location; the remaining MY_* fields
|
||||||
|
// together identify the station location (one callsign can own
|
||||||
|
// several locations, and one location can span several grids).
|
||||||
|
var myGrids = mutableSetOf<String>()
|
||||||
|
var myCallsign: String? = null
|
||||||
|
var myDxcc: String? = null
|
||||||
|
var myState: String? = null
|
||||||
|
var myCq: String? = null
|
||||||
|
var myItu: String? = null
|
||||||
|
var myIota: String? = null
|
||||||
|
var myCountry: String? = null
|
||||||
|
val gridsInRecord = mutableListOf<String>()
|
||||||
|
|
||||||
|
fun emitRecord() {
|
||||||
|
if (propMode != "SAT" || gridsInRecord.isEmpty()) return
|
||||||
|
val epochMs = adifTimestampToEpoch(qsoDate, timeOn)
|
||||||
|
val stationKey = buildString {
|
||||||
|
append(myCallsign ?: "")
|
||||||
|
append('|').append(myDxcc ?: "")
|
||||||
|
append('|').append(myState ?: "")
|
||||||
|
append('|').append(myCq ?: "")
|
||||||
|
append('|').append(myItu ?: "")
|
||||||
|
append('|').append(myIota ?: "")
|
||||||
|
append('|').append(myCountry ?: "")
|
||||||
|
}.ifBlank { null }
|
||||||
|
val qso = com.rtbishop.look4sat.core.domain.model.GridQso(
|
||||||
|
call = call, epochMs = epochMs, satName = satName,
|
||||||
|
mode = mode, bandUp = bandUp, bandDown = bandDown,
|
||||||
|
dxcc = dxcc, country = country, cqz = cqz, state = state,
|
||||||
|
myGrid = myGrids.firstOrNull(),
|
||||||
|
myGrids = myGrids,
|
||||||
|
myCallsign = myCallsign,
|
||||||
|
stationKey = stationKey
|
||||||
|
)
|
||||||
|
for (grid in gridsInRecord) {
|
||||||
|
result.getOrPut(grid) { mutableListOf() }.add(qso)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetRecord() {
|
||||||
|
propMode = null; call = ""; qsoDate = ""; timeOn = ""
|
||||||
|
satName = ""; mode = ""; bandUp = ""; bandDown = ""
|
||||||
|
dxcc = null; country = null; cqz = null; state = null
|
||||||
|
myGrids = mutableSetOf()
|
||||||
|
myCallsign = null; myDxcc = null; myState = null
|
||||||
|
myCq = null; myItu = null; myIota = null; myCountry = null
|
||||||
|
gridsInRecord.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
for (raw in body.lineSequence()) {
|
||||||
|
val line = raw.trim()
|
||||||
|
when {
|
||||||
|
line.equals("<EOR>", ignoreCase = true) -> {
|
||||||
|
emitRecord()
|
||||||
|
resetRecord()
|
||||||
|
}
|
||||||
|
line.startsWith("<PROP_MODE:") ->
|
||||||
|
propMode = adifValue(line).uppercase()
|
||||||
|
line.startsWith("<CALL:") ->
|
||||||
|
call = adifValue(line).uppercase()
|
||||||
|
line.startsWith("<QSO_DATE:") ->
|
||||||
|
qsoDate = adifValue(line)
|
||||||
|
line.startsWith("<TIME_ON:") ->
|
||||||
|
timeOn = adifValue(line)
|
||||||
|
line.startsWith("<SAT_NAME:") ->
|
||||||
|
satName = adifValue(line)
|
||||||
|
line.startsWith("<MODE:") ->
|
||||||
|
mode = adifValue(line)
|
||||||
|
line.startsWith("<BAND_RX:") ->
|
||||||
|
bandUp = adifValue(line).uppercase()
|
||||||
|
line.startsWith("<BAND:") && !line.startsWith("<BAND_RX:") ->
|
||||||
|
bandDown = adifValue(line).uppercase()
|
||||||
|
line.startsWith("<DXCC:") ->
|
||||||
|
dxcc = adifValue(line).toIntOrNull()
|
||||||
|
line.startsWith("<COUNTRY:") ->
|
||||||
|
country = adifValue(line).ifBlank { null }
|
||||||
|
line.startsWith("<CQZ:") ->
|
||||||
|
cqz = adifValue(line).toIntOrNull()
|
||||||
|
line.startsWith("<STATE:") ->
|
||||||
|
state = adifValue(line).trim().ifBlank { null }?.let { normalizeState(it) }
|
||||||
|
line.startsWith("<MY_GRIDSQUARE:") -> {
|
||||||
|
// Own-station grid (must not be confused with GRIDSQUARE —
|
||||||
|
// the opposite station's grid). Recorded per QSO so awards
|
||||||
|
// can be counted per operated grid. A station location may
|
||||||
|
// also carry MY_VUCC_GRIDS (below) for multi-grid roaming.
|
||||||
|
val value = adifValue(line)
|
||||||
|
if (value.length >= 4) myGrids.add(value.take(4).uppercase())
|
||||||
|
}
|
||||||
|
line.startsWith("<MY_VUCC_GRIDS:") -> {
|
||||||
|
// Own-station VUCC grids: a comma-separated list LoTW emits
|
||||||
|
// when one station location spans several grid squares
|
||||||
|
// (e.g. "OM60,OM50" with MY_GRIDSQUARE absent). Without
|
||||||
|
// this field the extra grids of a multi-grid 台址 silently
|
||||||
|
// vanished from both the blue stripes and the per-台址 VUCC
|
||||||
|
// count. Same split/truncate as VUCC_GRIDS below.
|
||||||
|
adifValue(line).split(',').forEach { grid ->
|
||||||
|
val field = grid.trim().uppercase()
|
||||||
|
if (field.length >= 4) myGrids.add(field.take(4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remaining MY_* fields identify the station location (台址):
|
||||||
|
// grouped together they form the stationKey used by the map's
|
||||||
|
// operated-grid selector. One callsign can own several 台址.
|
||||||
|
line.startsWith("<STATION_CALLSIGN:") ->
|
||||||
|
myCallsign = adifValue(line).trim().uppercase().ifBlank { null }
|
||||||
|
line.startsWith("<MY_DXCC:") ->
|
||||||
|
myDxcc = adifValue(line).trim().ifBlank { null }
|
||||||
|
line.startsWith("<MY_STATE:") ->
|
||||||
|
myState = adifValue(line).trim().ifBlank { null }?.let { normalizeState(it) }
|
||||||
|
line.startsWith("<MY_CQ_ZONE:") ->
|
||||||
|
myCq = adifValue(line).trim().ifBlank { null }
|
||||||
|
line.startsWith("<MY_ITU_ZONE:") ->
|
||||||
|
myItu = adifValue(line).trim().ifBlank { null }
|
||||||
|
line.startsWith("<MY_IOTA:") ->
|
||||||
|
myIota = adifValue(line).trim().uppercase().ifBlank { null }
|
||||||
|
line.startsWith("<MY_COUNTRY:") ->
|
||||||
|
myCountry = adifValue(line).trim().ifBlank { null }
|
||||||
|
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
|
||||||
|
// VUCC_GRIDS holds a comma-separated list of grids
|
||||||
|
// ("EN52en,EN53fa"), up to four for contacts spanning
|
||||||
|
// several squares; split and keep every 4-char field.
|
||||||
|
adifValue(line).split(',').forEach { grid ->
|
||||||
|
val field = grid.trim().uppercase()
|
||||||
|
if (field.length >= 4) gridsInRecord.add(field.take(4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ADIF field value: "<GRIDSQUARE:4>OL62" -> "OL62". */
|
||||||
|
private fun adifValue(line: String): String =
|
||||||
|
line.substringAfter('>').substringBefore("E<").trim()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LoTW returns STATE as "CODE // NAME" (e.g. "HB // Hubei" for China,
|
||||||
|
* "34 // Tottori-ken" for Japan, "CA // California" for the US). The award
|
||||||
|
* statistics match on the short CODE only (China 2-letter province pinyin,
|
||||||
|
* Japan 2-digit prefecture, US 2-letter state), so strip the " // NAME"
|
||||||
|
* suffix here once, storing the clean code for every downstream consumer
|
||||||
|
* (persistence, AwardCalculator).
|
||||||
|
*/
|
||||||
|
private fun normalizeState(raw: String): String = raw.substringBefore(" // ").trim()
|
||||||
|
|
||||||
|
/** "20260820" + "1130" (or "113000") -> UTC epoch ms; 0 when unparseable. */
|
||||||
|
private fun adifTimestampToEpoch(date: String, time: String): Long = try {
|
||||||
|
val d = date.trim()
|
||||||
|
val t = time.trim().padEnd(6, '0').take(6)
|
||||||
|
val fmt = java.time.format.DateTimeFormatterBuilder()
|
||||||
|
.appendPattern("yyyyMMddHHmmss")
|
||||||
|
.toFormatter()
|
||||||
|
.withZone(java.time.ZoneOffset.UTC)
|
||||||
|
java.time.Instant.from(fmt.parse(d + t)).toEpochMilli()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun parseConfirmedGrids(body: String): Set<String>? {
|
||||||
|
// LoTW answers with ADIF text; on bad credentials it returns a short error
|
||||||
|
// page without an <eoh> header terminator. Real reports always carry <eoh>
|
||||||
|
// (LoTW writes it lowercase). Match case-insensitively to be safe.
|
||||||
|
if (!body.contains("<eoh>", ignoreCase = true)) return null
|
||||||
|
val grids = mutableSetOf<String>()
|
||||||
|
// ADIF fields of one QSO record span multiple lines and are terminated by
|
||||||
|
// <EOR>. Satellite QSOs carry <PROP_MODE:3>SAT (plus <SAT_NAME>); ground
|
||||||
|
// QSOs omit it. Grid fields (GRIDSQUARE / VUCC_GRIDS) must only be
|
||||||
|
// collected for records whose PROP_MODE is SAT, otherwise the map mixes
|
||||||
|
// in terrestrial contacts.
|
||||||
|
//
|
||||||
|
// Buffered per-record pattern (same as parseConfirmedGridQsos): field order
|
||||||
|
// is NOT reliable — ADIF producers (incl. LoTW) emit fields alphabetically,
|
||||||
|
// so <GRIDSQUARE> (G) arrives BEFORE <PROP_MODE> (P) within a record. A
|
||||||
|
// sequential gate ("only add while propMode == SAT") silently drops every
|
||||||
|
// grid on real reports. Buffer the record and decide at <EOR> instead.
|
||||||
|
var propMode: String? = null
|
||||||
|
var pendingGrids = mutableListOf<String>()
|
||||||
|
for (raw in body.lineSequence()) {
|
||||||
|
val line = raw.trim()
|
||||||
|
when {
|
||||||
|
line.equals("<EOR>", ignoreCase = true) -> {
|
||||||
|
if (propMode == "SAT") grids.addAll(pendingGrids)
|
||||||
|
propMode = null
|
||||||
|
pendingGrids = mutableListOf()
|
||||||
|
}
|
||||||
|
line.startsWith("<PROP_MODE:") -> {
|
||||||
|
propMode = adifValue(line).uppercase()
|
||||||
|
}
|
||||||
|
line.startsWith("<GRIDSQUARE:") || line.startsWith("<VUCC_GRIDS:") -> {
|
||||||
|
// VUCC_GRIDS holds a comma-separated PAIR of grids
|
||||||
|
// ("EN52en,EN53fa") for contacts spanning two squares —
|
||||||
|
// split on ',' and take the 4-char field of each, or the
|
||||||
|
// second grid is silently dropped.
|
||||||
|
val value = adifValue(line)
|
||||||
|
value.split(',').forEach { grid ->
|
||||||
|
val field = grid.trim().uppercase()
|
||||||
|
if (field.length >= 4) pendingGrids.add(field.take(4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return grids
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Distinct 4-char gridsquares the account itself operated from, taken from
|
||||||
|
* ADIF <MY_GRIDSQUARE> of satellite QSO records only. This is the
|
||||||
|
* "roamed / activated" set shown as blue stripes on the map — the grids
|
||||||
|
* where the operator's own station was located during confirmed QSOs (a
|
||||||
|
* rover may log several distinct grids; the home grid is included too).
|
||||||
|
* Returns null when the body is not an ADIF report (mirrors the other parsers).
|
||||||
|
*/
|
||||||
|
internal fun parseRoamedGrids(body: String): Set<String>? {
|
||||||
|
if (!body.contains("<eoh>", ignoreCase = true)) return null
|
||||||
|
val grids = mutableSetOf<String>()
|
||||||
|
// Buffered per-record pattern (same as parseConfirmedGridQsos): field
|
||||||
|
// order is NOT reliable — ADIF producers (incl. LoTW) emit fields
|
||||||
|
// alphabetically, so <MY_GRIDSQUARE> (M) arrives BEFORE <PROP_MODE> (P)
|
||||||
|
// within a record. A sequential gate ("only add while propMode == SAT")
|
||||||
|
// would silently drop every own grid on real reports; buffer the record
|
||||||
|
// and decide at <EOR> instead.
|
||||||
|
var propMode: String? = null
|
||||||
|
var myGrids = mutableSetOf<String>()
|
||||||
|
for (raw in body.lineSequence()) {
|
||||||
|
val line = raw.trim()
|
||||||
|
when {
|
||||||
|
line.equals("<EOR>", ignoreCase = true) -> {
|
||||||
|
if (propMode == "SAT" && myGrids.isNotEmpty()) grids.addAll(myGrids)
|
||||||
|
propMode = null
|
||||||
|
myGrids = mutableSetOf()
|
||||||
|
}
|
||||||
|
line.startsWith("<PROP_MODE:") -> {
|
||||||
|
propMode = adifValue(line).uppercase()
|
||||||
|
}
|
||||||
|
line.startsWith("<MY_GRIDSQUARE:") -> {
|
||||||
|
val value = adifValue(line)
|
||||||
|
if (value.length >= 4) myGrids.add(value.take(4).uppercase())
|
||||||
|
}
|
||||||
|
line.startsWith("<MY_VUCC_GRIDS:") -> {
|
||||||
|
// Multi-grid 台址: each field of MY_VUCC_GRIDS is a grid the
|
||||||
|
// station location covered; without this the extra grids
|
||||||
|
// never reached the blue stripes.
|
||||||
|
adifValue(line).split(',').forEach { grid ->
|
||||||
|
val field = grid.trim().uppercase()
|
||||||
|
if (field.length >= 4) myGrids.add(field.take(4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return grids
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val BASE_URL = "https://lotw.arrl.org/lotwuser/lotwreport.adi"
|
||||||
|
|
||||||
|
/** Record count in the report header: `<APP_LoTW_NUMREC:4>2367`. */
|
||||||
|
val NUMREC_REGEX = Regex("<APP_LoTW_NUMREC:\\d+>(\\d+)")
|
||||||
|
/**
|
||||||
|
* Measured 2026-09-16 (BH6RJD, 2367 QSLs): 1.7 MB body streamed at
|
||||||
|
* ~9.5 KB/s ≈ 180 s, i.e. ≈ 720 bytes per ADIF record. Use a
|
||||||
|
* CONSERVATIVE (larger) estimate so the progress bar never hits 100%
|
||||||
|
* before the download truly ends — an under-estimate made the bar sit
|
||||||
|
* at 100% while data was still flowing, read as "finished but stuck".
|
||||||
|
*/
|
||||||
|
const val AVG_RECORD_BYTES = 900L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.data.database.QsoDao
|
||||||
|
import com.rtbishop.look4sat.core.data.database.entity.QsoEntity
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.AdifCodec
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.AdifImportResult
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.QsoStatus
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.sameConfirmedContact
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.withConfirmation
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.confirmationLookupKey
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class QsoRepository(
|
||||||
|
private val dao: QsoDao,
|
||||||
|
private val dispatcher: CoroutineDispatcher
|
||||||
|
) : IQsoRepository {
|
||||||
|
private val importMutex = Mutex()
|
||||||
|
|
||||||
|
override val records: Flow<List<QsoRecord>> = dao.observeAll().map { records -> records.map(QsoEntity::toDomain) }
|
||||||
|
|
||||||
|
override suspend fun find(id: Long): QsoRecord? = withContext(dispatcher) { dao.find(id)?.toDomain() }
|
||||||
|
|
||||||
|
override suspend fun save(record: QsoRecord): Long = withContext(dispatcher) {
|
||||||
|
importMutex.withLock {
|
||||||
|
val previous = if (record.id != 0L) dao.find(record.id)?.toDomain() else null
|
||||||
|
val saved = if (previous?.lotwConfirmed == true && sameConfirmedContact(record, previous)) {
|
||||||
|
record.withConfirmation(previous)
|
||||||
|
} else if (previous?.lotwConfirmed == true) record.copy(
|
||||||
|
lotwConfirmed = false, lotwQslDate = "", vuccGrids = emptyList(),
|
||||||
|
dxcc = null, country = "", cqZone = null, region = ""
|
||||||
|
) else record
|
||||||
|
val received = if (previous != null && (
|
||||||
|
stableQsoKey(record) != stableQsoKey(previous) || record.myGrid != previous.myGrid ||
|
||||||
|
record.rxFrequencyHz != previous.rxFrequencyHz || record.band != previous.band ||
|
||||||
|
record.rxBand != previous.rxBand || record.propagationMode != previous.propagationMode
|
||||||
|
)) false else saved.lotwReceived
|
||||||
|
dao.save(saved.copy(lotwReceived = received).toEntity())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun delete(id: Long) = withContext(dispatcher) { importMutex.withLock { dao.delete(id) } }
|
||||||
|
|
||||||
|
override suspend fun markUploaded(ids: List<Long>) = withContext(dispatcher) {
|
||||||
|
if (ids.isEmpty()) return@withContext
|
||||||
|
dao.markUploaded(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun exportAdi(ids: Set<Long>?, includeIncomplete: Boolean): String = withContext(dispatcher) {
|
||||||
|
val records = dao.getAll()
|
||||||
|
.asSequence()
|
||||||
|
.filter { ids == null || it.id in ids }
|
||||||
|
.map(QsoEntity::toDomain)
|
||||||
|
.filter { includeIncomplete || it.status == QsoStatus.COMPLETE }
|
||||||
|
.toList()
|
||||||
|
AdifCodec.encode(records)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun importAdi(content: String): AdifImportResult = withContext(dispatcher) {
|
||||||
|
importMutex.withLock {
|
||||||
|
mergeRecords(AdifCodec.decode(content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult = withContext(dispatcher) {
|
||||||
|
importMutex.withLock { mergeRecords(records.filter { it.lotwConfirmed }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun mergeLoTW(records: List<QsoRecord>): AdifImportResult = withContext(dispatcher) {
|
||||||
|
importMutex.withLock { mergeRecords(records, fromLoTW = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun mergeRecords(records: List<QsoRecord>, fromLoTW: Boolean = false): AdifImportResult {
|
||||||
|
val working = dao.getAll().map(QsoEntity::toDomain).toMutableList()
|
||||||
|
val lookup = working.indices.groupBy { working[it].confirmationLookupKey() }
|
||||||
|
.mapValues { it.value.toMutableList() }.toMutableMap()
|
||||||
|
val knownKeys = working.mapTo(mutableSetOf(), ::stableQsoKey)
|
||||||
|
val changes = linkedMapOf<Int, QsoRecord>()
|
||||||
|
var imported = 0
|
||||||
|
var updated = 0
|
||||||
|
var skipped = 0
|
||||||
|
records.forEach { remote ->
|
||||||
|
if (!fromLoTW && !remote.lotwConfirmed && stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
|
||||||
|
val candidates = if (fromLoTW || remote.lotwConfirmed) lookup[remote.confirmationLookupKey()].orEmpty()
|
||||||
|
.filter { sameConfirmedContact(working[it], remote) } else emptyList()
|
||||||
|
val exact = candidates.filter { working[it].startUtcMillis == remote.startUtcMillis }
|
||||||
|
val match = exact.singleOrNull() ?: candidates.singleOrNull()
|
||||||
|
if (match == null) {
|
||||||
|
if (stableQsoKey(remote) in knownKeys) { skipped++; return@forEach }
|
||||||
|
val added = remote.copy(id = 0L)
|
||||||
|
changes[working.size] = added
|
||||||
|
lookup.getOrPut(added.confirmationLookupKey()) { mutableListOf() }.add(working.size)
|
||||||
|
knownKeys += stableQsoKey(added)
|
||||||
|
working += added
|
||||||
|
imported++
|
||||||
|
} else {
|
||||||
|
val previous = working[match]
|
||||||
|
val merged = previous.withConfirmation(remote)
|
||||||
|
if (merged == previous) skipped++ else {
|
||||||
|
working[match] = merged
|
||||||
|
changes[match] = merged
|
||||||
|
updated++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dao.saveBatch(changes.values.map(QsoRecord::toEntity))
|
||||||
|
return AdifImportResult(imported, skipped, updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun QsoEntity.toDomain() = QsoRecord(
|
||||||
|
id = id,
|
||||||
|
startUtcMillis = startUtcMillis,
|
||||||
|
endUtcMillis = endUtcMillis,
|
||||||
|
theirCallsign = theirCallsign,
|
||||||
|
myCallsign = myCallsign,
|
||||||
|
theirGrid = theirGrid,
|
||||||
|
myGrid = myGrid,
|
||||||
|
sentReport = sentReport,
|
||||||
|
receivedReport = receivedReport,
|
||||||
|
txFrequencyHz = txFrequencyHz,
|
||||||
|
rxFrequencyHz = rxFrequencyHz,
|
||||||
|
band = band,
|
||||||
|
rxBand = rxBand,
|
||||||
|
mode = mode,
|
||||||
|
submode = submode,
|
||||||
|
satelliteName = satelliteName,
|
||||||
|
transponderName = transponderName,
|
||||||
|
satelliteMode = satelliteMode,
|
||||||
|
passAosUtcMillis = passAosUtcMillis,
|
||||||
|
automatic = automatic,
|
||||||
|
status = runCatching { QsoStatus.valueOf(status) }.getOrDefault(QsoStatus.DRAFT),
|
||||||
|
propagationMode = propagationMode,
|
||||||
|
lotwConfirmed = lotwConfirmed,
|
||||||
|
lotwUploaded = lotwUploaded,
|
||||||
|
lotwReceived = lotwReceived,
|
||||||
|
lotwQslDate = lotwQslDate,
|
||||||
|
vuccGrids = vuccGrids.split(',').filter(String::isNotBlank),
|
||||||
|
dxcc = dxcc,
|
||||||
|
country = country,
|
||||||
|
cqZone = cqZone,
|
||||||
|
region = region,
|
||||||
|
comment = comment
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun QsoRecord.toEntity() = QsoEntity(
|
||||||
|
id = id,
|
||||||
|
startUtcMillis = startUtcMillis,
|
||||||
|
endUtcMillis = endUtcMillis,
|
||||||
|
theirCallsign = theirCallsign.trim().uppercase(Locale.US),
|
||||||
|
myCallsign = myCallsign.trim().uppercase(Locale.US),
|
||||||
|
theirGrid = theirGrid.trim().uppercase(Locale.US),
|
||||||
|
myGrid = myGrid.trim().uppercase(Locale.US),
|
||||||
|
sentReport = sentReport.trim(),
|
||||||
|
receivedReport = receivedReport.trim(),
|
||||||
|
txFrequencyHz = txFrequencyHz,
|
||||||
|
rxFrequencyHz = rxFrequencyHz,
|
||||||
|
band = band,
|
||||||
|
rxBand = rxBand,
|
||||||
|
mode = mode.trim(),
|
||||||
|
submode = submode.trim(),
|
||||||
|
satelliteName = satelliteName,
|
||||||
|
transponderName = transponderName,
|
||||||
|
satelliteMode = satelliteMode,
|
||||||
|
passAosUtcMillis = passAosUtcMillis,
|
||||||
|
automatic = automatic,
|
||||||
|
status = status.name,
|
||||||
|
dedupeKey = stableQsoKey(this),
|
||||||
|
propagationMode = propagationMode.ifBlank { if (satelliteName.isNotBlank()) "SAT" else "" },
|
||||||
|
lotwConfirmed = lotwConfirmed,
|
||||||
|
lotwUploaded = lotwUploaded,
|
||||||
|
lotwReceived = lotwReceived,
|
||||||
|
lotwQslDate = lotwQslDate,
|
||||||
|
vuccGrids = vuccGrids.joinToString(","),
|
||||||
|
dxcc = dxcc,
|
||||||
|
country = country,
|
||||||
|
cqZone = cqZone,
|
||||||
|
region = region,
|
||||||
|
comment = comment
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun stableQsoKey(record: QsoRecord): String = listOf(
|
||||||
|
record.startUtcMillis.toString(),
|
||||||
|
record.theirCallsign.trim().uppercase(Locale.US),
|
||||||
|
record.myCallsign.trim().uppercase(Locale.US),
|
||||||
|
record.txFrequencyHz?.toString().orEmpty(),
|
||||||
|
record.mode.trim().uppercase(Locale.US),
|
||||||
|
record.submode.trim().uppercase(Locale.US),
|
||||||
|
record.satelliteName.trim().uppercase(Locale.US)
|
||||||
|
).joinToString("|")
|
||||||
+62
-33
@@ -34,6 +34,9 @@ import kotlinx.coroutines.coroutineScope
|
|||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
@@ -62,20 +65,35 @@ class SatelliteRepo(
|
|||||||
|
|
||||||
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
override suspend fun getRadiosWithId(id: Int) = localStorage.getRadiosWithId(id)
|
||||||
|
|
||||||
|
override suspend fun getSatelliteIdsWithModes(modes: List<String>) = localStorage.getIdsWithModes(modes)
|
||||||
|
|
||||||
|
override suspend fun getSatelliteIdsWithModesAndUplink(modes: List<String>) = localStorage.getIdsWithModesAndUplink(modes)
|
||||||
|
|
||||||
|
override suspend fun getSatelliteIdsWithModesAndAmateur(modes: List<String>) = localStorage.getIdsWithModesAndAmateur(modes)
|
||||||
|
|
||||||
override suspend fun initRepository() = withContext(dispatcher) {
|
override suspend fun initRepository() = withContext(dispatcher) {
|
||||||
settingsRepo.selectedIds.collect { selectedIds ->
|
combine(
|
||||||
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
settingsRepo.selectedIds,
|
||||||
val settings = settingsRepo.passesSettings.value
|
settingsRepo.stationPosition,
|
||||||
calculatePasses(
|
// Recalculate passes when the UTC display toggle changes, so an existing
|
||||||
time = System.currentTimeMillis(),
|
// AOS time window is reapplied in the newly selected timezone.
|
||||||
hoursAhead = settings.hoursAhead,
|
settingsRepo.otherSettings.map { it.stateOfUtc }.distinctUntilChanged()
|
||||||
minElevation = settings.minElevation,
|
) { selectedIds, _, _ -> selectedIds }
|
||||||
aosStartMinute = settings.aosStartMinute,
|
.collect { selectedIds ->
|
||||||
aosEndMinute = settings.aosEndMinute,
|
_satellites.update { localStorage.getEntriesWithIds(selectedIds) }
|
||||||
invertAosTimeWindow = settings.invertAosTimeWindow,
|
val settings = settingsRepo.passesSettings.value
|
||||||
modes = settings.selectedModes
|
val now = System.currentTimeMillis()
|
||||||
)
|
// 历史过境: 回看窗口 hoursBefore 小时, 过境列表从更早时间开始计算.
|
||||||
}
|
calculatePasses(
|
||||||
|
time = now - settings.hoursBefore * 60L * 60L * 1000L,
|
||||||
|
hoursAhead = settings.hoursBefore + settings.hoursAhead,
|
||||||
|
minElevation = settings.minElevation,
|
||||||
|
aosStartMinute = settings.aosStartMinute,
|
||||||
|
aosEndMinute = settings.aosEndMinute,
|
||||||
|
invertAosTimeWindow = settings.invertAosTimeWindow,
|
||||||
|
modes = settings.selectedModes
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
|
override suspend fun getPosition(sat: OrbitalObject, pos: GeoPos, time: Long): OrbitalPos {
|
||||||
@@ -95,8 +113,14 @@ class SatelliteRepo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getRadios(satPos: OrbitalPos, radios: List<SatRadio>): List<SatRadio> {
|
override suspend fun getRadios(
|
||||||
|
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) },
|
||||||
@@ -164,7 +188,16 @@ class SatelliteRepo(
|
|||||||
aosEndMinute: Int,
|
aosEndMinute: Int,
|
||||||
invertAosTimeWindow: Boolean
|
invertAosTimeWindow: Boolean
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val offsetMillis = TimeZone.getDefault().getOffset(aosTime).toLong()
|
// Follow the UTC display toggle: with UTC enabled the window is interpreted
|
||||||
|
// in UTC, otherwise in the device's local timezone. Otherwise the pass list
|
||||||
|
// shows UTC times while the filter silently uses local time — the two
|
||||||
|
// disagree by the timezone offset (e.g. 8h for China).
|
||||||
|
val tz = if (settingsRepo.otherSettings.value.stateOfUtc) {
|
||||||
|
TimeZone.getTimeZone("UTC")
|
||||||
|
} else {
|
||||||
|
TimeZone.getDefault()
|
||||||
|
}
|
||||||
|
val offsetMillis = tz.getOffset(aosTime).toLong()
|
||||||
val localMillis = Math.floorMod(aosTime + offsetMillis, 24L * 60L * 60L * 1000L)
|
val localMillis = Math.floorMod(aosTime + offsetMillis, 24L * 60L * 60L * 1000L)
|
||||||
val aosMinute = (localMillis / 60_000L).toInt()
|
val aosMinute = (localMillis / 60_000L).toInt()
|
||||||
val inRange = if (aosStartMinute <= aosEndMinute) {
|
val inRange = if (aosStartMinute <= aosEndMinute) {
|
||||||
@@ -236,16 +269,13 @@ 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 via binary search.
|
// refine AOS to ~500ms precision
|
||||||
// Elevation is monotonic across the horizon crossing, so binary search
|
calendarTimeMillis -= 60L * 1000L
|
||||||
// finds the crossing in ~8 SGP4 calls instead of up to 120 linear steps.
|
do {
|
||||||
var aosLo = calendarTimeMillis - 60L * 1000L // elevation < 0 (below horizon)
|
calendarTimeMillis += 500L
|
||||||
var aosHi = calendarTimeMillis // elevation >= 0 (above horizon)
|
elevation = sat.getElevation(pos, calendarTimeMillis)
|
||||||
while (aosHi - aosLo > 500L) {
|
if (elevation > maxElevation) maxElevation = elevation
|
||||||
val mid = (aosLo + aosHi) / 2
|
} while (elevation < 0.0)
|
||||||
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)
|
||||||
@@ -259,14 +289,13 @@ 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 via binary search (same monotonic argument)
|
// refine LOS to ~500ms precision
|
||||||
var losLo = calendarTimeMillis - 30L * 1000L // elevation > 0 (above horizon)
|
calendarTimeMillis -= 30L * 1000L
|
||||||
var losHi = calendarTimeMillis // elevation <= 0 (below horizon)
|
do {
|
||||||
while (losHi - losLo > 500L) {
|
calendarTimeMillis += 500L
|
||||||
val mid = (losLo + losHi) / 2
|
elevation = sat.getElevation(pos, calendarTimeMillis)
|
||||||
if (sat.getElevation(pos, mid) > 0.0) losLo = mid else losHi = mid
|
if (elevation > maxElevation) maxElevation = elevation
|
||||||
}
|
} 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)
|
||||||
|
|||||||
+64
-6
@@ -43,12 +43,15 @@ class SelectionRepo(
|
|||||||
|
|
||||||
// Resolve type IDs once when types change, then filter items reactively.
|
// Resolve type IDs once when types change, then filter items reactively.
|
||||||
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
// The HashSet gives O(1) catnum lookups instead of O(n) with a List.
|
||||||
|
// The three virtual types ("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV")
|
||||||
|
// are resolved from hardcoded FM/Linear catnum sets (Sources) or live
|
||||||
|
// SSTV transponder data instead of SharedPreferences.
|
||||||
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
|
private val itemsWithTypes = currentTypes.flatMapLatest { types: List<String> ->
|
||||||
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
val catnumSet: Set<Int>? = if (types.isEmpty()) {
|
||||||
null // null = no filtering
|
null // null = no filtering
|
||||||
} else {
|
} else {
|
||||||
val ids = settingsRepo.getSatelliteTypesIds(types)
|
val ids = resolveTypeIds(types)
|
||||||
if (ids.isEmpty()) null else ids.toHashSet()
|
if (ids.isEmpty()) emptySet() else ids.toHashSet()
|
||||||
}
|
}
|
||||||
currentItems.map { items ->
|
currentItems.map { items ->
|
||||||
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
|
if (catnumSet == null) items else items.filter { it.catnum in catnumSet }
|
||||||
@@ -61,8 +64,46 @@ class SelectionRepo(
|
|||||||
|
|
||||||
override fun getCurrentTypes() = currentTypes.value
|
override fun getCurrentTypes() = currentTypes.value
|
||||||
|
|
||||||
override fun getTypesList() = Sources.satelliteDataUrls.keys.sorted().toMutableList().apply {
|
/**
|
||||||
removeAt(0)
|
* Resolves a list of type names to satellite catnums. The three virtual
|
||||||
|
* transponder/activity types are resolved from hardcoded FM/Linear lists
|
||||||
|
* (Sources.amSatFmCatnums / amSatLinearCatnums) or local SSTV transponder
|
||||||
|
* data; all other types come from the per-type ID lists persisted by
|
||||||
|
* [ISettingsRepo].
|
||||||
|
*/
|
||||||
|
private suspend fun resolveTypeIds(types: List<String>): List<Int> {
|
||||||
|
val idsSet = mutableSetOf<Int>()
|
||||||
|
types.forEach { type ->
|
||||||
|
when (type) {
|
||||||
|
Sources.virtualTypeNames[0] -> idsSet.addAll(Sources.amSatFmCatnums)
|
||||||
|
Sources.virtualTypeNames[1] -> idsSet.addAll(Sources.amSatLinearCatnums)
|
||||||
|
// Live SSTV: mode=SSTV transponder records whose service class
|
||||||
|
// is "Amateur", excluding launcher debris / rocket bodies
|
||||||
|
// (names ending in "R/B" or "DEB" — e.g. Ariane 6 R/B) that
|
||||||
|
// carry an amateur payload transponder but are not satellites.
|
||||||
|
Sources.virtualTypeNames[2] -> {
|
||||||
|
val sstvIds = localSource.getIdsWithModesAndAmateur(listOf("SSTV")).toSet()
|
||||||
|
val inOrbitNames = currentItems.value.associate { it.catnum to it.name }
|
||||||
|
val filtered = sstvIds.filter { catnum ->
|
||||||
|
val name = inOrbitNames[catnum]?.uppercase().orEmpty()
|
||||||
|
!name.endsWith(" R/B") && !name.endsWith(" DEB") &&
|
||||||
|
!name.endsWith("R/B") && !name.endsWith("DEB")
|
||||||
|
}
|
||||||
|
idsSet.addAll(filtered)
|
||||||
|
}
|
||||||
|
else -> idsSet.addAll(settingsRepo.getSatelliteTypesIds(listOf(type)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return idsSet.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getTypesList() = buildList {
|
||||||
|
// 三个转发器/活动虚拟类型排在最前.
|
||||||
|
addAll(Sources.virtualTypeNames)
|
||||||
|
// 所有真实 TLE 源类型均可选(含 "All" = CelesTrak active);只排除
|
||||||
|
// "Other"(空 URL 占位,无数据)。不要用 removeAt(0) —— 字母序第一个
|
||||||
|
// 是 "All"/"ARISS" 这类真实源,删掉会让用户勾不到它们。
|
||||||
|
addAll(Sources.satelliteDataUrls.keys.sorted().filterNot { it == "Other" })
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
override suspend fun getEntriesFlow() = withContext(dispatcher) {
|
||||||
@@ -111,12 +152,29 @@ class SelectionRepo(
|
|||||||
/**
|
/**
|
||||||
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
|
* Filters items by query. Uses toIntOrNull() instead of exception-based flow,
|
||||||
* and lowercases the query once up front instead of per-item.
|
* and lowercases the query once up front instead of per-item.
|
||||||
|
*
|
||||||
|
* Fuzzy search: the query is split into space-separated tokens and every
|
||||||
|
* token must appear in the satellite name after both sides are normalized
|
||||||
|
* (lowercased, non-alphanumeric separators such as dashes, spaces, brackets
|
||||||
|
* and dots stripped). This makes "ao7" match "AO-7 (AMSAT-OSCAR 7)" and
|
||||||
|
* "iss zarya" match "ISS (ZARYA)" — exact continuous-substring matching
|
||||||
|
* previously failed whenever the name contained a separator the query lacked.
|
||||||
*/
|
*/
|
||||||
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
|
private fun filterByQuery(items: List<SatItem>, query: String): List<SatItem> {
|
||||||
if (query.isBlank()) return items
|
if (query.isBlank()) return items
|
||||||
val catnum = query.toIntOrNull()
|
val catnum = query.toIntOrNull()
|
||||||
if (catnum != null) return items.filter { it.catnum == catnum }
|
if (catnum != null) return items.filter { it.catnum == catnum }
|
||||||
val lowerQuery = query.lowercase()
|
val tokens = query.split(' ')
|
||||||
return items.filter { it.name.lowercase().contains(lowerQuery) }
|
.map { normalizeForSearch(it) }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
if (tokens.isEmpty()) return items
|
||||||
|
return items.filter { item ->
|
||||||
|
val normalizedName = normalizeForSearch(item.name)
|
||||||
|
tokens.all { normalizedName.contains(it) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lowercases and strips all non-alphanumeric chars for fuzzy matching. */
|
||||||
|
private fun normalizeForSearch(text: String): String =
|
||||||
|
text.lowercase().filter { it.isLetterOrDigit() }
|
||||||
}
|
}
|
||||||
@@ -27,6 +27,7 @@ import android.view.Display
|
|||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
import com.rtbishop.look4sat.core.domain.predict.RAD2DEG
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.CompassAccuracy
|
||||||
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISensorsRepo
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -41,7 +42,12 @@ class SensorsRepo(
|
|||||||
) : ISensorsRepo, SensorEventListener {
|
) : ISensorsRepo, SensorEventListener {
|
||||||
|
|
||||||
private val _sensorData = MutableStateFlow(Pair(0f, 0f))
|
private val _sensorData = MutableStateFlow(Pair(0f, 0f))
|
||||||
private val sensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
private val rotationSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
|
||||||
|
private val magneticSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)
|
||||||
|
private val _compassAccuracy = MutableStateFlow(
|
||||||
|
if (rotationSensor == null || magneticSensor == null) CompassAccuracy.UNAVAILABLE
|
||||||
|
else CompassAccuracy.UNRELIABLE
|
||||||
|
)
|
||||||
private val rotationMatrix = FloatArray(9)
|
private val rotationMatrix = FloatArray(9)
|
||||||
private val tempMatrix = FloatArray(9)
|
private val tempMatrix = FloatArray(9)
|
||||||
private val orientationValues = FloatArray(3)
|
private val orientationValues = FloatArray(3)
|
||||||
@@ -50,6 +56,7 @@ class SensorsRepo(
|
|||||||
private var hasInitialReading = false
|
private var hasInitialReading = false
|
||||||
|
|
||||||
override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
|
override val sensorData: StateFlow<Pair<Float, Float>> = _sensorData
|
||||||
|
override val compassAccuracy: StateFlow<CompassAccuracy> = _compassAccuracy
|
||||||
|
|
||||||
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
override fun getMagDeclination(geoPos: GeoPos, time: Long): Float {
|
||||||
return GeomagneticField(
|
return GeomagneticField(
|
||||||
@@ -62,15 +69,41 @@ class SensorsRepo(
|
|||||||
|
|
||||||
override fun enableSensor() {
|
override fun enableSensor() {
|
||||||
hasInitialReading = false
|
hasInitialReading = false
|
||||||
sensor?.let { sensorManager.registerListener(this, it, SENSOR_RATE_US) }
|
if (rotationSensor == null || magneticSensor == null) {
|
||||||
|
_compassAccuracy.value = CompassAccuracy.UNAVAILABLE
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_compassAccuracy.value = CompassAccuracy.UNRELIABLE
|
||||||
|
val rotationRegistered = sensorManager.registerListener(this, rotationSensor, SENSOR_RATE_US)
|
||||||
|
val magneticRegistered = sensorManager.registerListener(this, magneticSensor, SENSOR_RATE_US)
|
||||||
|
if (!rotationRegistered || !magneticRegistered) {
|
||||||
|
sensorManager.unregisterListener(this)
|
||||||
|
_compassAccuracy.value = CompassAccuracy.UNAVAILABLE
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun disableSensor() = sensorManager.unregisterListener(this)
|
override fun disableSensor() = sensorManager.unregisterListener(this)
|
||||||
|
|
||||||
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
|
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
|
||||||
|
if (sensor.type == Sensor.TYPE_MAGNETIC_FIELD) {
|
||||||
|
updateAccuracy(accuracy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onSensorChanged(event: SensorEvent) {
|
override fun onSensorChanged(event: SensorEvent) {
|
||||||
if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) handleSensorEvent(event)
|
when (event.sensor.type) {
|
||||||
|
Sensor.TYPE_ROTATION_VECTOR -> handleSensorEvent(event)
|
||||||
|
Sensor.TYPE_MAGNETIC_FIELD -> updateAccuracy(event.accuracy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateAccuracy(accuracy: Int) {
|
||||||
|
_compassAccuracy.value = when (accuracy) {
|
||||||
|
SensorManager.SENSOR_STATUS_ACCURACY_HIGH -> CompassAccuracy.HIGH
|
||||||
|
SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM -> CompassAccuracy.MEDIUM
|
||||||
|
SensorManager.SENSOR_STATUS_ACCURACY_LOW -> CompassAccuracy.LOW
|
||||||
|
else -> CompassAccuracy.UNRELIABLE
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDisplayRotation(): Int {
|
private fun getDisplayRotation(): Int {
|
||||||
|
|||||||
+507
-79
@@ -25,11 +25,12 @@ import androidx.core.location.LocationListenerCompat
|
|||||||
import androidx.core.location.LocationManagerCompat
|
import androidx.core.location.LocationManagerCompat
|
||||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.MapSource
|
||||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
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.model.WavelogSettings
|
||||||
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
|
||||||
@@ -38,9 +39,12 @@ import com.rtbishop.look4sat.core.domain.utility.round
|
|||||||
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 org.json.JSONObject
|
||||||
|
import java.util.Locale
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.Constants
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
|
|
||||||
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
|
||||||
@@ -55,6 +59,7 @@ class SettingsRepo(
|
|||||||
private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat"
|
private val keyBluetoothFrequencyFormat = "bluetoothFrequencyFormat"
|
||||||
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
|
private val keyFilterShowDeepSpace = "filterShowDeepSpace"
|
||||||
private val keyFilterHoursAhead = "filterHoursAhead"
|
private val keyFilterHoursAhead = "filterHoursAhead"
|
||||||
|
private val keyFilterHoursBefore = "filterHoursBefore"
|
||||||
private val keyFilterMinElevation = "filterMinElevation"
|
private val keyFilterMinElevation = "filterMinElevation"
|
||||||
private val keyFilterAosStartMinute = "filterAosStartMinute"
|
private val keyFilterAosStartMinute = "filterAosStartMinute"
|
||||||
private val keyFilterAosEndMinute = "filterAosEndMinute"
|
private val keyFilterAosEndMinute = "filterAosEndMinute"
|
||||||
@@ -69,17 +74,20 @@ class SettingsRepo(
|
|||||||
private val keyFrequencyAddress = "frequencyAddress"
|
private val keyFrequencyAddress = "frequencyAddress"
|
||||||
private val keyFrequencyPort = "frequencyPort"
|
private val keyFrequencyPort = "frequencyPort"
|
||||||
private val keyFrequencyFormat = "frequencyFormat"
|
private val keyFrequencyFormat = "frequencyFormat"
|
||||||
|
private val keyFrequencyOffsetHz = "frequencyOffsetHz"
|
||||||
private val keySelectedIds = "selectedIds"
|
private val keySelectedIds = "selectedIds"
|
||||||
private val keySelectedTypes = "selectedTypes"
|
private val keySelectedTypes = "selectedTypes"
|
||||||
private val keySelectedModes = "selectedModes"
|
private val keySelectedModes = "selectedModes"
|
||||||
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
private val keyStateOfAutoUpdate = "stateOfAutoUpdate"
|
||||||
|
private val keyStateOfAutoLotwSync = "stateOfAutoLotwSync"
|
||||||
private val keyStateOfSensors = "stateOfSensors"
|
private val keyStateOfSensors = "stateOfSensors"
|
||||||
|
private val keyCompassOffsetDegrees = "compassOffsetDegrees"
|
||||||
private val keyStateOfSweep = "stateOfSweep"
|
private val keyStateOfSweep = "stateOfSweep"
|
||||||
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 keyStateOfMapGrid = "stateOfMapGrid"
|
||||||
private val keyScreenOrder = "screenOrder"
|
private val keyStateOfMapFirstCall = "stateOfMapFirstCall"
|
||||||
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,16 +99,22 @@ class SettingsRepo(
|
|||||||
private val keySstvMode = "sstvMode"
|
private val keySstvMode = "sstvMode"
|
||||||
private val keyLowElevation = "lowElevation"
|
private val keyLowElevation = "lowElevation"
|
||||||
private val keyHighElevation = "highElevation"
|
private val keyHighElevation = "highElevation"
|
||||||
|
private val keyMapSource = "mapSource"
|
||||||
|
private val keyTiandituKey = "tiandituKey"
|
||||||
private val keyUseCustomTle = "useCustomTle"
|
private val keyUseCustomTle = "useCustomTle"
|
||||||
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
private val keyUseCustomTransceivers = "useCustomTransceivers"
|
||||||
private val keyTleUrl = "tleUrl"
|
private val keyTleUrl = "tleUrl"
|
||||||
private val keyTransceiversUrl = "transceiversUrl"
|
private val keyTransceiversUrl = "transceiversUrl"
|
||||||
private val keySubMenuOrder = "subMenuOrder"
|
private val keySatelliteUrls = "satelliteUrls"
|
||||||
private val keyWavelogUrl = "wavelogUrl"
|
private val keyTransceiversUrls = "transceiversUrls"
|
||||||
private val keyWavelogApiKey = "wavelogApiKey"
|
private val keySatelliteEnabled = "satelliteEnabled"
|
||||||
private val keyWavelogStationId = "wavelogStationId"
|
private val keyTransceiversEnabled = "transceiversEnabled"
|
||||||
private val keyWavelogAutoUpload = "wavelogAutoUpload"
|
private val keySatnogsTleSourceMigration = "satnogsTleSourceMigration"
|
||||||
|
private val keyAutoTleSourceMigration = "autoTleSourceMigration"
|
||||||
private val separatorComma = ","
|
private val separatorComma = ","
|
||||||
|
private val separatorUrl = "\n"
|
||||||
|
private val legacyCelestrakSatnogsUrl =
|
||||||
|
"https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv"
|
||||||
|
|
||||||
//region # Satellites selection settings
|
//region # Satellites selection settings
|
||||||
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
private val _satelliteSelection = MutableStateFlow(getSelectedIds())
|
||||||
@@ -133,13 +147,253 @@ class SettingsRepo(
|
|||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
//region # Passes filter settings
|
//region # Wavelog worked-grids settings
|
||||||
|
private val keyWavelogUrl = "wavelogUrl"
|
||||||
|
private val keyWavelogToken = "wavelogToken"
|
||||||
|
private val keyWorkedGrids = "workedGrids"
|
||||||
|
|
||||||
|
private val _wavelogSettings = MutableStateFlow(getWavelogSettings())
|
||||||
|
override val wavelogSettings: StateFlow<WavelogSettings> = _wavelogSettings
|
||||||
|
|
||||||
|
override fun updateWavelogSettings(settings: WavelogSettings) {
|
||||||
|
preferences.edit {
|
||||||
|
putString(keyWavelogUrl, settings.url.trim())
|
||||||
|
putString(keyWavelogToken, settings.token.trim())
|
||||||
|
}
|
||||||
|
_wavelogSettings.value = settings.copy(url = settings.url.trim(), token = settings.token.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getWavelogSettings(): WavelogSettings = WavelogSettings(
|
||||||
|
url = preferences.getString(keyWavelogUrl, null).orEmpty(),
|
||||||
|
token = preferences.getString(keyWavelogToken, null).orEmpty()
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun getWorkedGrids(): Set<String> {
|
||||||
|
val json = preferences.getString(keyWorkedGrids, null).orEmpty()
|
||||||
|
if (json.isBlank()) return emptySet()
|
||||||
|
return try {
|
||||||
|
val array = org.json.JSONArray(json)
|
||||||
|
(0 until array.length()).mapNotNull { i ->
|
||||||
|
array.optString(i).takeIf { it.isNotBlank() }
|
||||||
|
}.toSet()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptySet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setWorkedGrids(grids: Set<String>) {
|
||||||
|
val array = org.json.JSONArray()
|
||||||
|
grids.sorted().forEach { array.put(it) }
|
||||||
|
preferences.edit { putString(keyWorkedGrids, array.toString()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirmed satellite QSOs per worked gridsquare, persisted as a single JSON
|
||||||
|
// object: {"OL62":[{"c":call,"t":epochMs,"s":sat,"m":mode,"bu":up,"bd":down}]}.
|
||||||
|
private val keyWorkedGridQsos = "workedGridQsos"
|
||||||
|
|
||||||
|
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> {
|
||||||
|
val json = preferences.getString(keyWorkedGridQsos, null).orEmpty()
|
||||||
|
if (json.isBlank()) return emptyMap()
|
||||||
|
return try {
|
||||||
|
val root = org.json.JSONObject(json)
|
||||||
|
val result = mutableMapOf<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>()
|
||||||
|
for (grid in root.keys()) {
|
||||||
|
val array = root.optJSONArray(grid) ?: continue
|
||||||
|
val list = (0 until array.length()).mapNotNull { i ->
|
||||||
|
val o = array.optJSONObject(i) ?: return@mapNotNull null
|
||||||
|
com.rtbishop.look4sat.core.domain.model.GridQso(
|
||||||
|
call = o.optString("c"),
|
||||||
|
epochMs = o.optLong("t"),
|
||||||
|
satName = o.optString("s"),
|
||||||
|
mode = o.optString("m"),
|
||||||
|
bandUp = o.optString("bu"),
|
||||||
|
bandDown = o.optString("bd"),
|
||||||
|
// New award fields: absent in pre-award data -> null.
|
||||||
|
dxcc = o.optInt("dx", 0).takeIf { it > 0 },
|
||||||
|
country = o.optString("cty").ifBlank { null },
|
||||||
|
cqz = o.optInt("cq", 0).takeIf { it > 0 },
|
||||||
|
state = o.optString("st").ifBlank { null },
|
||||||
|
// Own-grid field: absent in pre-myGrid data -> null.
|
||||||
|
myGrid = o.optString("mg").ifBlank { null },
|
||||||
|
// Multi-grid 台址 fields (v4.4.7-ba7opf.16+): "mgs" is
|
||||||
|
// the full grid set (MY_GRIDSQUARE + MY_VUCC_GRIDS);
|
||||||
|
// pre-multi-grid data has only "mg" and falls back to a
|
||||||
|
// single-element set so every consumer sees myGrids.
|
||||||
|
myGrids = o.optJSONArray("mgs")?.let { arr ->
|
||||||
|
(0 until arr.length()).mapNotNull { i ->
|
||||||
|
arr.optString(i).takeIf { it.isNotBlank() }
|
||||||
|
}.toSet()
|
||||||
|
}?.takeIf { it.isNotEmpty() }
|
||||||
|
?: o.optString("mg").ifBlank { null }?.let { setOf(it) }
|
||||||
|
?: emptySet(),
|
||||||
|
myCallsign = o.optString("mc").ifBlank { null },
|
||||||
|
stationKey = o.optString("sk").ifBlank { null }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (list.isNotEmpty()) result[grid] = list
|
||||||
|
}
|
||||||
|
result
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) {
|
||||||
|
val root = org.json.JSONObject()
|
||||||
|
for ((grid, list) in qsos) {
|
||||||
|
val array = org.json.JSONArray()
|
||||||
|
for (q in list) {
|
||||||
|
array.put(
|
||||||
|
org.json.JSONObject()
|
||||||
|
.put("c", q.call)
|
||||||
|
.put("t", q.epochMs)
|
||||||
|
.put("s", q.satName)
|
||||||
|
.put("m", q.mode)
|
||||||
|
.put("bu", q.bandUp)
|
||||||
|
.put("bd", q.bandDown)
|
||||||
|
.put("dx", q.dxcc ?: 0)
|
||||||
|
.put("cty", q.country ?: "")
|
||||||
|
.put("cq", q.cqz ?: 0)
|
||||||
|
.put("st", q.state ?: "")
|
||||||
|
.put("mg", q.myGrid ?: "")
|
||||||
|
.put("mgs", org.json.JSONArray(q.myGrids.sorted()))
|
||||||
|
.put("mc", q.myCallsign ?: "")
|
||||||
|
.put("sk", q.stationKey ?: "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
root.put(grid, array)
|
||||||
|
}
|
||||||
|
preferences.edit { putString(keyWorkedGridQsos, root.toString()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distinct 4-char gridsquares the account operated from (LoTW <MY_GRIDSQUARE>,
|
||||||
|
// satellite QSOs only). Stored as a JSONArray like workedGrids.
|
||||||
|
private val keyRoamedGrids = "roamedGrids"
|
||||||
|
|
||||||
|
override fun getRoamedGrids(): Set<String> {
|
||||||
|
val json = preferences.getString(keyRoamedGrids, null).orEmpty()
|
||||||
|
if (json.isBlank()) return emptySet()
|
||||||
|
return try {
|
||||||
|
val array = org.json.JSONArray(json)
|
||||||
|
(0 until array.length()).mapNotNull { i ->
|
||||||
|
array.optString(i).takeIf { it.isNotBlank() }
|
||||||
|
}.toSet()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptySet()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setRoamedGrids(grids: Set<String>) {
|
||||||
|
val array = org.json.JSONArray()
|
||||||
|
grids.sorted().forEach { array.put(it) }
|
||||||
|
preferences.edit { putString(keyRoamedGrids, array.toString()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marked stations in unworked gridsquares, persisted as a single JSON
|
||||||
|
// object: {"OL62":[{"c":call,"t":epochMs}, ...]}. Each grid holds an
|
||||||
|
// ordered list (first = pinned/top). v14.1 stored a single object per
|
||||||
|
// grid; reads transparently migrate that old shape to a one-element list.
|
||||||
|
private val keyMarkedGridStations = "markedGridStations"
|
||||||
|
|
||||||
|
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> {
|
||||||
|
val json = preferences.getString(keyMarkedGridStations, null).orEmpty()
|
||||||
|
if (json.isBlank()) return emptyMap()
|
||||||
|
return try {
|
||||||
|
val root = org.json.JSONObject(json)
|
||||||
|
val result = mutableMapOf<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>()
|
||||||
|
for (grid in root.keys()) {
|
||||||
|
val value = root.opt(grid)
|
||||||
|
val list = when (value) {
|
||||||
|
// v14.2+ shape: JSON array of station objects.
|
||||||
|
is org.json.JSONArray -> (0 until value.length()).mapNotNull { i ->
|
||||||
|
val o = value.optJSONObject(i) ?: return@mapNotNull null
|
||||||
|
val call = o.optString("c").ifBlank { return@mapNotNull null }
|
||||||
|
com.rtbishop.look4sat.core.domain.model.MarkedStation(call = call, epochMs = o.optLong("t"))
|
||||||
|
}
|
||||||
|
// v14.1 legacy shape: a single station object per grid.
|
||||||
|
is org.json.JSONObject -> {
|
||||||
|
val call = value.optString("c").ifBlank { null }
|
||||||
|
if (call == null) emptyList()
|
||||||
|
else listOf(com.rtbishop.look4sat.core.domain.model.MarkedStation(call = call, epochMs = value.optLong("t")))
|
||||||
|
}
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
if (list.isNotEmpty()) result[grid] = list
|
||||||
|
}
|
||||||
|
result
|
||||||
|
} catch (_: Exception) {
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) {
|
||||||
|
val root = org.json.JSONObject()
|
||||||
|
for ((grid, list) in stations) {
|
||||||
|
val array = org.json.JSONArray()
|
||||||
|
for (station in list) {
|
||||||
|
array.put(
|
||||||
|
org.json.JSONObject()
|
||||||
|
.put("c", station.call)
|
||||||
|
.put("t", station.epochMs)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
root.put(grid, array)
|
||||||
|
}
|
||||||
|
preferences.edit { putString(keyMarkedGridStations, root.toString()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoTW credentials (stored locally on the device only)
|
||||||
|
private val keyLoTWCall = "lotwCallsign"
|
||||||
|
private val keyLoTWPass = "lotwPassword"
|
||||||
|
|
||||||
|
override val lotwSettings: kotlinx.coroutines.flow.StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
|
||||||
|
get() = _lotwSettings
|
||||||
|
private val _lotwSettings = MutableStateFlow(getLoTWSettings())
|
||||||
|
|
||||||
|
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) {
|
||||||
|
preferences.edit {
|
||||||
|
putString(keyLoTWCall, settings.callsign.trim().uppercase())
|
||||||
|
putString(keyLoTWPass, settings.password)
|
||||||
|
}
|
||||||
|
_lotwSettings.value = settings.copy(
|
||||||
|
callsign = settings.callsign.trim().uppercase(),
|
||||||
|
password = settings.password
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getLoTWSettings(): com.rtbishop.look4sat.core.domain.model.LoTWSettings =
|
||||||
|
com.rtbishop.look4sat.core.domain.model.LoTWSettings(
|
||||||
|
callsign = preferences.getString(keyLoTWCall, null).orEmpty(),
|
||||||
|
password = preferences.getString(keyLoTWPass, null).orEmpty()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Last successful sync bookkeeping: date ("yyyyMMdd") and callsign. Used to
|
||||||
|
// decide incremental (same callsign) vs full (first time / callsign change)
|
||||||
|
// report requests and to merge increments into the stored grid data.
|
||||||
|
private val keyLastLotwSyncDate = "lotwLastSyncDate"
|
||||||
|
private val keyLastLotwSyncCallsign = "lotwLastSyncCallsign"
|
||||||
|
|
||||||
|
override fun getLastLotwSyncDate(): String =
|
||||||
|
preferences.getString(keyLastLotwSyncDate, null).orEmpty()
|
||||||
|
|
||||||
|
override fun setLastLotwSyncDate(date: String) =
|
||||||
|
preferences.edit { putString(keyLastLotwSyncDate, date) }
|
||||||
|
|
||||||
|
override fun getLastLotwSyncCallsign(): String =
|
||||||
|
preferences.getString(keyLastLotwSyncCallsign, null).orEmpty()
|
||||||
|
|
||||||
|
override fun setLastLotwSyncCallsign(callsign: String) =
|
||||||
|
preferences.edit { putString(keyLastLotwSyncCallsign, callsign.trim().uppercase()) }
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # Transceivers settings
|
||||||
private val _passesSettings = MutableStateFlow(getPassesSettings())
|
private val _passesSettings = MutableStateFlow(getPassesSettings())
|
||||||
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
|
override val passesSettings: StateFlow<PassesSettings> = _passesSettings
|
||||||
|
|
||||||
override fun setPassesSettings(settings: PassesSettings) = preferences.edit {
|
override fun setPassesSettings(settings: PassesSettings) = preferences.edit {
|
||||||
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
|
putBoolean(keyFilterShowDeepSpace, settings.showDeepSpace)
|
||||||
putInt(keyFilterHoursAhead, settings.hoursAhead)
|
putInt(keyFilterHoursAhead, settings.hoursAhead)
|
||||||
|
putInt(keyFilterHoursBefore, settings.hoursBefore)
|
||||||
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
|
putLong(keyFilterMinElevation, settings.minElevation.toRawBits())
|
||||||
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
putInt(keyFilterAosStartMinute, settings.aosStartMinute)
|
||||||
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
putInt(keyFilterAosEndMinute, settings.aosEndMinute)
|
||||||
@@ -151,6 +405,7 @@ class SettingsRepo(
|
|||||||
private fun getPassesSettings(): PassesSettings {
|
private fun getPassesSettings(): PassesSettings {
|
||||||
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
|
val showDeepSpace = preferences.getBoolean(keyFilterShowDeepSpace, true)
|
||||||
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
|
val hoursAhead = preferences.getInt(keyFilterHoursAhead, 24)
|
||||||
|
val hoursBefore = preferences.getInt(keyFilterHoursBefore, 0).coerceIn(0, 240)
|
||||||
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
|
val minElevation = Double.fromBits(preferences.getLong(keyFilterMinElevation, 16.0.toRawBits()))
|
||||||
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
|
val aosStartMinute = preferences.getInt(keyFilterAosStartMinute, 0).coerceIn(0, 23 * 60 + 59)
|
||||||
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
|
val aosEndMinute = preferences.getInt(keyFilterAosEndMinute, 23 * 60 + 59).coerceIn(0, 23 * 60 + 59)
|
||||||
@@ -164,7 +419,8 @@ class SettingsRepo(
|
|||||||
aosStartMinute,
|
aosStartMinute,
|
||||||
aosEndMinute,
|
aosEndMinute,
|
||||||
invertAosTimeWindow,
|
invertAosTimeWindow,
|
||||||
selectedModes
|
selectedModes,
|
||||||
|
hoursBefore
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
@@ -187,41 +443,23 @@ class SettingsRepo(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GPS 定位: 一次性 getCurrentLocation(GPS 优先, 15 秒超时), 拿到位置才返回 true */
|
override fun setStationPosition(): Boolean {
|
||||||
override suspend fun setStationPosition(): Boolean {
|
|
||||||
// 权限前置: 无定位权限直接失败(不吞异常)
|
|
||||||
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
|
||||||
return kotlinx.coroutines.suspendCancellableCoroutine { cont ->
|
try {
|
||||||
val signal = android.os.CancellationSignal()
|
|
||||||
val handler = android.os.Handler(android.os.Looper.getMainLooper())
|
|
||||||
val executor = java.util.concurrent.Executor { handler.post(it) }
|
|
||||||
// 15 秒超时
|
|
||||||
val timeout = handler.postDelayed({
|
|
||||||
signal.cancel()
|
|
||||||
if (cont.isActive) cont.resume(false) { }
|
|
||||||
}, 15_000L)
|
|
||||||
val listener = androidx.core.util.Consumer<Location> { location ->
|
|
||||||
handler.removeCallbacksAndMessages(null)
|
|
||||||
setStationPosition(location.latitude, location.longitude, location.altitude)
|
|
||||||
if (cont.isActive) cont.resume(true) { }
|
|
||||||
}
|
|
||||||
val hasGps = LocationManagerCompat.hasProvider(locationManager, providerGps)
|
val hasGps = LocationManagerCompat.hasProvider(locationManager, providerGps)
|
||||||
val provider = if (hasGps) providerGps else providerNet
|
val hasNet = LocationManagerCompat.hasProvider(locationManager, providerNet)
|
||||||
try {
|
val provider = if (hasGps) providerGps else if (hasNet) providerNet else providerDef
|
||||||
LocationManagerCompat.getCurrentLocation(locationManager, provider, signal, executor, listener)
|
val location = locationManager.getLastKnownLocation(providerDef)
|
||||||
} catch (exception: SecurityException) {
|
if (location == null || System.currentTimeMillis() - location.time > 600_000L) {
|
||||||
handler.removeCallbacksAndMessages(null)
|
println("Requesting location for $provider provider")
|
||||||
if (cont.isActive) cont.resume(false) { }
|
locationManager.requestLocationUpdates(provider, 0L, 0f, this)
|
||||||
|
} else {
|
||||||
|
setStationPosition(location.latitude, location.longitude, location.altitude)
|
||||||
}
|
}
|
||||||
cont.invokeOnCancellation { signal.cancel(); handler.removeCallbacksAndMessages(null) }
|
} catch (exception: SecurityException) {
|
||||||
|
println("No permissions were given - $exception")
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setStationPosition(locator: String): Boolean {
|
override fun setStationPosition(locator: String): Boolean {
|
||||||
@@ -240,8 +478,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(5)
|
val newLat = latitude.round(4)
|
||||||
val newLon = longitude.round(5)
|
val newLon = longitude.round(4)
|
||||||
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")
|
||||||
@@ -277,7 +515,9 @@ class SettingsRepo(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
||||||
if (type == "All") return
|
// "All" is a real TLE source type (CelesTrak active group) whose ids
|
||||||
|
// must be persisted like any other type — skipping it made the "All"
|
||||||
|
// filter resolve to an empty set and show nothing.
|
||||||
val typesString = ids.joinToString(separatorComma)
|
val typesString = ids.joinToString(separatorComma)
|
||||||
preferences.edit { putString("type$type", typesString) }
|
preferences.edit { putString("type$type", typesString) }
|
||||||
}
|
}
|
||||||
@@ -325,6 +565,10 @@ class SettingsRepo(
|
|||||||
override val rcSettings: StateFlow<RCSettings> = _rcSettings
|
override val rcSettings: StateFlow<RCSettings> = _rcSettings
|
||||||
|
|
||||||
override fun updateRCSettings(settings: RCSettings) {
|
override fun updateRCSettings(settings: RCSettings) {
|
||||||
|
val clampedFreqOffsetHz = settings.frequencyOffsetHz.coerceIn(
|
||||||
|
Constants.FREQ_OFFSET_MIN_HZ,
|
||||||
|
Constants.FREQ_OFFSET_MAX_HZ
|
||||||
|
)
|
||||||
preferences.edit {
|
preferences.edit {
|
||||||
putBoolean(keyRotatorState, settings.rotatorState)
|
putBoolean(keyRotatorState, settings.rotatorState)
|
||||||
putString(keyRotatorAddress, settings.rotatorAddress)
|
putString(keyRotatorAddress, settings.rotatorAddress)
|
||||||
@@ -334,6 +578,7 @@ class SettingsRepo(
|
|||||||
putString(keyFrequencyAddress, settings.frequencyAddress)
|
putString(keyFrequencyAddress, settings.frequencyAddress)
|
||||||
putString(keyFrequencyPort, settings.frequencyPort)
|
putString(keyFrequencyPort, settings.frequencyPort)
|
||||||
putString(keyFrequencyFormat, settings.frequencyFormat)
|
putString(keyFrequencyFormat, settings.frequencyFormat)
|
||||||
|
putLong(keyFrequencyOffsetHz, clampedFreqOffsetHz)
|
||||||
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
|
putBoolean(keyBluetoothRotatorState, settings.bluetoothRotatorState)
|
||||||
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
|
putString(keyBluetoothRotatorFormat, settings.bluetoothRotatorFormat)
|
||||||
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
|
putString(keyBluetoothRotatorName, settings.bluetoothRotatorName)
|
||||||
@@ -342,7 +587,7 @@ class SettingsRepo(
|
|||||||
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
|
putString(keyBluetoothFrequencyFormat, settings.bluetoothFrequencyFormat)
|
||||||
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
|
putString(keyBluetoothFrequencyAddress, settings.bluetoothFrequencyAddress)
|
||||||
}
|
}
|
||||||
_rcSettings.value = settings
|
_rcSettings.value = settings.copy(frequencyOffsetHz = clampedFreqOffsetHz)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getRCSettings(): RCSettings = RCSettings(
|
private fun getRCSettings(): RCSettings = RCSettings(
|
||||||
@@ -354,6 +599,8 @@ class SettingsRepo(
|
|||||||
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
|
frequencyAddress = preferences.getString(keyFrequencyAddress, null) ?: "127.0.0.1",
|
||||||
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
|
frequencyPort = preferences.getString(keyFrequencyPort, null) ?: "4532",
|
||||||
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
|
frequencyFormat = preferences.getString(keyFrequencyFormat, null) ?: $$"F $FREQ",
|
||||||
|
frequencyOffsetHz = preferences.getLong(keyFrequencyOffsetHz, 0L)
|
||||||
|
.coerceIn(Constants.FREQ_OFFSET_MIN_HZ, Constants.FREQ_OFFSET_MAX_HZ),
|
||||||
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
|
bluetoothRotatorState = preferences.getBoolean(keyBluetoothRotatorState, false),
|
||||||
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
|
bluetoothRotatorFormat = preferences.getString(keyBluetoothRotatorFormat, null) ?: $$"P $AZ $EL",
|
||||||
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
bluetoothRotatorName = preferences.getString(keyBluetoothRotatorName, null) ?: "Default",
|
||||||
@@ -370,26 +617,30 @@ class SettingsRepo(
|
|||||||
|
|
||||||
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) {
|
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) {
|
||||||
_otherSettings.update { current ->
|
_otherSettings.update { current ->
|
||||||
val new = transform(current)
|
val new = transform(current).let {
|
||||||
|
it.copy(
|
||||||
|
mapSource = MapSource.normalize(it.mapSource),
|
||||||
|
tiandituKey = it.tiandituKey.trim()
|
||||||
|
)
|
||||||
|
}
|
||||||
preferences.edit {
|
preferences.edit {
|
||||||
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
|
putBoolean(keyStateOfAutoUpdate, new.stateOfAutoUpdate)
|
||||||
|
putBoolean(keyStateOfAutoLotwSync, new.stateOfAutoLotwSync)
|
||||||
putBoolean(keyStateOfSensors, new.stateOfSensors)
|
putBoolean(keyStateOfSensors, new.stateOfSensors)
|
||||||
|
putFloat(keyCompassOffsetDegrees, new.compassOffsetDegrees.coerceIn(-180f, 180f))
|
||||||
putBoolean(keyStateOfSweep, new.stateOfSweep)
|
putBoolean(keyStateOfSweep, new.stateOfSweep)
|
||||||
putBoolean(keyStateOfUtc, new.stateOfUtc)
|
putBoolean(keyStateOfUtc, new.stateOfUtc)
|
||||||
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
|
putBoolean(keyStateOfLightTheme, new.stateOfLightTheme)
|
||||||
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
|
putBoolean(keyStateOfNightMode, new.stateOfNightMode)
|
||||||
|
putBoolean(keyStateOfMapGrid, new.stateOfMapGrid)
|
||||||
|
putBoolean(keyStateOfMapFirstCall, new.stateOfMapFirstCall)
|
||||||
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
putBoolean(keyShouldSeeWarning, new.shouldSeeWarning)
|
||||||
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
putBoolean(keyShouldSeeWhatsNew, new.shouldSeeWhatsNew)
|
||||||
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(keyMapSource, new.mapSource)
|
||||||
putString(keyScreenOrder, new.screenOrder.joinToString(","))
|
putString(keyTiandituKey, new.tiandituKey)
|
||||||
putString(keySubMenuOrder, new.subMenuOrder.joinToString(","))
|
|
||||||
putString(keyWavelogUrl, new.wavelogUrl)
|
|
||||||
putString(keyWavelogApiKey, new.wavelogApiKey)
|
|
||||||
putString(keyWavelogStationId, new.wavelogStationId)
|
|
||||||
putBoolean(keyWavelogAutoUpload, new.wavelogAutoUpload)
|
|
||||||
}
|
}
|
||||||
new
|
new
|
||||||
}
|
}
|
||||||
@@ -397,23 +648,22 @@ class SettingsRepo(
|
|||||||
|
|
||||||
private fun getOtherSettings(): OtherSettings = OtherSettings(
|
private fun getOtherSettings(): OtherSettings = OtherSettings(
|
||||||
stateOfAutoUpdate = preferences.getBoolean(keyStateOfAutoUpdate, true),
|
stateOfAutoUpdate = preferences.getBoolean(keyStateOfAutoUpdate, true),
|
||||||
|
stateOfAutoLotwSync = preferences.getBoolean(keyStateOfAutoLotwSync, true),
|
||||||
stateOfSensors = preferences.getBoolean(keyStateOfSensors, true),
|
stateOfSensors = preferences.getBoolean(keyStateOfSensors, true),
|
||||||
|
compassOffsetDegrees = preferences.getFloat(keyCompassOffsetDegrees, 0f).coerceIn(-180f, 180f),
|
||||||
stateOfSweep = preferences.getBoolean(keyStateOfSweep, true),
|
stateOfSweep = preferences.getBoolean(keyStateOfSweep, true),
|
||||||
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
|
stateOfUtc = preferences.getBoolean(keyStateOfUtc, false),
|
||||||
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
|
stateOfLightTheme = preferences.getBoolean(keyStateOfLightTheme, false),
|
||||||
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
stateOfNightMode = preferences.getBoolean(keyStateOfNightMode, false),
|
||||||
|
stateOfMapGrid = preferences.getBoolean(keyStateOfMapGrid, false),
|
||||||
|
stateOfMapFirstCall = preferences.getBoolean(keyStateOfMapFirstCall, false),
|
||||||
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
shouldSeeWarning = preferences.getBoolean(keyShouldSeeWarning, true),
|
||||||
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
shouldSeeWhatsNew = preferences.getBoolean(keyShouldSeeWhatsNew, true),
|
||||||
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
sstvMode = preferences.getString(keySstvMode, null) ?: "Auto",
|
||||||
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) ?: "",
|
mapSource = MapSource.normalize(preferences.getString(keyMapSource, null).orEmpty()),
|
||||||
wavelogApiKey = preferences.getString(keyWavelogApiKey, null) ?: "",
|
tiandituKey = preferences.getString(keyTiandituKey, null).orEmpty().trim()
|
||||||
wavelogStationId = preferences.getString(keyWavelogStationId, null) ?: "",
|
|
||||||
wavelogAutoUpload = preferences.getBoolean(keyWavelogAutoUpload, false)
|
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -422,28 +672,130 @@ class SettingsRepo(
|
|||||||
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
|
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = _dataSourcesSettings
|
||||||
|
|
||||||
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
|
override fun updateDataSourcesSettings(settings: DataSourcesSettings) {
|
||||||
|
// Normalize the enabled lists so they are positionally aligned with the URL lists.
|
||||||
|
// Missing entries default to enabled (true), keeping the persisted "one flag per URL"
|
||||||
|
// invariant intact even when a default empty list is used to construct the model.
|
||||||
|
val normalized = settings.copy(
|
||||||
|
satelliteEnabled = alignFlags(settings.satelliteUrls, settings.satelliteEnabled),
|
||||||
|
transceiversEnabled = alignFlags(settings.transceiversUrls, settings.transceiversEnabled)
|
||||||
|
)
|
||||||
preferences.edit {
|
preferences.edit {
|
||||||
putBoolean(keyUseCustomTle, settings.useCustomTLE)
|
putString(keySatelliteUrls, normalized.satelliteUrls.joinToString(separatorUrl))
|
||||||
putBoolean(keyUseCustomTransceivers, settings.useCustomTransceivers)
|
putString(keyTransceiversUrls, normalized.transceiversUrls.joinToString(separatorUrl))
|
||||||
putString(keyTleUrl, settings.tleUrl)
|
putString(keySatelliteEnabled, normalized.satelliteEnabled.joinToString(separatorComma))
|
||||||
putString(keyTransceiversUrl, settings.transceiversUrl)
|
putString(keyTransceiversEnabled, normalized.transceiversEnabled.joinToString(separatorComma))
|
||||||
|
putBoolean(keySatnogsTleSourceMigration, true)
|
||||||
|
putBoolean(keyAutoTleSourceMigration, true)
|
||||||
}
|
}
|
||||||
_dataSourcesSettings.value = settings
|
_dataSourcesSettings.value = normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDataSourcesSettings(): DataSourcesSettings {
|
private fun getDataSourcesSettings(): DataSourcesSettings = DataSourcesSettings(
|
||||||
// 4.4.8 修复: 旧版 example.com 占位 URL 视为未配置 -> 替换为真实默认 URL 且开关强制关闭,
|
satelliteUrls = getDataSourceUrls(
|
||||||
// 否则在线更新的 All/SatNOGS 源会指向错误地址导致更新失败
|
key = keySatelliteUrls,
|
||||||
val storedTleUrl = preferences.getString(keyTleUrl, Sources.defaultTleUrl) ?: Sources.defaultTleUrl
|
defaultUrls = Sources.satelliteDataUrls.values.filter { it.isNotBlank() },
|
||||||
val storedTxUrl = preferences.getString(keyTransceiversUrl, Sources.defaultTransceiversUrl) ?: Sources.defaultTransceiversUrl
|
legacyEnabledKey = keyUseCustomTle,
|
||||||
val tleUrl = if (storedTleUrl == "https://example.com/tle.txt") Sources.defaultTleUrl else storedTleUrl
|
legacyUrlKey = keyTleUrl
|
||||||
val txUrl = if (storedTxUrl == "https://example.com/radio.json") Sources.defaultTransceiversUrl else storedTxUrl
|
).migrateSatnogsTleSource().migrateAutoTleSources(),
|
||||||
return DataSourcesSettings(
|
transceiversUrls = getDataSourceUrls(
|
||||||
useCustomTLE = preferences.getBoolean(keyUseCustomTle, false) && tleUrl != Sources.defaultTleUrl,
|
key = keyTransceiversUrls,
|
||||||
useCustomTransceivers = preferences.getBoolean(keyUseCustomTransceivers, false) && txUrl != Sources.defaultTransceiversUrl,
|
defaultUrls = Sources.transceiversDataUrls.values.filter { it.isNotBlank() },
|
||||||
tleUrl = tleUrl,
|
legacyEnabledKey = keyUseCustomTransceivers,
|
||||||
transceiversUrl = txUrl
|
legacyUrlKey = keyTransceiversUrl
|
||||||
)
|
),
|
||||||
|
satelliteEnabled = readEnabledFlags(keySatelliteEnabled),
|
||||||
|
transceiversEnabled = readEnabledFlags(keyTransceiversEnabled)
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Read the persisted per-source enabled flags (empty when never stored). */
|
||||||
|
private fun readEnabledFlags(key: String): List<Boolean> {
|
||||||
|
return preferences.getString(key, null)
|
||||||
|
?.split(separatorComma)
|
||||||
|
?.mapNotNull { it.trim() }
|
||||||
|
?.filter { it == "true" || it == "false" }
|
||||||
|
?.map { it == "true" }
|
||||||
|
?: emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the flags positionally aligned with the URL list, defaulting to enabled. */
|
||||||
|
private fun alignFlags(urls: List<String>, flags: List<Boolean>): List<Boolean> {
|
||||||
|
if (flags.size >= urls.size) return flags.take(urls.size)
|
||||||
|
return flags + List(urls.size - flags.size) { true }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDataSourceUrls(
|
||||||
|
key: String,
|
||||||
|
defaultUrls: List<String>,
|
||||||
|
legacyEnabledKey: String,
|
||||||
|
legacyUrlKey: String
|
||||||
|
): List<String> {
|
||||||
|
val stored = preferences.getString(key, null)
|
||||||
|
?.split(separatorUrl)
|
||||||
|
?.map { it.trim() }
|
||||||
|
?.filter { it.isNotBlank() }
|
||||||
|
if (stored != null) return stored
|
||||||
|
val legacyCustomUrl = preferences.getString(legacyUrlKey, null)?.trim().orEmpty()
|
||||||
|
return if (preferences.getBoolean(legacyEnabledKey, false) && legacyCustomUrl.isNotBlank()) {
|
||||||
|
(defaultUrls + legacyCustomUrl).distinct()
|
||||||
|
} else {
|
||||||
|
defaultUrls
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<String>.migrateSatnogsTleSource(): List<String> {
|
||||||
|
if (preferences.getBoolean(keySatnogsTleSourceMigration, false)) return this
|
||||||
|
val satnogsTleUrl = Sources.satelliteDataUrls["SatNOGS"].orEmpty()
|
||||||
|
if (satnogsTleUrl.isBlank() || containsSourceUrl(satnogsTleUrl)) return this
|
||||||
|
val celestrakSatnogsIndex = indexOfFirst { isSameSourceUrl(it, legacyCelestrakSatnogsUrl) }
|
||||||
|
if (celestrakSatnogsIndex < 0) return this
|
||||||
|
|
||||||
|
val migrated = toMutableList().apply { add(celestrakSatnogsIndex + 1, satnogsTleUrl) }
|
||||||
|
preferences.edit {
|
||||||
|
putString(keySatelliteUrls, migrated.joinToString(separatorUrl))
|
||||||
|
putBoolean(keySatnogsTleSourceMigration, true)
|
||||||
|
}
|
||||||
|
return migrated
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<String>.migrateAutoTleSources(): List<String> {
|
||||||
|
if (preferences.getBoolean(keyAutoTleSourceMigration, false)) return this
|
||||||
|
val autoTleUrls = listOfNotNull(
|
||||||
|
Sources.satelliteDataUrls["BI4PYM AutoTLE (GitHub)"],
|
||||||
|
Sources.satelliteDataUrls["BI4PYM AutoTLE (Mirror)"]
|
||||||
|
).filter { it.isNotBlank() }
|
||||||
|
val missingUrls = autoTleUrls.filterNot { containsSourceUrl(it) }
|
||||||
|
if (missingUrls.isEmpty()) {
|
||||||
|
preferences.edit { putBoolean(keyAutoTleSourceMigration, true) }
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
val migrated = this + missingUrls
|
||||||
|
val enabled = alignFlags(migrated, readEnabledFlags(keySatelliteEnabled))
|
||||||
|
preferences.edit {
|
||||||
|
putString(keySatelliteUrls, migrated.joinToString(separatorUrl))
|
||||||
|
putString(keySatelliteEnabled, enabled.joinToString(separatorComma))
|
||||||
|
putBoolean(keyAutoTleSourceMigration, true)
|
||||||
|
}
|
||||||
|
return migrated
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<String>.containsSourceUrl(url: String): Boolean = any { isSameSourceUrl(it, url) }
|
||||||
|
|
||||||
|
private fun isSameSourceUrl(first: String, second: String): Boolean =
|
||||||
|
normalizeSourceUrl(first).equals(normalizeSourceUrl(second), ignoreCase = true)
|
||||||
|
|
||||||
|
private fun normalizeSourceUrl(url: String): String {
|
||||||
|
val trimmed = url.trim()
|
||||||
|
return if (trimmed.startsWith("http", ignoreCase = true)) trimmed else "https://$trimmed"
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # Data sources status
|
||||||
|
private val _dataSourcesStatus = MutableStateFlow<Map<String, Int>>(emptyMap())
|
||||||
|
override val dataSourcesStatus: StateFlow<Map<String, Int>> = _dataSourcesStatus
|
||||||
|
|
||||||
|
override fun updateDataSourcesStatus(status: Map<String, Int>) {
|
||||||
|
_dataSourcesStatus.value = status
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -485,4 +837,80 @@ class SettingsRepo(
|
|||||||
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
splitMode = preferences.getBoolean(keyRadioSplitMode, false)
|
||||||
)
|
)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
//region # Per-satellite calculator offset settings
|
||||||
|
private val keySatelliteOffsets = "satelliteOffsets"
|
||||||
|
private val keyLegacySatelliteOffsetPrefix = "offset_khz_"
|
||||||
|
|
||||||
|
override fun getSatelliteOffset(catnum: Int): String {
|
||||||
|
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
||||||
|
val stored = try {
|
||||||
|
JSONObject(json).optString(catnum.toString(), "")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
if (stored.isNotEmpty()) return stored
|
||||||
|
|
||||||
|
// Lazy migration from the fork's old UI-layer implementation, which stored
|
||||||
|
// one SharedPreferences entry per satellite directly from TransceiversPage.
|
||||||
|
val legacyKey = "$keyLegacySatelliteOffsetPrefix$catnum"
|
||||||
|
val legacy = preferences.getString(legacyKey, "").orEmpty()
|
||||||
|
if (legacy.isNotEmpty()) {
|
||||||
|
setSatelliteOffset(catnum, legacy)
|
||||||
|
}
|
||||||
|
return legacy
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setSatelliteOffset(catnum: Int, offset: String) {
|
||||||
|
val json = preferences.getString(keySatelliteOffsets, "{}") ?: "{}"
|
||||||
|
val updated = try {
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
if (offset.isEmpty()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), offset)
|
||||||
|
obj.toString()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
if (offset.isEmpty()) "{}" else """{"$catnum": "$offset"}"""
|
||||||
|
}
|
||||||
|
preferences.edit {
|
||||||
|
putString(keySatelliteOffsets, updated)
|
||||||
|
remove("$keyLegacySatelliteOffsetPrefix$catnum")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # Per-satellite logbook mode preset settings
|
||||||
|
private val keySatelliteModes = "satelliteModes"
|
||||||
|
|
||||||
|
override fun getSatelliteMode(catnum: Int): String {
|
||||||
|
val json = preferences.getString(keySatelliteModes, "{}") ?: "{}"
|
||||||
|
return try {
|
||||||
|
JSONObject(json).optString(catnum.toString(), "")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setSatelliteMode(catnum: Int, mode: String) {
|
||||||
|
val json = preferences.getString(keySatelliteModes, "{}") ?: "{}"
|
||||||
|
val updated = try {
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
if (mode.isBlank()) obj.remove(catnum.toString()) else obj.put(catnum.toString(), mode.uppercase())
|
||||||
|
obj.toString()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
if (mode.isBlank()) "{}" else """{"$catnum": "${mode.uppercase()}"}"""
|
||||||
|
}
|
||||||
|
preferences.edit { putString(keySatelliteModes, updated) }
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # AMSAT status report settings
|
||||||
|
private val keyAmSatCallsign = "amSatCallsign"
|
||||||
|
|
||||||
|
override fun getAmSatCallsign(): String {
|
||||||
|
return preferences.getString(keyAmSatCallsign, "").orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setAmSatCallsign(callsign: String) {
|
||||||
|
preferences.edit { putString(keyAmSatCallsign, callsign.trim().uppercase(Locale.US)) }
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
}
|
}
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.LatestRelease
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IUpdateRepository
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class UpdateRepository(
|
||||||
|
private val remoteSource: IRemoteSource
|
||||||
|
) : IUpdateRepository {
|
||||||
|
|
||||||
|
override suspend fun getLatestRelease(): LatestRelease? = withContext(Dispatchers.IO) {
|
||||||
|
// Use the GitHub releases web page instead of the REST API endpoint:
|
||||||
|
// the API endpoint is rate-limited to 60 requests/hour per IP, which is
|
||||||
|
// quickly exhausted on shared egress IPs (e.g. VPN proxies), causing 403
|
||||||
|
// failures. The web endpoint redirects to the latest tag with no such limit.
|
||||||
|
// When GitHub is unreachable (common on mainland-China networks without a
|
||||||
|
// proxy), fall back to community GitHub accelerator mirrors that proxy the
|
||||||
|
// same page; each mirror resolves the identical tag and asset URLs.
|
||||||
|
for (baseUrl in LATEST_RELEASE_URLS) {
|
||||||
|
val result = remoteSource.getNetworkStream(baseUrl)
|
||||||
|
val stream = result.stream ?: continue
|
||||||
|
val parsed = try {
|
||||||
|
parseRelease(stream.bufferedReader().use { it.readText() }, baseUrl)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("UpdateRepository parse failure: $e")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (parsed != null) return@withContext parsed
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun downloadApk(url: String, dest: File): Boolean = withContext(Dispatchers.IO) {
|
||||||
|
if (url.isBlank()) return@withContext false
|
||||||
|
val result = remoteSource.getNetworkStream(url)
|
||||||
|
val stream = result.stream ?: return@withContext false
|
||||||
|
try {
|
||||||
|
dest.outputStream().use { out -> stream.use { it.copyTo(out) } }
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("UpdateRepository download failure: $e")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseRelease(html: String, sourceUrl: String): LatestRelease? {
|
||||||
|
// The redirect target is the latest release's tag page. Extract the tag
|
||||||
|
// from the og:url meta tag ("…/releases/tag/v4.4.6-ba7opf.8") — stable and
|
||||||
|
// unambiguous. The <title> also holds the release name, but that is free
|
||||||
|
// text and cannot be used to build the asset URL.
|
||||||
|
val ogUrl = Regex("<meta\\s+property=\"og:url\"\\s+content=\"([^\"]*)\"").find(html)
|
||||||
|
?.groupValues?.get(1) ?: return null
|
||||||
|
val tag = Regex("/releases/tag/(v[0-9][0-9A-Za-z.\\-]*)$").find(ogUrl)
|
||||||
|
?.groupValues?.get(1) ?: return null
|
||||||
|
val title = Regex("<title>(.*?)</title>", RegexOption.DOT_MATCHES_ALL)
|
||||||
|
.find(html)?.groupValues?.get(1)?.trim()
|
||||||
|
?.substringBefore("·")?.removePrefix("Release")?.trim() ?: tag
|
||||||
|
// Release notes live in the page's markdown-body section (first occurrence
|
||||||
|
// is the release description). Strip HTML tags for plain-text display.
|
||||||
|
val raw = Regex("<div[^>]*class=\"[^\"]*markdown-body[^\"]*\"[^>]*>(.*?)</div>", RegexOption.DOT_MATCHES_ALL)
|
||||||
|
.find(html)?.groupValues?.get(1) ?: ""
|
||||||
|
val body = raw.replace(Regex("<[^>]+>"), "")
|
||||||
|
.replace("<", "<").replace(">", ">").replace("&", "&")
|
||||||
|
.replace(""", "\"").replace("'", "'")
|
||||||
|
.trim()
|
||||||
|
// The release APK asset naming: Look4Sat-<tag without leading v>.apk
|
||||||
|
// (since v4.4.6-ba7opf.9.9 the releases are uploaded with the bare
|
||||||
|
// versioned filename, no "-release" suffix). When the page was fetched
|
||||||
|
// through an accelerator mirror (https://<mirror>/https://github.com/...),
|
||||||
|
// download through the same mirror — raw github.com is unreachable on the
|
||||||
|
// networks that needed the mirror in the first place.
|
||||||
|
val mirrorPrefix = sourceUrl.substringBefore("https://github.com")
|
||||||
|
val apkName = "Look4Sat-${tag.removePrefix("v")}.apk"
|
||||||
|
val apkUrl = if (mirrorPrefix.isEmpty()) {
|
||||||
|
"$DOWNLOAD_BASE_URL/$tag/$apkName"
|
||||||
|
} else {
|
||||||
|
"${mirrorPrefix}https://github.com/atsunatsu/Look4Sat/releases/download/$tag/$apkName"
|
||||||
|
}
|
||||||
|
return LatestRelease(
|
||||||
|
versionTag = tag,
|
||||||
|
title = title,
|
||||||
|
body = body,
|
||||||
|
apkUrl = apkUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
// Web pages are used instead of api.github.com to avoid the 60 req/hour
|
||||||
|
// anonymous rate limit (see getLatestRelease above). The GitHub URL is
|
||||||
|
// tried first; if it is unreachable (no proxy on mainland networks) the
|
||||||
|
// accelerator mirrors are tried in order. Download URLs always point at
|
||||||
|
// the mirrors too — raw github.com release downloads are equally blocked
|
||||||
|
// without a proxy, so a mirror-resolved tag must be downloaded via the
|
||||||
|
// same mirror.
|
||||||
|
val LATEST_RELEASE_URLS = listOf(
|
||||||
|
"https://github.com/atsunatsu/Look4Sat/releases/latest",
|
||||||
|
"https://ghfast.top/https://github.com/atsunatsu/Look4Sat/releases/latest",
|
||||||
|
"https://gh.llkk.cc/https://github.com/atsunatsu/Look4Sat/releases/latest",
|
||||||
|
"https://github.moeyy.xyz/https://github.com/atsunatsu/Look4Sat/releases/latest"
|
||||||
|
)
|
||||||
|
const val DOWNLOAD_BASE_URL = "https://github.com/atsunatsu/Look4Sat/releases/download"
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
* 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.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.IWavelogRepository
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the list of worked gridsquares from a self-hosted Wavelog instance.
|
||||||
|
*
|
||||||
|
* Uses the Wavelog v1 API endpoint (verified against Wavelog 3.0.2):
|
||||||
|
* POST {base}/index.php/api/logbook_get_worked_grids
|
||||||
|
* Body: {"key": "<api key>", "logbook_id": "<logbook id>"}
|
||||||
|
* Response 201: JSON array of 4-char gridsquares, e.g. ["JN47","JO30"]
|
||||||
|
* (see Wavelog Api.php logbook_get_worked_grids / Api_model::get_grids_worked_in_logbook)
|
||||||
|
*
|
||||||
|
* The token field carries "<api_key>:<logbook_id>" so one credential line
|
||||||
|
* configures both values (the dialog explains this to the user).
|
||||||
|
*/
|
||||||
|
class WavelogRepository : IWavelogRepository {
|
||||||
|
|
||||||
|
override suspend fun fetchWorkedGrids(url: String, token: String): Set<String>? = withContext(Dispatchers.IO) {
|
||||||
|
val parts = token.trim().split(":")
|
||||||
|
val apiKey = parts.getOrNull(0)?.trim().orEmpty()
|
||||||
|
val logbookId = parts.getOrNull(1)?.trim().orEmpty()
|
||||||
|
if (url.isBlank() || apiKey.isBlank() || logbookId.isBlank()) return@withContext null
|
||||||
|
val base = url.trim().trimEnd('/')
|
||||||
|
val requestUrl = "$base/index.php/api/logbook_get_worked_grids"
|
||||||
|
try {
|
||||||
|
val connection = java.net.URL(requestUrl).openConnection() as java.net.HttpURLConnection
|
||||||
|
connection.connectTimeout = 10_000
|
||||||
|
connection.readTimeout = 15_000
|
||||||
|
connection.requestMethod = "POST"
|
||||||
|
connection.doOutput = true
|
||||||
|
connection.setRequestProperty("Content-Type", "application/json")
|
||||||
|
val body = JSONObject().apply {
|
||||||
|
put("key", apiKey)
|
||||||
|
put("logbook_id", logbookId)
|
||||||
|
}.toString().toByteArray()
|
||||||
|
connection.outputStream.use { it.write(body) }
|
||||||
|
val code = connection.responseCode
|
||||||
|
if (code !in 200..299) {
|
||||||
|
connection.disconnect()
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
val response = connection.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
connection.disconnect()
|
||||||
|
parseWorkedGrids(response)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println("WavelogRepository fetch failure: $e")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun parseWorkedGrids(body: String): Set<String>? {
|
||||||
|
return try {
|
||||||
|
val array = when {
|
||||||
|
body.trimStart().startsWith("[") -> JSONArray(body)
|
||||||
|
else -> {
|
||||||
|
// Tolerate {"grids": [...]} wrappers too
|
||||||
|
JSONObject(body).optJSONArray("grids") ?: return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val result = mutableSetOf<String>()
|
||||||
|
for (i in 0 until array.length()) {
|
||||||
|
val grid = array.optString(i, "").trim().uppercase()
|
||||||
|
// Keep only well-formed 4-character Maidenhead squares (e.g. OL62)
|
||||||
|
if (grid.length == 4 && grid[0] in 'A'..'R' && grid[1] in 'A'..'R'
|
||||||
|
&& grid[2] in '0'..'9' && grid[3] in '0'..'9'
|
||||||
|
) {
|
||||||
|
result.add(grid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
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 卫星状态页解析器(https://amsat.org/status/)
|
|
||||||
*
|
|
||||||
* 页面结构(静态 HTML, 2026-08 实测):
|
|
||||||
* - 状态表: <table> 内 48 行, 表头 = Name + 6 天(每个 colspan=12)
|
|
||||||
* 数据行 73 格: [0]=卫星名, [1..72] = 6 天 × 12 个 2 小时槽(新→旧)
|
|
||||||
* 每格 <td width=9 bgcolor="颜色">数字</td>, 有报告时数字带
|
|
||||||
* docTips.show('id') 链接
|
|
||||||
* - 报告详情在页面内嵌 JS:
|
|
||||||
* tips.a885153 = new Array(5,5,120,'状态<br>呼号<br>网格<br>日期<br>时间段 UTC')
|
|
||||||
*
|
|
||||||
* 状态色: #648fff=活跃, #ffb000=仅遥测, #dc267f=未听到, #fe6100=矛盾
|
|
||||||
*/
|
|
||||||
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*'([^']*)'"
|
|
||||||
)
|
|
||||||
|
|
||||||
/** 解析完整页面 */
|
|
||||||
fun parse(html: String, fetchedAtUtcMs: Long): SatStatusPage {
|
|
||||||
val reports = parseReports(html)
|
|
||||||
val statuses = parseStatusTable(html, reports)
|
|
||||||
return SatStatusPage(fetchedAtUtcMs, statuses, reports)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 提取全部报告(tips.* JS 数组) */
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 解析状态表(48 行卫星) */
|
|
||||||
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) {
|
|
||||||
// 表头: Name + 6 天(每个 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()
|
|
||||||
}
|
|
||||||
@@ -48,15 +48,17 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
|||||||
override suspend fun deleteEntries() = look4SatDao.deleteEntries()
|
override suspend fun deleteEntries() = look4SatDao.deleteEntries()
|
||||||
|
|
||||||
override suspend fun getIdsWithModes(modes: List<String>) = look4SatDao.getIdsWithModes(modes)
|
override suspend fun getIdsWithModes(modes: List<String>) = look4SatDao.getIdsWithModes(modes)
|
||||||
|
override suspend fun getIdsWithModesAndUplink(modes: List<String>) = look4SatDao.getIdsWithModesAndUplink(modes)
|
||||||
|
override suspend fun getIdsWithModesAndAmateur(modes: List<String>) = look4SatDao.getIdsWithModesAndAmateur(modes)
|
||||||
|
|
||||||
private fun FrameworkEntry.toDomain() = OrbitalData(
|
private fun FrameworkEntry.toDomain() = OrbitalData(
|
||||||
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
||||||
this.raan, this.argper, this.meanan, this.catnum, this.bstar
|
this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun OrbitalData.toEntity() = FrameworkEntry(
|
private fun OrbitalData.toEntity() = FrameworkEntry(
|
||||||
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
this.name, this.epoch, this.meanmo, this.eccn, this.incl,
|
||||||
this.raan, this.argper, this.meanan, this.catnum, this.bstar
|
this.raan, this.argper, this.meanan, this.catnum, this.bstar, this.ndot
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() }
|
private fun List<OrbitalData>.toEntity() = this.map { item -> item.toEntity() }
|
||||||
@@ -73,20 +75,24 @@ class LocalSource(private val look4SatDao: Look4SatDao) : ILocalSource {
|
|||||||
return look4SatDao.getRadiosWithId(id).toDomainRadios()
|
return look4SatDao.getRadiosWithId(id).toDomainRadios()
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
|
||||||
look4SatDao.insertRadios(radios.toFrameworkRadios())
|
look4SatDao.insertRadios(radios.map { it.copy(isCustom = isCustom) }.toFrameworkRadios())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteManagedRadios() = look4SatDao.deleteManagedRadios()
|
||||||
|
|
||||||
override suspend fun deleteRadios() = look4SatDao.deleteRadios()
|
override suspend fun deleteRadios() = look4SatDao.deleteRadios()
|
||||||
|
|
||||||
private fun DomainRadio.toFramework() = FrameworkRadio(
|
private fun DomainRadio.toFramework() = FrameworkRadio(
|
||||||
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
||||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
|
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum,
|
||||||
|
this.service, this.isCustom
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun FrameworkRadio.toDomain() = DomainRadio(
|
private fun FrameworkRadio.toDomain() = DomainRadio(
|
||||||
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
this.uuid, this.info, this.isAlive, this.downlinkLow, this.downlinkHigh, this.downlinkMode,
|
||||||
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum
|
this.uplinkLow, this.uplinkHigh, this.uplinkMode, this.isInverted, this.catnum,
|
||||||
|
this.service, this.isCustom
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() }
|
private fun List<DomainRadio>.toFrameworkRadios() = this.map { radio -> radio.toFramework() }
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ package com.rtbishop.look4sat.core.data.source
|
|||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import java.io.ByteArrayInputStream
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
class RemoteSource(
|
class RemoteSource(
|
||||||
@@ -43,31 +45,74 @@ class RemoteSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getStatusHtml(): String? = withContext(dispatcher) {
|
override suspend fun getNetworkStream(url: String): NetworkResult = withContext(dispatcher) {
|
||||||
|
try {
|
||||||
|
val networkRequest = Request.Builder().url(url).build()
|
||||||
|
val response = httpClient.newCall(networkRequest).execute()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val code = response.code
|
||||||
|
response.close()
|
||||||
|
return@withContext NetworkResult(code, null)
|
||||||
|
}
|
||||||
|
// Return the body stream directly as the caller is responsible for closing it.
|
||||||
|
// Closing the stream returns the connection to OkHttp's pool.
|
||||||
|
val body = response.body
|
||||||
|
if (body == null) {
|
||||||
|
response.close()
|
||||||
|
return@withContext NetworkResult(response.code, null)
|
||||||
|
}
|
||||||
|
NetworkResult(response.code, body.byteStream().buffered())
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
println("RemoteSource network stream exception: $exception")
|
||||||
|
NetworkResult(NetworkResult.CONNECTION_ERROR, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAmSatCatalog(): String? = withContext(dispatcher) {
|
||||||
try {
|
try {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url("https://amsat.org/status/")
|
.url("https://www.amsat.org/status/api/v1/catalog.php")
|
||||||
.header("User-Agent", "Mozilla/5.0 (Linux; Android 13) Look4Sat/4.5")
|
.header("User-Agent", "Look4Sat")
|
||||||
.build()
|
.build()
|
||||||
httpClient.newCall(request).execute().use { response ->
|
httpClient.newCall(request).execute().use { response ->
|
||||||
if (!response.isSuccessful) return@use null
|
if (!response.isSuccessful) return@use null
|
||||||
response.body?.string()
|
response.body.string()
|
||||||
}
|
}
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
println("RemoteSource amsat status exception: $exception")
|
println("RemoteSource getAmSatCatalog exception: $exception")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getNetworkStream(url: String): InputStream? = withContext(dispatcher) {
|
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = withContext(dispatcher) {
|
||||||
try {
|
try {
|
||||||
val networkRequest = Request.Builder().url(url).build()
|
val request = Request.Builder()
|
||||||
httpClient.newCall(networkRequest).execute().use { response ->
|
.url("https://www.amsat.org/status/api/v1/reports.php?hours=$hours&limit=$limit")
|
||||||
if (!response.isSuccessful) return@withContext null
|
.header("User-Agent", "Look4Sat")
|
||||||
ByteArrayInputStream(response.body.bytes())
|
.build()
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) return@use null
|
||||||
|
response.body.string()
|
||||||
}
|
}
|
||||||
} catch (exception: Exception) {
|
} catch (exception: Exception) {
|
||||||
println("RemoteSource network stream exception: $exception")
|
println("RemoteSource getAmSatReports exception: $exception")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = withContext(dispatcher) {
|
||||||
|
try {
|
||||||
|
val body = payloadJson.toRequestBody("application/json; charset=utf-8".toMediaType())
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("https://www.amsat.org/status/api/v1/reports.php")
|
||||||
|
.header("User-Agent", "Look4Sat")
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
httpClient.newCall(request).execute().use { response ->
|
||||||
|
response.code to response.body.string()
|
||||||
|
}
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
println("RemoteSource submitAmSatReport exception: $exception")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,4 @@ 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,78 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.lotw
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWZonePair
|
||||||
|
import java.io.File
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/** Parses the real bundled ARRL config.tq6 to verify region-field and zonemap extraction. */
|
||||||
|
class LoTWConfigTest {
|
||||||
|
|
||||||
|
private val config = LoTWConfig(File("src/main/assets/lotw/config.tq6").inputStream())
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `china exposes province field with all 31 options and zonemaps`() {
|
||||||
|
val meta = config.stationMeta(318)
|
||||||
|
assertEquals("CN_PROVINCE", meta.regionField?.id)
|
||||||
|
assertEquals("Province", meta.regionField?.label)
|
||||||
|
assertEquals(31, meta.regionField?.options?.size)
|
||||||
|
val gd = meta.regionField?.options?.first { it.code == "GD" }
|
||||||
|
assertEquals("Guangdong", gd?.name)
|
||||||
|
assertEquals(listOf(LoTWZonePair(44, 24)), gd?.zones)
|
||||||
|
val gx = meta.regionField?.options?.first { it.code == "GX" }
|
||||||
|
assertEquals(listOf(LoTWZonePair(43, 24), LoTWZonePair(44, 24)), gx?.zones)
|
||||||
|
// China spans 8 zone pairs — the UI must NOT prefill from the national map.
|
||||||
|
assertTrue(meta.countryZones.size > 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `usa exposes state field with 49 continental options`() {
|
||||||
|
val meta = config.stationMeta(291)
|
||||||
|
assertEquals("US_STATE", meta.regionField?.id)
|
||||||
|
assertEquals("State", meta.regionField?.label)
|
||||||
|
// AK and HI are separate DXCC entities (6 and 110) with their own 1-option blocks.
|
||||||
|
assertEquals(49, meta.regionField?.options?.size)
|
||||||
|
assertTrue(meta.regionField!!.options.none { it.code == "AK" || it.code == "HI" })
|
||||||
|
assertTrue(meta.countryZones.size > 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `alaska and hawaii each expose their own single-state block`() {
|
||||||
|
val ak = config.stationMeta(6)
|
||||||
|
assertEquals(listOf("AK"), ak.regionField?.options?.map { it.code })
|
||||||
|
val hi = config.stationMeta(110)
|
||||||
|
assertEquals(listOf("HI"), hi.regionField?.options?.map { it.code })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `germany has no region field and one national zone pair`() {
|
||||||
|
val meta = config.stationMeta(230)
|
||||||
|
assertNull(meta.regionField)
|
||||||
|
assertEquals(listOf(LoTWZonePair(28, 14)), meta.countryZones)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `india has no region field and one national zone pair`() {
|
||||||
|
val meta = config.stationMeta(324)
|
||||||
|
assertNull(meta.regionField)
|
||||||
|
assertEquals(listOf(LoTWZonePair(41, 22)), meta.countryZones)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown entity yields empty meta`() {
|
||||||
|
val meta = config.stationMeta(999999)
|
||||||
|
assertNull(meta.regionField)
|
||||||
|
assertTrue(meta.countryZones.isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/*
|
||||||
|
* 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.data.lotw
|
||||||
|
|
||||||
|
import java.security.Signature
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the PBES2/AES-256-CBC PKCS12 reader against a fixture generated with
|
||||||
|
* `openssl pkcs12 -export -certpbe NONE` — same layout as modern TQSL exports
|
||||||
|
* (plain certificate bags + PBES2-shrouded private key), which Android's legacy
|
||||||
|
* Bouncy Castle parser cannot read.
|
||||||
|
*/
|
||||||
|
class Pkcs12ReaderTest {
|
||||||
|
|
||||||
|
private fun fixture(): ByteArray =
|
||||||
|
javaClass.classLoader!!.getResourceAsStream("test_pbes2.p12")!!.use { it.readBytes() }
|
||||||
|
|
||||||
|
private fun fixture(name: String): ByteArray =
|
||||||
|
javaClass.classLoader!!.getResourceAsStream(name)!!.use { it.readBytes() }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun readsAes192Variant() {
|
||||||
|
val (key, cert) = Pkcs12Reader.read(fixture("test_pbes2_aes192.p12"), "testpass123".toCharArray())
|
||||||
|
assertEquals("RSA", key.algorithm)
|
||||||
|
assertEquals("RSA", cert.publicKey.algorithm)
|
||||||
|
assertTrue("key/cert pair", keyMatches(key, cert))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun readsDesEde3CbcVariant() {
|
||||||
|
val (key, cert) = Pkcs12Reader.read(fixture("test_pbes2_desede3.p12"), "testpass123".toCharArray())
|
||||||
|
assertEquals("RSA", key.algorithm)
|
||||||
|
assertEquals("RSA", cert.publicKey.algorithm)
|
||||||
|
assertTrue("key/cert pair", keyMatches(key, cert))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unsupportedCipherNamesTheAlgorithm() {
|
||||||
|
// A valid PBES2 file re-encrypted with an unsupported cipher must report the
|
||||||
|
// algorithm name (so the user can see exactly what to re-export with).
|
||||||
|
val fixtureBytes = fixture("test_pbes2.p12")
|
||||||
|
val e = assertThrows(IllegalStateException::class.java) {
|
||||||
|
// Simulate: patch the AES-256-CBC OID inside the file to an unknown OID.
|
||||||
|
val bogus = ByteArray(fixtureBytes.size) { fixtureBytes[it] }
|
||||||
|
// find AES-256-CBC OID bytes 0x60 86 48 01 65 03 04 01 2a
|
||||||
|
val oid = byteArrayOf(0x60, 0x86.toByte(), 0x48, 0x01, 0x65, 0x03, 0x04, 0x01, 0x2a)
|
||||||
|
var idx = -1
|
||||||
|
outer@ for (i in 0..bogus.size - oid.size) {
|
||||||
|
for (j in oid.indices) if (bogus[i + j] != oid[j]) continue@outer
|
||||||
|
idx = i; break
|
||||||
|
}
|
||||||
|
require(idx >= 0) { "AES-256-CBC OID not found in fixture" }
|
||||||
|
bogus[idx] = 0x7f.toByte() // corrupt the OID tag byte → unknown cipher
|
||||||
|
Pkcs12Reader.read(bogus, "testpass123".toCharArray())
|
||||||
|
}
|
||||||
|
assertTrue("mentions algorithm", e.message.orEmpty().contains("algorithm"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun keyMatches(key: java.security.PrivateKey, cert: java.security.cert.X509Certificate): Boolean {
|
||||||
|
val challenge = "Look4Sat LoTW certificate key check".toByteArray(Charsets.US_ASCII)
|
||||||
|
val signed = Signature.getInstance("SHA1withRSA").run {
|
||||||
|
initSign(key); update(challenge); sign()
|
||||||
|
}
|
||||||
|
return Signature.getInstance("SHA1withRSA").run {
|
||||||
|
initVerify(cert); update(challenge); verify(signed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun wrongPasswordFailsWithBadPadding() {
|
||||||
|
// A wrong password must surface as a decryption failure (BadPadding), which
|
||||||
|
// LoTWKeyMaterial maps to CERTIFICATE_PASSWORD — not a format error.
|
||||||
|
val e = assertThrows(Exception::class.java) {
|
||||||
|
Pkcs12Reader.read(fixture(), "definitely-wrong-password".toCharArray())
|
||||||
|
}
|
||||||
|
assertTrue("BadPadding", e is javax.crypto.BadPaddingException)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun readsPbes2KeyAndCertificate() {
|
||||||
|
val (key, cert) = Pkcs12Reader.read(fixture(), "testpass123".toCharArray())
|
||||||
|
assertEquals("RSA", key.algorithm)
|
||||||
|
assertEquals("RSA", cert.publicKey.algorithm)
|
||||||
|
assertNotNull(cert.subjectX500Principal)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun keyPairsWithCertificate() {
|
||||||
|
val (key, cert) = Pkcs12Reader.read(fixture(), "testpass123".toCharArray())
|
||||||
|
val challenge = "Look4Sat LoTW certificate key check".toByteArray(Charsets.US_ASCII)
|
||||||
|
val signed = Signature.getInstance("SHA1withRSA").run {
|
||||||
|
initSign(key); update(challenge); sign()
|
||||||
|
}
|
||||||
|
val valid = Signature.getInstance("SHA1withRSA").run {
|
||||||
|
initVerify(cert); update(challenge); verify(signed)
|
||||||
|
}
|
||||||
|
assertTrue("private key must match certificate", valid)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun wrongPasswordThrows() {
|
||||||
|
assertThrows(Exception::class.java) {
|
||||||
|
Pkcs12Reader.read(fixture(), "wrongpass".toCharArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun garbageBytesThrows() {
|
||||||
|
assertThrows(Exception::class.java) {
|
||||||
|
Pkcs12Reader.read("not a p12 file at all".toByteArray(), "x".toCharArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatus
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertSame
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
class AmSatRepositoryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fetchStatusReturnsSeededCacheUntilCacheIsCleared() = runTest {
|
||||||
|
val remoteSource = FakeAmSatRemoteSource()
|
||||||
|
val repository = AmSatRepository(remoteSource)
|
||||||
|
val cachedPage = SatStatusPage(
|
||||||
|
fetchedAtUtcMs = 123L,
|
||||||
|
statuses = listOf(SatStatus(name = "AO-7", days = emptyList())),
|
||||||
|
reports = emptyMap()
|
||||||
|
)
|
||||||
|
repository.seedStatusCache(cachedPage)
|
||||||
|
|
||||||
|
val firstPage = repository.fetchStatus()
|
||||||
|
val secondPage = repository.fetchStatus()
|
||||||
|
|
||||||
|
assertSame(cachedPage, firstPage)
|
||||||
|
assertSame(cachedPage, secondPage)
|
||||||
|
assertSame(cachedPage, repository.getCachedStatus())
|
||||||
|
assertEquals(0, remoteSource.catalogRequests)
|
||||||
|
assertEquals(0, remoteSource.reportRequests)
|
||||||
|
|
||||||
|
repository.clearStatusCache()
|
||||||
|
assertNull(repository.getCachedStatus())
|
||||||
|
repository.fetchStatus()
|
||||||
|
|
||||||
|
assertEquals(1, remoteSource.catalogRequests)
|
||||||
|
assertEquals(1, remoteSource.reportRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun forceRefreshBypassesCachedPage() = runTest {
|
||||||
|
val remoteSource = FakeAmSatRemoteSource()
|
||||||
|
val repository = AmSatRepository(remoteSource)
|
||||||
|
repository.seedStatusCache(
|
||||||
|
SatStatusPage(
|
||||||
|
fetchedAtUtcMs = 123L,
|
||||||
|
statuses = listOf(SatStatus(name = "AO-7", days = emptyList())),
|
||||||
|
reports = emptyMap()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.fetchStatus(forceRefresh = true)
|
||||||
|
|
||||||
|
assertEquals(1, remoteSource.catalogRequests)
|
||||||
|
assertEquals(1, remoteSource.reportRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun fetchStatusSharesStartupPrefetchRequest() = runTest {
|
||||||
|
val remoteSource = FakeAmSatRemoteSource(responseDelayMillis = 50)
|
||||||
|
val repository = AmSatRepository(remoteSource)
|
||||||
|
|
||||||
|
val prefetch = async { repository.prefetchStatus() }
|
||||||
|
val pageFetch = async { repository.fetchStatus() }
|
||||||
|
prefetch.await()
|
||||||
|
val page = pageFetch.await()
|
||||||
|
|
||||||
|
assertSame(page, repository.getCachedStatus())
|
||||||
|
assertEquals(1, remoteSource.catalogRequests)
|
||||||
|
assertEquals(1, remoteSource.reportRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildStatusesStreakCountsConsecutiveSameStatusReportsPerDay() = runTest {
|
||||||
|
val nowSec = System.currentTimeMillis() / 1000
|
||||||
|
val remoteSource = FakeAmSatRemoteSource(
|
||||||
|
reportsJson = amSatReportsJson(
|
||||||
|
// 当天(day 0)最新槽(slot 0)混合状态:最新 Heard、次新 Heard、再旧 Not Heard → 连续=2
|
||||||
|
report("r1", "AO-7", "Heard", nowSec - 3600),
|
||||||
|
report("r2", "AO-7", "Heard", nowSec - 5400),
|
||||||
|
report("r3", "AO-7", "Not Heard", nowSec - 7000),
|
||||||
|
// 空时段(无报告)不打断:slot 8 的 Heard 更旧,不应计入(被 r3 打断)
|
||||||
|
report("r4", "AO-7", "Heard", nowSec - 63000),
|
||||||
|
// 前一天(day 1):最新 Heard,更旧的 Not Heard → 连续=1,且不跨天合并
|
||||||
|
report("r5", "AO-7", "Heard", nowSec - 90000),
|
||||||
|
report("r6", "AO-7", "Not Heard", nowSec - 100000)
|
||||||
|
// day 2 无报告 → 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val repository = AmSatRepository(remoteSource)
|
||||||
|
|
||||||
|
val page = repository.fetchStatus()
|
||||||
|
val status = page?.statuses?.single()
|
||||||
|
assertEquals("AO-7", status?.name)
|
||||||
|
assertEquals(3, status?.days?.size)
|
||||||
|
|
||||||
|
assertEquals(2, status?.days?.get(0)?.streakCount)
|
||||||
|
assertEquals(1, status?.days?.get(1)?.streakCount)
|
||||||
|
assertEquals(0, status?.days?.get(2)?.streakCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildStatusesStreakCountsAllReportsWhenStatusNeverChanges() = runTest {
|
||||||
|
val nowSec = System.currentTimeMillis() / 1000
|
||||||
|
val remoteSource = FakeAmSatRemoteSource(
|
||||||
|
reportsJson = amSatReportsJson(
|
||||||
|
report("r1", "AO-7", "Heard", nowSec - 1800),
|
||||||
|
report("r2", "AO-7", "Heard", nowSec - 3600),
|
||||||
|
report("r3", "AO-7", "Heard", nowSec - 5400),
|
||||||
|
report("r4", "AO-7", "Heard", nowSec - 30000)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val repository = AmSatRepository(remoteSource)
|
||||||
|
|
||||||
|
val page = repository.fetchStatus()
|
||||||
|
assertEquals(4, page?.statuses?.single()?.days?.get(0)?.streakCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AmSatRepository.seedStatusCache(page: SatStatusPage) {
|
||||||
|
val cacheField = AmSatRepository::class.java.getDeclaredField("statusCache")
|
||||||
|
cacheField.isAccessible = true
|
||||||
|
cacheField.set(this, page)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeAmSatRemoteSource(
|
||||||
|
private val responseDelayMillis: Long = 0L,
|
||||||
|
private val reportsJson: String = """{"data":[]}"""
|
||||||
|
) : IRemoteSource {
|
||||||
|
var catalogRequests = 0
|
||||||
|
var reportRequests = 0
|
||||||
|
|
||||||
|
override suspend fun getFileStream(uri: String): InputStream? = null
|
||||||
|
|
||||||
|
override suspend fun getNetworkStream(url: String): NetworkResult = NetworkResult(404, null)
|
||||||
|
|
||||||
|
override suspend fun getAmSatCatalog(): String? {
|
||||||
|
if (responseDelayMillis > 0L) delay(responseDelayMillis)
|
||||||
|
catalogRequests += 1
|
||||||
|
return """{"data":[{"name":"AO-7"}]}"""
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getAmSatReports(hours: Int, limit: Int): String? {
|
||||||
|
if (responseDelayMillis > 0L) delay(responseDelayMillis)
|
||||||
|
reportRequests += 1
|
||||||
|
return reportsJson
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun amSatReportsJson(vararg reports: String): String =
|
||||||
|
"""{"data":[${reports.joinToString(",")}]}"""
|
||||||
|
|
||||||
|
private fun report(id: String, name: String, status: String, epochSec: Long): String =
|
||||||
|
"""{"id":"$id","name":"$name","callsign":"BA7OPF","report":"$status","grid_square":"OL62","reported_time":"${isoUtc(epochSec)}"}"""
|
||||||
|
|
||||||
|
private fun isoUtc(epochSec: Long): String =
|
||||||
|
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
|
||||||
|
timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
}.format(Date(epochSec * 1000))
|
||||||
+134
-15
@@ -19,6 +19,7 @@ package com.rtbishop.look4sat.core.data.repository
|
|||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||||
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
@@ -31,6 +32,8 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
|||||||
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
import com.rtbishop.look4sat.core.domain.source.IRemoteSource
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.NetworkResult
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.Sources
|
||||||
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
import com.rtbishop.look4sat.core.domain.utility.DataParser
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -91,10 +94,8 @@ class DatabaseRepoTest {
|
|||||||
}
|
}
|
||||||
val settingsRepo = FakeSettingsRepo(
|
val settingsRepo = FakeSettingsRepo(
|
||||||
dataSources = DataSourcesSettings(
|
dataSources = DataSourcesSettings(
|
||||||
useCustomTLE = true,
|
satelliteUrls = listOf(customCsvUrl),
|
||||||
useCustomTransceivers = false,
|
transceiversUrls = emptyList()
|
||||||
tleUrl = customCsvUrl,
|
|
||||||
transceiversUrl = ""
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
@@ -102,7 +103,50 @@ class DatabaseRepoTest {
|
|||||||
repository.updateFromRemote()
|
repository.updateFromRemote()
|
||||||
|
|
||||||
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
assertTrue(localSource.insertedEntries.any { it.catnum == 25544 })
|
||||||
// 新语义: 开关开 + URL 非空 -> All 源用自定义 URL, 数据归入 All 类型
|
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["Other"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `remote update imports satellites from real SatNOGS TLE source`() = runTest(dispatcher) {
|
||||||
|
val satnogsUrl = Sources.satelliteDataUrls.getValue("SatNOGS")
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource().apply {
|
||||||
|
networkStreams[satnogsUrl] = { jamxTleStream() }
|
||||||
|
}
|
||||||
|
val settingsRepo = FakeSettingsRepo(
|
||||||
|
dataSources = DataSourcesSettings(
|
||||||
|
satelliteUrls = listOf(satnogsUrl),
|
||||||
|
transceiversUrls = emptyList()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
|
||||||
|
repository.updateFromRemote()
|
||||||
|
|
||||||
|
assertTrue(localSource.insertedEntries.any { it.name == "JAMX-0825b" && it.catnum == 98248 })
|
||||||
|
assertEquals(listOf(98248), settingsRepo.satelliteTypeIdsByType["SatNOGS"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `remote update persists All source type ids`() = runTest(dispatcher) {
|
||||||
|
// Regression: "All" (CelesTrak active) is a real TLE source type whose
|
||||||
|
// ids must be persisted on sync — the old `if (type == "All") return`
|
||||||
|
// silently dropped them, making the "All" filter resolve to nothing.
|
||||||
|
val allUrl = Sources.satelliteDataUrls.getValue("All")
|
||||||
|
val localSource = FakeLocalSource()
|
||||||
|
val remoteSource = FakeRemoteSource().apply {
|
||||||
|
networkStreams[allUrl] = { validCsvStream() }
|
||||||
|
}
|
||||||
|
val settingsRepo = FakeSettingsRepo(
|
||||||
|
dataSources = DataSourcesSettings(
|
||||||
|
satelliteUrls = listOf(allUrl),
|
||||||
|
transceiversUrls = emptyList()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
val repository = DatabaseRepo(dispatcher, dataParser, localSource, remoteSource, settingsRepo)
|
||||||
|
|
||||||
|
repository.updateFromRemote()
|
||||||
|
|
||||||
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
|
assertEquals(listOf(25544), settingsRepo.satelliteTypeIdsByType["All"])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +160,12 @@ class DatabaseRepoTest {
|
|||||||
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
1 25544U 98067A 21320.51955234 .00001288 00000+0 31985-4 0 9990
|
||||||
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
2 25544 51.6447 309.4881 0004694 203.6966 299.8876 15.48582035312205
|
||||||
""".trimIndent().byteInputStream()
|
""".trimIndent().byteInputStream()
|
||||||
|
|
||||||
|
private fun jamxTleStream(): InputStream = """
|
||||||
|
JAMX-0825b
|
||||||
|
1 98248U 26237.16675926 .00015724 00000-0 97477-3 0 00013
|
||||||
|
2 98248 097.5373 310.9694 0011309 278.1232 340.7230 15.09766181000012
|
||||||
|
""".trimIndent().byteInputStream()
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FakeRemoteSource : IRemoteSource {
|
private class FakeRemoteSource : IRemoteSource {
|
||||||
@@ -124,7 +174,14 @@ private class FakeRemoteSource : IRemoteSource {
|
|||||||
|
|
||||||
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
override suspend fun getFileStream(uri: String): InputStream? = fileStreams[uri]?.invoke()
|
||||||
|
|
||||||
override suspend fun getNetworkStream(url: String): InputStream? = networkStreams[url]?.invoke()
|
override suspend fun getNetworkStream(url: String): NetworkResult =
|
||||||
|
networkStreams[url]?.let { NetworkResult(200, it()) } ?: NetworkResult(404, null)
|
||||||
|
|
||||||
|
override suspend fun getAmSatCatalog(): String? = null
|
||||||
|
|
||||||
|
override suspend fun getAmSatReports(hours: Int, limit: Int): String? = null
|
||||||
|
|
||||||
|
override suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FakeLocalSource : ILocalSource {
|
private class FakeLocalSource : ILocalSource {
|
||||||
@@ -133,7 +190,8 @@ private class FakeLocalSource : ILocalSource {
|
|||||||
|
|
||||||
override suspend fun getEntriesTotal(): Int = insertedEntries.size
|
override suspend fun getEntriesTotal(): Int = insertedEntries.size
|
||||||
|
|
||||||
override suspend fun getEntriesList(): List<SatItem> = emptyList()
|
override suspend fun getEntriesList(): List<SatItem> =
|
||||||
|
insertedEntries.map { SatItem(it.catnum, it.name) }
|
||||||
|
|
||||||
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||||
|
|
||||||
@@ -146,13 +204,19 @@ private class FakeLocalSource : ILocalSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = emptyList()
|
||||||
|
override suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int> = emptyList()
|
||||||
|
override suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int> = emptyList()
|
||||||
|
|
||||||
override suspend fun getRadiosTotal(): Int = insertedRadios.size
|
override suspend fun getRadiosTotal(): Int = insertedRadios.size
|
||||||
|
|
||||||
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||||
|
|
||||||
override suspend fun insertRadios(radios: List<SatRadio>) {
|
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) {
|
||||||
insertedRadios += radios
|
insertedRadios += radios.map { it.copy(isCustom = isCustom) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteManagedRadios() {
|
||||||
|
insertedRadios.removeAll { !it.isCustom }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteRadios() {
|
override suspend fun deleteRadios() {
|
||||||
@@ -177,11 +241,14 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||||
|
|
||||||
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||||
RCSettings(false, "", "", "", false, "", "", "", false, "", "", "", false, "", "")
|
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||||
)
|
)
|
||||||
|
|
||||||
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||||
OtherSettings(false, false, false, false, false, false, false, false)
|
OtherSettings(
|
||||||
|
false, false, false, false, false, false, false,
|
||||||
|
shouldSeeWarning = false, shouldSeeWhatsNew = false
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
|
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> = MutableStateFlow(dataSources)
|
||||||
@@ -222,14 +289,66 @@ private class FakeSettingsRepo(dataSources: DataSourcesSettings = defaultDataSou
|
|||||||
dataSourcesSettings.value = settings
|
dataSourcesSettings.value = settings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||||
|
|
||||||
|
override fun updateDataSourcesStatus(status: Map<String, Int>) {
|
||||||
|
(dataSourcesStatus as? MutableStateFlow)?.value = status
|
||||||
|
}
|
||||||
|
|
||||||
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||||
|
|
||||||
|
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||||
|
override fun getSatelliteMode(catnum: Int): String = ""
|
||||||
|
override fun setSatelliteMode(catnum: Int, mode: String) {}
|
||||||
|
|
||||||
|
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||||
|
|
||||||
|
override fun getAmSatCallsign(): String = ""
|
||||||
|
|
||||||
|
override fun setAmSatCallsign(callsign: String) = Unit
|
||||||
|
|
||||||
|
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
|
||||||
|
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
|
||||||
|
|
||||||
|
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
|
||||||
|
|
||||||
|
override fun getWorkedGrids(): Set<String> = emptySet()
|
||||||
|
|
||||||
|
private val workedGrids = MutableStateFlow(emptySet<String>())
|
||||||
|
|
||||||
|
override fun setWorkedGrids(grids: Set<String>) {
|
||||||
|
workedGrids.value = grids
|
||||||
|
}
|
||||||
|
|
||||||
|
override val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings> =
|
||||||
|
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
|
||||||
|
|
||||||
|
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit
|
||||||
|
override fun getLastLotwSyncDate(): String = ""
|
||||||
|
override fun setLastLotwSyncDate(date: String) = Unit
|
||||||
|
override fun getLastLotwSyncCallsign(): String = ""
|
||||||
|
override fun setLastLotwSyncCallsign(callsign: String) = Unit
|
||||||
|
|
||||||
|
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
|
||||||
|
|
||||||
|
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) = Unit
|
||||||
|
|
||||||
|
override fun getRoamedGrids(): Set<String> = emptySet()
|
||||||
|
|
||||||
|
private val roamedGrids = MutableStateFlow(emptySet<String>())
|
||||||
|
|
||||||
|
override fun setRoamedGrids(grids: Set<String>) {
|
||||||
|
roamedGrids.value = grids
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
|
||||||
|
|
||||||
|
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) = Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
private fun defaultDataSourcesSettings(): DataSourcesSettings {
|
||||||
return DataSourcesSettings(
|
return DataSourcesSettings(
|
||||||
useCustomTLE = false,
|
satelliteUrls = emptyList(),
|
||||||
useCustomTransceivers = false,
|
transceiversUrls = emptyList()
|
||||||
tleUrl = "",
|
|
||||||
transceiversUrl = ""
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+383
@@ -0,0 +1,383 @@
|
|||||||
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.LoTWResult
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class LoTWRepositoryTest {
|
||||||
|
|
||||||
|
private val repo = LoTWRepository()
|
||||||
|
|
||||||
|
private fun report(vararg records: String): String =
|
||||||
|
buildString {
|
||||||
|
append("<ADIF_VERS:5>3.1.4\n")
|
||||||
|
append("<PROGRAMID:8>look4sat\n")
|
||||||
|
append("<eoh>\n")
|
||||||
|
records.forEach { append(it).append("\n") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseRejectsBodyWithoutEoh() {
|
||||||
|
assertNull(repo.parseConfirmedGrids("<HTML>Username/password incorrect</HTML>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseCollectsOnlySatelliteGrids() {
|
||||||
|
// Satellite QSO: PROP_MODE SAT, confirmed, opponent grid.
|
||||||
|
val satQso = "<CALL:6>BA7OPF\n" +
|
||||||
|
"<QSO_DATE:8>20260820\n" +
|
||||||
|
"<PROP_MODE:3>SAT\n" +
|
||||||
|
"<SAT_NAME:5>FO-29\n" +
|
||||||
|
"<GRIDSQUARE:4>NL47\n" +
|
||||||
|
"<EOR>\n"
|
||||||
|
// Ground QSO: same grid but no PROP_MODE -> must be excluded.
|
||||||
|
val groundQso = "<CALL:6>BA7OPF\n" +
|
||||||
|
"<QSO_DATE:8>20260821\n" +
|
||||||
|
"<GRIDSQUARE:4>NL47\n" +
|
||||||
|
"<EOR>\n"
|
||||||
|
val result = repo.parseConfirmedGrids(report(satQso, groundQso))
|
||||||
|
assertEquals(setOf("NL47"), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseSupportsVuccGridPairsAndSixCharGrids() {
|
||||||
|
val satQso = "<PROP_MODE:3>SAT\n" +
|
||||||
|
"<SAT_NAME:5>SO-50\n" +
|
||||||
|
"<GRIDSQUARE:6>OM60IL\n" +
|
||||||
|
"<VUCC_GRIDS:11>EN52en,EN53fa\n" +
|
||||||
|
"<EOR>\n"
|
||||||
|
val result = repo.parseConfirmedGrids(report(satQso))
|
||||||
|
assertEquals(setOf("OM60", "EN52", "EN53"), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parsePropModeDoesNotLeakAcrossRecords() {
|
||||||
|
// PROP_MODE SAT record first, then a ground QSO with a grid — the second
|
||||||
|
// record must not inherit the satellite flag after its own <EOR>.
|
||||||
|
val satQso = "<PROP_MODE:3>SAT\n<GRIDSQUARE:4>OL62\n<EOR>\n"
|
||||||
|
val groundQso = "<GRIDSQUARE:4>PM00\n<EOR>\n"
|
||||||
|
val result = repo.parseConfirmedGrids(report(satQso, groundQso))
|
||||||
|
assertEquals(setOf("OL62"), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseHandlesAlphabeticalFieldOrder() {
|
||||||
|
// Real lotwreport.adi emits fields in ALPHABETICAL order: GRIDSQUARE (G)
|
||||||
|
// and VUCC_GRIDS come BEFORE PROP_MODE (P) inside each record. The old
|
||||||
|
// sequential gate (add only while propMode == SAT) dropped every grid on
|
||||||
|
// real reports — the buffered parser must collect them regardless of
|
||||||
|
// field order and filter at <EOR>.
|
||||||
|
val satQso = "<CALL:6>BA7OPF\n" +
|
||||||
|
"<GRIDSQUARE:4>NL47\n" +
|
||||||
|
"<MODE:3>FM\n" +
|
||||||
|
"<PROP_MODE:3>SAT\n" +
|
||||||
|
"<SAT_NAME:5>FO-29\n" +
|
||||||
|
"<VUCC_GRIDS:11>EN52en,EN53fa\n" +
|
||||||
|
"<EOR>\n"
|
||||||
|
val groundQso = "<CALL:6>BA7OPF\n" +
|
||||||
|
"<GRIDSQUARE:4>PM95\n" +
|
||||||
|
"<QSO_DATE:8>20260821\n" +
|
||||||
|
"<EOR>\n"
|
||||||
|
val result = repo.parseConfirmedGrids(report(satQso, groundQso))
|
||||||
|
assertEquals(setOf("NL47", "EN52", "EN53"), result)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseReturnsEmptySetForReportWithoutGrids() {
|
||||||
|
val satQso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>AO-07\n<EOR>\n"
|
||||||
|
assertEquals(emptySet<String>(), repo.parseConfirmedGrids(report(satQso)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// region parseConfirmedGridQsos
|
||||||
|
|
||||||
|
private val satQsoFull = "<CALL:5>A50QO\n" +
|
||||||
|
"<QSO_DATE:8>20260820\n" +
|
||||||
|
"<TIME_ON:6>113045\n" +
|
||||||
|
"<PROP_MODE:3>SAT\n" +
|
||||||
|
"<SAT_NAME:5>FO-29\n" +
|
||||||
|
"<MODE:2>CW\n" +
|
||||||
|
"<BAND:3>70CM\n" +
|
||||||
|
"<BAND_RX:3>2M\n" +
|
||||||
|
"<GRIDSQUARE:4>NL47\n" +
|
||||||
|
"<EOR>\n"
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseQsosExtractsSatelliteDetail() {
|
||||||
|
val result = repo.parseConfirmedGridQsos(report(satQsoFull))!!
|
||||||
|
val qsos = result["NL47"]!!
|
||||||
|
assertEquals(1, qsos.size)
|
||||||
|
val qso = qsos[0]
|
||||||
|
assertEquals("A50QO", qso.call)
|
||||||
|
assertEquals("FO-29", qso.satName)
|
||||||
|
assertEquals("CW", qso.mode)
|
||||||
|
assertEquals("2M", qso.bandUp)
|
||||||
|
assertEquals("70CM", qso.bandDown)
|
||||||
|
assertEquals("V/U", qso.bandLabel)
|
||||||
|
assertEquals(2026, java.time.Instant.ofEpochMilli(qso.epochMs).atZone(java.time.ZoneOffset.UTC).year)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseQsosExcludesGroundQsos() {
|
||||||
|
val groundQso = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||||
|
val result = repo.parseConfirmedGridQsos(report(satQsoFull, groundQso))!!
|
||||||
|
assertEquals(1, result["NL47"]!!.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseQsosVuccPairFeedsBothGrids() {
|
||||||
|
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
|
||||||
|
"<QSO_DATE:8>20260819\n<TIME_ON:4>1231\n" +
|
||||||
|
"<VUCC_GRIDS:11>EN52en,EN53fa\n<EOR>\n"
|
||||||
|
val result = repo.parseConfirmedGridQsos(report(qso))!!
|
||||||
|
assertEquals(setOf("EN52", "EN53"), result.keys)
|
||||||
|
assertEquals(result["EN52"]!!.first(), result["EN53"]!!.first())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseQsosVuccQuadrupleFeedsAllFourGrids() {
|
||||||
|
// VUCC allows up to four grids in one contact.
|
||||||
|
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
|
||||||
|
"<QSO_DATE:8>20260819\n<TIME_ON:4>1231\n" +
|
||||||
|
"<VUCC_GRIDS:23>EN52en,EN53fa,EN42gj,EN43kh\n<EOR>\n"
|
||||||
|
val result = repo.parseConfirmedGridQsos(report(qso))!!
|
||||||
|
assertEquals(setOf("EN52", "EN53", "EN42", "EN43"), result.keys)
|
||||||
|
result.values.forEach { assertEquals(1, it.size) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseQsosRejectsBodyWithoutEoh() {
|
||||||
|
assertNull(repo.parseConfirmedGridQsos("<HTML>Username/password incorrect</HTML>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseQsosCapturesOwnGridPerQso() {
|
||||||
|
// MY_GRIDSQUARE arrives BEFORE PROP_MODE (ADIF fields are emitted
|
||||||
|
// alphabetically); the per-record buffer must still attach it to the QSO.
|
||||||
|
val qso1 = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<MY_GRIDSQUARE:4>OL62\n" +
|
||||||
|
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<MODE:2>CW\n" +
|
||||||
|
"<BAND:3>70CM\n<BAND_RX:3>2M\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||||
|
val qso2 = "<CALL:6>BG7ZFK\n<QSO_DATE:8>20260819\n<MY_GRIDSQUARE:4>OL72\n" +
|
||||||
|
"<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n<MODE:3>FM\n" +
|
||||||
|
"<BAND:3>70CM\n<BAND_RX:3>2M\n<GRIDSQUARE:4>OK48\n<EOR>\n"
|
||||||
|
val result = repo.parseConfirmedGridQsos(report(qso1, qso2))!!
|
||||||
|
assertEquals("OL62", result["NL47"]!!.first().myGrid)
|
||||||
|
assertEquals("OL72", result["OK48"]!!.first().myGrid)
|
||||||
|
// Ground QSO (no PROP_MODE=SAT) must not leak its MY_GRIDSQUARE.
|
||||||
|
val ground = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<MY_GRIDSQUARE:4>OL62\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||||
|
assertTrue(repo.parseConfirmedGridQsos(report(qso1, ground))!!.values.all { it.all { q -> q.myGrid == "OL62" } })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseQsosCapturesMyVuccGridsMultiGridStation() {
|
||||||
|
// A station location (台址) can span several grids: LoTW emits
|
||||||
|
// <MY_VUCC_GRIDS> (comma-separated) with MY_GRIDSQUARE absent. Both
|
||||||
|
// grids must land in myGrids, and myGrid (back-compat) = the first.
|
||||||
|
val qso = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||||
|
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<MODE:2>CW\n" +
|
||||||
|
"<MY_VUCC_GRIDS:11>OM60,OM50\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||||
|
val result = repo.parseConfirmedGridQsos(report(qso))!!
|
||||||
|
val parsed = result["NL47"]!!.first()
|
||||||
|
assertEquals(setOf("OM60", "OM50"), parsed.myGrids)
|
||||||
|
assertEquals("OM60", parsed.myGrid)
|
||||||
|
// 6-char fields are truncated to 4 like every other grid path.
|
||||||
|
val six = "<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
|
||||||
|
"<MY_VUCC_GRIDS:17>OM60IL70,OM50MA20\n<GRIDSQUARE:4>OK48\n<EOR>\n"
|
||||||
|
assertEquals(setOf("OM60", "OM50"), repo.parseConfirmedGridQsos(report(six))!!["OK48"]!!.first().myGrids)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseQsosBuildsStationKeyFromMyFields() {
|
||||||
|
// 台址 identity = the MY_* station snapshot (callsign + dxcc + state +
|
||||||
|
// CQ/ITU + IOTA + country). A record with the full snapshot yields the
|
||||||
|
// stable key the operated-grid selector groups by.
|
||||||
|
val qso = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||||
|
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
|
||||||
|
"<STATION_CALLSIGN:6>BH6RJD\n<MY_DXCC:3>318\n<MY_STATE:11>JS // Jiangsu\n" +
|
||||||
|
"<MY_CQ_ZONE:2>24\n<MY_ITU_ZONE:2>44\n<MY_COUNTRY:5>CHINA\n" +
|
||||||
|
"<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||||
|
val parsed = repo.parseConfirmedGridQsos(report(qso))!!["NL47"]!!.first()
|
||||||
|
assertEquals("BH6RJD", parsed.myCallsign)
|
||||||
|
assertEquals("BH6RJD|318|JS|24|44||CHINA", parsed.stationKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseQsosStationKeyDistinguishesLocationsUnderSameCallsign() {
|
||||||
|
// One callsign can own several 台址 (e.g. BH6RJD with a Hubei location
|
||||||
|
// and a Zhejiang location); different MY_STATE -> different stationKey.
|
||||||
|
val hb = "<CALL:5>A50QO\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||||
|
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
|
||||||
|
"<STATION_CALLSIGN:6>BH6RJD\n<MY_STATE:11>HB // Hubei\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||||
|
val zj = "<CALL:5>BG7ZFK\n<QSO_DATE:8>20260819\n<TIME_ON:4>1130\n" +
|
||||||
|
"<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n" +
|
||||||
|
"<STATION_CALLSIGN:6>BH6RJD\n<MY_STATE:11>ZJ // Zhejiang\n<GRIDSQUARE:4>OK48\n<EOR>\n"
|
||||||
|
val result = repo.parseConfirmedGridQsos(report(hb, zj))!!
|
||||||
|
assertTrue(result["NL47"]!!.first().stationKey != result["OK48"]!!.first().stationKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region parseRoamedGrids (MY_GRIDSQUARE = grids the account operated from)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseRoamedGridsCollectsOwnStationGrids() {
|
||||||
|
// Satellite QSOs carry MY_GRIDSQUARE = the grid the account itself
|
||||||
|
// operated from (the "roamed/activated" set shown as blue stripes).
|
||||||
|
val qso1 = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260820\n<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n" +
|
||||||
|
"<MY_GRIDSQUARE:4>OL62\n<GRIDSQUARE:4>PM95\n<EOR>\n"
|
||||||
|
val qso2 = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260901\n<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n" +
|
||||||
|
"<MY_GRIDSQUARE:4>OL72\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||||
|
assertEquals(setOf("OL62", "OL72"), repo.parseRoamedGrids(report(qso1, qso2)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseRoamedGridsIgnoresGroundQsos() {
|
||||||
|
val ground = "<CALL:6>BA7OPF\n<QSO_DATE:8>20260821\n<MY_GRIDSQUARE:4>OL72\n<EOR>\n"
|
||||||
|
assertEquals(emptySet<String>(), repo.parseRoamedGrids(report(ground)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseRoamedGridsDoesNotConfuseGridsquareWithMyGridsquare() {
|
||||||
|
// GRIDSQUARE (opposite station's grid) must never leak into the roamed set.
|
||||||
|
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>SO-50\n<MY_GRIDSQUARE:4>OL62\n" +
|
||||||
|
"<GRIDSQUARE:6>OM60IL\n<EOR>\n"
|
||||||
|
assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseRoamedGridsTruncatesSixCharToFour() {
|
||||||
|
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n<MY_GRIDSQUARE:6>OL62AA\n<EOR>\n"
|
||||||
|
assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseRoamedGridsHandlesAlphabeticalFieldOrder() {
|
||||||
|
// Real lotwreport.adi emits fields alphabetically, so <MY_GRIDSQUARE>
|
||||||
|
// (M) arrives BEFORE <PROP_MODE> (P). The old sequential gate (add only
|
||||||
|
// while propMode == SAT) silently returned an EMPTY set on real reports —
|
||||||
|
// this is the exact bug that made the blue roamed stripes never appear.
|
||||||
|
val qso = "<CALL:6>BA7OPF\n" +
|
||||||
|
"<GRIDSQUARE:4>PM95\n" +
|
||||||
|
"<MY_GRIDSQUARE:6>OL72XX\n" +
|
||||||
|
"<PROP_MODE:3>SAT\n" +
|
||||||
|
"<SAT_NAME:5>IO-86\n" +
|
||||||
|
"<EOR>\n"
|
||||||
|
assertEquals(setOf("OL72"), repo.parseRoamedGrids(report(qso)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseRoamedGridsRejectsBodyWithoutEoh() {
|
||||||
|
assertNull(repo.parseRoamedGrids("<HTML>Username/password incorrect</HTML>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseRoamedGridsCollectsMyVuccGrids() {
|
||||||
|
// Multi-grid 台址: MY_VUCC_GRIDS carries the extra grids (MY_GRIDSQUARE
|
||||||
|
// may be absent); every field must reach the roamed set for the stripes.
|
||||||
|
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
|
||||||
|
"<MY_VUCC_GRIDS:11>OM60,OM50\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||||
|
assertEquals(setOf("OM60", "OM50"), repo.parseRoamedGrids(report(qso)))
|
||||||
|
// Combined with MY_GRIDSQUARE the union is kept.
|
||||||
|
val both = "<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n" +
|
||||||
|
"<MY_GRIDSQUARE:4>OL62\n<MY_VUCC_GRIDS:11>OM60,OM50\n<GRIDSQUARE:4>NL47\n<EOR>\n"
|
||||||
|
assertEquals(setOf("OL62", "OM60", "OM50"), repo.parseRoamedGrids(report(both)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseRoamedGridsUppercasesLowercaseMyGrid() {
|
||||||
|
// LoTW emits the <eor> tag lowercase, so field CASE is not guaranteed:
|
||||||
|
// a lowercase MY_GRIDSQUARE must still match the overlay's uppercase
|
||||||
|
// labels (regression: take(4) without .uppercase() silently dropped
|
||||||
|
// stripes for lowercase grids).
|
||||||
|
val qso = "<PROP_MODE:3>SAT\n<SAT_NAME:5>IO-86\n" +
|
||||||
|
"<MY_GRIDSQUARE:4>ol62\n<GRIDSQUARE:4>PM95\n<EOR>\n"
|
||||||
|
assertEquals(setOf("OL62"), repo.parseRoamedGrids(report(qso)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region failure classification (fetchReportBody -> LoTWResult mapping)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun failureClassificationMapsEachException() {
|
||||||
|
// LoTWRepository.toResult() must keep each failure cause distinct so
|
||||||
|
// the UI can show a specific message per cause.
|
||||||
|
assertEquals(
|
||||||
|
LoTWResult.BadCredentials,
|
||||||
|
repo.toResult(LoTWRepository.CredentialsException())
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
LoTWResult.RateLimited,
|
||||||
|
repo.toResult(LoTWRepository.RateLimitException())
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
LoTWResult.Timeout,
|
||||||
|
repo.toResult(LoTWRepository.TimeoutException("read timed out"))
|
||||||
|
)
|
||||||
|
val net = repo.toResult(java.io.IOException("HTTP 500"))
|
||||||
|
assertEquals(LoTWResult.NetworkError("HTTP 500"), net)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseQsosExtractsAwardFields() {
|
||||||
|
// Award statistics rely on the DXCC/CQZ/STATE fields coming straight
|
||||||
|
// from LoTW's qso_qsldetail report (STATE depends on DXCC). LoTW
|
||||||
|
// writes STATE as "CODE // NAME" (verified with a real report 2026-09);
|
||||||
|
// the parser must strip the name suffix and keep the bare code.
|
||||||
|
val qso = "<CALL:5>BG7XYZ\n" +
|
||||||
|
"<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||||
|
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<MODE:2>CW\n" +
|
||||||
|
"<DXCC:3>318\n<COUNTRY:5>CHINA\n<CQZ:2>24\n<STATE:13>GD // Guangdong\n" +
|
||||||
|
"<GRIDSQUARE:4>OL62\n<EOR>\n"
|
||||||
|
val result = repo.parseConfirmedGridQsos(report(qso))!!
|
||||||
|
val parsed = result["OL62"]!!.first()
|
||||||
|
assertEquals(318, parsed.dxcc)
|
||||||
|
assertEquals("CHINA", parsed.country)
|
||||||
|
assertEquals(24, parsed.cqz)
|
||||||
|
assertEquals("GD", parsed.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseQsosStripsStateNameSuffixForAllEntities() {
|
||||||
|
// Japan prefecture: "34 // Tottori-ken" -> "34" (WAJA matching needs
|
||||||
|
// the 2-digit code). US states also arrive as "CODE // Name".
|
||||||
|
val jp = "<CALL:5>JH0ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||||
|
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<DXCC:3>339\n<COUNTRY:7>JAPAN\n" +
|
||||||
|
"<STATE:18>34 // Tottori-ken\n<GRIDSQUARE:4>PM95\n<EOR>\n"
|
||||||
|
val us = "<CALL:5>K1ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||||
|
"<PROP_MODE:3>SAT\n<SAT_NAME:5>AO-07\n<DXCC:3>291\n<COUNTRY:12>UNITED STATES\n" +
|
||||||
|
"<STATE:22>CA // California\n<GRIDSQUARE:4>EM40\n<EOR>\n"
|
||||||
|
val result = repo.parseConfirmedGridQsos(report(jp, us))!!
|
||||||
|
assertEquals("34", result["PM95"]!!.first().state)
|
||||||
|
assertEquals("CA", result["EM40"]!!.first().state)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseQsosLeavesAwardFieldsNullWhenAbsent() {
|
||||||
|
val qso = "<CALL:5>JH0ABC\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||||
|
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<GRIDSQUARE:4>PM95\n<EOR>\n"
|
||||||
|
val result = repo.parseConfirmedGridQsos(report(qso))!!
|
||||||
|
val parsed = result["PM95"]!!.first()
|
||||||
|
assertNull(parsed.dxcc)
|
||||||
|
assertNull(parsed.country)
|
||||||
|
assertNull(parsed.cqz)
|
||||||
|
assertNull(parsed.state)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parseQsosAwardFieldsDoNotLeakAcrossRecords() {
|
||||||
|
val withFields = "<CALL:5>BG7AAA\n<QSO_DATE:8>20260820\n<TIME_ON:4>1130\n" +
|
||||||
|
"<PROP_MODE:3>SAT\n<SAT_NAME:5>FO-29\n<DXCC:3>318\n<CQZ:2>24\n<STATE:2>GD\n" +
|
||||||
|
"<GRIDSQUARE:4>OL62\n<EOR>\n"
|
||||||
|
val withoutFields = "<CALL:5>JH0BBB\n<QSO_DATE:8>20260821\n<TIME_ON:4>1130\n" +
|
||||||
|
"<PROP_MODE:3>SAT\n<SAT_NAME:5>RS-44\n<GRIDSQUARE:4>PM95\n<EOR>\n"
|
||||||
|
val result = repo.parseConfirmedGridQsos(report(withFields, withoutFields))!!
|
||||||
|
assertNull(result["PM95"]!!.first().dxcc)
|
||||||
|
assertNull(result["PM95"]!!.first().cqz)
|
||||||
|
assertNull(result["PM95"]!!.first().state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
}
|
||||||
+365
@@ -0,0 +1,365 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.data.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatItem
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalObject
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISelectionRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.repository.ISettingsRepo
|
||||||
|
import com.rtbishop.look4sat.core.domain.source.ILocalSource
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class SelectionRepoSearchTest {
|
||||||
|
|
||||||
|
private val sampleItems = listOf(
|
||||||
|
SatItem(catnum = 25544, name = "ISS (ZARYA)"),
|
||||||
|
SatItem(catnum = 7530, name = "AO-7 (AMSAT-OSCAR 7)"),
|
||||||
|
SatItem(catnum = 39444, name = "AO-73 (FUNcube-1)"),
|
||||||
|
SatItem(catnum = 43803, name = "JO-97 (BIRDS-3)"),
|
||||||
|
SatItem(catnum = 99999, name = "FO-29"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `query without separators matches name with dashes spaces brackets`() = runTest {
|
||||||
|
val repo = createRepo(sampleItems)
|
||||||
|
repo.setQuery("ao7")
|
||||||
|
val results = repo.getEntriesFlow().first()
|
||||||
|
// "ao7" is a substring of the normalized "AO-73 (FUNcube-1)" too, so a
|
||||||
|
// fuzzy search legitimately returns both AO-7 (first) and AO-73. The
|
||||||
|
// key guarantee is that AO-7 — which was unreachable before because of
|
||||||
|
// the dashes/brackets — is now found.
|
||||||
|
assertTrue(results.map { it.catnum }.contains(7530))
|
||||||
|
assertEquals(7530, results.first().catnum)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `query without separators matches name with only dashes`() = runTest {
|
||||||
|
val repo = createRepo(sampleItems)
|
||||||
|
repo.setQuery("fo29")
|
||||||
|
val results = repo.getEntriesFlow().first()
|
||||||
|
assertEquals(listOf(99999), results.map { it.catnum })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `space-separated tokens all must match in any order`() = runTest {
|
||||||
|
val repo = createRepo(sampleItems)
|
||||||
|
repo.setQuery("zarya iss")
|
||||||
|
val results = repo.getEntriesFlow().first()
|
||||||
|
assertEquals(listOf(25544), results.map { it.catnum })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `exact name query still matches`() = runTest {
|
||||||
|
val repo = createRepo(sampleItems)
|
||||||
|
repo.setQuery("AO-7 (AMSAT-OSCAR 7)")
|
||||||
|
val results = repo.getEntriesFlow().first()
|
||||||
|
assertEquals(listOf(7530), results.map { it.catnum })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `partial token query matches substring`() = runTest {
|
||||||
|
val repo = createRepo(sampleItems)
|
||||||
|
repo.setQuery("funcube")
|
||||||
|
val results = repo.getEntriesFlow().first()
|
||||||
|
assertEquals(listOf(39444), results.map { it.catnum })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `numeric query matches catnum exactly`() = runTest {
|
||||||
|
val repo = createRepo(sampleItems)
|
||||||
|
repo.setQuery("25544")
|
||||||
|
val results = repo.getEntriesFlow().first()
|
||||||
|
assertEquals(listOf(25544), results.map { it.catnum })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no query returns all items`() = runTest {
|
||||||
|
val repo = createRepo(sampleItems)
|
||||||
|
repo.setQuery("")
|
||||||
|
val results = repo.getEntriesFlow().first()
|
||||||
|
assertEquals(sampleItems.size, results.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unmatched query returns nothing`() = runTest {
|
||||||
|
val repo = createRepo(sampleItems)
|
||||||
|
repo.setQuery("zzzznomatch")
|
||||||
|
assertTrue(repo.getEntriesFlow().first().isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `FM virtual type shows only hardcoded FM satellites`() = runTest {
|
||||||
|
// AO-123 ASRTU-1 (61781) is on the hardcoded FM list; a random
|
||||||
|
// satellite (99999) is not.
|
||||||
|
val items = sampleItems + SatItem(catnum = 61781, name = "ASRTU-1")
|
||||||
|
val repo = createRepo(items)
|
||||||
|
repo.setTypes(listOf("AMSAT Live FM"))
|
||||||
|
val results = repo.getEntriesFlow().first()
|
||||||
|
assertEquals(setOf(25544, 61781), results.map { it.catnum }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Linear virtual type shows only hardcoded linear satellites`() = runTest {
|
||||||
|
val repo = createRepo(sampleItems)
|
||||||
|
repo.setTypes(listOf("AMSAT Live Linear"))
|
||||||
|
val results = repo.getEntriesFlow().first()
|
||||||
|
// Hardcoded linear list: RS-44 (44909), FO-29 (24278), AO-7 (7530),
|
||||||
|
// AO-73 (39444), JO-97 (43803). Present in the sample DB: 7530, 39444, 43803.
|
||||||
|
assertEquals(setOf(7530, 39444, 43803), results.map { it.catnum }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `SSTV virtual type shows only satellites with SSTV radios`() = runTest {
|
||||||
|
val repo = createRepo(
|
||||||
|
items = sampleItems,
|
||||||
|
sstvIds = listOf(43803)
|
||||||
|
)
|
||||||
|
repo.setTypes(listOf("Live SSTV"))
|
||||||
|
val results = repo.getEntriesFlow().first()
|
||||||
|
assertEquals(listOf(43803), results.map { it.catnum })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `SSTV virtual type uses the amateur-only radio query`() = runTest {
|
||||||
|
// The repo delegates to getIdsWithModesAndAmateur: the fake returns
|
||||||
|
// only catnums the DAO would have filtered to Amateur service (43803).
|
||||||
|
val repo = createRepo(
|
||||||
|
items = sampleItems,
|
||||||
|
sstvIds = listOf(43803)
|
||||||
|
)
|
||||||
|
repo.setTypes(listOf("Live SSTV"))
|
||||||
|
val results = repo.getEntriesFlow().first()
|
||||||
|
assertEquals(listOf(43803), results.map { it.catnum })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `SSTV virtual type excludes rocket-body debris by name`() = runTest {
|
||||||
|
// 60239 is "ARIANE 6 R/B" (launcher debris carrying an amateur SSTV
|
||||||
|
// payload); it must be dropped even though its radio is Amateur.
|
||||||
|
val items = sampleItems + SatItem(catnum = 60239, name = "ARIANE 6 R/B")
|
||||||
|
val repo = createRepo(
|
||||||
|
items = items,
|
||||||
|
sstvIds = listOf(43803, 60239)
|
||||||
|
)
|
||||||
|
repo.setTypes(listOf("Live SSTV"))
|
||||||
|
val results = repo.getEntriesFlow().first()
|
||||||
|
assertEquals(listOf(43803), results.map { it.catnum })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `multiple virtual types union their satellites`() = runTest {
|
||||||
|
val repo = createRepo(
|
||||||
|
items = sampleItems,
|
||||||
|
sstvIds = listOf(43803)
|
||||||
|
)
|
||||||
|
repo.setTypes(listOf("AMSAT Live FM", "Live SSTV"))
|
||||||
|
val results = repo.getEntriesFlow().first()
|
||||||
|
assertEquals(setOf(25544, 43803), results.map { it.catnum }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `clearing types restores full list`() = runTest {
|
||||||
|
val repo = createRepo(sampleItems)
|
||||||
|
repo.setTypes(listOf("AMSAT Live FM"))
|
||||||
|
assertTrue(repo.getEntriesFlow().first().isNotEmpty())
|
||||||
|
repo.setTypes(emptyList())
|
||||||
|
assertEquals(sampleItems.size, repo.getEntriesFlow().first().size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `virtual type with no matching satellites shows empty not everything`() = runTest {
|
||||||
|
// 硬编码 FM 列表 (27607/25544/61781) 里没有样本库中的卫星时, 选虚拟
|
||||||
|
// 类型应显示空列表, 而非全部卫星.
|
||||||
|
val items = listOf(SatItem(catnum = 7530, name = "AO-7 (AMSAT-OSCAR 7)"))
|
||||||
|
val repo = createRepo(items)
|
||||||
|
repo.setTypes(listOf("AMSAT Live FM"))
|
||||||
|
assertTrue(repo.getEntriesFlow().first().isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `virtual type combined with regular type unions both lists`() = runTest {
|
||||||
|
val repo = createRepo(items = sampleItems)
|
||||||
|
repo.setTypes(listOf("AMSAT Live FM", "AMSAT Live Linear"))
|
||||||
|
val results = repo.getEntriesFlow().first()
|
||||||
|
assertEquals(
|
||||||
|
setOf(25544, 7530, 39444, 43803),
|
||||||
|
results.map { it.catnum }.toSet()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `types list has virtual transponder types first and keeps All`() {
|
||||||
|
val repo = createRepo(sampleItems)
|
||||||
|
val types = repo.getTypesList()
|
||||||
|
assertTrue(types.size >= 4)
|
||||||
|
assertEquals("AMSAT Live FM", types[0])
|
||||||
|
assertEquals("AMSAT Live Linear", types[1])
|
||||||
|
assertEquals("Live SSTV", types[2])
|
||||||
|
// "All" must be kept in the list (first original type shown after virtual ones).
|
||||||
|
assertTrue("All" in types)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `All type resolves to persisted CelesTrak active ids`() = runTest {
|
||||||
|
// Regression: "All" is a real TLE source type (CelesTrak active). Its
|
||||||
|
// ids are persisted on sync like any other type; selecting it must
|
||||||
|
// show the synced satellites, NOT an empty list.
|
||||||
|
val fake = FakeSettingsRepoForSearch()
|
||||||
|
fake.setSatelliteTypeIds("All", sampleItems.map { it.catnum })
|
||||||
|
val repo = SelectionRepo(
|
||||||
|
dispatcher = Dispatchers.Unconfined,
|
||||||
|
localSource = FakeLocalSourceForSearch(sampleItems),
|
||||||
|
settingsRepo = fake
|
||||||
|
)
|
||||||
|
repo.setTypes(listOf("All"))
|
||||||
|
val results = repo.getEntriesFlow().first()
|
||||||
|
assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `getTypesList excludes only the Other placeholder`() = runTest {
|
||||||
|
val repo = createRepo(sampleItems)
|
||||||
|
val types = repo.getTypesList()
|
||||||
|
// All real TLE source types must be selectable, including "All"
|
||||||
|
// (CelesTrak active) and "ARISS" (removed by the old removeAt(0)).
|
||||||
|
assertTrue("All" in types)
|
||||||
|
assertTrue("ARISS" in types)
|
||||||
|
assertTrue("R4UAB" in types)
|
||||||
|
assertTrue("SatNOGS" in types)
|
||||||
|
assertTrue("Other" !in types)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all types shows all items`() = runTest {
|
||||||
|
val repo = createRepo(sampleItems)
|
||||||
|
repo.setTypes(emptyList()) // "All" = no type filtering
|
||||||
|
val results = repo.getEntriesFlow().first()
|
||||||
|
assertEquals(sampleItems.size, results.size)
|
||||||
|
assertEquals(sampleItems.map { it.catnum }.toSet(), results.map { it.catnum }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createRepo(
|
||||||
|
items: List<SatItem>,
|
||||||
|
sstvIds: List<Int> = emptyList()
|
||||||
|
): ISelectionRepo {
|
||||||
|
return SelectionRepo(
|
||||||
|
dispatcher = Dispatchers.Unconfined,
|
||||||
|
localSource = FakeLocalSourceForSearch(items, sstvIds),
|
||||||
|
settingsRepo = FakeSettingsRepoForSearch()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeLocalSourceForSearch(
|
||||||
|
private val items: List<SatItem>,
|
||||||
|
private val sstvIds: List<Int> = emptyList()
|
||||||
|
) : ILocalSource {
|
||||||
|
override suspend fun getEntriesTotal(): Int = items.size
|
||||||
|
override suspend fun getEntriesList(): List<SatItem> = items
|
||||||
|
override suspend fun getEntriesWithIds(ids: List<Int>): List<OrbitalObject> = emptyList()
|
||||||
|
override suspend fun insertEntries(entries: List<OrbitalData>) = Unit
|
||||||
|
override suspend fun deleteEntries() = Unit
|
||||||
|
override suspend fun getIdsWithModes(modes: List<String>): List<Int> = sstvIds
|
||||||
|
override suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int> = sstvIds
|
||||||
|
override suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int> = sstvIds
|
||||||
|
override suspend fun getRadiosTotal(): Int = 0
|
||||||
|
override suspend fun getRadiosWithId(id: Int): List<SatRadio> = emptyList()
|
||||||
|
override suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean) = Unit
|
||||||
|
override suspend fun deleteManagedRadios() = Unit
|
||||||
|
override suspend fun deleteRadios() = Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeSettingsRepoForSearch : ISettingsRepo {
|
||||||
|
private val typeIds = mutableMapOf<String, List<Int>>()
|
||||||
|
override val appVersionName: String = "test"
|
||||||
|
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||||
|
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||||
|
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||||
|
PassesSettings(hoursAhead = 24, minElevation = 0.0, selectedModes = emptyList())
|
||||||
|
)
|
||||||
|
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(0.0, 0.0))
|
||||||
|
override val databaseState: MutableStateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||||
|
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||||
|
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||||
|
)
|
||||||
|
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||||
|
OtherSettings(
|
||||||
|
false, false, false, false, false, false, false,
|
||||||
|
shouldSeeWarning = false, shouldSeeWhatsNew = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
override val dataSourcesSettings: MutableStateFlow<DataSourcesSettings> =
|
||||||
|
MutableStateFlow(DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList()))
|
||||||
|
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||||
|
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||||
|
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||||
|
)
|
||||||
|
override val wavelogSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.WavelogSettings> =
|
||||||
|
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.WavelogSettings())
|
||||||
|
override val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings> =
|
||||||
|
MutableStateFlow(com.rtbishop.look4sat.core.domain.model.LoTWSettings())
|
||||||
|
|
||||||
|
override fun setSelectedIds(ids: List<Int>) = Unit
|
||||||
|
override fun setSelectedTypes(types: List<String>) = Unit
|
||||||
|
override fun setPassesSettings(settings: PassesSettings) = Unit
|
||||||
|
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = true
|
||||||
|
override fun setStationPosition(): Boolean = true
|
||||||
|
override fun setStationPosition(locator: String): Boolean = true
|
||||||
|
override fun getSatelliteTypesIds(types: List<String>): List<Int> =
|
||||||
|
types.flatMap { typeIds[it].orEmpty() }.distinct()
|
||||||
|
|
||||||
|
override fun setSatelliteTypeIds(type: String, ids: List<Int>) {
|
||||||
|
typeIds[type] = ids
|
||||||
|
}
|
||||||
|
override fun updateDatabaseState(state: DatabaseState) = Unit
|
||||||
|
override fun updateRCSettings(settings: RCSettings) = Unit
|
||||||
|
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = Unit
|
||||||
|
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = Unit
|
||||||
|
override fun updateDataSourcesStatus(status: Map<String, Int>) = Unit
|
||||||
|
override fun updateRadioControlSettings(settings: RadioControlSettings) = Unit
|
||||||
|
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||||
|
override fun getSatelliteMode(catnum: Int): String = ""
|
||||||
|
override fun setSatelliteMode(catnum: Int, mode: String) {}
|
||||||
|
override fun setSatelliteOffset(catnum: Int, offset: String) = Unit
|
||||||
|
override fun getAmSatCallsign(): String = ""
|
||||||
|
override fun setAmSatCallsign(callsign: String) = Unit
|
||||||
|
override fun updateWavelogSettings(settings: com.rtbishop.look4sat.core.domain.model.WavelogSettings) = Unit
|
||||||
|
override fun getWorkedGrids(): Set<String> = emptySet()
|
||||||
|
override fun setWorkedGrids(grids: Set<String>) = Unit
|
||||||
|
override fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>> = emptyMap()
|
||||||
|
override fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>) = Unit
|
||||||
|
override fun getRoamedGrids(): Set<String> = emptySet()
|
||||||
|
override fun setRoamedGrids(grids: Set<String>) = Unit
|
||||||
|
override fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>> = emptyMap()
|
||||||
|
override fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>) = Unit
|
||||||
|
override fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings) = Unit
|
||||||
|
override fun getLastLotwSyncDate(): String = ""
|
||||||
|
override fun setLastLotwSyncDate(date: String) = Unit
|
||||||
|
override fun getLastLotwSyncCallsign(): String = ""
|
||||||
|
override fun setLastLotwSyncCallsign(callsign: String) = Unit
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,8 +1,3 @@
|
|||||||
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,163 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.logbook
|
||||||
|
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
/** Small ADI codec for the logbook's actual field set (ADIF 3.1.7). */
|
||||||
|
object AdifCodec {
|
||||||
|
private val utc = TimeZone.getTimeZone("UTC")
|
||||||
|
|
||||||
|
fun encode(records: List<QsoRecord>): String = buildString {
|
||||||
|
append(field("ADIF_VER", "3.1.7"))
|
||||||
|
append(field("PROGRAMID", "Look4Sat"))
|
||||||
|
append("<EOH>\r\n")
|
||||||
|
records.forEach { record ->
|
||||||
|
val startDate = format(record.startUtcMillis, "yyyyMMdd")
|
||||||
|
val startTime = format(record.startUtcMillis, "HHmmss")
|
||||||
|
append(field("QSO_DATE", startDate))
|
||||||
|
append(field("TIME_ON", startTime))
|
||||||
|
record.endUtcMillis?.let {
|
||||||
|
append(field("QSO_DATE_OFF", format(it, "yyyyMMdd")))
|
||||||
|
append(field("TIME_OFF", format(it, "HHmmss")))
|
||||||
|
}
|
||||||
|
append(field("CALL", record.theirCallsign))
|
||||||
|
append(field("STATION_CALLSIGN", record.myCallsign))
|
||||||
|
appendOptional("MODE", record.mode)
|
||||||
|
appendOptional("SUBMODE", record.submode)
|
||||||
|
appendOptional("GRIDSQUARE", record.theirGrid)
|
||||||
|
appendOptional("MY_GRIDSQUARE", record.myGrid)
|
||||||
|
appendOptional("RST_SENT", record.sentReport)
|
||||||
|
appendOptional("RST_RCVD", record.receivedReport)
|
||||||
|
record.txFrequencyHz?.let { append(field("FREQ", hzToMhz(it))) }
|
||||||
|
record.rxFrequencyHz?.let { append(field("FREQ_RX", hzToMhz(it))) }
|
||||||
|
appendOptional("BAND", record.band)
|
||||||
|
appendOptional("BAND_RX", record.rxBand)
|
||||||
|
appendOptional("PROP_MODE", record.propagationMode.ifBlank { if (record.satelliteName.isNotBlank()) "SAT" else "" })
|
||||||
|
appendOptional("SAT_NAME", record.satelliteName)
|
||||||
|
appendOptional("SAT_MODE", record.satelliteMode)
|
||||||
|
if (record.lotwConfirmed) append(field("LOTW_QSL_RCVD", "Y"))
|
||||||
|
if (record.lotwReceived) append(field("LOTW_QSL_SENT", "Y"))
|
||||||
|
appendOptional("LOTW_QSLRDATE", record.lotwQslDate)
|
||||||
|
appendOptional("VUCC_GRIDS", record.vuccGrids.joinToString(","))
|
||||||
|
record.dxcc?.let { append(field("DXCC", it.toString())) }
|
||||||
|
appendOptional("COUNTRY", record.country)
|
||||||
|
record.cqZone?.let { append(field("CQZ", it.toString())) }
|
||||||
|
appendOptional("STATE", record.region)
|
||||||
|
appendOptional("COMMENT", record.comment)
|
||||||
|
appendOptional("APP_LOOK4SAT_TRANSPONDER", record.transponderName)
|
||||||
|
record.passAosUtcMillis?.let { append(field("APP_LOOK4SAT_PASS_AOS", it.toString())) }
|
||||||
|
append(field("APP_LOOK4SAT_AUTOMATIC", if (record.automatic) "Y" else "N"))
|
||||||
|
append(field("APP_LOOK4SAT_STATUS", record.status.name))
|
||||||
|
append("<EOR>\r\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decode(content: String): List<QsoRecord> = decodeRecords(splitRecords(content))
|
||||||
|
|
||||||
|
fun decodeRecords(records: List<Map<String, String>>): List<QsoRecord> = records.mapNotNull { values ->
|
||||||
|
val call = values["CALL"].orEmpty().trim().uppercase(Locale.US)
|
||||||
|
val date = values["QSO_DATE"].orEmpty()
|
||||||
|
val time = values["TIME_ON"].orEmpty()
|
||||||
|
if (call.isBlank() || date.length != 8 || time.length !in setOf(4, 6)) return@mapNotNull null
|
||||||
|
val start = parse(date, time) ?: return@mapNotNull null
|
||||||
|
val end = values["QSO_DATE_OFF"]?.let { endDate ->
|
||||||
|
values["TIME_OFF"]?.let { endTime -> parse(endDate, endTime) }
|
||||||
|
}
|
||||||
|
QsoRecord(
|
||||||
|
startUtcMillis = start,
|
||||||
|
endUtcMillis = end,
|
||||||
|
theirCallsign = call,
|
||||||
|
myCallsign = values["STATION_CALLSIGN"].orEmpty().uppercase(Locale.US),
|
||||||
|
theirGrid = values["GRIDSQUARE"].orEmpty().uppercase(Locale.US),
|
||||||
|
myGrid = values["MY_GRIDSQUARE"].orEmpty().uppercase(Locale.US),
|
||||||
|
sentReport = values["RST_SENT"].orEmpty(),
|
||||||
|
receivedReport = values["RST_RCVD"].orEmpty(),
|
||||||
|
txFrequencyHz = mhzToHz(values["FREQ"]),
|
||||||
|
rxFrequencyHz = mhzToHz(values["FREQ_RX"]),
|
||||||
|
band = values["BAND"].orEmpty(),
|
||||||
|
rxBand = values["BAND_RX"].orEmpty(),
|
||||||
|
mode = values["MODE"].orEmpty(),
|
||||||
|
submode = values["SUBMODE"].orEmpty(),
|
||||||
|
satelliteName = values["SAT_NAME"].orEmpty(),
|
||||||
|
transponderName = values["APP_LOOK4SAT_TRANSPONDER"].orEmpty(),
|
||||||
|
satelliteMode = values["SAT_MODE"].orEmpty(),
|
||||||
|
passAosUtcMillis = values["APP_LOOK4SAT_PASS_AOS"]?.toLongOrNull(),
|
||||||
|
automatic = values["APP_LOOK4SAT_AUTOMATIC"].equals("Y", true),
|
||||||
|
status = values["APP_LOOK4SAT_STATUS"]?.let { runCatching { QsoStatus.valueOf(it) }.getOrNull() }
|
||||||
|
?: QsoStatus.COMPLETE,
|
||||||
|
propagationMode = values["PROP_MODE"].orEmpty().trim().uppercase(Locale.US),
|
||||||
|
lotwConfirmed = values["LOTW_QSL_RCVD"].equals("Y", true),
|
||||||
|
lotwReceived = values["LOTW_QSL_SENT"].equals("Y", true),
|
||||||
|
lotwQslDate = values["LOTW_QSLRDATE"].orEmpty(),
|
||||||
|
vuccGrids = values["VUCC_GRIDS"].orEmpty().split(',').map { it.trim().uppercase(Locale.US) }
|
||||||
|
.filter { it.matches(Regex("[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?")) }.distinct(),
|
||||||
|
dxcc = values["DXCC"]?.toIntOrNull(),
|
||||||
|
country = values["COUNTRY"].orEmpty(),
|
||||||
|
cqZone = values["CQZ"]?.toIntOrNull()?.takeIf { it in 1..40 },
|
||||||
|
region = values["STATE"].orEmpty().substringBefore(" // ").trim().uppercase(Locale.US),
|
||||||
|
comment = values["COMMENT"].orEmpty()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun splitRecords(content: String): List<Map<String, String>> {
|
||||||
|
val records = mutableListOf<Map<String, String>>()
|
||||||
|
var values = linkedMapOf<String, String>()
|
||||||
|
var index = 0
|
||||||
|
while (index < content.length) {
|
||||||
|
val start = content.indexOf('<', index)
|
||||||
|
if (start < 0) break
|
||||||
|
val end = content.indexOf('>', start + 1)
|
||||||
|
if (end < 0) break
|
||||||
|
val descriptor = content.substring(start + 1, end)
|
||||||
|
val parts = descriptor.split(':')
|
||||||
|
val name = parts.first().trim().uppercase(Locale.US)
|
||||||
|
index = end + 1
|
||||||
|
when (name) {
|
||||||
|
"EOH" -> values.clear()
|
||||||
|
"EOR" -> {
|
||||||
|
if (values.isNotEmpty()) records += values.toMap()
|
||||||
|
values = linkedMapOf()
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val length = parts.getOrNull(1)?.toIntOrNull() ?: continue
|
||||||
|
if (length < 0 || index + length > content.length) continue
|
||||||
|
values[name] = content.substring(index, index + length)
|
||||||
|
index += length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun StringBuilder.appendOptional(name: String, value: String) {
|
||||||
|
if (value.isNotBlank()) append(field(name, value))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun field(name: String, value: String): String = "<$name:${value.length}>$value"
|
||||||
|
|
||||||
|
private fun hzToMhz(value: Long): String = String.format(Locale.US, "%.6f", value / 1_000_000.0)
|
||||||
|
|
||||||
|
private fun mhzToHz(value: String?): Long? = value?.toDoubleOrNull()?.let { (it * 1_000_000.0).toLong() }
|
||||||
|
|
||||||
|
private fun format(value: Long, pattern: String): String =
|
||||||
|
SimpleDateFormat(pattern, Locale.US).apply { timeZone = utc }.format(Date(value))
|
||||||
|
|
||||||
|
private fun parse(date: String, time: String): Long? = runCatching {
|
||||||
|
val normalizedTime = if (time.length == 4) "${time}00" else time
|
||||||
|
SimpleDateFormat("yyyyMMddHHmmss", Locale.US).apply {
|
||||||
|
isLenient = false
|
||||||
|
timeZone = utc
|
||||||
|
}.parse(date + normalizedTime)?.time
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.logbook
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
interface IQsoRepository {
|
||||||
|
val records: Flow<List<QsoRecord>>
|
||||||
|
|
||||||
|
suspend fun find(id: Long): QsoRecord?
|
||||||
|
suspend fun save(record: QsoRecord): Long
|
||||||
|
suspend fun delete(id: Long)
|
||||||
|
suspend fun markUploaded(ids: List<Long>)
|
||||||
|
suspend fun exportAdi(ids: Set<Long>? = null, includeIncomplete: Boolean = false): String
|
||||||
|
suspend fun importAdi(content: String): AdifImportResult
|
||||||
|
suspend fun mergeConfirmed(records: List<QsoRecord>): AdifImportResult
|
||||||
|
suspend fun mergeLoTW(records: List<QsoRecord>): AdifImportResult
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.logbook
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||||
|
|
||||||
|
/** Bridge a LoTW report QSO (confirmed, download side) into a logbook record
|
||||||
|
* so the logbook can show downloaded confirmations and mark local uploads
|
||||||
|
* as confirmed (matched by [sameConfirmedContact]). */
|
||||||
|
fun GridQso.toConfirmedRecord(accountCallsign: String): QsoRecord = QsoRecord(
|
||||||
|
startUtcMillis = epochMs,
|
||||||
|
theirCallsign = call,
|
||||||
|
myCallsign = accountCallsign.trim().uppercase(),
|
||||||
|
myGrid = (myGrids.firstOrNull() ?: myGrid).orEmpty(),
|
||||||
|
vuccGrids = myGrids.toList(),
|
||||||
|
band = bandUp,
|
||||||
|
rxBand = bandDown,
|
||||||
|
mode = mode,
|
||||||
|
// On satellites, MODE=MFSK means FT4 (ADIF pairing); keep the sub-mode so
|
||||||
|
// confirmations match local FT4 records (mode=MFSK, submode=FT4).
|
||||||
|
submode = if (mode.equals("MFSK", true)) "FT4" else "",
|
||||||
|
satelliteName = satName,
|
||||||
|
propagationMode = "SAT",
|
||||||
|
status = QsoStatus.COMPLETE,
|
||||||
|
lotwConfirmed = true,
|
||||||
|
dxcc = dxcc,
|
||||||
|
country = country.orEmpty(),
|
||||||
|
cqZone = cqz,
|
||||||
|
region = state.orEmpty()
|
||||||
|
)
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.logbook
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/** LoTW official name vs SatNOGS name ("SO-50 (SaudiOSCAR 50)"): compare the
|
||||||
|
* parenthetical-free prefix so local records and downloaded confirmations match. */
|
||||||
|
fun satMatchKey(name: String): String = name.substringBefore('(').trim().uppercase(Locale.US)
|
||||||
|
|
||||||
|
/** LoTW omits frequency and may round time to a minute. Only merge an unambiguous contact. */
|
||||||
|
fun sameConfirmedContact(local: QsoRecord, remote: QsoRecord): Boolean =
|
||||||
|
local.theirCallsign.trim().equals(remote.theirCallsign.trim(), true) &&
|
||||||
|
(local.myCallsign.isBlank() || remote.myCallsign.isBlank() || local.myCallsign.equals(remote.myCallsign, true)) &&
|
||||||
|
satMatchKey(local.satelliteName) == satMatchKey(remote.satelliteName) &&
|
||||||
|
local.isSatellite == remote.isSatellite &&
|
||||||
|
local.displayMode == remote.displayMode &&
|
||||||
|
(local.band.isBlank() || remote.band.isBlank() || local.band.equals(remote.band, true)) &&
|
||||||
|
abs(local.startUtcMillis - remote.startUtcMillis) < 60_000L
|
||||||
|
|
||||||
|
fun QsoRecord.confirmationLookupKey(): String = listOf(
|
||||||
|
theirCallsign.trim().uppercase(Locale.US), satMatchKey(satelliteName), displayMode
|
||||||
|
).joinToString("|")
|
||||||
|
|
||||||
|
fun QsoRecord.withConfirmation(confirmed: QsoRecord): QsoRecord = copy(
|
||||||
|
myCallsign = myCallsign.ifBlank { confirmed.myCallsign },
|
||||||
|
theirGrid = confirmed.theirGrid.ifBlank { theirGrid },
|
||||||
|
myGrid = myGrid.ifBlank { confirmed.myGrid },
|
||||||
|
sentReport = sentReport.ifBlank { confirmed.sentReport },
|
||||||
|
receivedReport = receivedReport.ifBlank { confirmed.receivedReport },
|
||||||
|
txFrequencyHz = txFrequencyHz ?: confirmed.txFrequencyHz,
|
||||||
|
rxFrequencyHz = rxFrequencyHz ?: confirmed.rxFrequencyHz,
|
||||||
|
band = band.ifBlank { confirmed.band },
|
||||||
|
rxBand = rxBand.ifBlank { confirmed.rxBand },
|
||||||
|
propagationMode = propagationMode.ifBlank { confirmed.propagationMode },
|
||||||
|
status = QsoStatus.COMPLETE,
|
||||||
|
lotwConfirmed = lotwConfirmed || confirmed.lotwConfirmed,
|
||||||
|
lotwReceived = lotwReceived || confirmed.lotwReceived || confirmed.lotwConfirmed,
|
||||||
|
lotwQslDate = confirmed.lotwQslDate.ifBlank { lotwQslDate },
|
||||||
|
vuccGrids = confirmed.vuccGrids.ifEmpty { vuccGrids },
|
||||||
|
dxcc = confirmed.dxcc ?: dxcc,
|
||||||
|
country = confirmed.country.ifBlank { country },
|
||||||
|
cqZone = confirmed.cqZone ?: cqZone,
|
||||||
|
region = confirmed.region.ifBlank { region }
|
||||||
|
)
|
||||||
|
|
||||||
|
val QsoRecord.displayMode: String
|
||||||
|
get() {
|
||||||
|
// Satellite FT4 is MODE=MFSK + SUBMODE=FT4; any other mode keeps its own
|
||||||
|
// label even when a stale submode default ("FT4") was persisted.
|
||||||
|
val label = if (mode.equals("MFSK", true) && submode.isNotBlank()) submode else mode
|
||||||
|
return label.trim().uppercase(Locale.US)
|
||||||
|
}
|
||||||
|
|
||||||
|
val QsoRecord.isSatellite: Boolean
|
||||||
|
get() = propagationMode.equals("SAT", true) || (propagationMode.isBlank() && satelliteName.isNotBlank())
|
||||||
|
|
||||||
|
fun frequencyBand(hz: Long?): String = when (hz) {
|
||||||
|
null -> ""
|
||||||
|
in 28_000_000L..29_700_000L -> "10M"
|
||||||
|
in 50_000_000L..54_000_000L -> "6M"
|
||||||
|
in 144_000_000L..148_000_000L -> "2M"
|
||||||
|
in 219_000_000L..225_000_000L -> "1.25M"
|
||||||
|
in 420_000_000L..450_000_000L -> "70CM"
|
||||||
|
in 902_000_000L..928_000_000L -> "33CM"
|
||||||
|
in 1_240_000_000L..1_300_000_000L -> "23CM"
|
||||||
|
in 2_300_000_000L..2_450_000_000L -> "13CM"
|
||||||
|
in 5_650_000_000L..5_925_000_000L -> "6CM"
|
||||||
|
in 10_000_000_000L..10_500_000_000L -> "3CM"
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.logbook
|
||||||
|
|
||||||
|
enum class QsoStatus { DRAFT, COMPLETE, ABORTED }
|
||||||
|
|
||||||
|
data class QsoRecord(
|
||||||
|
val id: Long = 0L,
|
||||||
|
val startUtcMillis: Long,
|
||||||
|
val endUtcMillis: Long? = null,
|
||||||
|
val theirCallsign: String,
|
||||||
|
val myCallsign: String,
|
||||||
|
val theirGrid: String = "",
|
||||||
|
val myGrid: String = "",
|
||||||
|
val sentReport: String = "",
|
||||||
|
val receivedReport: String = "",
|
||||||
|
val txFrequencyHz: Long? = null,
|
||||||
|
val rxFrequencyHz: Long? = null,
|
||||||
|
val band: String = "",
|
||||||
|
val rxBand: String = "",
|
||||||
|
val mode: String = "FM",
|
||||||
|
val submode: String = "",
|
||||||
|
val satelliteName: String = "",
|
||||||
|
val transponderName: String = "",
|
||||||
|
val satelliteMode: String = "",
|
||||||
|
val passAosUtcMillis: Long? = null,
|
||||||
|
val automatic: Boolean = false,
|
||||||
|
val status: QsoStatus = QsoStatus.DRAFT,
|
||||||
|
val propagationMode: String = if (satelliteName.isNotBlank()) "SAT" else "",
|
||||||
|
val lotwConfirmed: Boolean = false,
|
||||||
|
val lotwUploaded: Boolean = false,
|
||||||
|
val lotwReceived: Boolean = false,
|
||||||
|
val lotwQslDate: String = "",
|
||||||
|
val vuccGrids: List<String> = emptyList(),
|
||||||
|
val dxcc: Int? = null,
|
||||||
|
val country: String = "",
|
||||||
|
val cqZone: Int? = null,
|
||||||
|
val region: String = "",
|
||||||
|
val comment: String = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AdifImportResult(val imported: Int, val skipped: Int, val updated: Int = 0)
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.model
|
||||||
|
|
||||||
|
/** One public AMSAT satellite status report submission. */
|
||||||
|
data class AmSatReportSubmission(
|
||||||
|
val name: String,
|
||||||
|
val report: String,
|
||||||
|
val callsign: String,
|
||||||
|
val gridSquare: String,
|
||||||
|
val reportedAtUtcMillis: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Result of submitting an AMSAT satellite status report. */
|
||||||
|
data class AmSatReportSubmitResult(
|
||||||
|
val success: Boolean,
|
||||||
|
val message: String = "",
|
||||||
|
val reportId: String? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
/*
|
||||||
|
* 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.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Award progress computed from the confirmed-QSO store.
|
||||||
|
*
|
||||||
|
* @param type which award
|
||||||
|
* @param workedKeys matching keys for the boundary assets: each is compared
|
||||||
|
* against the region "code" field of the award's GeoJSON
|
||||||
|
* (e.g. WAPC -> "GD", WAJA -> "34", WAZ -> "24",
|
||||||
|
* WAS -> "CA", DXCC -> "318", VUCC -> "OL62").
|
||||||
|
* @param count distinct worked units (grids / entities / provinces / ...)
|
||||||
|
* @param target units required for the award
|
||||||
|
*/
|
||||||
|
data class AwardProgress(
|
||||||
|
val type: AwardType,
|
||||||
|
val workedKeys: Set<String>,
|
||||||
|
val count: Int,
|
||||||
|
val target: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class AwardType { DXCC, VUCC, WAPC, WAJA, WAZ, WAS }
|
||||||
|
|
||||||
|
object AwardTargets {
|
||||||
|
const val DXCC = 100
|
||||||
|
const val VUCC = 100
|
||||||
|
const val WAPC = 34
|
||||||
|
const val WAJA = 47
|
||||||
|
const val WAZ = 40
|
||||||
|
const val WAS = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives six-award progress from the confirmed QSO store.
|
||||||
|
*
|
||||||
|
* Award rules (ADIF / award-program semantics):
|
||||||
|
* - VUCC: distinct 4-char gridsquares.
|
||||||
|
* - DXCC: distinct ARRL DXCC entities (from the QSO's dxcc field; falls back
|
||||||
|
* to a callsign-prefix guess when LoTW omitted it).
|
||||||
|
* - WAPC: distinct Chinese provinces (31 mainland STATE codes) plus the
|
||||||
|
* Hong Kong / Macao / Taiwan entities (counted as their own provinces).
|
||||||
|
* - WAJA: distinct Japanese prefectures (STATE code "01".."47").
|
||||||
|
* - WAZ: distinct CQ zones.
|
||||||
|
* - WAS: distinct US states (STATE abbreviations).
|
||||||
|
*
|
||||||
|
* The ADIF STATE field's meaning depends on the DXCC entity
|
||||||
|
* ("STATE depends on DXCC"), so every QSO is first classified by entity.
|
||||||
|
*/
|
||||||
|
object AwardCalculator {
|
||||||
|
|
||||||
|
fun calculate(qsosByGrid: Map<String, List<GridQso>>): List<AwardProgress> {
|
||||||
|
val all = qsosByGrid.values.flatten()
|
||||||
|
return listOf(
|
||||||
|
AwardProgress(
|
||||||
|
AwardType.VUCC,
|
||||||
|
workedKeys = qsosByGrid.keys,
|
||||||
|
count = qsosByGrid.size,
|
||||||
|
target = AwardTargets.VUCC
|
||||||
|
),
|
||||||
|
calculateDxcc(all),
|
||||||
|
calculateWapc(all),
|
||||||
|
calculateWaja(all),
|
||||||
|
calculateWaz(all),
|
||||||
|
calculateWas(all)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateDxcc(all: List<GridQso>): AwardProgress {
|
||||||
|
val keys = mutableSetOf<String>()
|
||||||
|
for (q in all) {
|
||||||
|
val code = q.dxcc ?: prefixEntityCode(q.call)
|
||||||
|
if (code != null) keys.add(code.toString())
|
||||||
|
}
|
||||||
|
return AwardProgress(AwardType.DXCC, keys, keys.size, AwardTargets.DXCC)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateWapc(all: List<GridQso>): AwardProgress {
|
||||||
|
val keys = mutableSetOf<String>()
|
||||||
|
for (q in all) {
|
||||||
|
when (entityOf(q)) {
|
||||||
|
Entity.CHINA -> q.state?.trim()?.uppercase()?.takeIf { it in CN_STATES }?.let { keys.add(it) }
|
||||||
|
Entity.HONG_KONG -> keys.add(HK_CODE)
|
||||||
|
Entity.MACAO -> keys.add(MO_CODE)
|
||||||
|
Entity.TAIWAN -> keys.add(TW_CODE)
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return AwardProgress(AwardType.WAPC, keys, keys.size, AwardTargets.WAPC)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateWaja(all: List<GridQso>): AwardProgress {
|
||||||
|
val keys = mutableSetOf<String>()
|
||||||
|
for (q in all) {
|
||||||
|
if (entityOf(q) != Entity.JAPAN) continue
|
||||||
|
q.state?.trim()?.takeIf { it.length == 2 && it.all(Char::isDigit) }?.let { keys.add(it) }
|
||||||
|
}
|
||||||
|
return AwardProgress(AwardType.WAJA, keys, keys.size, AwardTargets.WAJA)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateWaz(all: List<GridQso>): AwardProgress {
|
||||||
|
val keys = mutableSetOf<String>()
|
||||||
|
for (q in all) {
|
||||||
|
q.cqz?.takeIf { it in 1..40 }?.let { keys.add(it.toString()) }
|
||||||
|
}
|
||||||
|
return AwardProgress(AwardType.WAZ, keys, keys.size, AwardTargets.WAZ)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateWas(all: List<GridQso>): AwardProgress {
|
||||||
|
val keys = mutableSetOf<String>()
|
||||||
|
for (q in all) {
|
||||||
|
if (entityOf(q) != Entity.USA) continue
|
||||||
|
q.state?.trim()?.uppercase()?.takeIf { it in US_STATES }?.let { keys.add(it) }
|
||||||
|
}
|
||||||
|
return AwardProgress(AwardType.WAS, keys, keys.size, AwardTargets.WAS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class Entity { CHINA, HONG_KONG, MACAO, TAIWAN, JAPAN, USA, OTHER }
|
||||||
|
|
||||||
|
private fun entityOf(q: GridQso): Entity = when (q.dxcc) {
|
||||||
|
318 -> Entity.CHINA
|
||||||
|
321 -> Entity.HONG_KONG
|
||||||
|
152 -> Entity.MACAO
|
||||||
|
386 -> Entity.TAIWAN
|
||||||
|
339 -> Entity.JAPAN
|
||||||
|
291 -> Entity.USA
|
||||||
|
else -> prefixEntity(q.call)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Coarse entity from callsign prefix, used when LoTW omitted the DXCC field. */
|
||||||
|
private fun prefixEntity(call: String): Entity {
|
||||||
|
val c = call.uppercase().substringBefore('/')
|
||||||
|
return when {
|
||||||
|
CN_PREFIXES.any { c.startsWith(it) } -> Entity.CHINA
|
||||||
|
c.startsWith("VR") -> Entity.HONG_KONG
|
||||||
|
c.startsWith("XX9") -> Entity.MACAO
|
||||||
|
TW_PREFIXES.any { c.startsWith(it) } -> Entity.TAIWAN
|
||||||
|
JP_PREFIXES.any { c.startsWith(it) } -> Entity.JAPAN
|
||||||
|
US_PREFIXES.any { c.startsWith(it) } -> Entity.USA
|
||||||
|
else -> Entity.OTHER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ARRL DXCC entity code guessed from callsign prefix (very coarse). */
|
||||||
|
private fun prefixEntityCode(call: String): Int? {
|
||||||
|
return when (prefixEntity(call)) {
|
||||||
|
Entity.CHINA -> 318
|
||||||
|
Entity.HONG_KONG -> 321
|
||||||
|
Entity.MACAO -> 152
|
||||||
|
Entity.TAIWAN -> 386
|
||||||
|
Entity.JAPAN -> 339
|
||||||
|
Entity.USA -> 291
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val CN_PREFIXES = listOf("BA", "BD", "BG", "BH", "BI", "BY", "BJ", "BK", "BL", "BQ", "BR", "BS")
|
||||||
|
private val TW_PREFIXES = listOf("BM", "BV", "BU", "BW", "BX", "BN", "BO", "BT", "BZ")
|
||||||
|
private val JP_PREFIXES =
|
||||||
|
listOf("7J", "7K", "7L", "7M", "7N") + ('A'..'Z').map { "J$it" }
|
||||||
|
private val US_PREFIXES =
|
||||||
|
listOf("AA", "AB", "AC", "AD", "AE", "AF", "AG", "AH", "AI", "AJ", "AK", "AL", "K", "N", "W")
|
||||||
|
|
||||||
|
private val CN_STATES = setOf(
|
||||||
|
"AH", "BJ", "CQ", "FJ", "GD", "GS", "GX", "GZ", "HA", "HB", "HE", "HI", "HL", "HN", "JL",
|
||||||
|
"JS", "JX", "LN", "NM", "NX", "QH", "SC", "SD", "SH", "SN", "SX", "TJ", "XJ", "XZ", "YN", "ZJ"
|
||||||
|
)
|
||||||
|
|
||||||
|
private val US_STATES = setOf(
|
||||||
|
"AL", "AK", "AZ", "AR", "CA", "CO", "CT", "DE", "FL", "GA", "HI", "ID", "IL", "IN", "IA",
|
||||||
|
"KS", "KY", "LA", "ME", "MD", "MA", "MI", "MN", "MS", "MO", "MT", "NE", "NV", "NH", "NJ",
|
||||||
|
"NM", "NY", "NC", "ND", "OH", "OK", "OR", "PA", "RI", "SC", "SD", "TN", "TX", "UT", "VT",
|
||||||
|
"VA", "WA", "WV", "WI", "WY"
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val HK_CODE = "HK"
|
||||||
|
private const val MO_CODE = "MO"
|
||||||
|
private const val TW_CODE = "TW"
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
* 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.model
|
||||||
|
|
||||||
|
object Constants {
|
||||||
|
const val FREQ_OFFSET_MIN_HZ = -50_000L
|
||||||
|
const val FREQ_OFFSET_MAX_HZ = 50_000L
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
* 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.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One confirmed satellite QSO as reported by LoTW, kept per worked gridsquare
|
||||||
|
* so the map can show, for any worked grid, which callsigns were worked there
|
||||||
|
* and when.
|
||||||
|
*
|
||||||
|
* @param call opposite station callsign (as logged by the opposite op)
|
||||||
|
* @param epochMs QSO date+time in UTC milliseconds
|
||||||
|
* @param satName satellite name (e.g. "FO-29")
|
||||||
|
* @param mode ADIF mode (FM / CW / SSB ...)
|
||||||
|
* @param bandUp uplink band as reported by LoTW (BAND_RX: "70CM", "2M", "10M"...)
|
||||||
|
* @param bandDown downlink band (BAND: "2M", "70CM"...) — may be empty when
|
||||||
|
* LoTW did not include it
|
||||||
|
* @param dxcc ARRL DXCC entity code of the opposite station (from ADIF
|
||||||
|
* <DXCC>), or null when LoTW omitted it
|
||||||
|
* @param country DXCC entity name (ADIF <COUNTRY>), or null
|
||||||
|
* @param cqz CQ zone of the opposite station (ADIF <CQZ>), or null
|
||||||
|
* @param state Primary administrative subdivision of the opposite station
|
||||||
|
* (ADIF <STATE>). Interpretation depends on the DXCC entity:
|
||||||
|
* China -> province code ("GD"), Japan -> prefecture number
|
||||||
|
* ("34"), USA -> state abbreviation ("CA"). Null when absent.
|
||||||
|
*/
|
||||||
|
data class GridQso(
|
||||||
|
val call: String,
|
||||||
|
val epochMs: Long,
|
||||||
|
val satName: String,
|
||||||
|
val mode: String,
|
||||||
|
val bandUp: String,
|
||||||
|
val bandDown: String,
|
||||||
|
val dxcc: Int? = null,
|
||||||
|
val country: String? = null,
|
||||||
|
val cqz: Int? = null,
|
||||||
|
val state: String? = null,
|
||||||
|
/** 4-char grid the station itself operated from (ADIF MY_GRIDSQUARE).
|
||||||
|
* Kept as the first entry of [myGrids] for backward compatibility with
|
||||||
|
* consumers that predate multi-grid station locations. */
|
||||||
|
val myGrid: String? = null,
|
||||||
|
/** Every 4-char grid the station operated from for this QSO — ADIF
|
||||||
|
* <MY_GRIDSQUARE> plus every field of <MY_VUCC_GRIDS> (a comma-separated
|
||||||
|
* list LoTW emits when one station location roams across several grid
|
||||||
|
* squares; MY_GRIDSQUARE may be absent on those records). A station
|
||||||
|
* location (台址) can legitimately span multiple grids, and the map's
|
||||||
|
* stripes and the operated-grid selector must show all of them. */
|
||||||
|
val myGrids: Set<String> = emptySet(),
|
||||||
|
/** Callsign of the station location this QSO was logged under (ADIF
|
||||||
|
* <STATION_CALLSIGN>; e.g. "BH6RJD", "BH6RJD/P"). NOT the 台址 identity
|
||||||
|
* on its own — one callsign can have several station locations. */
|
||||||
|
val myCallsign: String? = null,
|
||||||
|
/** Stable key of the station location (台址) this QSO was logged under,
|
||||||
|
* derived from the MY_* station fields (STATION_CALLSIGN + MY_DXCC +
|
||||||
|
* MY_STATE + MY_CQ_ZONE + MY_ITU_ZONE + MY_IOTA + MY_COUNTRY), so the
|
||||||
|
* operated-grid selector can group QSOs per 台址 and show each 台址 with
|
||||||
|
* its full grid set. Null for data synced before this field existed. */
|
||||||
|
val stationKey: String? = null
|
||||||
|
) {
|
||||||
|
/** Short uplink/downlink band label ("U/V", "V/A"), or "" when unknown. */
|
||||||
|
val bandLabel: String
|
||||||
|
get() {
|
||||||
|
val up = bandAbbrev(bandUp)
|
||||||
|
val down = bandAbbrev(bandDown)
|
||||||
|
return when {
|
||||||
|
up.isNotEmpty() && down.isNotEmpty() -> "$up/$down"
|
||||||
|
up.isNotEmpty() -> up
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bandAbbrev(band: String): String = when (band.trim().uppercase()) {
|
||||||
|
"70CM" -> "U"
|
||||||
|
"2M" -> "V"
|
||||||
|
"10M" -> "A"
|
||||||
|
"6M" -> "V"
|
||||||
|
"1.2G" -> "L"
|
||||||
|
"2.4G" -> "S"
|
||||||
|
"23CM" -> "L"
|
||||||
|
"13CM" -> "S"
|
||||||
|
else -> band.trim().uppercase()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.model
|
||||||
|
|
||||||
|
/** Latest release info fetched from the fork's GitHub releases API. */
|
||||||
|
data class LatestRelease(
|
||||||
|
val versionTag: String, // e.g. "v4.4.6-ba7opf.6"
|
||||||
|
val title: String, // release name, may be empty
|
||||||
|
val body: String, // release notes / update description
|
||||||
|
val apkUrl: String? // first .apk asset download URL, null if absent
|
||||||
|
)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/*
|
||||||
|
* 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.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A station the user marked in a gridsquare they have NOT yet worked — a
|
||||||
|
* reminder to try to contact that callsign. Stored per 4-char grid (one mark
|
||||||
|
* per grid).
|
||||||
|
*
|
||||||
|
* @param call the callsign the user wants to work (uppercase)
|
||||||
|
* @param epochMs the moment the mark was created, UTC milliseconds
|
||||||
|
*/
|
||||||
|
data class MarkedStation(
|
||||||
|
val call: String,
|
||||||
|
val epochMs: Long
|
||||||
|
)
|
||||||
@@ -32,5 +32,11 @@ data class SatRadio(
|
|||||||
@SerialName("uplink_high") val uplinkHigh: Long?,
|
@SerialName("uplink_high") val uplinkHigh: Long?,
|
||||||
@SerialName("uplink_mode") val uplinkMode: String?,
|
@SerialName("uplink_mode") val uplinkMode: String?,
|
||||||
@SerialName("invert") val isInverted: Boolean,
|
@SerialName("invert") val isInverted: Boolean,
|
||||||
@SerialName("norad_cat_id") val catnum: Int?
|
@SerialName("norad_cat_id") val catnum: Int?,
|
||||||
|
/** Service class from the transceiver source ("Amateur"/"Unknown" etc.).
|
||||||
|
* Used by the SSTV virtual filter to tell amateur satellites apart from
|
||||||
|
* debris / weather birds (TIROS) / launcher stages (Ariane 6 R/B). */
|
||||||
|
@SerialName("service") val service: String? = null,
|
||||||
|
/** Set for manually imported transceivers, which remote updates must not replace. */
|
||||||
|
val isCustom: Boolean = false
|
||||||
)
|
)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.model
|
package com.rtbishop.look4sat.core.domain.model
|
||||||
|
|
||||||
/** 单条卫星状态报告(AMSAT 网站 tooltip 数据) */
|
/** One satellite status report (AMSAT site tooltip data) */
|
||||||
data class SatReport(
|
data class SatReport(
|
||||||
val id: String, // 报告 ID(a885153)
|
val id: String, // 报告 ID(a885153)
|
||||||
val statusText: String, // Heard / Telemetry Only / Not Heard ...
|
val statusText: String, // Heard / Telemetry Only / Not Heard ...
|
||||||
@@ -10,26 +10,27 @@ data class SatReport(
|
|||||||
val timeUtc: String // 2:46-:59 UTC
|
val timeUtc: String // 2:46-:59 UTC
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 单个 2 小时槽的状态 */
|
/** State of one 2-hour slot */
|
||||||
data class SatSlot(
|
data class SatSlot(
|
||||||
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
|
val statusColor: Long, // ARGB 状态色(-1 = 无报告)
|
||||||
val count: Int, // 报告数量(0 = 无)
|
val count: Int, // 报告数量(0 = 无)
|
||||||
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
|
val reportIds: List<String> = emptyList() // 该槽报告 ID 列表
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 卫星一天的状态(12 个 2 小时槽) */
|
/** One satellite day (12 two-hour slots) */
|
||||||
data class SatDay(
|
data class SatDay(
|
||||||
val dateLabel: String, // "Aug 4"
|
val dateLabel: String, // "Aug 4"
|
||||||
val slots: List<SatSlot> // 12 槽(00-02 ... 22-24)
|
val slots: List<SatSlot>, // 12 槽(00-02 ... 22-24)
|
||||||
|
val streakCount: Int = 0 // 当天最近连续相同状态报告数(0 = 当天无报告)
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 单个卫星 6 天状态 */
|
/** One satellite, 3 days of state */
|
||||||
data class SatStatus(
|
data class SatStatus(
|
||||||
val name: String, // "AO-123_[FM]"
|
val name: String, // "AO-123_[FM]"
|
||||||
val days: List<SatDay> // 6 天(新→旧)
|
val days: List<SatDay> // 3 days (newest first)
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 页面整体解析结果 */
|
/** Overall page parse result */
|
||||||
data class SatStatusPage(
|
data class SatStatusPage(
|
||||||
val fetchedAtUtcMs: Long,
|
val fetchedAtUtcMs: Long,
|
||||||
val statuses: List<SatStatus>,
|
val statuses: List<SatStatus>,
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ data class PassesSettings(
|
|||||||
val aosStartMinute: Int = 0,
|
val aosStartMinute: Int = 0,
|
||||||
val aosEndMinute: Int = 23 * 60 + 59,
|
val aosEndMinute: Int = 23 * 60 + 59,
|
||||||
val invertAosTimeWindow: Boolean = false,
|
val invertAosTimeWindow: Boolean = false,
|
||||||
val selectedModes: List<String>
|
val selectedModes: List<String>,
|
||||||
|
/** 历史过境回看窗口(小时): 过境列表从 now-hoursBefore 开始计算. */
|
||||||
|
val hoursBefore: Int = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
data class RCSettings(
|
data class RCSettings(
|
||||||
@@ -42,6 +44,7 @@ data class RCSettings(
|
|||||||
val frequencyAddress: String,
|
val frequencyAddress: String,
|
||||||
val frequencyPort: String,
|
val frequencyPort: String,
|
||||||
val frequencyFormat: String,
|
val frequencyFormat: String,
|
||||||
|
val frequencyOffsetHz: Long = 0L,
|
||||||
val bluetoothRotatorState: Boolean,
|
val bluetoothRotatorState: Boolean,
|
||||||
val bluetoothRotatorFormat: String,
|
val bluetoothRotatorFormat: String,
|
||||||
val bluetoothRotatorName: String,
|
val bluetoothRotatorName: String,
|
||||||
@@ -58,30 +61,60 @@ data class OtherSettings(
|
|||||||
val stateOfUtc: Boolean,
|
val stateOfUtc: Boolean,
|
||||||
val stateOfLightTheme: Boolean,
|
val stateOfLightTheme: Boolean,
|
||||||
val stateOfNightMode: Boolean = false,
|
val stateOfNightMode: Boolean = false,
|
||||||
|
val stateOfMapGrid: Boolean = false,
|
||||||
|
/** Grid mode: label worked (green) cells with the FIRST callsign worked
|
||||||
|
* in that grid instead of the Maidenhead code; non-worked cells get no
|
||||||
|
* label at all. */
|
||||||
|
val stateOfMapFirstCall: Boolean = false,
|
||||||
val shouldSeeWarning: Boolean,
|
val shouldSeeWarning: Boolean,
|
||||||
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 设置: 底部导航栏隐藏的页面(Screen simpleName 列表, 默认空 = 全部显示)
|
/** Independent auto-sync toggle for LoTW confirmed grids (separate from the
|
||||||
val hiddenScreens: List<String> = emptyList(),
|
* ephemeris auto-update; only effective after at least one manual sync). */
|
||||||
// UI 设置: 页面顺序(空 = 默认顺序: 卫星/过境/雷达/地图/设置)
|
val stateOfAutoLotwSync: Boolean = true,
|
||||||
val screenOrder: List<String> = emptyList(),
|
/** 指南针手动航向偏置(度, -180..180): 雷达方位 = 传感器方位 + 磁偏角 + 该偏置. */
|
||||||
// UI 设置: 更多菜单顺序(空 = 默认: 匹配/漫游/CW解码)
|
val compassOffsetDegrees: Float = 0f,
|
||||||
val subMenuOrder: List<String> = emptyList(),
|
val mapSource: String = MapSource.OSM,
|
||||||
// WaveLog 日志系统(4.5.2): 服务器配置
|
val tiandituKey: String = ""
|
||||||
val wavelogUrl: String = "",
|
|
||||||
val wavelogApiKey: String = "",
|
|
||||||
val wavelogStationId: String = "",
|
|
||||||
val wavelogAutoUpload: Boolean = false
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
object MapSource {
|
||||||
|
const val OSM = "osm"
|
||||||
|
const val TIANDITU_VECTOR = "tianditu_vector"
|
||||||
|
const val TIANDITU_IMAGE = "tianditu_image"
|
||||||
|
private const val LEGACY_TIANDITU = "tianditu"
|
||||||
|
|
||||||
|
fun normalize(source: String): String = when (source) {
|
||||||
|
OSM, TIANDITU_VECTOR, TIANDITU_IMAGE -> source
|
||||||
|
LEGACY_TIANDITU -> TIANDITU_VECTOR
|
||||||
|
else -> OSM
|
||||||
|
}
|
||||||
|
}
|
||||||
data class DataSourcesSettings(
|
data class DataSourcesSettings(
|
||||||
val useCustomTLE: Boolean,
|
val satelliteUrls: List<String>,
|
||||||
val useCustomTransceivers: Boolean,
|
val transceiversUrls: List<String>,
|
||||||
val tleUrl: String,
|
val satelliteEnabled: List<Boolean> = emptyList(),
|
||||||
val transceiversUrl: String
|
val transceiversEnabled: List<Boolean> = emptyList()
|
||||||
)
|
) {
|
||||||
|
fun isSatelliteEnabled(index: Int): Boolean = satelliteEnabled.getOrElse(index) { true }
|
||||||
|
fun isTransceiverEnabled(index: Int): Boolean = transceiversEnabled.getOrElse(index) { true }
|
||||||
|
}
|
||||||
|
|
||||||
|
data class WavelogSettings(
|
||||||
|
val url: String = "",
|
||||||
|
val token: String = ""
|
||||||
|
) {
|
||||||
|
val isConfigured: Boolean get() = url.isNotBlank() && token.isNotBlank()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class LoTWSettings(
|
||||||
|
val callsign: String = "",
|
||||||
|
val password: String = ""
|
||||||
|
) {
|
||||||
|
val isConfigured: Boolean get() = callsign.isNotBlank() && password.isNotBlank()
|
||||||
|
}
|
||||||
|
|
||||||
data class RadioControlSettings(
|
data class RadioControlSettings(
|
||||||
val enabled: Boolean,
|
val enabled: Boolean,
|
||||||
|
|||||||
+7
-3
@@ -424,11 +424,15 @@ object CelestialComputer {
|
|||||||
}
|
}
|
||||||
if (sunrise == 0.0) sunrise = daynum
|
if (sunrise == 0.0) sunrise = daynum
|
||||||
|
|
||||||
// Phase 4: fast-forward through the day until sun drops back below threshold
|
// Phase 4: fast-forward through the day until sun drops back below threshold.
|
||||||
daynum = sunrise
|
// Start from just after sunrise (small offset) so the sun is clearly above
|
||||||
|
// the threshold. This prevents a bug where Phase 3 converges to a point
|
||||||
|
// slightly below -threshold, causing Phase 4 to skip and Phase 5 to converge
|
||||||
|
// to the same time as sunrise, producing identical sunrise/sunset times.
|
||||||
|
daynum = sunrise + 0.001
|
||||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
guard = 0
|
guard = 0
|
||||||
while (sunPos.elevation > -threshold && guard++ < 500) {
|
while (sunPos.elevation >= -threshold && guard++ < 500) {
|
||||||
daynum += 0.008
|
daynum += 0.008
|
||||||
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
sunPos = getSunPosition(observer, daynumToMillis(daynum))
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-3
@@ -1,9 +1,23 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.repository
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmission
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.AmSatReportSubmitResult
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
import com.rtbishop.look4sat.core.domain.model.SatStatusPage
|
||||||
|
|
||||||
/** AMSAT 卫星状态数据源 */
|
/** AMSAT satellite status data source */
|
||||||
interface IAmSatRepository {
|
interface IAmSatRepository {
|
||||||
/** 抓取并解析 AMSAT 状态页; 失败返回 null */
|
/** Cached AMSAT status page for the current foreground session, if any. */
|
||||||
suspend fun fetchStatus(): SatStatusPage?
|
fun getCachedStatus(): SatStatusPage?
|
||||||
|
|
||||||
|
/** Fetch and parse the AMSAT status page; null on failure. */
|
||||||
|
suspend fun fetchStatus(forceRefresh: Boolean = false): SatStatusPage?
|
||||||
|
|
||||||
|
/** Warm the foreground-session cache without forcing a network reload. */
|
||||||
|
suspend fun prefetchStatus()
|
||||||
|
|
||||||
|
/** Clear the foreground-session status cache. */
|
||||||
|
fun clearStatusCache()
|
||||||
|
|
||||||
|
/** Submit a public AMSAT satellite status report. */
|
||||||
|
suspend fun submitReport(submission: AmSatReportSubmission): AmSatReportSubmitResult
|
||||||
}
|
}
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
/*
|
||||||
|
* 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.repository
|
||||||
|
|
||||||
|
/** Fetches confirmed gridsquares directly from ARRL LoTW. */
|
||||||
|
interface ILoTWRepository {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all confirmed (QSL_RCVD=Y) gridsquares for the given LoTW account.
|
||||||
|
* Returns the 4-char grid set with an explicit failure cause on error.
|
||||||
|
*/
|
||||||
|
suspend fun fetchConfirmedGrids(callsign: String, password: String): LoTWResult
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same report, but keeps the per-QSO detail of every confirmed satellite
|
||||||
|
* QSO (call / time / satellite / mode / bands), grouped by worked 4-char
|
||||||
|
* gridsquare. Failure cause is kept explicit.
|
||||||
|
*
|
||||||
|
* @param since QSL-since date ("yyyyMMdd" or "yyyy-MM-dd"); use an early
|
||||||
|
* date for a full report, or the last sync date for an
|
||||||
|
* incremental pull. Empty falls back to a full report.
|
||||||
|
* @param onProgress streamed download progress (phase + bytes + estimate),
|
||||||
|
* invoked from the IO dispatcher; may be called on every
|
||||||
|
* read chunk.
|
||||||
|
*/
|
||||||
|
suspend fun fetchConfirmedGridQsos(
|
||||||
|
callsign: String,
|
||||||
|
password: String,
|
||||||
|
since: String = "",
|
||||||
|
onProgress: (LoTWProgress) -> Unit = {}
|
||||||
|
): LoTWResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Streamed progress of a LoTW report download. */
|
||||||
|
data class LoTWProgress(
|
||||||
|
val phase: LoTWPhase,
|
||||||
|
/** Bytes read so far (uncompressed body). */
|
||||||
|
val bytesRead: Long = 0,
|
||||||
|
/** Estimated total body bytes, derived from <APP_LoTW_NUMREC>; 0 before known. */
|
||||||
|
val expectedBytes: Long = 0,
|
||||||
|
/** Smoothed download speed in bytes/second; 0 while unknown. */
|
||||||
|
val speedBps: Long = 0,
|
||||||
|
/** Total QSL records reported in the header (<APP_LoTW_NUMREC>); 0 before known. */
|
||||||
|
val qsoCount: Long = 0
|
||||||
|
) {
|
||||||
|
/** Fraction 0..1 of the estimated body already read; 0 while unknown. */
|
||||||
|
val fraction: Float
|
||||||
|
get() = if (expectedBytes > 0 && bytesRead > 0)
|
||||||
|
(bytesRead.toFloat() / expectedBytes).coerceIn(0f, 1f) else 0f
|
||||||
|
|
||||||
|
/** Estimated seconds remaining; 0 while size or speed is unknown. */
|
||||||
|
val remainingSeconds: Long
|
||||||
|
get() = if (expectedBytes > 0 && speedBps > 0)
|
||||||
|
((expectedBytes - bytesRead) / speedBps).coerceAtLeast(0) else 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class LoTWPhase { Connecting, Downloading }
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.QsoRecord
|
||||||
|
|
||||||
|
interface ILoTWUploadRepository {
|
||||||
|
suspend fun certificate(): LoTWCertificate?
|
||||||
|
suspend fun station(): LoTWStation?
|
||||||
|
suspend fun importCertificate(data: ByteArray, password: CharArray): LoTWCertificate
|
||||||
|
suspend fun saveCertificatePassword(password: CharArray): LoTWCertificate
|
||||||
|
suspend fun saveStation(station: LoTWStation): LoTWStation
|
||||||
|
suspend fun removeCertificate()
|
||||||
|
/** Region field (State/Province/Prefecture…) and national CQZ/ITUZ map for a DXCC entity. */
|
||||||
|
suspend fun stationMeta(dxcc: Int): LoTWStationMeta
|
||||||
|
suspend fun audit(records: List<QsoRecord>): LoTWUploadAudit
|
||||||
|
suspend fun prepare(records: List<QsoRecord>, resubmit: Boolean): LoTWUploadPreview
|
||||||
|
suspend fun upload(previewId: String): LoTWUploadResult
|
||||||
|
fun discardPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class LoTWCertificate(
|
||||||
|
val callsign: String,
|
||||||
|
val dxcc: Int,
|
||||||
|
val serial: String,
|
||||||
|
val expires: String,
|
||||||
|
val firstQsoDate: String,
|
||||||
|
val lastQsoDate: String,
|
||||||
|
val passwordSaved: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The location is explicitly confirmed for the selected contacts, not inferred from today's GPS. */
|
||||||
|
data class LoTWStation(
|
||||||
|
val grid: String = "",
|
||||||
|
val cqZone: String = "",
|
||||||
|
val ituZone: String = "",
|
||||||
|
val region: String = "",
|
||||||
|
val county: String = "",
|
||||||
|
val iota: String = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
/** One (ITU:CQ) pair from a LoTW zonemap, e.g. Guangdong is 44:24. */
|
||||||
|
data class LoTWZonePair(val itu: Int, val cq: Int)
|
||||||
|
|
||||||
|
/** One selectable region code with the zones it maps to (cross-zone regions carry several pairs). */
|
||||||
|
data class LoTWRegionOption(val code: String, val name: String, val zones: List<LoTWZonePair>)
|
||||||
|
|
||||||
|
/** Country-dependent region field (US_STATE, CN_PROVINCE, …) with its selectable options. */
|
||||||
|
data class LoTWRegionField(val id: String, val label: String, val options: List<LoTWRegionOption>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the station-location UI needs for one DXCC entity:
|
||||||
|
* the region field to show (null when the country has none) and the national
|
||||||
|
* zonemap (used to prefill CQZ/ITUZ when the country has a single zone pair).
|
||||||
|
*/
|
||||||
|
data class LoTWStationMeta(
|
||||||
|
val regionField: LoTWRegionField? = null,
|
||||||
|
val countryZones: List<LoTWZonePair> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LoTWUploadPreview(
|
||||||
|
val id: String,
|
||||||
|
val callsign: String,
|
||||||
|
val dxcc: Int,
|
||||||
|
val grid: String,
|
||||||
|
val count: Int,
|
||||||
|
val skipped: Int,
|
||||||
|
val firstUtc: String,
|
||||||
|
val lastUtc: String,
|
||||||
|
val contacts: List<String>,
|
||||||
|
val unknownSkipped: Int = 0,
|
||||||
|
/** Un-signable records (invalid call/date/…) skipped instead of aborting the batch. */
|
||||||
|
val unavailableSkipped: Int = 0,
|
||||||
|
/** Ids of the records that actually made it into this TQ8 batch. Only these
|
||||||
|
* may be marked "uploaded" after an accepted POST — never the full candidate list. */
|
||||||
|
val submittedIds: List<Long> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Local comparison against downloaded LoTW receipt flags and this app's durable upload receipts. */
|
||||||
|
data class LoTWUploadAudit(
|
||||||
|
val total: Int,
|
||||||
|
val pending: Int,
|
||||||
|
val uploaded: Int,
|
||||||
|
val unknown: Int,
|
||||||
|
val unavailable: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface LoTWUploadResult {
|
||||||
|
data class Accepted(val count: Int) : LoTWUploadResult
|
||||||
|
data class Rejected(val message: String = "") : LoTWUploadResult
|
||||||
|
/** May have reached LoTW. Do not retry automatically. */
|
||||||
|
data object Unknown : LoTWUploadResult
|
||||||
|
data object ExpiredPreview : LoTWUploadResult
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoTWOperationException(val reason: LoTWProblem, val detail: String = "") : Exception(reason.name)
|
||||||
|
|
||||||
|
enum class LoTWProblem {
|
||||||
|
CERTIFICATE_PASSWORD, CERTIFICATE_INVALID, CERTIFICATE_EXPIRED, CERTIFICATE_MISSING,
|
||||||
|
CERTIFICATE_FORMAT, STORAGE, EMPTY_SELECTION, CALLSIGN_MISMATCH, QSO_DATE, STATION_GRID,
|
||||||
|
STATION_REGION, STATION_ZONE, STATION_IOTA, MODE, BAND, SATELLITE, INVALID_CONTACT,
|
||||||
|
LOCATION_MISMATCH, TOO_MANY_CONTACTS
|
||||||
|
}
|
||||||
+7
-4
@@ -16,6 +16,7 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.domain.repository
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
import com.rtbishop.look4sat.core.domain.logbook.IQsoRepository
|
||||||
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
import com.rtbishop.look4sat.core.domain.model.SatRadio
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
import com.rtbishop.look4sat.core.domain.usecase.IAddToCalendar
|
||||||
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
import com.rtbishop.look4sat.core.domain.usecase.IAudioCapture
|
||||||
@@ -30,7 +31,12 @@ interface IMainContainer {
|
|||||||
val selectionRepo: ISelectionRepo
|
val selectionRepo: ISelectionRepo
|
||||||
val satelliteRepo: ISatelliteRepo
|
val satelliteRepo: ISatelliteRepo
|
||||||
val databaseRepo: IDatabaseRepo
|
val databaseRepo: IDatabaseRepo
|
||||||
val amSatRepo: IAmSatRepository
|
val amSatRepo: IAmSatRepository
|
||||||
|
val updateRepo: IUpdateRepository
|
||||||
|
val wavelogRepo: IWavelogRepository
|
||||||
|
val lotwRepo: com.rtbishop.look4sat.core.domain.repository.ILoTWRepository
|
||||||
|
val qsoRepository: IQsoRepository
|
||||||
|
val lotwUploadRepository: ILoTWUploadRepository
|
||||||
val radioTrackingService: IRadioTrackingService
|
val radioTrackingService: IRadioTrackingService
|
||||||
val mutualPassData: StateFlow<MutualPassData>
|
val mutualPassData: StateFlow<MutualPassData>
|
||||||
fun setMutualPassData(data: MutualPassData)
|
fun setMutualPassData(data: MutualPassData)
|
||||||
@@ -43,9 +49,6 @@ interface IMainContainer {
|
|||||||
fun provideRxRadioController(): IRadioController
|
fun provideRxRadioController(): IRadioController
|
||||||
fun provideAudioCapture(): IAudioCapture
|
fun provideAudioCapture(): IAudioCapture
|
||||||
fun provideSaveImage(): ISaveImage
|
fun provideSaveImage(): ISaveImage
|
||||||
// WaveLog 日志(4.5.2)
|
|
||||||
val wavelogQueue: com.rtbishop.look4sat.core.domain.wavelog.WavelogQueue
|
|
||||||
fun provideWavelogUploader(): com.rtbishop.look4sat.core.domain.wavelog.WavelogUploader
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class MutualPassData(
|
data class MutualPassData(
|
||||||
|
|||||||
+16
-1
@@ -61,8 +61,23 @@ 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(satPos: OrbitalPos, radios: List<SatRadio>): List<SatRadio>
|
suspend fun getRadios(sat: OrbitalObject, pos: GeoPos, radios: List<SatRadio>, time: Long): 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>
|
||||||
|
|
||||||
|
/** Satellite catnums whose radios carry any of the given downlink modes.
|
||||||
|
* Empty modes -> empty result; used to filter the mutual-match satellite
|
||||||
|
* set to transponder satellites (FM voice / linear). */
|
||||||
|
suspend fun getSatelliteIdsWithModes(modes: List<String>): List<Int>
|
||||||
|
|
||||||
|
/** Like [getSatelliteIdsWithModes] but only matches records with an uplink
|
||||||
|
* (real transponders), excluding downlink-only beacons/telemetry that share
|
||||||
|
* the same mode label. Used by the FM/Linear fallback filter. */
|
||||||
|
suspend fun getSatelliteIdsWithModesAndUplink(modes: List<String>): List<Int>
|
||||||
|
|
||||||
|
/** Like [getSatelliteIdsWithModes] but only matches records whose service
|
||||||
|
* class is "Amateur", so SSTV never matches weather birds (TIROS),
|
||||||
|
* launcher debris or other non-amateur transmitters. */
|
||||||
|
suspend fun getSatelliteIdsWithModesAndAmateur(modes: List<String>): List<Int>
|
||||||
}
|
}
|
||||||
+11
@@ -20,8 +20,19 @@ package com.rtbishop.look4sat.core.domain.repository
|
|||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/** 指南针校准精度等级, 由磁场传感器 accuracy 事件映射而来. */
|
||||||
|
enum class CompassAccuracy {
|
||||||
|
UNAVAILABLE,
|
||||||
|
UNRELIABLE,
|
||||||
|
LOW,
|
||||||
|
MEDIUM,
|
||||||
|
HIGH
|
||||||
|
}
|
||||||
|
|
||||||
interface ISensorsRepo {
|
interface ISensorsRepo {
|
||||||
val sensorData: StateFlow<Pair<Float, Float>>
|
val sensorData: StateFlow<Pair<Float, Float>>
|
||||||
|
/** 指南针当前校准精度 (磁场传感器 accuracy), 用于校准对话框进度. */
|
||||||
|
val compassAccuracy: StateFlow<CompassAccuracy>
|
||||||
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
|
fun getMagDeclination(geoPos: GeoPos, time: Long = System.currentTimeMillis()): Float
|
||||||
fun enableSensor()
|
fun enableSensor()
|
||||||
fun disableSensor()
|
fun disableSensor()
|
||||||
|
|||||||
+52
-2
@@ -23,6 +23,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.model.WavelogSettings
|
||||||
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
@@ -45,8 +46,7 @@ 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
|
||||||
/** GPS 定位(挂起): 拿到位置才返回 true; 权限缺失/超时/无信号返回 false */
|
fun setStationPosition(): Boolean
|
||||||
suspend fun setStationPosition(): Boolean
|
|
||||||
fun setStationPosition(locator: String): Boolean
|
fun setStationPosition(locator: String): Boolean
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@@ -74,8 +74,58 @@ interface ISettingsRepo {
|
|||||||
fun updateDataSourcesSettings(settings: DataSourcesSettings)
|
fun updateDataSourcesSettings(settings: DataSourcesSettings)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
//region # Data sources status
|
||||||
|
val dataSourcesStatus: StateFlow<Map<String, Int>>
|
||||||
|
fun updateDataSourcesStatus(status: Map<String, Int>)
|
||||||
|
//endregion
|
||||||
|
|
||||||
//region # Radio control settings
|
//region # Radio control settings
|
||||||
val radioControlSettings: StateFlow<RadioControlSettings>
|
val radioControlSettings: StateFlow<RadioControlSettings>
|
||||||
fun updateRadioControlSettings(settings: RadioControlSettings)
|
fun updateRadioControlSettings(settings: RadioControlSettings)
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
//region # Per-satellite calculator offset settings
|
||||||
|
fun getSatelliteOffset(catnum: Int): String
|
||||||
|
fun setSatelliteOffset(catnum: Int, offset: String)
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # Per-satellite logbook mode preset settings
|
||||||
|
/** Last selected logbook mode (CW/SSB/FT4) for a satellite; empty when unset. */
|
||||||
|
fun getSatelliteMode(catnum: Int): String
|
||||||
|
fun setSatelliteMode(catnum: Int, mode: String)
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # AMSAT status report settings
|
||||||
|
fun getAmSatCallsign(): String
|
||||||
|
fun setAmSatCallsign(callsign: String)
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # Wavelog worked-grids settings
|
||||||
|
val wavelogSettings: StateFlow<WavelogSettings>
|
||||||
|
fun updateWavelogSettings(settings: WavelogSettings)
|
||||||
|
fun getWorkedGrids(): Set<String>
|
||||||
|
fun setWorkedGrids(grids: Set<String>)
|
||||||
|
/** Confirmed satellite QSOs grouped by worked 4-char gridsquare (map tap detail). */
|
||||||
|
fun getWorkedGridQsos(): Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>
|
||||||
|
fun setWorkedGridQsos(qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>)
|
||||||
|
/** Distinct 4-char gridsquares the account operated from (LoTW <MY_GRIDSQUARE>). */
|
||||||
|
fun getRoamedGrids(): Set<String>
|
||||||
|
fun setRoamedGrids(grids: Set<String>)
|
||||||
|
/** Stations the user marked as "want to contact" in unworked gridsquares
|
||||||
|
* (grid -> ordered list of marks; the first is the pinned/top one). The
|
||||||
|
* whole grid clears automatically once it becomes worked. */
|
||||||
|
fun getMarkedGridStations(): Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>
|
||||||
|
fun setMarkedGridStations(stations: Map<String, List<com.rtbishop.look4sat.core.domain.model.MarkedStation>>)
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region # LoTW confirmed-grids settings
|
||||||
|
val lotwSettings: StateFlow<com.rtbishop.look4sat.core.domain.model.LoTWSettings>
|
||||||
|
fun updateLoTWSettings(settings: com.rtbishop.look4sat.core.domain.model.LoTWSettings)
|
||||||
|
/** Last successful LoTW sync date ("yyyyMMdd", empty when never synced). */
|
||||||
|
fun getLastLotwSyncDate(): String
|
||||||
|
fun setLastLotwSyncDate(date: String)
|
||||||
|
/** Callsign of the last successful LoTW sync (empty when never synced). */
|
||||||
|
fun getLastLotwSyncCallsign(): String
|
||||||
|
fun setLastLotwSyncCallsign(callsign: String)
|
||||||
|
//endregion
|
||||||
}
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.LatestRelease
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
interface IUpdateRepository {
|
||||||
|
/** Fetch the latest release of the fork repo from GitHub; null on failure. */
|
||||||
|
suspend fun getLatestRelease(): LatestRelease?
|
||||||
|
|
||||||
|
/** Download the release APK from [url] into [dest]; true on success. */
|
||||||
|
suspend fun downloadApk(url: String, dest: File): Boolean
|
||||||
|
}
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
/*
|
||||||
|
* 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.repository
|
||||||
|
|
||||||
|
/** Fetches worked gridsquares from a self-hosted Wavelog instance (API v2). */
|
||||||
|
interface IWavelogRepository {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch worked grids for the configured Wavelog URL/token.
|
||||||
|
* Returns the grid set, or null on any failure.
|
||||||
|
*/
|
||||||
|
suspend fun fetchWorkedGrids(url: String, token: String): Set<String>?
|
||||||
|
}
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
/*
|
||||||
|
* 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.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared LoTW sync orchestration used by BOTH the manual sync (settings screen)
|
||||||
|
* and the automatic sync on app start (MainApplication), so the two paths can
|
||||||
|
* never drift apart:
|
||||||
|
*
|
||||||
|
* - [LoTWSyncMode.Incremental] pulls only the confirmations since the last sync
|
||||||
|
* and MERGES them into the stored grids/QSOs (grids only grow; QSO detail is
|
||||||
|
* deduped by call + QSO time).
|
||||||
|
* - [LoTWSyncMode.Full] pulls everything and REPLACES the stored data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UTC date ("yyyyMMdd") of the given instant — the "already synced today"
|
||||||
|
* gate granularity.
|
||||||
|
*/
|
||||||
|
fun lotwSyncToday(now: Long = System.currentTimeMillis()): String =
|
||||||
|
SimpleDateFormat("yyyyMMdd", Locale.US)
|
||||||
|
.apply { timeZone = TimeZone.getTimeZone("UTC") }
|
||||||
|
.format(Date(now))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full UTC timestamp cursor ("yyyy-MM-dd HH:mm:ss") written after a
|
||||||
|
* successful sync. This is exactly the format LoTW's qso_qslsince accepts
|
||||||
|
* (measured live 2026-09-17: the legacy "yyyyMMdd" cursor is silently
|
||||||
|
* ignored by LoTW, which falls back to its own system-supplied default),
|
||||||
|
* and it keeps enough precision to display the last-sync time like the
|
||||||
|
* ephemeris update time.
|
||||||
|
*/
|
||||||
|
fun lotwSyncCursor(now: Long = System.currentTimeMillis()): String =
|
||||||
|
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||||
|
.apply { timeZone = TimeZone.getTimeZone("UTC") }
|
||||||
|
.format(Date(now))
|
||||||
|
|
||||||
|
/** Date part of a stored cursor as "yyyyMMdd", tolerating the legacy
|
||||||
|
* "yyyyMMdd" format and the current "yyyy-MM-dd HH:mm:ss" format. */
|
||||||
|
fun lotwCursorDate(cursor: String): String = when {
|
||||||
|
cursor.length >= 10 && cursor[4] == '-' -> cursor.substring(0, 10).replace("-", "")
|
||||||
|
else -> cursor.take(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** qso_qslsince value for the API: passes the full timestamp through and
|
||||||
|
* normalizes a legacy "yyyyMMdd" cursor to the documented "yyyy-MM-dd". */
|
||||||
|
fun lotwCursorApi(cursor: String): String = when {
|
||||||
|
cursor.isBlank() -> ""
|
||||||
|
cursor.length >= 10 && cursor[4] == '-' -> cursor
|
||||||
|
else -> "${cursor.take(4)}-${cursor.substring(4, 6)}-${cursor.substring(6, 8)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stored cursor parsed to UTC epoch ms for display; 0 when unparseable. */
|
||||||
|
fun lotwCursorEpochMs(cursor: String): Long = try {
|
||||||
|
val pattern = if (cursor.length >= 10 && cursor[4] == '-') "yyyy-MM-dd HH:mm:ss" else "yyyyMMdd"
|
||||||
|
SimpleDateFormat(pattern, Locale.US)
|
||||||
|
.apply { timeZone = TimeZone.getTimeZone("UTC") }
|
||||||
|
.parse(cursor)?.time ?: 0L
|
||||||
|
} catch (_: Exception) {
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the effective sync mode for a given request:
|
||||||
|
* - an explicit [LoTWSyncMode.Full] request is always honored;
|
||||||
|
* - an [LoTWSyncMode.Incremental] request is honored only when the callsign
|
||||||
|
* matches the last sync — a first sync, an unknown/empty record or a
|
||||||
|
* callsign change falls back to [LoTWSyncMode.Full] so no stale grids from
|
||||||
|
* another account linger.
|
||||||
|
* With [requested] == null (automatic sync) it picks incremental whenever
|
||||||
|
* possible and full otherwise.
|
||||||
|
*/
|
||||||
|
fun resolveLoTWSyncMode(
|
||||||
|
lastSyncCallsign: String,
|
||||||
|
callsign: String,
|
||||||
|
requested: LoTWSyncMode?
|
||||||
|
): LoTWSyncMode {
|
||||||
|
if (requested == LoTWSyncMode.Full) return LoTWSyncMode.Full
|
||||||
|
val sameCall = lastSyncCallsign.isNotBlank() && lastSyncCallsign == callsign
|
||||||
|
return if (sameCall) LoTWSyncMode.Incremental else LoTWSyncMode.Full
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the automatic LoTW sync should run now — mirrors the ephemeris
|
||||||
|
* auto-update check: credentials configured, the LoTW auto-sync toggle on, at
|
||||||
|
* least one prior manual sync (the first sync stays manual/full), and not
|
||||||
|
* already synced today (ARRL limits report pulls, ~once a day per account).
|
||||||
|
*/
|
||||||
|
fun shouldAutoSyncLoTW(
|
||||||
|
isConfigured: Boolean,
|
||||||
|
autoLotwSyncEnabled: Boolean,
|
||||||
|
lastSyncDate: String,
|
||||||
|
today: String
|
||||||
|
): Boolean {
|
||||||
|
val lastDate = lotwCursorDate(lastSyncDate)
|
||||||
|
return isConfigured && autoLotwSyncEnabled && lastDate.isNotBlank() && lastDate != today
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a successful LoTW report to the stored grid data and advances the
|
||||||
|
* sync cursor so the next incremental pull asks LoTW for only the
|
||||||
|
* confirmations since today. Persistence runs on the IO dispatcher — building
|
||||||
|
* the per-grid QSO JSON and writing SharedPreferences blocks the calling
|
||||||
|
* thread, and for large accounts (multi-MB JSON) that froze the main thread
|
||||||
|
* after a manual sync ('bar done but app stuck').
|
||||||
|
*
|
||||||
|
* @return the resulting number of worked grids.
|
||||||
|
*/
|
||||||
|
suspend fun applyLoTWGridResult(
|
||||||
|
settingsRepo: ISettingsRepo,
|
||||||
|
result: LoTWResult.Success,
|
||||||
|
mode: LoTWSyncMode,
|
||||||
|
callsign: String,
|
||||||
|
now: Long = System.currentTimeMillis()
|
||||||
|
): Int {
|
||||||
|
val existingGrids = settingsRepo.getWorkedGrids()
|
||||||
|
val existingQsos = settingsRepo.getWorkedGridQsos()
|
||||||
|
val existingRoamed = settingsRepo.getRoamedGrids()
|
||||||
|
val mergedGrids = if (mode == LoTWSyncMode.Incremental) existingGrids + result.grids else result.grids
|
||||||
|
val mergedQsos = if (mode == LoTWSyncMode.Incremental) mergeGridQsos(existingQsos, result.qsos) else result.qsos
|
||||||
|
val mergedRoamed = if (mode == LoTWSyncMode.Incremental) existingRoamed + result.roamedGrids else result.roamedGrids
|
||||||
|
settingsRepo.setLastLotwSyncDate(lotwSyncCursor(now))
|
||||||
|
settingsRepo.setLastLotwSyncCallsign(callsign)
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
settingsRepo.setWorkedGrids(mergedGrids)
|
||||||
|
settingsRepo.setWorkedGridQsos(mergedQsos)
|
||||||
|
settingsRepo.setRoamedGrids(mergedRoamed)
|
||||||
|
}
|
||||||
|
return mergedGrids.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge fresh QSO detail into existing per-grid lists, dedup by call + QSO time. */
|
||||||
|
fun mergeGridQsos(
|
||||||
|
existing: Map<String, List<GridQso>>,
|
||||||
|
fresh: Map<String, List<GridQso>>
|
||||||
|
): Map<String, List<GridQso>> {
|
||||||
|
val merged = existing.mapValues { (_, list) -> list.toMutableList() }.toMutableMap()
|
||||||
|
fresh.forEach { (grid, list) ->
|
||||||
|
val target = merged.getOrPut(grid) { mutableListOf() }
|
||||||
|
val known = target.mapTo(mutableSetOf()) { it.call to it.epochMs }
|
||||||
|
list.forEach { qso ->
|
||||||
|
if (known.add(qso.call to qso.epochMs)) target.add(qso)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/*
|
||||||
|
* 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.repository
|
||||||
|
|
||||||
|
/** Result of a LoTW report fetch, with the failure cause kept explicit. */
|
||||||
|
sealed class LoTWResult {
|
||||||
|
|
||||||
|
/** Report downloaded and parsed successfully. */
|
||||||
|
data class Success(
|
||||||
|
val grids: Set<String>,
|
||||||
|
val qsos: Map<String, List<com.rtbishop.look4sat.core.domain.model.GridQso>>,
|
||||||
|
/** Distinct 4-char gridsquares the account itself operated from
|
||||||
|
* (ADIF <MY_GRIDSQUARE>, satellite QSOs only) — "roamed/activated" grids. */
|
||||||
|
val roamedGrids: Set<String> = emptySet()
|
||||||
|
) : LoTWResult()
|
||||||
|
|
||||||
|
/** HTTP 200 but LoTW replied with its login-error page (bad callsign/password). */
|
||||||
|
data object BadCredentials : LoTWResult()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report endpoint refused the request without a usable error page. LoTW
|
||||||
|
* limits downloads to one in progress per user id and flags accounts that
|
||||||
|
* pull the full report too often; the server answers those with a bare
|
||||||
|
* non-ADIF body (no <eoh>).
|
||||||
|
*/
|
||||||
|
data object RateLimited : LoTWResult()
|
||||||
|
|
||||||
|
/** Connect/read timed out — typically a slow route to the ARRL servers. */
|
||||||
|
data object Timeout : LoTWResult()
|
||||||
|
|
||||||
|
/** Any other network-level failure (DNS, TLS, connection reset, HTTP 5xx). */
|
||||||
|
data class NetworkError(val detail: String) : LoTWResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a LoTW sync does: pull everything, or only new QSLs since the last sync. */
|
||||||
|
enum class LoTWSyncMode { Full, Incremental }
|
||||||
@@ -29,8 +29,13 @@ interface ILocalSource {
|
|||||||
suspend fun insertEntries(entries: List<OrbitalData>)
|
suspend fun insertEntries(entries: List<OrbitalData>)
|
||||||
suspend fun deleteEntries()
|
suspend fun deleteEntries()
|
||||||
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
suspend fun getIdsWithModes(modes: List<String>): List<Int>
|
||||||
|
suspend fun getIdsWithModesAndUplink(modes: List<String>): List<Int>
|
||||||
|
suspend fun getIdsWithModesAndAmateur(modes: List<String>): List<Int>
|
||||||
suspend fun getRadiosTotal(): Int
|
suspend fun getRadiosTotal(): Int
|
||||||
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
suspend fun getRadiosWithId(id: Int): List<SatRadio>
|
||||||
suspend fun insertRadios(radios: List<SatRadio>)
|
suspend fun insertRadios(radios: List<SatRadio>, isCustom: Boolean = false)
|
||||||
|
|
||||||
|
/** Delete all non-custom transceivers, keeping manually imported ones. */
|
||||||
|
suspend fun deleteManagedRadios()
|
||||||
suspend fun deleteRadios()
|
suspend fun deleteRadios()
|
||||||
}
|
}
|
||||||
+12
-4
@@ -19,10 +19,18 @@ package com.rtbishop.look4sat.core.domain.source
|
|||||||
|
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
|
/** Result of a network download: the HTTP status code and the response body stream. */
|
||||||
|
data class NetworkResult(val code: Int, val stream: InputStream?) {
|
||||||
|
companion object {
|
||||||
|
/** Code used when a request fails before any HTTP response is received. */
|
||||||
|
const val CONNECTION_ERROR = -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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): NetworkResult
|
||||||
|
suspend fun getAmSatCatalog(): String?
|
||||||
/** 抓取 AMSAT 状态页 HTML(带 UA, 返回 null = 失败) */
|
suspend fun getAmSatReports(hours: Int, limit: Int): String?
|
||||||
suspend fun getStatusHtml(): String?
|
suspend fun submitAmSatReport(payloadJson: String): Pair<Int, String>?
|
||||||
}
|
}
|
||||||
@@ -18,12 +18,13 @@
|
|||||||
package com.rtbishop.look4sat.core.domain.source
|
package com.rtbishop.look4sat.core.domain.source
|
||||||
|
|
||||||
object Sources {
|
object Sources {
|
||||||
// 在线更新的默认 URL(用户可通过"自定义URL"对话框修改/重置)
|
|
||||||
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(
|
||||||
|
"LAPAN-A2" to "https://www.kaggle.com/api/v1/datasets/download/muazamnugroho/lapan-a2-satellite-two-line-element-tle-dataset/LAPAN-A2_TLE_latest.txt",
|
||||||
|
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
||||||
|
"BI4PYM AutoTLE (GitHub)" to "https://raw.githubusercontent.com/BI4PYM/AutoTLE/refs/heads/master/AutoTLE.txt",
|
||||||
|
"BI4PYM AutoTLE (Mirror)" to "https://autotle.bi4pym.cn/AutoTLE.txt",
|
||||||
"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",
|
||||||
|
"SatNOGS" to "https://db.satnogs.org/api/tle/?format=3le",
|
||||||
"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",
|
||||||
"Brightest" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=csv",
|
"Brightest" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=visual&FORMAT=csv",
|
||||||
"Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv",
|
"Cubesat" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=cubesat&FORMAT=csv",
|
||||||
@@ -39,7 +40,7 @@ object Sources {
|
|||||||
"OneWeb" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=oneweb&FORMAT=csv",
|
"OneWeb" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=oneweb&FORMAT=csv",
|
||||||
"Orbcomm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=orbcomm&FORMAT=csv",
|
"Orbcomm" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=orbcomm&FORMAT=csv",
|
||||||
"Resource" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=resource&FORMAT=csv",
|
"Resource" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=resource&FORMAT=csv",
|
||||||
"SatNOGS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv",
|
"CelesTrak SatNOGS" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=satnogs&FORMAT=csv",
|
||||||
"Science" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=science&FORMAT=csv",
|
"Science" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=science&FORMAT=csv",
|
||||||
"Spire" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=spire&FORMAT=csv",
|
"Spire" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=spire&FORMAT=csv",
|
||||||
"Starlink" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=csv",
|
"Starlink" to "https://celestrak.org/NORAD/elements/gp.php?GROUP=starlink&FORMAT=csv",
|
||||||
@@ -49,10 +50,32 @@ object Sources {
|
|||||||
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
|
"Amsat" to "https://amsat.org/tle/current/nasabare.txt",
|
||||||
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
|
"Classified" to "https://www.mmccants.org/tles/classfd.zip",
|
||||||
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
|
"McCants" to "https://www.mmccants.org/tles/inttles.zip",
|
||||||
"R4UAB" to "https://r4uab.ru/satonline.txt",
|
"ARISS" to "https://live.ariss.org/iss.txt",
|
||||||
"Other" to "" // key for sats filter
|
"Other" to "" // key for sats filter
|
||||||
)
|
)
|
||||||
val transceiversDataUrls = mapOf(
|
val transceiversDataUrls = mapOf(
|
||||||
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active"
|
"SatNOGS" to "https://db.satnogs.org/api/transmitters/?format=json&status=active",
|
||||||
|
"R4UAB" to "https://r4uab.ru/transmitters.json"
|
||||||
)
|
)
|
||||||
|
/**
|
||||||
|
* Hardcoded AMSAT Live FM satellites (NORAD catnums):
|
||||||
|
* SO-50 (27607), ISS ZARYA (25544), AO-123 ASRTU-1 (61781).
|
||||||
|
* Replaces the AMSAT live-page fetch: stable on any network, no
|
||||||
|
* sync-time dependency, no stale-list or timeout failure modes.
|
||||||
|
*/
|
||||||
|
val amSatFmCatnums = setOf(27607, 25544, 61781)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hardcoded AMSAT Live linear (SSB/CW) satellites:
|
||||||
|
* RS-44 (44909), FO-29 (24278), AO-7 (7530),
|
||||||
|
* AO-73 FUNcube-1 (39444), JO-97 JY1SAT (43803).
|
||||||
|
*/
|
||||||
|
val amSatLinearCatnums = setOf(44909, 24278, 7530, 39444, 43803)
|
||||||
|
|
||||||
|
/** Virtual satellite-selection types: transponder/activity filters shown
|
||||||
|
* at the top of the type picker. They resolve to live lists (hardcoded
|
||||||
|
* FM/Linear catnum sets or mode=SSTV radios) instead of persisted
|
||||||
|
* per-type IDs, and are mutually exclusive with the regular TLE-source
|
||||||
|
* types in the picker. */
|
||||||
|
val virtualTypeNames = listOf("AMSAT Live FM", "AMSAT Live Linear", "Live SSTV")
|
||||||
}
|
}
|
||||||
@@ -19,7 +19,4 @@ package com.rtbishop.look4sat.core.domain.usecase
|
|||||||
|
|
||||||
interface IShowToast {
|
interface IShowToast {
|
||||||
operator fun invoke(message: String)
|
operator fun invoke(message: String)
|
||||||
|
|
||||||
/** 按资源 ID 显示(四语文案) */
|
|
||||||
operator fun invoke(resId: Int)
|
|
||||||
}
|
}
|
||||||
+75
-26
@@ -16,20 +16,28 @@ import java.util.Locale
|
|||||||
/**
|
/**
|
||||||
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
* Computes Doppler-corrected reciprocal frequencies for linear transponders.
|
||||||
*
|
*
|
||||||
* For a linear (passband) transponder, uplink and downlink frequencies are
|
* The full physical path:
|
||||||
* related by a fixed passband offset. When the satellite moves, both are
|
|
||||||
* Doppler-shifted. Given one, we compute the other:
|
|
||||||
*
|
*
|
||||||
* downlink → uplink: mapDownlinkToUplink (passband) → getUplinkFreq (Doppler)
|
* TX→RX (uplink → downlink):
|
||||||
* uplink → downlink: mapUplinkToDownlink (passband) → getDownlinkFreq (Doppler)
|
* ① 地面发射 f_tx
|
||||||
|
* ② 卫星收到 f_tx × (c - v) / c (上行多普勒)
|
||||||
|
* ③ 卫星转发 = passband映射(②) (在卫星上做映射)
|
||||||
|
* ④ 地面听到 ③ × (c - v) / c (下行多普勒)
|
||||||
|
*
|
||||||
|
* RX→TX (downlink → uplink):
|
||||||
|
* ④ 地面听到 f_rx
|
||||||
|
* ③ 卫星转发 = f_rx × (c + v) / c (逆下行多普勒)
|
||||||
|
* ② 卫星收到 = 逆passband映射(③)
|
||||||
|
* ① 地面应发射 = ② × (c + v) / c (逆上行多普勒)
|
||||||
*
|
*
|
||||||
* Addresses GitHub issue #91 (Custom frequency Doppler correction).
|
* Addresses GitHub issue #91 (Custom frequency Doppler correction).
|
||||||
*/
|
*/
|
||||||
object DopplerFrequencyCalculator {
|
object DopplerFrequencyCalculator {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given a downlink frequency, compute the Doppler-corrected uplink frequency.
|
* Given a downlink frequency (what the user hears), compute the
|
||||||
* Returns null if the transponder is not a linear passband type.
|
* uplink frequency the user should transmit.
|
||||||
|
* Full path: ④→③→②→①
|
||||||
*/
|
*/
|
||||||
fun computeUplinkFromDownlink(
|
fun computeUplinkFromDownlink(
|
||||||
downlinkHz: Long,
|
downlinkHz: Long,
|
||||||
@@ -37,17 +45,22 @@ object DopplerFrequencyCalculator {
|
|||||||
orbitalPos: OrbitalPos
|
orbitalPos: OrbitalPos
|
||||||
): Long? {
|
): Long? {
|
||||||
if (!isLinearTransponder(transponder)) return null
|
if (!isLinearTransponder(transponder)) return null
|
||||||
val baseUplink = TransponderMapper.mapDownlinkToUplink(downlinkHz, transponder) ?: return null
|
// ④→③ 逆下行多普勒:卫星转发的频率
|
||||||
return orbitalPos.getUplinkFreq(baseUplink)
|
val satTx = orbitalPos.getUplinkFreq(downlinkHz)
|
||||||
|
// ③→② 逆 passband 映射
|
||||||
|
val satRx = TransponderMapper.mapDownlinkToUplink(satTx, transponder) ?: return null
|
||||||
|
// ②→① 逆上行多普勒:地面应发射的频率
|
||||||
|
return orbitalPos.getUplinkFreq(satRx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given a downlink frequency, compute the Doppler-corrected uplink frequency
|
* Given a downlink frequency (what the user hears), compute the
|
||||||
* with an offset applied to the downlink (in Hz).
|
* uplink frequency the user should transmit, with an offset applied
|
||||||
* Returns null if the transponder is not a linear passband type.
|
* to the downlink (in Hz).
|
||||||
|
* Full path: ④→③→②→①
|
||||||
*
|
*
|
||||||
* The user-entered downlink frequency already includes the offset, so subtract
|
* The user-entered downlink frequency already includes the offset, so subtract
|
||||||
* it before mapping the downlink passband position back to the uplink.
|
* it before the inverse downlink Doppler.
|
||||||
*/
|
*/
|
||||||
fun computeUplinkFromDownlinkWithOffset(
|
fun computeUplinkFromDownlinkWithOffset(
|
||||||
downlinkHz: Long,
|
downlinkHz: Long,
|
||||||
@@ -56,13 +69,20 @@ 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
|
// ④→③ 逆下行多普勒:卫星转发的频率(含 offset)
|
||||||
return orbitalPos.getUplinkFreq(baseUplink)
|
val satTxWithOffset = orbitalPos.getUplinkFreq(downlinkHz)
|
||||||
|
// ③ 去掉 offset(offset 在卫星本地频率域)
|
||||||
|
val satTx = satTxWithOffset - offsetHz
|
||||||
|
// ③→② 逆 passband 映射
|
||||||
|
val satRx = TransponderMapper.mapDownlinkToUplink(satTx, transponder) ?: return null
|
||||||
|
// ②→① 逆上行多普勒:地面应发射的频率
|
||||||
|
return orbitalPos.getUplinkFreq(satRx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given an uplink frequency, compute the Doppler-corrected downlink frequency.
|
* Given an uplink frequency (what the user transmits), compute the
|
||||||
* Returns null if the transponder is not a linear passband type.
|
* downlink frequency the user will hear.
|
||||||
|
* Full path: ①→②→③→④
|
||||||
*/
|
*/
|
||||||
fun computeDownlinkFromUplink(
|
fun computeDownlinkFromUplink(
|
||||||
uplinkHz: Long,
|
uplinkHz: Long,
|
||||||
@@ -70,14 +90,19 @@ object DopplerFrequencyCalculator {
|
|||||||
orbitalPos: OrbitalPos
|
orbitalPos: OrbitalPos
|
||||||
): Long? {
|
): Long? {
|
||||||
if (!isLinearTransponder(transponder)) return null
|
if (!isLinearTransponder(transponder)) return null
|
||||||
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
|
// ①→② 上行多普勒:卫星收到的频率
|
||||||
return orbitalPos.getDownlinkFreq(baseDownlink)
|
val satRx = orbitalPos.getDownlinkFreq(uplinkHz)
|
||||||
|
// ②→③ passband 映射
|
||||||
|
val satTx = TransponderMapper.mapUplinkToDownlink(satRx, transponder) ?: return null
|
||||||
|
// ③→④ 下行多普勒:地面听到的
|
||||||
|
return orbitalPos.getDownlinkFreq(satTx)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given an uplink frequency, compute the Doppler-corrected downlink frequency
|
* Given an uplink frequency (what the user transmits), compute the
|
||||||
* with an offset applied to the downlink (in Hz).
|
* downlink frequency the user will hear, with an offset applied
|
||||||
* Returns null if the transponder is not a linear passband type.
|
* to the downlink (in Hz).
|
||||||
|
* Full path: ①→②→③→④
|
||||||
*/
|
*/
|
||||||
fun computeDownlinkFromUplinkWithOffset(
|
fun computeDownlinkFromUplinkWithOffset(
|
||||||
uplinkHz: Long,
|
uplinkHz: Long,
|
||||||
@@ -86,8 +111,13 @@ object DopplerFrequencyCalculator {
|
|||||||
offsetHz: Long
|
offsetHz: Long
|
||||||
): Long? {
|
): Long? {
|
||||||
if (!isLinearTransponder(transponder)) return null
|
if (!isLinearTransponder(transponder)) return null
|
||||||
val baseDownlink = TransponderMapper.mapUplinkToDownlink(uplinkHz, transponder) ?: return null
|
// ①→② 上行多普勒:卫星收到的频率
|
||||||
return orbitalPos.getDownlinkFreq(baseDownlink + offsetHz)
|
val satRx = orbitalPos.getDownlinkFreq(uplinkHz)
|
||||||
|
// ②→③ passband 映射
|
||||||
|
val satTx = TransponderMapper.mapUplinkToDownlink(satRx, transponder) ?: return null
|
||||||
|
// ③ 加上 offset(offset 在卫星本地频率域)
|
||||||
|
// ③→④ 下行多普勒:地面听到的
|
||||||
|
return orbitalPos.getDownlinkFreq(satTx + offsetHz)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True if this transponder supports linear passband mapping. */
|
/** True if this transponder supports linear passband mapping. */
|
||||||
@@ -114,11 +144,30 @@ object DopplerFrequencyCalculator {
|
|||||||
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
|
val modes = listOfNotNull(transponder.downlinkMode, transponder.uplinkMode)
|
||||||
.joinToString(separator = " ")
|
.joinToString(separator = " ")
|
||||||
.lowercase(Locale.ENGLISH)
|
.lowercase(Locale.ENGLISH)
|
||||||
val hasLinearName = info.contains("linear")
|
val hasLinearName = info.contains("linear") || info.contains(" lin") || info.startsWith("lin")
|
||||||
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
|
val hasTransponderName = info.contains("transponder") || info.contains("transp") ||
|
||||||
info.contains("xponder") || info.contains("xpdr")
|
info.contains("xponder") || info.contains("xpdr")
|
||||||
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
|
val hasLinearMode = listOf("ssb", "usb", "lsb", "cw").any { modes.contains(it) }
|
||||||
|
|
||||||
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode)
|
return (hasLinearName && hasTransponderName) || (hasTransponderName && hasLinearMode) ||
|
||||||
|
(hasLinearName && hasLinearMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes duplicate transponder entries that describe the same physical
|
||||||
|
* transponder with different mode labels (e.g. SatNOGS lists AO-7's Mode A
|
||||||
|
* as both "Lin SSB" and "Lin CW", and JO-97's U/V transponder as both
|
||||||
|
* "CW Transponder" and "SSB Transponder").
|
||||||
|
*
|
||||||
|
* Entries sharing the same uplink/downlink frequency range are considered
|
||||||
|
* the same transponder. The non-CW entry is preferred because its invert
|
||||||
|
* flag is more reliable (e.g. JO-97's CW entry wrongly has invert=false).
|
||||||
|
*/
|
||||||
|
fun deduplicateTransponders(radios: List<SatRadio>): List<SatRadio> {
|
||||||
|
return radios.groupBy { radio ->
|
||||||
|
listOf(radio.uplinkLow, radio.uplinkHigh, radio.downlinkLow, radio.downlinkHigh)
|
||||||
|
}.values.map { group ->
|
||||||
|
group.firstOrNull { it.downlinkMode?.equals("CW", ignoreCase = true) != true } ?: group.first()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+38
-100
@@ -20,77 +20,53 @@ 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.
|
* Convert a Maidenhead locator (4, 6 or 8 chars) to the position of its
|
||||||
* Supports 6-char (3 pair), 8-char (4 pair) and 10-char (5 pair) locators.
|
* square / subsquare / extended-square centre. Longer input is truncated to
|
||||||
* The returned position is the center of the finest cell encoded by the locator:
|
* 8 chars (the Maidenhead maximum); malformed or out-of-range input yields
|
||||||
* - 6 char: 5' lon x 2.5' lat cell center
|
* null.
|
||||||
* - 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.trim().uppercase()
|
val trimmedQth = locator.trim().uppercase().take(8)
|
||||||
if (!isValidLocator(trimmedQth)) return null
|
if (trimmedQth.length !in listOf(4, 6, 8) || !isValidLocator(trimmedQth)) return null
|
||||||
val lonFirst = (trimmedQth[0].code - 65) * 20
|
val lonFirst = (trimmedQth[0].code - 65) * 20
|
||||||
val latFirst = (trimmedQth[1].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
|
// Start from the 4-char square centre, refine for 6/8-char subsquares.
|
||||||
val latThird = (trimmedQth[5].lowercaseChar().code - 97) / 24.0
|
var longitude = lonFirst + lonSecond + 1.0
|
||||||
var longitude = lonFirst + lonSecond + lonThird - 180
|
var latitude = latFirst + latSecond + 0.5
|
||||||
var latitude = latFirst + latSecond + latThird - 90
|
if (trimmedQth.length >= 6) {
|
||||||
// 8-char extension: 4th pair, digits, 30" lon x 15" lat cells
|
val lonSub = (trimmedQth[4].code - 65) * 5.0 / 60.0
|
||||||
if (trimmedQth.length >= 8) {
|
val latSub = (trimmedQth[5].code - 65) * 2.5 / 60.0
|
||||||
longitude += trimmedQth[6].toString().toInt() / 120.0
|
if (lonSub < 0.0 || lonSub > 115.0 / 60.0 || latSub < 0.0 || latSub > 57.5 / 60.0) return null
|
||||||
latitude += trimmedQth[7].toString().toInt() / 240.0
|
longitude = lonFirst + lonSecond + lonSub + 2.5 / 60.0
|
||||||
}
|
latitude = latFirst + latSecond + latSub + 1.25 / 60.0
|
||||||
// 10-char extension: 5th pair, letters, 1.25" lon x 0.625" lat cells
|
if (trimmedQth.length >= 8) {
|
||||||
if (trimmedQth.length >= 10) {
|
val lonExt = (trimmedQth[6].code - 48) * 30.0 / 3600.0
|
||||||
longitude += (trimmedQth[8].lowercaseChar().code - 97) / 2880.0
|
val latExt = (trimmedQth[7].code - 48) * 15.0 / 3600.0
|
||||||
latitude += (trimmedQth[9].lowercaseChar().code - 97) / 5760.0
|
if (lonExt < 0.0 || lonExt > 270.0 / 3600.0 || latExt < 0.0 || latExt > 135.0 / 3600.0) return null
|
||||||
}
|
longitude += lonExt + 15.0 / 3600.0
|
||||||
// Offset to the center of the finest encoded cell
|
latitude += latExt + 7.5 / 3600.0
|
||||||
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))
|
return GeoPos((latitude - 90.0).round(4), (longitude - 180.0).round(4))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a GeoPos to a Maidenhead locator (QTH grid square).
|
* Convert a position to its 6-char Maidenhead locator, upper-cased (field,
|
||||||
* Default precision is 8 characters (4 pairs) giving 30" lon x 15" lat resolution,
|
* square and subsquare letters). Returns null for out-of-range positions.
|
||||||
* 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? {
|
fun positionToQth(latitude: Double, longitude: Double): String? {
|
||||||
if (!isValidPosition(latitude, longitude)) return null
|
if (!isValidPosition(latitude, longitude)) return null
|
||||||
val newLongitude = longitude + 180
|
val newLongitude = if (longitude > 180.0) longitude else longitude + 180
|
||||||
val newLatitude = latitude + 90
|
val newLatitude = latitude + 90
|
||||||
val lonFirst = (65 + (newLongitude / 20).toInt().coerceIn(0, 17)).toChar()
|
val lonFirst = (65 + (newLongitude / 20)).toInt().toChar()
|
||||||
val latFirst = (65 + (newLatitude / 10).toInt().coerceIn(0, 17)).toChar()
|
val latFirst = (65 + (newLatitude / 10)).toInt().toChar()
|
||||||
val lonSecond = ((newLongitude % 20) / 2).toInt()
|
val lonSecond = ((newLongitude / 2) % 10).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().uppercaseChar()
|
||||||
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().lowercaseChar()
|
val latThird = (65 + (newLatitude % 1) * 24).toInt().toChar().uppercaseChar()
|
||||||
val qth = "$lonFirst$latFirst$lonSecond$latSecond$lonThird$latThird"
|
return "$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 {
|
||||||
@@ -98,49 +74,11 @@ 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]{2}\\d{2}[a-xA-X]{2}(?:\\d{2}(?:[a-xA-X]{2})?)?".toRegex())
|
// 4 chars: [A-X][A-X]\d\d ; 6 chars: + [A-X][A-X] ; 8 chars: + \d\d
|
||||||
}
|
return when (locator.length) {
|
||||||
|
4 -> Regex("[A-X]{2}\\d{2}").matches(locator)
|
||||||
/**
|
6 -> Regex("[A-X]{2}\\d{2}[A-X]{2}").matches(locator)
|
||||||
* Returns the 4-char field+square part of a locator, e.g. "OL42ih45" -> "OL42".
|
8 -> Regex("[A-X]{2}\\d{2}[A-X]{2}\\d{2}").matches(locator)
|
||||||
* A 4-char input is returned as-is when valid.
|
else -> false
|
||||||
*/
|
|
||||||
fun qthToSquare(locator: String): String {
|
|
||||||
val upper = locator.trim().uppercase()
|
|
||||||
return when {
|
|
||||||
upper.length >= 4 && isValidLocator(upper) -> upper.take(4)
|
|
||||||
upper.length == 4 && upper.matches("[a-xA-X]{2}\\d{2}".toRegex()) -> upper
|
|
||||||
else -> "----"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
* at boundaries (e.g. "AA00" wraps to "RR99" at the south-west corner).
|
|
||||||
* Mirrors the neighbor logic decompiled from the QTH定位器 app.
|
|
||||||
*/
|
|
||||||
fun qthNeighbors(square: String): List<String> {
|
|
||||||
if (square.length != 4) return emptyList()
|
|
||||||
val lonField = (square[0].uppercaseChar().code - 65).coerceIn(0, 17)
|
|
||||||
val latField = (square[1].uppercaseChar().code - 65).coerceIn(0, 17)
|
|
||||||
val lonSquare = square[2].digitToCharOrNull() ?: return emptyList()
|
|
||||||
val latSquare = square[3].digitToCharOrNull() ?: return emptyList()
|
|
||||||
val result = mutableListOf<String>()
|
|
||||||
for (dLat in 1 downTo -1) { // north -> south
|
|
||||||
for (dLon in -1..1) { // west -> east
|
|
||||||
var lf = lonField
|
|
||||||
var tf = latField
|
|
||||||
var ls = lonSquare + dLon
|
|
||||||
var ts = latSquare + dLat
|
|
||||||
if (ls < 0) { lf -= 1; ls = 9 } else if (ls > 9) { lf += 1; ls = 0 }
|
|
||||||
if (ts < 0) { tf -= 1; ts = 9 } else if (ts > 9) { tf += 1; ts = 0 }
|
|
||||||
lf = (lf + 18) % 18
|
|
||||||
tf = (tf + 18) % 18
|
|
||||||
result += "${('A' + lf).toChar()}${('A' + tf).toChar()}$ls$ts"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Char.digitToCharOrNull(): Int? = digitToIntOrNull()
|
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain.utility
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares two version strings of the form "<major>.<minor>.<patch>[-<build>]" used by the
|
||||||
|
* BA7OPF fork releases (e.g. "4.4.6-ba7opf.6" or "v4.4.6-ba7opf.9.1").
|
||||||
|
*
|
||||||
|
* The comparison is done on the numeric version segments first; when the base versions are
|
||||||
|
* equal, the trailing build numbers of the suffix decide. All numeric segments of the suffix
|
||||||
|
* are compared as a sequence, so multi-level suffixes work correctly:
|
||||||
|
* "4.4.6-ba7opf.9.1" > "4.4.6-ba7opf.9" > "4.4.6-ba7opf.8".
|
||||||
|
*/
|
||||||
|
object VersionComparator {
|
||||||
|
|
||||||
|
/** True when [candidate] is a newer version than [current]. */
|
||||||
|
fun isNewer(candidate: String, current: String): Boolean {
|
||||||
|
val cand = parse(candidate)
|
||||||
|
val curr = parse(current)
|
||||||
|
val maxLen = maxOf(cand.first.size, curr.first.size)
|
||||||
|
for (i in 0 until maxLen) {
|
||||||
|
val c = cand.first.getOrElse(i) { 0 }
|
||||||
|
val k = curr.first.getOrElse(i) { 0 }
|
||||||
|
if (c != k) return c > k
|
||||||
|
}
|
||||||
|
val maxBuild = maxOf(cand.second.size, curr.second.size)
|
||||||
|
for (i in 0 until maxBuild) {
|
||||||
|
val c = cand.second.getOrElse(i) { 0 }
|
||||||
|
val k = curr.second.getOrElse(i) { 0 }
|
||||||
|
if (c != k) return c > k
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a version string into (numeric base segments, numeric suffix segments).
|
||||||
|
* "v4.4.6-ba7opf.9.1" -> ([4,4,6], [9,1]); "4.4.6-ba7opf.8" -> ([4,4,6], [8]);
|
||||||
|
* "4.4.6" -> ([4,4,6], []); "4.4.6-ba7opf" -> ([4,4,6], []).
|
||||||
|
*/
|
||||||
|
fun parse(version: String): Pair<List<Int>, List<Int>> {
|
||||||
|
val cleaned = version.trim().removePrefix("v")
|
||||||
|
val dashIndex = cleaned.indexOf('-')
|
||||||
|
val basePart = if (dashIndex >= 0) cleaned.substring(0, dashIndex) else cleaned
|
||||||
|
val suffixPart = if (dashIndex >= 0) cleaned.substring(dashIndex + 1) else ""
|
||||||
|
val base = basePart.split('.').mapNotNull { it.toIntOrNull() }
|
||||||
|
// All numeric segments of the suffix, in order (e.g. "ba7opf.9.1" -> [9, 1]).
|
||||||
|
val build = suffixPart.split('.').mapNotNull { it.toIntOrNull() }
|
||||||
|
return base to build
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
/*
|
|
||||||
* WaveLogApi.kt — WaveLog 日志服务器 API 客户端(4.5.2 覆盖修复 2)。
|
|
||||||
*
|
|
||||||
* 同时支持 v1 与 v2(用户服务器实测只有 v1, v2 返回 404):
|
|
||||||
* v2: POST {base}/api/v2/qso (Authorization: Bearer + JSON 字段)
|
|
||||||
* v1: POST {base}/index.php/api/qso (key 在 JSON body + ADIF 字符串)
|
|
||||||
* 策略: 优先 v2, 404 自动降级 v1。
|
|
||||||
* 测试连接: v2 GET api/v2/token → 404 时 v1 POST api/get_contacts_adif。
|
|
||||||
* 站点网格: 仅 v2 有 GET api/v2/station/{id}; v1 无此端点 → 降级用用户 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
|
|
||||||
|
|
||||||
/** 站点信息(GET /api/v2/station/{id} 结果) */
|
|
||||||
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
|
|
||||||
|
|
||||||
/** 规范化服务器地址: 去尾斜杠/去尾部 index.php; 无协议前缀时补 https:// */
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 测试连接: v2 GET api/v2/token → 404 时 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 在 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)")
|
|
||||||
}
|
|
||||||
// 无 index.php 的 v1 尝试
|
|
||||||
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} — 请确认服务器地址/密钥正确")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 站点信息: 仅 v2; v1 无此端点返回 null 标记(网格检测降级用用户 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 无 station 端点 → 返回 Success 空(调用方降级用用户 QTH)
|
|
||||||
WavelogResult.Success("")
|
|
||||||
}
|
|
||||||
|
|
||||||
/** LoTW 认可的卫星名: 取括号前主名 + 大写(ISS 特判) */
|
|
||||||
fun normalizeSatName(raw: String): String {
|
|
||||||
val main = raw.substringBefore('(').trim()
|
|
||||||
.ifBlank { raw.trim() }
|
|
||||||
.uppercase(Locale.ENGLISH)
|
|
||||||
// ISS 特判: 主名是 ISS/ZARYA/ARISS 变体 → ISS(LoTW 认可名)
|
|
||||||
return when {
|
|
||||||
main == "ZARYA" || main.startsWith("ISS") -> "ISS"
|
|
||||||
main == "ARISS" -> "ISS"
|
|
||||||
else -> main
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 创建 QSO: 优先 v2, 404 降级 v1(ADIF) */
|
|
||||||
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 字段)
|
|
||||||
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", qso.freqTxHz)
|
|
||||||
put("freq_rx", qso.freqRxHz)
|
|
||||||
put("gridsquare", gridsquare)
|
|
||||||
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 在 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)")
|
|
||||||
|
|
||||||
// 无 index.php 的 v1
|
|
||||||
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 字符串(频率 MHz, 长度=UTF-8 字节数, sat_name 已规范化) */
|
|
||||||
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)))
|
|
||||||
if (gridsquare.isNotBlank()) append(field("gridsquare", gridsquare.take(4)))
|
|
||||||
if (satName.isNotBlank()) {
|
|
||||||
append(field("sat_name", satName))
|
|
||||||
append(field("prop_mode", "SAT"))
|
|
||||||
}
|
|
||||||
append("<eor>")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 通用 HTTP 请求(返回 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 错误页
|
|
||||||
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
/*
|
|
||||||
* WavelogQueue.kt — WaveLog 本地日志队列(4.5.2)。
|
|
||||||
*
|
|
||||||
* 纯 Kotlin(不依赖 Android): 存储走 IWavelogQueueStore 接口,
|
|
||||||
* 由 core/data 用 SharedPreferences 实现。
|
|
||||||
* 队列上限 500 条(超出丢最旧)。
|
|
||||||
*/
|
|
||||||
package com.rtbishop.look4sat.core.domain.wavelog
|
|
||||||
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
/** 存储抽象(SharedPreferences 实现见 core/data) */
|
|
||||||
interface IWavelogQueueStore {
|
|
||||||
fun load(): String
|
|
||||||
fun save(json: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 待上传的 QSO 条目(本地队列元素,与 POST /api/v2/qso 字段对应) */
|
|
||||||
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 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"),
|
|
||||||
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 })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 标记为已上传(保留在队列, 表格打勾) */
|
|
||||||
@Synchronized
|
|
||||||
fun markUploaded(id: String) {
|
|
||||||
save(all().map { if (it.id == id) it.copy(uploaded = true) else it })
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 移除所有已上传条目(可选项, 保持队列精简) */
|
|
||||||
@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("uploaded", q.uploaded)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
store.save(arr.toString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-91
@@ -1,91 +0,0 @@
|
|||||||
/*
|
|
||||||
* WavelogUploader.kt — WaveLog 队列上传调度(4.5.2)。
|
|
||||||
*
|
|
||||||
* 手动/周期上传共用: 逐条网格检测(用户 QTH 前 4 位 vs 台站网格前 4 位)
|
|
||||||
* → POST /api/v2/qso → 成功移出队列。
|
|
||||||
* 网格不一致: 返回 NeedConfirm(由 UI 弹窗「忽略并上传/取消」),
|
|
||||||
* 确认后带 force=true 重试本批。
|
|
||||||
*/
|
|
||||||
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
|
|
||||||
) {
|
|
||||||
|
|
||||||
// 台站网格缓存(每次上传前刷新; 失败用旧值)
|
|
||||||
private var cachedStationGrid: String? = null
|
|
||||||
|
|
||||||
/** 上传整个队列。force=true 跳过网格确认(用户已选「忽略并上传」) */
|
|
||||||
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. 拉站点信息(拿台站网格); v1 无此端点时降级用用户 QTH
|
|
||||||
val stationGrid = getStationGrid(url, apiKey, stationId) ?: userQthGrid()
|
|
||||||
if (stationGrid.isNullOrBlank()) {
|
|
||||||
return UploadOutcome.Done(0, queue.all().size, "无法获取站点信息(检查站点 ID/密钥权限)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 网格检测: 用户当前 QTH 前 4 位 vs 台站网格前 4 位(v1 降级时网格相同, 跳过)
|
|
||||||
if (!force) {
|
|
||||||
val userGrid = userQthGrid()
|
|
||||||
if (userGrid != null && stationGrid.take(4).lowercase() != userGrid.take(4).lowercase()) {
|
|
||||||
return UploadOutcome.NeedConfirm(stationGrid, userGrid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 逐条上传(成功后标记 uploaded, 保留在本地供日志页打勾)
|
|
||||||
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, stationGrid)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 用户当前 QTH 网格(前 4 位; 无 QTH 返回 null = 跳过检测) */
|
|
||||||
private fun userQthGrid(): String? {
|
|
||||||
return settingsRepo.stationPosition.value.qthLocator?.takeIf { it.length >= 4 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+84
-1
@@ -9,6 +9,7 @@ import org.junit.Test
|
|||||||
class DopplerFrequencyCalculatorTest {
|
class DopplerFrequencyCalculatorTest {
|
||||||
|
|
||||||
private fun linearTransponder(
|
private fun linearTransponder(
|
||||||
|
uuid: String = "linear",
|
||||||
upLow: Long = 145_000_000L,
|
upLow: Long = 145_000_000L,
|
||||||
upHigh: Long = 145_500_000L,
|
upHigh: Long = 145_500_000L,
|
||||||
downLow: Long = 435_000_000L,
|
downLow: Long = 435_000_000L,
|
||||||
@@ -18,7 +19,7 @@ class DopplerFrequencyCalculatorTest {
|
|||||||
downlinkMode: String? = "USB",
|
downlinkMode: String? = "USB",
|
||||||
uplinkMode: String? = "LSB"
|
uplinkMode: String? = "LSB"
|
||||||
) = SatRadio(
|
) = SatRadio(
|
||||||
uuid = "linear", info = info, isAlive = true,
|
uuid = uuid, info = info, isAlive = true,
|
||||||
downlinkLow = downLow, downlinkHigh = downHigh,
|
downlinkLow = downLow, downlinkHigh = downHigh,
|
||||||
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
|
downlinkMode = downlinkMode, uplinkLow = upLow, uplinkHigh = upHigh,
|
||||||
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
|
uplinkMode = uplinkMode, isInverted = inverted, catnum = 12345
|
||||||
@@ -68,11 +69,93 @@ class DopplerFrequencyCalculatorTest {
|
|||||||
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
|
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(driftingRangeEntry))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsTrueForAbbreviatedLinName() {
|
||||||
|
// AO-7 style: "Mode V/A (A) Lin SSB" — "Lin" abbreviation, no "transponder" word
|
||||||
|
val ao7Entry = linearTransponder(info = "Mode V/A (A) Lin SSB", downlinkMode = "USB", uplinkMode = "USB")
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7Entry))
|
||||||
|
val ao7CwEntry = linearTransponder(info = "Mode V/A (A) Lin CW", downlinkMode = "CW", uplinkMode = "CW")
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7CwEntry))
|
||||||
|
val ao7ModeBEntry = linearTransponder(info = "Mode U/V (B) Lin", downlinkMode = "USB", uplinkMode = "LSB")
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao7ModeBEntry))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsTrueForLinearWithoutTransponderWord() {
|
||||||
|
// AO-73 style: "Mode U/V Linear" — has "Linear" but no "transponder"
|
||||||
|
val ao73Entry = linearTransponder(info = "Mode U/V Linear", downlinkMode = "USB", uplinkMode = "LSB")
|
||||||
|
assertTrue(DopplerFrequencyCalculator.isNamedLinearTransponder(ao73Entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isNamedLinearTransponder_returnsFalseForDownlinkContainingLinInsideWord() {
|
||||||
|
// "Downlink" contains "lin" but is not a linear-transponder name
|
||||||
|
val downlinkEntry = linearTransponder(info = "Mode U Downlink", downlinkMode = "FM", uplinkMode = "FM")
|
||||||
|
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(downlinkEntry))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
|
fun isNamedLinearTransponder_returnsFalseForFmRepeater() {
|
||||||
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
|
assertFalse(DopplerFrequencyCalculator.isNamedLinearTransponder(fmTransponder()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deduplicateTransponders_mergesSameFrequencyRange() {
|
||||||
|
// AO-7's Mode A: same range, SSB and CW entries
|
||||||
|
val ssb = linearTransponder(
|
||||||
|
uuid = "ssb-uuid", info = "Mode V/A (A) Lin SSB",
|
||||||
|
downlinkMode = "USB", uplinkMode = "USB"
|
||||||
|
)
|
||||||
|
val cw = linearTransponder(
|
||||||
|
uuid = "cw-uuid", info = "Mode V/A (A) Lin CW",
|
||||||
|
downlinkMode = "CW", uplinkMode = "CW"
|
||||||
|
)
|
||||||
|
val modeB = linearTransponder(
|
||||||
|
uuid = "modeb-uuid", info = "Mode U/V (B) Lin",
|
||||||
|
upLow = 432_125_000L, upHigh = 432_175_000L,
|
||||||
|
downLow = 145_925_000L, downHigh = 145_975_000L,
|
||||||
|
downlinkMode = "USB", uplinkMode = "LSB"
|
||||||
|
)
|
||||||
|
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(ssb, cw, modeB))
|
||||||
|
assertEquals(2, result.size)
|
||||||
|
// SSB entry should be preferred over CW (same range)
|
||||||
|
assertEquals("ssb-uuid", result[0].uuid)
|
||||||
|
assertEquals("modeb-uuid", result[1].uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deduplicateTransponders_prefersNonCwEntry() {
|
||||||
|
// JO-97: CW entry has invert=false (wrong), SSB has invert=true (correct)
|
||||||
|
val cw = linearTransponder(
|
||||||
|
uuid = "cw-uuid", info = "U/V CW Transponder",
|
||||||
|
downlinkMode = "CW", uplinkMode = "CW",
|
||||||
|
upLow = 435_100_000L, upHigh = 435_120_000L,
|
||||||
|
downLow = 145_855_000L, downHigh = 145_875_000L
|
||||||
|
)
|
||||||
|
val ssb = linearTransponder(
|
||||||
|
uuid = "ssb-uuid", info = "U/V SSB Transponder",
|
||||||
|
downlinkMode = "USB", uplinkMode = "LSB",
|
||||||
|
upLow = 435_100_000L, upHigh = 435_120_000L,
|
||||||
|
downLow = 145_855_000L, downHigh = 145_875_000L,
|
||||||
|
inverted = true
|
||||||
|
)
|
||||||
|
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(cw, ssb))
|
||||||
|
assertEquals(1, result.size)
|
||||||
|
assertEquals("ssb-uuid", result[0].uuid)
|
||||||
|
// Verify the correct invert flag is preserved
|
||||||
|
assertTrue(result[0].isInverted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deduplicateTransponders_preservesUniqueEntries() {
|
||||||
|
val t1 = linearTransponder(uuid = "t1", upLow = 145_000_000L, upHigh = 145_500_000L,
|
||||||
|
downLow = 435_000_000L, downHigh = 435_500_000L)
|
||||||
|
val t2 = linearTransponder(uuid = "t2", upLow = 435_000_000L, upHigh = 435_500_000L,
|
||||||
|
downLow = 145_000_000L, downHigh = 145_500_000L)
|
||||||
|
val result = DopplerFrequencyCalculator.deduplicateTransponders(listOf(t1, t2))
|
||||||
|
assertEquals(2, result.size)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun computeUplinkFromDownlink_linear_noDoppler() {
|
fun computeUplinkFromDownlink_linear_noDoppler() {
|
||||||
val xpdr = linearTransponder()
|
val xpdr = linearTransponder()
|
||||||
|
|||||||
@@ -1,63 +1,38 @@
|
|||||||
/*
|
|
||||||
* 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
|
package com.rtbishop.look4sat.core.domain
|
||||||
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
import com.rtbishop.look4sat.core.domain.utility.positionToQth
|
||||||
import com.rtbishop.look4sat.core.domain.utility.qthNeighbors
|
|
||||||
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
import com.rtbishop.look4sat.core.domain.utility.qthToPosition
|
||||||
import com.rtbishop.look4sat.core.domain.utility.qthToSquare
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class QthConverterTest {
|
class QthConverterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Given valid QTH returns correct POS`() {
|
fun `Given valid QTH returns correct POS`() {
|
||||||
|
// 8-char locator -> centre of the extended square (higher precision)
|
||||||
var result = qthToPosition("io91VL39FX")
|
var result = qthToPosition("io91VL39FX")
|
||||||
assert(result?.latitude == 51.499913 && result.longitude == -0.22309)
|
assert(result?.latitude == 51.5188 && result.longitude == -0.1792)
|
||||||
result = qthToPosition("gf15vc")
|
result = qthToPosition("gf15vc")
|
||||||
assert(result?.latitude == -34.895833 && result.longitude == -56.208333)
|
assert(result?.latitude == -34.8958 && result.longitude == -56.2083)
|
||||||
// 8-char locators: finer 30" x 15" cell center
|
// 4-char locator -> centre of the 2deg x 1deg square
|
||||||
result = qthToPosition("io91vl47")
|
result = qthToPosition("JN58")
|
||||||
assert(result?.latitude == 51.489583 && result.longitude == -0.2125)
|
assert(result?.latitude == 48.5 && result.longitude == 11.0)
|
||||||
result = qthToPosition("jn58td25")
|
// lowercase input is accepted and normalised
|
||||||
assert(result?.latitude == 48.147917 && result.longitude == 11.604167)
|
result = qthToPosition("ol63pd")
|
||||||
|
assert(result?.latitude == 23.1458 && result.longitude == 113.2917)
|
||||||
}
|
}
|
||||||
|
|
||||||
@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("JN5") == null) // odd length
|
||||||
assert(qthToPosition("io9") == null)
|
assert(qthToPosition("JN58Z") == null) // 5 chars
|
||||||
assert(qthToPosition("IO91VL7") == null)
|
assert(qthToPosition("JN58ZA") == null) // Z out of A-X range
|
||||||
assert(qthToPosition("IO91VL4X") == null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `Given valid POS returns correct QTH`() {
|
fun `Given valid POS returns correct QTH`() {
|
||||||
// default precision is 8 chars
|
assert(positionToQth(51.4878, -0.2146) == "IO91VL")
|
||||||
assert(positionToQth(51.4878, -0.2146) == "IO91vl47")
|
assert(positionToQth(48.1466, 11.6083) == "JN58TD")
|
||||||
assert(positionToQth(48.1466, 11.6083) == "JN58td25")
|
assert(positionToQth(23.13, 113.26) == "OL63PD")
|
||||||
// 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
|
||||||
@@ -65,61 +40,4 @@ 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" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `Given square returns correct 3x3 neighbors`() {
|
|
||||||
// Reference grid from the QTH定位器 screenshot: OL42
|
|
||||||
val neighbors = qthNeighbors("OL42")
|
|
||||||
assert(neighbors == listOf(
|
|
||||||
"OL33", "OL43", "OL53",
|
|
||||||
"OL32", "OL42", "OL52",
|
|
||||||
"OL31", "OL41", "OL51"
|
|
||||||
)) { "OL42 grid mismatch: $neighbors" }
|
|
||||||
// Center cell must be the input itself
|
|
||||||
assert(neighbors[4] == "OL42")
|
|
||||||
// 9 cells, all distinct
|
|
||||||
assert(neighbors.size == 9 && neighbors.toSet().size == 9)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `Given boundary square wraps fields correctly`() {
|
|
||||||
// South-west corner: AA00 neighbors wrap to RR99 / RA90 etc.
|
|
||||||
val sw = qthNeighbors("AA00")
|
|
||||||
assert(sw.size == 9 && sw.toSet().size == 9)
|
|
||||||
assert(sw[0] == "RA91" && sw[4] == "AA00" && sw[6] == "RR99" && sw[8] == "AR19")
|
|
||||||
// North-east corner: RR99 wraps to AA00
|
|
||||||
val ne = qthNeighbors("RR99")
|
|
||||||
assert(ne.size == 9 && ne.toSet().size == 9)
|
|
||||||
assert(ne[0] == "RA80" && ne[4] == "RR99" && ne[8] == "AR08")
|
|
||||||
// Field boundary: IO91's east neighbors cross into J field
|
|
||||||
val london = qthNeighbors("IO91")
|
|
||||||
assert(london[2] == "JO02" && london[5] == "JO01")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `Given full locator returns square part`() {
|
|
||||||
assert(qthToSquare("OL42ih45") == "OL42")
|
|
||||||
assert(qthToSquare("io91VL39FX") == "IO91")
|
|
||||||
assert(qthToSquare("JN58") == "JN58")
|
|
||||||
assert(qthToSquare("garbage!!") == "----")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.rtbishop.look4sat.core.domain
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.utility.VersionComparator
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class VersionComparatorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `newer build number wins when base version equal`() {
|
||||||
|
assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.7", "4.4.6-ba7opf.6"))
|
||||||
|
assertTrue(VersionComparator.isNewer("v4.4.6-ba7opf.6", "4.4.6-ba7opf.5"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `newer base version wins regardless of build number`() {
|
||||||
|
assertTrue(VersionComparator.isNewer("4.5.0", "4.4.6-ba7opf.99"))
|
||||||
|
assertTrue(VersionComparator.isNewer("4.4.7-ba7opf.1", "4.4.6-ba7opf.99"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `older versions are not newer`() {
|
||||||
|
assertFalse(VersionComparator.isNewer("4.4.6-ba7opf.5", "4.4.6-ba7opf.6"))
|
||||||
|
assertFalse(VersionComparator.isNewer("4.4.5", "4.4.6-ba7opf.1"))
|
||||||
|
assertFalse(VersionComparator.isNewer("4.4.6-ba7opf.6", "4.4.6-ba7opf.6"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `v prefix is ignored`() {
|
||||||
|
assertTrue(VersionComparator.isNewer("v4.4.7", "4.4.6"))
|
||||||
|
assertFalse(VersionComparator.isNewer("v4.4.6", "4.4.6-ba7opf.1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non numeric suffix without number treated as zero build`() {
|
||||||
|
assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.1", "4.4.6-ba7opf"))
|
||||||
|
assertFalse(VersionComparator.isNewer("4.4.6-ba7opf", "4.4.6-ba7opf.1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `multi level suffixes compare correctly`() {
|
||||||
|
// The .9.1 hotfix case that exposed the old single-build-number logic.
|
||||||
|
assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.9.1", "4.4.6-ba7opf.9"))
|
||||||
|
assertTrue(VersionComparator.isNewer("v4.4.6-ba7opf.9.1", "4.4.6-ba7opf.8"))
|
||||||
|
assertFalse(VersionComparator.isNewer("4.4.6-ba7opf.9", "4.4.6-ba7opf.9.1"))
|
||||||
|
assertTrue(VersionComparator.isNewer("4.4.6-ba7opf.10", "4.4.6-ba7opf.9.1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `parse extracts base and suffix segments`() {
|
||||||
|
assertEquals(listOf(4, 4, 6) to listOf(7), VersionComparator.parse("v4.4.6-ba7opf.7"))
|
||||||
|
assertEquals(listOf(4, 4, 6) to listOf(9, 1), VersionComparator.parse("v4.4.6-ba7opf.9.1"))
|
||||||
|
assertEquals(listOf(4, 4, 6) to emptyList<Int>(), VersionComparator.parse("4.4.6"))
|
||||||
|
assertEquals(listOf(4, 4, 6) to emptyList<Int>(), VersionComparator.parse("4.4.6-ba7opf"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.logbook
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AdifCodecTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun encodeDecode_roundTrip() {
|
||||||
|
val record = QsoRecord(
|
||||||
|
startUtcMillis = 1_700_000_000_000L,
|
||||||
|
endUtcMillis = 1_700_000_000_100L,
|
||||||
|
theirCallsign = "BG5JVM",
|
||||||
|
myCallsign = "BA7OPF",
|
||||||
|
theirGrid = "OM60",
|
||||||
|
myGrid = "OL62TI",
|
||||||
|
sentReport = "59",
|
||||||
|
receivedReport = "59",
|
||||||
|
txFrequencyHz = 145_850_000L,
|
||||||
|
rxFrequencyHz = 436_805_000L,
|
||||||
|
band = "2M",
|
||||||
|
rxBand = "70CM",
|
||||||
|
mode = "FM",
|
||||||
|
satelliteName = "SO-50",
|
||||||
|
satelliteMode = "FM",
|
||||||
|
propagationMode = "SAT",
|
||||||
|
status = QsoStatus.COMPLETE,
|
||||||
|
lotwConfirmed = true,
|
||||||
|
lotwQslDate = "2026-09-20",
|
||||||
|
vuccGrids = listOf("OM60", "OM50")
|
||||||
|
)
|
||||||
|
val decoded = AdifCodec.decode(AdifCodec.encode(listOf(record))).single()
|
||||||
|
assertEquals(record.startUtcMillis, decoded.startUtcMillis)
|
||||||
|
assertEquals(record.theirCallsign, decoded.theirCallsign)
|
||||||
|
assertEquals(record.myCallsign, decoded.myCallsign)
|
||||||
|
assertEquals(record.satelliteName, decoded.satelliteName)
|
||||||
|
assertEquals("2M", decoded.band)
|
||||||
|
assertEquals("70CM", decoded.rxBand)
|
||||||
|
assertTrue(decoded.lotwConfirmed)
|
||||||
|
assertEquals(listOf("OM60", "OM50"), decoded.vuccGrids)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decode_lotwStyleReportFields() {
|
||||||
|
// LoTW report subset: lowercase eor, alphabetical fields, VUCC_GRIDS list.
|
||||||
|
val adi = """
|
||||||
|
<EOH>
|
||||||
|
<CALL:6>BH6RJD
|
||||||
|
<QSO_DATE:8>20260916
|
||||||
|
<TIME_ON:6>051300
|
||||||
|
<MODE:2>FM
|
||||||
|
<SAT_NAME:5>SO-50
|
||||||
|
<PROP_MODE:3>SAT
|
||||||
|
<BAND:3>2M
|
||||||
|
<BAND_RX:4>70CM
|
||||||
|
<GRIDSQUARE:4>OM60
|
||||||
|
<MY_GRIDSQUARE:8>OM60IL70
|
||||||
|
<VUCC_GRIDS:9>OM60,OM50
|
||||||
|
<STATION_CALLSIGN:6>BH6RJD
|
||||||
|
<DXCC:3>318
|
||||||
|
<COUNTRY:5>China
|
||||||
|
<LOTW_QSL_RCVD:1>Y
|
||||||
|
<eor>
|
||||||
|
""".trimIndent()
|
||||||
|
val records = AdifCodec.decode(adi)
|
||||||
|
assertEquals(1, records.size)
|
||||||
|
val record = records.single()
|
||||||
|
assertEquals("BH6RJD", record.theirCallsign)
|
||||||
|
assertEquals("SO-50", record.satelliteName)
|
||||||
|
assertEquals("FM", record.displayMode)
|
||||||
|
assertEquals("OM60IL70", record.myGrid)
|
||||||
|
assertEquals(listOf("OM60", "OM50"), record.vuccGrids)
|
||||||
|
assertTrue(record.lotwConfirmed)
|
||||||
|
assertTrue(record.isSatellite)
|
||||||
|
}
|
||||||
|
}
|
||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
package com.rtbishop.look4sat.core.domain.logbook
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class LogbookMergeTest {
|
||||||
|
|
||||||
|
private val local = QsoRecord(
|
||||||
|
startUtcMillis = 1_700_000_000_000L,
|
||||||
|
theirCallsign = "bg5jvm",
|
||||||
|
myCallsign = "ba7opf",
|
||||||
|
txFrequencyHz = 145_850_000L,
|
||||||
|
mode = "FM",
|
||||||
|
submode = "",
|
||||||
|
satelliteName = "SO-50 (SaudiOSCAR 50)",
|
||||||
|
propagationMode = "SAT",
|
||||||
|
status = QsoStatus.COMPLETE
|
||||||
|
)
|
||||||
|
|
||||||
|
private val confirmed = QsoRecord(
|
||||||
|
startUtcMillis = 1_700_000_000_000L,
|
||||||
|
theirCallsign = "BG5JVM",
|
||||||
|
myCallsign = "BA7OPF",
|
||||||
|
mode = "FM",
|
||||||
|
submode = "",
|
||||||
|
satelliteName = "SO-50",
|
||||||
|
propagationMode = "SAT",
|
||||||
|
status = QsoStatus.COMPLETE,
|
||||||
|
lotwConfirmed = true,
|
||||||
|
vuccGrids = listOf("OM60", "OM50")
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun satMatchKey_stripsParenthetical() {
|
||||||
|
assertEquals("SO-50", satMatchKey("SO-50 (SaudiOSCAR 50)"))
|
||||||
|
assertEquals("SO-50", satMatchKey("SO-50"))
|
||||||
|
assertEquals("AO-91", satMatchKey("AO-91 (FUNcube-1)"))
|
||||||
|
assertEquals("IO-86", satMatchKey("IO-86"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sameConfirmedContact_matchesAcrossSatNOGSAndOfficialName() {
|
||||||
|
assertTrue(sameConfirmedContact(local, confirmed))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sameConfirmedContact_rejectsDifferentSatellite() {
|
||||||
|
val other = confirmed.copy(satelliteName = "AO-91")
|
||||||
|
assertFalse(sameConfirmedContact(local, other))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sameConfirmedContact_rejectsTimeSkew() {
|
||||||
|
val skewed = confirmed.copy(startUtcMillis = confirmed.startUtcMillis + 120_000L)
|
||||||
|
assertFalse(sameConfirmedContact(local, skewed))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun confirmationLookupKey_usesNormalizedSatellite() {
|
||||||
|
assertEquals(local.confirmationLookupKey(), confirmed.confirmationLookupKey())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun withConfirmation_marksConfirmedAndMergesVuccGrids() {
|
||||||
|
val merged = local.withConfirmation(confirmed)
|
||||||
|
assertTrue(merged.lotwConfirmed)
|
||||||
|
assertTrue(merged.lotwReceived)
|
||||||
|
assertEquals(listOf("OM60", "OM50"), merged.vuccGrids)
|
||||||
|
// Local myCallsign is kept when present; only blanks are backfilled.
|
||||||
|
assertEquals("ba7opf", merged.myCallsign)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun toConfirmedRecord_mapsGridQso() {
|
||||||
|
val qso = GridQso(
|
||||||
|
call = "BH6RJD",
|
||||||
|
epochMs = 1_700_000_000_000L,
|
||||||
|
satName = "SO-50",
|
||||||
|
mode = "FM",
|
||||||
|
bandUp = "70CM",
|
||||||
|
bandDown = "2M",
|
||||||
|
dxcc = 318,
|
||||||
|
country = "China",
|
||||||
|
cqz = 24,
|
||||||
|
state = "GD",
|
||||||
|
myGrid = "OM60",
|
||||||
|
myGrids = setOf("OM60", "OM50")
|
||||||
|
)
|
||||||
|
val record = qso.toConfirmedRecord("ba7opf")
|
||||||
|
assertEquals("BH6RJD", record.theirCallsign)
|
||||||
|
assertEquals("BA7OPF", record.myCallsign)
|
||||||
|
assertEquals("OM60", record.myGrid)
|
||||||
|
assertEquals(listOf("OM60", "OM50"), record.vuccGrids)
|
||||||
|
assertTrue(record.lotwConfirmed)
|
||||||
|
assertTrue(record.isSatellite)
|
||||||
|
assertEquals("70CM", record.band)
|
||||||
|
assertEquals("2M", record.rxBand)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun frequencyBand_edges() {
|
||||||
|
assertEquals("2M", frequencyBand(144_000_000L))
|
||||||
|
assertEquals("70CM", frequencyBand(450_000_000L))
|
||||||
|
assertEquals("10M", frequencyBand(29_000_000L))
|
||||||
|
assertEquals("", frequencyBand(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun displayMode_prefersSubmode() {
|
||||||
|
val ft4 = QsoRecord(startUtcMillis = 0, theirCallsign = "x", myCallsign = "y", mode = "MFSK", submode = "FT4")
|
||||||
|
assertEquals("FT4", ft4.displayMode)
|
||||||
|
assertEquals("FM", local.displayMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun displayMode_ignoresStaleSubmodeForOtherModes() {
|
||||||
|
// Regression: records persisted with the old default submode="FT4" must not
|
||||||
|
// show FT4 when the actual mode is FM/CW/SSB.
|
||||||
|
val staleFm = QsoRecord(startUtcMillis = 0, theirCallsign = "x", myCallsign = "y", mode = "FM", submode = "FT4")
|
||||||
|
val staleCw = QsoRecord(startUtcMillis = 0, theirCallsign = "x", myCallsign = "y", mode = "CW", submode = "FT4")
|
||||||
|
assertEquals("FM", staleFm.displayMode)
|
||||||
|
assertEquals("CW", staleCw.displayMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
+155
@@ -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.core.domain.model
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AwardCalculatorTest {
|
||||||
|
|
||||||
|
private fun qso(
|
||||||
|
call: String,
|
||||||
|
dxcc: Int? = null,
|
||||||
|
cqz: Int? = null,
|
||||||
|
state: String? = null,
|
||||||
|
grid: String = "OL62"
|
||||||
|
) = GridQso(
|
||||||
|
call = call, epochMs = 0L, satName = "SO-50", mode = "FM",
|
||||||
|
bandUp = "70CM", bandDown = "2M", dxcc = dxcc, cqz = cqz, state = state
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun byGrid(vararg qsos: GridQso) =
|
||||||
|
qsos.groupBy { "OL62" } // all share one grid for award-math tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `vucc counts distinct grid keys`() {
|
||||||
|
val qsos = mapOf(
|
||||||
|
"OL62" to listOf(qso("BG7XYZ")),
|
||||||
|
"PM95" to listOf(qso("JH0ABC")),
|
||||||
|
"OL62" to listOf(qso("BA7AAA"))
|
||||||
|
)
|
||||||
|
// map literal dedupes keys: OL62/PM95 -> 2 grids
|
||||||
|
val progress = AwardCalculator.calculate(qsos).first { it.type == AwardType.VUCC }
|
||||||
|
assertEquals(2, progress.count)
|
||||||
|
assertTrue(progress.workedKeys.containsAll(setOf("OL62", "PM95")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dxcc counts distinct entities from dxcc field`() {
|
||||||
|
val all = listOf(
|
||||||
|
qso("BG7XYZ", dxcc = 318),
|
||||||
|
qso("JH0ABC", dxcc = 339),
|
||||||
|
qso("YD1XYZ", dxcc = 327),
|
||||||
|
qso("BG7ABC", dxcc = 318)
|
||||||
|
)
|
||||||
|
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.DXCC }
|
||||||
|
assertEquals(3, progress.count)
|
||||||
|
assertTrue(progress.workedKeys.containsAll(setOf("318", "339", "327")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dxcc falls back to prefix when dxcc missing`() {
|
||||||
|
val all = listOf(qso("JH0ABC"), qso("BG7XYZ"), qso("VR2XYZ"), qso("XX9A"))
|
||||||
|
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.DXCC }
|
||||||
|
assertEquals(4, progress.count)
|
||||||
|
assertTrue(progress.workedKeys.containsAll(setOf("339", "318", "321", "152")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `wapc counts mainland provinces plus hk mo tw`() {
|
||||||
|
val all = listOf(
|
||||||
|
qso("BG7XYZ", dxcc = 318, state = "GD"), // Guangdong
|
||||||
|
qso("BD5ABC", dxcc = 318, state = "ZJ"), // Zhejiang
|
||||||
|
qso("BD5ABC", dxcc = 318, state = "ZJ"), // dup
|
||||||
|
qso("VR2X", dxcc = 321), // Hong Kong
|
||||||
|
qso("XX9A", dxcc = 152), // Macao
|
||||||
|
qso("BM4X", dxcc = 386) // Taiwan
|
||||||
|
)
|
||||||
|
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAPC }
|
||||||
|
assertEquals(5, progress.count)
|
||||||
|
assertTrue(progress.workedKeys.containsAll(setOf("GD", "ZJ", "HK", "MO", "TW")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `wapc ignores non-china states and missing states`() {
|
||||||
|
val all = listOf(
|
||||||
|
qso("JH0ABC", dxcc = 339, state = "34"), // Japan state ignored
|
||||||
|
qso("BG7XYZ", dxcc = 318, state = null), // missing province
|
||||||
|
qso("K1ABC", dxcc = 291, state = "HI") // Hawaii != Hainan
|
||||||
|
)
|
||||||
|
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAPC }
|
||||||
|
assertEquals(0, progress.count)
|
||||||
|
assertTrue(progress.workedKeys.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `waja counts japanese prefectures only`() {
|
||||||
|
val all = listOf(
|
||||||
|
qso("JH0ABC", dxcc = 339, state = "10"), // Tokyo
|
||||||
|
qso("JH0ABC", dxcc = 339, state = "47"), // Okinawa
|
||||||
|
qso("JH0ABC", dxcc = 339, state = "10"), // dup
|
||||||
|
qso("BG7XYZ", dxcc = 318, state = "10"), // Chinese call, state=10 must NOT count
|
||||||
|
qso("JE2XYZ", dxcc = 339, state = null) // missing
|
||||||
|
)
|
||||||
|
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAJA }
|
||||||
|
assertEquals(2, progress.count)
|
||||||
|
assertTrue(progress.workedKeys.containsAll(setOf("10", "47")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `waz counts distinct cq zones in range`() {
|
||||||
|
val all = listOf(
|
||||||
|
qso("BG7XYZ", cqz = 24),
|
||||||
|
qso("JH0ABC", cqz = 25),
|
||||||
|
qso("YD1XYZ", cqz = 28),
|
||||||
|
qso("BG7AAA", cqz = 24),
|
||||||
|
qso("BG7BBB", cqz = null),
|
||||||
|
qso("BG7CCC", cqz = 0)
|
||||||
|
)
|
||||||
|
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAZ }
|
||||||
|
assertEquals(3, progress.count)
|
||||||
|
assertTrue(progress.workedKeys.containsAll(setOf("24", "25", "28")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `was counts us states only`() {
|
||||||
|
val all = listOf(
|
||||||
|
qso("K1ABC", dxcc = 291, state = "CA"),
|
||||||
|
qso("W2XYZ", dxcc = 291, state = "NY"),
|
||||||
|
qso("N3QRS", dxcc = 291, state = "ca"), // lowercase normalized
|
||||||
|
qso("BG7XYZ", dxcc = 318, state = "CA"), // not US
|
||||||
|
qso("K1AAA", dxcc = 291, state = "XX") // invalid state
|
||||||
|
)
|
||||||
|
val progress = AwardCalculator.calculate(byGrid(*all.toTypedArray())).first { it.type == AwardType.WAS }
|
||||||
|
assertEquals(2, progress.count)
|
||||||
|
assertTrue(progress.workedKeys.containsAll(setOf("CA", "NY")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `all six awards present with correct targets`() {
|
||||||
|
val progress = AwardCalculator.calculate(emptyMap())
|
||||||
|
assertEquals(6, progress.size)
|
||||||
|
assertEquals(100, progress.first { it.type == AwardType.DXCC }.target)
|
||||||
|
assertEquals(100, progress.first { it.type == AwardType.VUCC }.target)
|
||||||
|
assertEquals(34, progress.first { it.type == AwardType.WAPC }.target)
|
||||||
|
assertEquals(47, progress.first { it.type == AwardType.WAJA }.target)
|
||||||
|
assertEquals(40, progress.first { it.type == AwardType.WAZ }.target)
|
||||||
|
assertEquals(50, progress.first { it.type == AwardType.WAS }.target)
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
/*
|
||||||
|
* 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.predict
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.time.Instant
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
class CelestialComputerTest {
|
||||||
|
|
||||||
|
private data class RiseSetCase(
|
||||||
|
val name: String,
|
||||||
|
val observer: GeoPos,
|
||||||
|
val startIso: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `findSunRiseSet returns distinct sunrise and sunset for representative locations`() {
|
||||||
|
val cases = listOf(
|
||||||
|
RiseSetCase("Equator at March equinox", GeoPos(0.0, 0.0), "2026-03-20T00:00:00Z"),
|
||||||
|
RiseSetCase("Equator at September equinox", GeoPos(0.0, 0.0), "2026-09-23T00:00:00Z"),
|
||||||
|
RiseSetCase("Sydney winter", GeoPos(-33.8688, 151.2093), "2026-06-21T00:00:00Z"),
|
||||||
|
RiseSetCase("Buenos Aires winter", GeoPos(-34.6037, -58.3816), "2026-06-21T00:00:00Z"),
|
||||||
|
RiseSetCase("Cape Town winter", GeoPos(-33.9249, 18.4241), "2026-06-21T00:00:00Z"),
|
||||||
|
RiseSetCase("London summer", GeoPos(51.5074, -0.1278), "2026-06-21T00:00:00Z")
|
||||||
|
)
|
||||||
|
|
||||||
|
cases.forEach { testCase ->
|
||||||
|
val result = CelestialComputer.findSunRiseSet(testCase.observer, testCase.startIso.toMillis())
|
||||||
|
val daylightDuration = result.setTimeMillis - result.riseTimeMillis
|
||||||
|
|
||||||
|
assertTrue("${testCase.name}: sunrise should be non-zero", result.riseTimeMillis > 0L)
|
||||||
|
assertTrue("${testCase.name}: sunset should be non-zero", result.setTimeMillis > 0L)
|
||||||
|
assertTrue("${testCase.name}: sunset should be after sunrise", result.setTimeMillis > result.riseTimeMillis)
|
||||||
|
assertTrue("${testCase.name}: daylight duration should be longer than 1 hour", daylightDuration > HOUR_MILLIS)
|
||||||
|
assertTrue("${testCase.name}: daylight duration should be shorter than 24 hours", daylightDuration < DAY_MILLIS)
|
||||||
|
|
||||||
|
val riseElevation = CelestialComputer.getSunPosition(testCase.observer, result.riseTimeMillis).elevation
|
||||||
|
val setElevation = CelestialComputer.getSunPosition(testCase.observer, result.setTimeMillis).elevation
|
||||||
|
assertEquals("${testCase.name}: sunrise should converge near the standard threshold", SUNRISE_SET_THRESHOLD, riseElevation, 0.02)
|
||||||
|
assertEquals("${testCase.name}: sunset should converge near the standard threshold", SUNRISE_SET_THRESHOLD, setElevation, 0.02)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `findSunRiseSet does not return the same instant for equinox regression cases`() {
|
||||||
|
listOf("2026-03-20T00:00:00Z", "2026-09-23T00:00:00Z").forEach { startIso ->
|
||||||
|
val result = CelestialComputer.findSunRiseSet(GeoPos(0.0, 0.0), startIso.toMillis())
|
||||||
|
val separationMillis = abs(result.setTimeMillis - result.riseTimeMillis)
|
||||||
|
|
||||||
|
assertTrue("$startIso: sunrise and sunset should be separated", separationMillis > HOUR_MILLIS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.toMillis(): Long = Instant.parse(this).toEpochMilli()
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
private const val SUNRISE_SET_THRESHOLD = -0.8333
|
||||||
|
private const val HOUR_MILLIS = 60L * 60L * 1000L
|
||||||
|
private const val DAY_MILLIS = 24L * HOUR_MILLIS
|
||||||
|
}
|
||||||
|
}
|
||||||
+296
@@ -0,0 +1,296 @@
|
|||||||
|
/*
|
||||||
|
* 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.repository
|
||||||
|
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.DatabaseState
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.DataSourcesSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.GridQso
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.LoTWSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.MarkedStation
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.OtherSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.PassesSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RCSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.RadioControlSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.model.WavelogSettings
|
||||||
|
import com.rtbishop.look4sat.core.domain.predict.GeoPos
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class LoTWGridSyncTest {
|
||||||
|
|
||||||
|
// 2026-09-16T00:00:00Z — fixed "now" for the cursor assertions.
|
||||||
|
private val now = 1_789_516_800_000L
|
||||||
|
|
||||||
|
// region resolveLoTWSyncMode
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resolveModeHonorsExplicitFull() {
|
||||||
|
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("BA7OPF", "BA7OPF", LoTWSyncMode.Full))
|
||||||
|
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("", "BA7OPF", LoTWSyncMode.Full))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resolveModeAllowsIncrementalOnlyForSameCallsign() {
|
||||||
|
assertEquals(
|
||||||
|
LoTWSyncMode.Incremental,
|
||||||
|
resolveLoTWSyncMode("BA7OPF", "BA7OPF", LoTWSyncMode.Incremental)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
LoTWSyncMode.Full,
|
||||||
|
resolveLoTWSyncMode("BG7ABC", "BA7OPF", LoTWSyncMode.Incremental)
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
LoTWSyncMode.Full,
|
||||||
|
resolveLoTWSyncMode("", "BA7OPF", LoTWSyncMode.Incremental)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resolveModeForAutoSyncPicksIncrementalWhenPossible() {
|
||||||
|
assertEquals(LoTWSyncMode.Incremental, resolveLoTWSyncMode("BA7OPF", "BA7OPF", null))
|
||||||
|
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("BG7ABC", "BA7OPF", null))
|
||||||
|
assertEquals(LoTWSyncMode.Full, resolveLoTWSyncMode("", "BA7OPF", null))
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region shouldAutoSyncLoTW
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun autoSyncGateRequiresEverything() {
|
||||||
|
val today = "20260916"
|
||||||
|
// Not configured.
|
||||||
|
assertFalse(shouldAutoSyncLoTW(false, true, "2026-09-15 10:00:00", today))
|
||||||
|
// LoTW auto-sync toggle off.
|
||||||
|
assertFalse(shouldAutoSyncLoTW(true, false, "2026-09-15 10:00:00", today))
|
||||||
|
// Never synced manually — first sync stays manual/full.
|
||||||
|
assertFalse(shouldAutoSyncLoTW(true, true, "", today))
|
||||||
|
// Already synced today (both legacy and current cursor formats) — ARRL rate-limit guard.
|
||||||
|
assertFalse(shouldAutoSyncLoTW(true, true, "20260916", today))
|
||||||
|
assertFalse(shouldAutoSyncLoTW(true, true, "2026-09-16 08:00:00", today))
|
||||||
|
// All gates open — yesterday in either format.
|
||||||
|
assertTrue(shouldAutoSyncLoTW(true, true, "20260915", today))
|
||||||
|
assertTrue(shouldAutoSyncLoTW(true, true, "2026-09-15 10:00:00", today))
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region cursor helpers
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cursorApiNormalizesLegacyAndPassesFullThrough() {
|
||||||
|
assertEquals("2026-09-16", lotwCursorApi("20260916"))
|
||||||
|
assertEquals("2026-09-16 08:16:02", lotwCursorApi("2026-09-16 08:16:02"))
|
||||||
|
assertEquals("", lotwCursorApi(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cursorDateToleratesBothFormats() {
|
||||||
|
assertEquals("20260916", lotwCursorDate("20260916"))
|
||||||
|
assertEquals("20260916", lotwCursorDate("2026-09-16 08:16:02"))
|
||||||
|
assertEquals("", lotwCursorDate(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cursorEpochMsParsesBothFormatsUtc() {
|
||||||
|
// 2026-09-16T00:00:00Z in both spellings.
|
||||||
|
assertEquals(now, lotwCursorEpochMs("20260916"))
|
||||||
|
assertEquals(now, lotwCursorEpochMs("2026-09-16 00:00:00"))
|
||||||
|
assertEquals(0L, lotwCursorEpochMs(""))
|
||||||
|
assertEquals(0L, lotwCursorEpochMs("garbage"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region mergeGridQsos
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mergeGridQsosDedupsByCallAndTime() {
|
||||||
|
val existing = mapOf(
|
||||||
|
"OL62" to listOf(qso("BA7ABC", 1000L)),
|
||||||
|
"OL63" to listOf(qso("BA7DEF", 2000L))
|
||||||
|
)
|
||||||
|
val fresh = mapOf(
|
||||||
|
// Same grid, same QSO re-delivered by an overlapping since-date.
|
||||||
|
"OL62" to listOf(qso("BA7ABC", 1000L)),
|
||||||
|
// Same grid, brand-new QSO.
|
||||||
|
"OL62" to listOf(qso("BA7GHI", 3000L)),
|
||||||
|
// New grid entirely.
|
||||||
|
"PM95" to listOf(qso("BA7JKL", 4000L))
|
||||||
|
)
|
||||||
|
val merged = mergeGridQsos(existing, fresh)
|
||||||
|
assertEquals(setOf("OL62", "OL63", "PM95"), merged.keys)
|
||||||
|
assertEquals(2, merged["OL62"]?.size) // BA7ABC once, BA7GHI added
|
||||||
|
assertEquals(1, merged["OL63"]?.size)
|
||||||
|
assertEquals(1, merged["PM95"]?.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mergeGridQsosHandlesEmptySides() {
|
||||||
|
val fresh = mapOf("OL62" to listOf(qso("BA7ABC", 1000L)))
|
||||||
|
assertEquals(fresh, mergeGridQsos(emptyMap(), fresh))
|
||||||
|
// An empty incremental pull must leave the stored side untouched.
|
||||||
|
assertEquals(fresh, mergeGridQsos(fresh, emptyMap()))
|
||||||
|
assertEquals(emptyMap<String, List<GridQso>>(), mergeGridQsos(emptyMap(), emptyMap()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
// region applyLoTWGridResult
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun applyIncrementalMergesAndAdvancesCursor() = runBlocking {
|
||||||
|
val repo = FakeSettingsRepo().apply {
|
||||||
|
setWorkedGrids(setOf("OL62"))
|
||||||
|
setWorkedGridQsos(mapOf("OL62" to listOf(qso("BA7ABC", 1000L))))
|
||||||
|
setRoamedGrids(setOf("OL62"))
|
||||||
|
setLastLotwSyncDate("20260915")
|
||||||
|
setLastLotwSyncCallsign("BA7OPF")
|
||||||
|
}
|
||||||
|
val result = LoTWResult.Success(
|
||||||
|
grids = setOf("OL62", "PM95"),
|
||||||
|
qsos = mapOf("OL62" to listOf(qso("BA7ABC", 1000L)), "PM95" to listOf(qso("BA7GHI", 2000L))),
|
||||||
|
roamedGrids = setOf("PM95")
|
||||||
|
)
|
||||||
|
val count = applyLoTWGridResult(repo, result, LoTWSyncMode.Incremental, "BA7OPF", now)
|
||||||
|
|
||||||
|
assertEquals(2, count)
|
||||||
|
assertEquals(setOf("OL62", "PM95"), repo.getWorkedGrids())
|
||||||
|
assertEquals(1, repo.getWorkedGridQsos()["OL62"]?.size) // deduped
|
||||||
|
assertEquals(setOf("OL62", "PM95"), repo.getRoamedGrids())
|
||||||
|
assertEquals("2026-09-16 00:00:00", repo.getLastLotwSyncDate())
|
||||||
|
assertEquals("BA7OPF", repo.getLastLotwSyncCallsign())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun applyFullReplacesEverythingAndAdvancesCursor() = runBlocking {
|
||||||
|
val repo = FakeSettingsRepo().apply {
|
||||||
|
setWorkedGrids(setOf("OL62", "STALE"))
|
||||||
|
setWorkedGridQsos(mapOf("STALE" to listOf(qso("BG7OLD", 1L))))
|
||||||
|
setRoamedGrids(setOf("STALE"))
|
||||||
|
setLastLotwSyncDate("20260910")
|
||||||
|
setLastLotwSyncCallsign("BG7OLD")
|
||||||
|
}
|
||||||
|
val result = LoTWResult.Success(
|
||||||
|
grids = setOf("PM95"),
|
||||||
|
qsos = mapOf("PM95" to listOf(qso("BA7GHI", 2000L))),
|
||||||
|
roamedGrids = setOf("PM95")
|
||||||
|
)
|
||||||
|
val count = applyLoTWGridResult(repo, result, LoTWSyncMode.Full, "BA7OPF", now)
|
||||||
|
|
||||||
|
assertEquals(1, count)
|
||||||
|
assertEquals(setOf("PM95"), repo.getWorkedGrids())
|
||||||
|
assertEquals(setOf("PM95"), repo.getWorkedGridQsos().keys)
|
||||||
|
assertEquals(setOf("PM95"), repo.getRoamedGrids())
|
||||||
|
assertEquals("2026-09-16 00:00:00", repo.getLastLotwSyncDate())
|
||||||
|
assertEquals("BA7OPF", repo.getLastLotwSyncCallsign())
|
||||||
|
}
|
||||||
|
|
||||||
|
// endregion
|
||||||
|
|
||||||
|
private fun qso(call: String, epochMs: Long): GridQso =
|
||||||
|
GridQso(call, epochMs, "FO-29", "FM", "70CM", "2M")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal ISettingsRepo fake backing only the LoTW sync surface; the rest
|
||||||
|
* throws (mirrors the FakeSettingsRepo pattern used in feature tests).
|
||||||
|
*/
|
||||||
|
private class FakeSettingsRepo : ISettingsRepo {
|
||||||
|
override val stationPosition: StateFlow<GeoPos> = MutableStateFlow(GeoPos(23.13, 113.26))
|
||||||
|
override val appVersionName: String = "test"
|
||||||
|
override val selectedIds: StateFlow<List<Int>> = MutableStateFlow(emptyList())
|
||||||
|
override val selectedTypes: StateFlow<List<String>> = MutableStateFlow(emptyList())
|
||||||
|
override val passesSettings: StateFlow<PassesSettings> = MutableStateFlow(
|
||||||
|
PassesSettings(hoursAhead = 24, minElevation = 10.0, selectedModes = emptyList())
|
||||||
|
)
|
||||||
|
override val databaseState: StateFlow<DatabaseState> = MutableStateFlow(DatabaseState(0, 0, 0L))
|
||||||
|
override val rcSettings: StateFlow<RCSettings> = MutableStateFlow(
|
||||||
|
RCSettings(false, "", "", "", false, "", "", "", 0L, false, "", "", "", false, "", "")
|
||||||
|
)
|
||||||
|
override val otherSettings: StateFlow<OtherSettings> = MutableStateFlow(
|
||||||
|
OtherSettings(
|
||||||
|
stateOfAutoUpdate = true,
|
||||||
|
stateOfSensors = false,
|
||||||
|
stateOfSweep = false,
|
||||||
|
stateOfUtc = false,
|
||||||
|
stateOfLightTheme = false,
|
||||||
|
stateOfNightMode = false,
|
||||||
|
stateOfMapGrid = false,
|
||||||
|
shouldSeeWarning = false,
|
||||||
|
shouldSeeWhatsNew = false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
override val dataSourcesSettings: StateFlow<DataSourcesSettings> = MutableStateFlow(
|
||||||
|
DataSourcesSettings(satelliteUrls = emptyList(), transceiversUrls = emptyList())
|
||||||
|
)
|
||||||
|
override val dataSourcesStatus: StateFlow<Map<String, Int>> = MutableStateFlow(emptyMap())
|
||||||
|
override val radioControlSettings: StateFlow<RadioControlSettings> = MutableStateFlow(
|
||||||
|
RadioControlSettings(false, RadioControlSettings.MODEL_YAESU_FT817, "", "", "", "", 9600)
|
||||||
|
)
|
||||||
|
override val wavelogSettings: StateFlow<WavelogSettings> = MutableStateFlow(WavelogSettings())
|
||||||
|
override val lotwSettings: StateFlow<LoTWSettings> = MutableStateFlow(LoTWSettings())
|
||||||
|
|
||||||
|
private var workedGrids: Set<String> = emptySet()
|
||||||
|
private var workedGridQsos: Map<String, List<GridQso>> = emptyMap()
|
||||||
|
private var roamedGrids: Set<String> = emptySet()
|
||||||
|
private var lastSyncDate: String = ""
|
||||||
|
private var lastSyncCallsign: String = ""
|
||||||
|
|
||||||
|
override fun getWorkedGrids(): Set<String> = workedGrids
|
||||||
|
override fun setWorkedGrids(grids: Set<String>) { workedGrids = grids }
|
||||||
|
override fun getWorkedGridQsos(): Map<String, List<GridQso>> = workedGridQsos
|
||||||
|
override fun setWorkedGridQsos(qsos: Map<String, List<GridQso>>) { workedGridQsos = qsos }
|
||||||
|
override fun getRoamedGrids(): Set<String> = roamedGrids
|
||||||
|
override fun setRoamedGrids(grids: Set<String>) { roamedGrids = grids }
|
||||||
|
private var markedGridStations: Map<String, List<MarkedStation>> = emptyMap()
|
||||||
|
override fun getMarkedGridStations(): Map<String, List<MarkedStation>> = markedGridStations
|
||||||
|
override fun setMarkedGridStations(stations: Map<String, List<MarkedStation>>) { markedGridStations = stations }
|
||||||
|
override fun getLastLotwSyncDate(): String = lastSyncDate
|
||||||
|
override fun setLastLotwSyncDate(date: String) { lastSyncDate = date }
|
||||||
|
override fun getLastLotwSyncCallsign(): String = lastSyncCallsign
|
||||||
|
override fun setLastLotwSyncCallsign(callsign: String) { lastSyncCallsign = callsign }
|
||||||
|
|
||||||
|
override fun setSelectedIds(ids: List<Int>) = TODO()
|
||||||
|
override fun setSelectedTypes(types: List<String>) = TODO()
|
||||||
|
override fun setPassesSettings(settings: PassesSettings) = TODO()
|
||||||
|
override fun setStationPosition(latitude: Double, longitude: Double, altitude: Double): Boolean = TODO()
|
||||||
|
override fun setStationPosition(): Boolean = TODO()
|
||||||
|
override fun setStationPosition(locator: String): Boolean = TODO()
|
||||||
|
override fun getSatelliteTypesIds(types: List<String>): List<Int> = TODO()
|
||||||
|
override fun setSatelliteTypeIds(type: String, ids: List<Int>) = TODO()
|
||||||
|
override fun updateDatabaseState(state: DatabaseState) = TODO()
|
||||||
|
override fun updateRCSettings(settings: RCSettings) = TODO()
|
||||||
|
override fun updateOtherSettings(transform: (OtherSettings) -> OtherSettings) = TODO()
|
||||||
|
override fun updateDataSourcesSettings(settings: DataSourcesSettings) = TODO()
|
||||||
|
override fun updateDataSourcesStatus(status: Map<String, Int>) = TODO()
|
||||||
|
override fun updateRadioControlSettings(settings: RadioControlSettings) = TODO()
|
||||||
|
override fun getSatelliteOffset(catnum: Int): String = ""
|
||||||
|
override fun getSatelliteMode(catnum: Int): String = ""
|
||||||
|
override fun setSatelliteMode(catnum: Int, mode: String) {}
|
||||||
|
override fun setSatelliteOffset(catnum: Int, offset: String) = TODO()
|
||||||
|
override fun getAmSatCallsign(): String = ""
|
||||||
|
override fun setAmSatCallsign(callsign: String) = TODO()
|
||||||
|
override fun updateWavelogSettings(settings: WavelogSettings) = TODO()
|
||||||
|
override fun updateLoTWSettings(settings: LoTWSettings) = TODO()
|
||||||
|
}
|
||||||
|
}
|
||||||
+222
-8
@@ -17,9 +17,15 @@
|
|||||||
*/
|
*/
|
||||||
package com.rtbishop.look4sat.core.presentation
|
package com.rtbishop.look4sat.core.presentation
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.animate
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.MarqueeSpacing
|
import androidx.compose.foundation.MarqueeSpacing
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.basicMarquee
|
import androidx.compose.foundation.basicMarquee
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -29,7 +35,9 @@ import androidx.compose.foundation.layout.RowScope
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
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.fillMaxHeight
|
||||||
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.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
@@ -49,16 +57,28 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
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.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
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.semantics.hideFromAccessibility
|
import androidx.compose.ui.semantics.hideFromAccessibility
|
||||||
@@ -82,6 +102,8 @@ import com.rtbishop.look4sat.core.domain.predict.OrbitalData
|
|||||||
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
import com.rtbishop.look4sat.core.domain.predict.OrbitalPass
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import java.util.TimeZone
|
import java.util.TimeZone
|
||||||
|
|
||||||
@@ -210,12 +232,20 @@ fun RowScope.NextPassRow(pass: OrbitalPass, modifier: Modifier = Modifier, isUtc
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CardButton(onClick: () -> Unit, text: String, modifier: Modifier = Modifier) {
|
fun CardButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
text: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
isEnabled: Boolean = true
|
||||||
|
) {
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
|
enabled = isEnabled,
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
|
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
|
||||||
),
|
),
|
||||||
shape = MaterialTheme.shapes.small,
|
shape = MaterialTheme.shapes.small,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -281,10 +311,56 @@ fun getDefaultPass(): OrbitalPass = OrbitalPass(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SharedDialog(
|
fun InfoDialog(
|
||||||
title: String, onCancel: () -> Unit, onAccept: () -> Unit, content: @Composable () -> Unit
|
title: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onAccept: () -> Unit,
|
||||||
|
extraAction: (@Composable () -> Unit)? = null,
|
||||||
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
SharedDialog(title = title, onDismissRequest = onCancel, onCancel = onCancel, onAccept = onAccept) { _ ->
|
DialogShell(onDismissRequest = onDismiss) { padding ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = padding, top = padding, end = padding)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
extraAction?.let {
|
||||||
|
it()
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
||||||
|
}
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SharedDialog(
|
||||||
|
title: String,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
onAccept: () -> Unit,
|
||||||
|
acceptText: String? = null,
|
||||||
|
acceptEnabled: Boolean = true,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
SharedDialog(
|
||||||
|
title = title,
|
||||||
|
onDismissRequest = onCancel,
|
||||||
|
onCancel = onCancel,
|
||||||
|
onAccept = onAccept,
|
||||||
|
acceptText = acceptText,
|
||||||
|
acceptEnabled = acceptEnabled
|
||||||
|
) { _ ->
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,6 +371,8 @@ fun SharedDialog(
|
|||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
onCancel: (() -> Unit)? = null,
|
onCancel: (() -> Unit)? = null,
|
||||||
onAccept: (() -> Unit)? = null,
|
onAccept: (() -> Unit)? = null,
|
||||||
|
acceptText: String? = null,
|
||||||
|
acceptEnabled: Boolean = true,
|
||||||
titleFontSize: Int = 16,
|
titleFontSize: Int = 16,
|
||||||
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
|
titleTextAlign: TextAlign = if (onCancel != null && onAccept != null) TextAlign.Center else TextAlign.Start,
|
||||||
content: @Composable (padding: Dp) -> Unit
|
content: @Composable (padding: Dp) -> Unit
|
||||||
@@ -319,10 +397,17 @@ fun SharedDialog(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(horizontal = if (onCancel != null && onAccept != null) padding else 0.dp)
|
.padding(
|
||||||
|
start = if (onCancel != null) padding else 0.dp,
|
||||||
|
end = if (onAccept != null) padding else 0.dp
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if (onAccept != null) {
|
if (onAccept != null) {
|
||||||
CardButton(onClick = onAccept, text = stringResource(R.string.btn_accept))
|
CardButton(
|
||||||
|
onClick = onAccept,
|
||||||
|
text = acceptText ?: stringResource(R.string.btn_accept),
|
||||||
|
isEnabled = acceptEnabled
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
content(padding)
|
content(padding)
|
||||||
@@ -384,6 +469,134 @@ fun isVerticalLayout(): Boolean = !hasEnoughWidth()
|
|||||||
fun Modifier.infiniteMarquee(): Modifier =
|
fun Modifier.infiniteMarquee(): Modifier =
|
||||||
basicMarquee(iterations = Int.MAX_VALUE, spacing = MarqueeSpacing(16.dp))
|
basicMarquee(iterations = Int.MAX_VALUE, spacing = MarqueeSpacing(16.dp))
|
||||||
|
|
||||||
|
/** 共享的滑动行展开状态:同一列表内同时只允许一行展开(短信式联动)。 */
|
||||||
|
class SwipeController {
|
||||||
|
var openKey by mutableStateOf<String?>(null)
|
||||||
|
private set
|
||||||
|
fun open(key: String) {
|
||||||
|
openKey = key
|
||||||
|
}
|
||||||
|
fun close() {
|
||||||
|
openKey = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberSwipeController(): SwipeController = remember { SwipeController() }
|
||||||
|
|
||||||
|
/** 短信式滑动行:右划露出 [revealButton](默认红底垃圾桶+文字),点击按钮触发 [revealAction]。
|
||||||
|
* 通过 [controller] 联动:滑开新行时其他行自动收回;点按已展开的行内容也可收回(无涟漪)。 */
|
||||||
|
@Composable
|
||||||
|
fun SwipeRevealRow(
|
||||||
|
key: String,
|
||||||
|
controller: SwipeController,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
revealWidth: Dp = 64.dp,
|
||||||
|
revealAction: () -> Unit,
|
||||||
|
revealButton: @Composable () -> Unit = {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.error),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.ic_delete),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val revealPx = with(density) { revealWidth.toPx() }
|
||||||
|
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||||
|
var settleJob by remember { mutableStateOf<Job?>(null) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val haptics = LocalHapticFeedback.current
|
||||||
|
val isOpen = controller.openKey == key
|
||||||
|
|
||||||
|
fun settle(target: Float) {
|
||||||
|
settleJob?.cancel()
|
||||||
|
settleJob = scope.launch {
|
||||||
|
animate(offsetX, target, animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing)) { value, _ ->
|
||||||
|
offsetX = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 另一行展开(或全部收回)时,本行随之收回。
|
||||||
|
LaunchedEffect(controller.openKey) {
|
||||||
|
if (!isOpen && offsetX < 0f) settle(0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier.fillMaxWidth().clipToBounds()) {
|
||||||
|
// 背景删除按钮:初始位于内容右侧屏幕外,与内容同步平移(跟手滑入)。
|
||||||
|
// 父 Box clipToBounds 裁剪容器外部分——即便外层 item 带 padding、按钮
|
||||||
|
// 左缘落在容器外的屏幕内,也因裁剪而不可见、不可点。
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterEnd)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.width(revealWidth)
|
||||||
|
.offset(x = revealWidth)
|
||||||
|
.graphicsLayer { translationX = offsetX }
|
||||||
|
.clickable {
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||||
|
controller.close()
|
||||||
|
settle(0f)
|
||||||
|
revealAction()
|
||||||
|
}
|
||||||
|
) { revealButton() }
|
||||||
|
// 前景内容:右划平移露出按钮
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.graphicsLayer { translationX = offsetX }
|
||||||
|
.pointerInput(revealPx) {
|
||||||
|
detectHorizontalDragGestures(
|
||||||
|
onDragStart = { settleJob?.cancel() },
|
||||||
|
onHorizontalDrag = { change, dragAmount ->
|
||||||
|
change.consume()
|
||||||
|
offsetX = (offsetX + dragAmount).coerceIn(-revealPx, 0f)
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
if (offsetX < -revealPx / 2) {
|
||||||
|
controller.open(key)
|
||||||
|
haptics.performHapticFeedback(HapticFeedbackType.SegmentTick)
|
||||||
|
settle(-revealPx)
|
||||||
|
} else {
|
||||||
|
controller.close()
|
||||||
|
settle(0f)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
if (offsetX < -revealPx / 2) {
|
||||||
|
controller.open(key)
|
||||||
|
settle(-revealPx)
|
||||||
|
} else {
|
||||||
|
controller.close()
|
||||||
|
settle(0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// 已展开的行,点按内容直接收回(无涟漪,避免整行出现点击反馈)
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null
|
||||||
|
) {
|
||||||
|
if (isOpen) {
|
||||||
|
controller.close()
|
||||||
|
settle(0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Modifier.layoutPadding(): Modifier {
|
fun Modifier.layoutPadding(): Modifier {
|
||||||
val spacing = LocalSpacing.current.extraSmall
|
val spacing = LocalSpacing.current.extraSmall
|
||||||
@@ -518,5 +731,6 @@ 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
|
||||||
return String.format(Locale.ENGLISH, "%d.%03d", mhz, khz)
|
val hz = frequencyHz % 1_000
|
||||||
|
return String.format(Locale.ENGLISH, "%d.%03d.%03d", mhz, khz, hz)
|
||||||
}
|
}
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
package com.rtbishop.look4sat.core.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
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.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.drawscope.rotate
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlin.math.atan2
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a Maidenhead locator for display. Per IARU convention and the user's
|
||||||
|
* preference: field letters (1-2) and square digits (3-4) uppercase, sub-square
|
||||||
|
* letters (5-6 of a 6/8-char locator) lowercase, e.g. "OL62" / "OL62kl".
|
||||||
|
*/
|
||||||
|
fun formatGridDisplay(grid: String): String? {
|
||||||
|
val g = grid.trim().uppercase()
|
||||||
|
if (g.length !in listOf(4, 6, 8) || g.any { !it.isLetterOrDigit() }) return null
|
||||||
|
if (g.length == 4) return g
|
||||||
|
if (g.length == 6) return g.take(4) + g.drop(4).lowercase()
|
||||||
|
return g.take(4) + g.drop(4).take(2).lowercase() + g.drop(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-bar chip for the mutual-match screen: navigation needle (rotated by the
|
||||||
|
* great-circle bearing to the target grid, 0 deg = north), grid locator and
|
||||||
|
* distance. Shows a dimmed placeholder when no target grid is entered.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GridTargetChip(
|
||||||
|
grid: String?,
|
||||||
|
distanceKm: Double?,
|
||||||
|
bearingDeg: Double?,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val configured = grid != null && distanceKm != null && bearingDeg != null
|
||||||
|
// The app's signature accent yellow (MaterialTheme primary in both themes).
|
||||||
|
val accent = MaterialTheme.colorScheme.primary
|
||||||
|
val placeholder = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = modifier
|
||||||
|
.height(36.dp)
|
||||||
|
.background(
|
||||||
|
color = MaterialTheme.colorScheme.surface.copy(alpha = if (configured) 0.9f else 0.45f),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
.padding(horizontal = 12.dp)
|
||||||
|
) {
|
||||||
|
NavigationNeedle(
|
||||||
|
bearingDeg = bearingDeg ?: 0.0,
|
||||||
|
color = if (configured) accent else placeholder,
|
||||||
|
dimmed = !configured,
|
||||||
|
modifier = Modifier.size(22.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = grid?.let { formatGridDisplay(it) } ?: "----",
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = if (configured) accent else placeholder,
|
||||||
|
letterSpacing = 1.sp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = distanceKm?.let { "${"%,.0f".format(it)} km" } ?: "-- km",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map-style navigation needle: slim triangular pointer whose tip points at
|
||||||
|
* [bearingDeg] (0 = north/up, clockwise positive).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun NavigationNeedle(
|
||||||
|
bearingDeg: Double,
|
||||||
|
color: Color,
|
||||||
|
dimmed: Boolean,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Canvas(modifier = modifier) {
|
||||||
|
val cx = size.width / 2f
|
||||||
|
val cy = size.height / 2f
|
||||||
|
val r = size.minDimension / 2f
|
||||||
|
if (dimmed) {
|
||||||
|
drawCircle(color = color, radius = r * 0.92f, style = Stroke(width = 1.5f))
|
||||||
|
}
|
||||||
|
rotate(degrees = bearingDeg.toFloat(), pivot = Offset(cx, cy)) {
|
||||||
|
val path = Path().apply {
|
||||||
|
moveTo(cx, cy - r * 0.9f) // tip (north before rotation)
|
||||||
|
lineTo(cx - r * 0.42f, cy + r * 0.55f)
|
||||||
|
lineTo(cx, cy + r * 0.28f)
|
||||||
|
lineTo(cx + r * 0.42f, cy + r * 0.55f)
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
drawPath(path, color = color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Great-circle distance in km between two positions. */
|
||||||
|
fun greatCircleDistanceKm(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
|
||||||
|
val rad = Math.PI / 180.0
|
||||||
|
val dLat = (lat2 - lat1) * rad
|
||||||
|
val dLon = (lon2 - lon1) * rad
|
||||||
|
val a = sin(dLat / 2) * sin(dLat / 2) +
|
||||||
|
cos(lat1 * rad) * cos(lat2 * rad) * sin(dLon / 2) * sin(dLon / 2)
|
||||||
|
return 6371.0 * 2 * kotlin.math.atan2(kotlin.math.sqrt(a), kotlin.math.sqrt(1 - a))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initial great-circle bearing in degrees (0 = north, clockwise). */
|
||||||
|
fun greatCircleBearingDeg(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
|
||||||
|
val rad = Math.PI / 180.0
|
||||||
|
val dLon = (lon2 - lon1) * rad
|
||||||
|
val y = sin(dLon) * cos(lat2 * rad)
|
||||||
|
val x = cos(lat1 * rad) * sin(lat2 * rad) -
|
||||||
|
sin(lat1 * rad) * cos(lat2 * rad) * cos(dLon)
|
||||||
|
return (Math.atan2(y, x) / rad + 360.0) % 360.0
|
||||||
|
}
|
||||||
Loaded 100 of 189 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user